新老觀眾老爺們!我鴿子王何賢又雙叒叕回歸啦!😆
真的是好久不見!記得上一次發文章還是在上一次。一轉眼就兩個月沒更新了。
這期間,我憑藉之前的 9 篇文章成功晉級到創作等級 LV.5 🎉(會不會是掘金史上最快傳說?)獲得了 1K+ 的粉絲。
衷心感謝大家一直以來的支持 ❤️。
不知不覺,今年都快結束了。從最初的「周更博主」到「月更博主」,再到如今的「半年更博主」😂,每次被催更都覺得有點心虛。
不行!這次絕不能再鴿了!🕊️
重新自我介紹一下——我是一名在 Three.js 領域裡摸爬滾打的初級玩家。在這裡,我會分享自己的所見、所聞與所想。
歡迎閱讀本篇文章!在深入探討 Three.js 與 Shader (GLSL) 的進階內容之前,請確保您已經具備以下基礎知識:
Three.js 基礎
您需要熟悉 Three.js 的基本概念與使用方法,包括場景(Scene)、相機(Camera)、渲染器(Renderer)、幾何體(Geometry)、材質(Material)和網格(Mesh)等核心組件。
如果您對這些內容還不熟悉,建議先學習 Three.js 的入門教程。我比較推薦外網知名博主 Bruno Simon 的課程 threejs-journey(B 站上有免費版,但如果條件允許,建議支持正版課程)。
當然,如果您希望我分享自己的學習路徑,可以在評論區留言。人數夠多的話,我會著手撰寫一篇系統的學習路線文章。
Shader 語法
本文將涉及 GLSL(OpenGL Shading Language)的編寫,因此您需要了解 GLSL 的基本語法,包括頂點著色器(Vertex Shader)與片元著色器(Fragment Shader)的結構,以及如何在 Three.js 中使用自定義著色器。
“Hero Section” 是網頁設計中的一個術語,通常指頁面頂部的大型橫幅區域。
對於開發者而言,它可以更直觀地理解為:用戶在訪問網站瞬間所感受到的視覺衝擊,或促使他們停留在網站的關鍵視覺因素。
相信大家偶爾也會刷到一些以星球為主題的官網,看起來既夢幻又酷炫。

這些網站往往擁有天馬行空的頁面佈局,美麗的星球在畫面中靜靜流動,營造出一種未來科技與宇宙幻想交織的氛圍。
於是,我讓 GPT 🧠 通過文生圖生成了一張原型設計稿:

並嘗試將其復原,於是得到了以下結果

由於平台圖片體積限制, 動畫畫質存在大幅抽幀和壓縮,還請各位可以在 PC 端自行體驗
PC端在線預覽地址(需要魔法): isgalaxias.vercel.app/
DeBug 在線調試界面: isgalaxias.vercel.app/#debug
Github 倉庫地址: github.com/hexianWeb/i…
關注我的掘友最熟悉的環節了,那麼讓我來介紹本次項目,它被 Threejs Journey 課程作者 Bruno Simon 轉發

(其實項目 8 月底就寫完了,但是我瘋狂鴿子,非常抱歉!)
由於本專欄主要聚焦於 Three.js,因此本文不會詳細講解從 0 到 1 的完整頁面實現過程,而是重點介紹與 Three.js 相關的實現部分。
首先,讓我們分析一下當前場景(Scene)中的主要元素:
這一部分的實現非常簡單:只需將加載好的星雲貼圖設置為場景的 background 即可。
在此也順便推薦一個優秀的 3D 貼圖资源站 —— Solar System Scope。
本項目中使用的所有行星與星雲貼圖,均來自該網站。
這裡找到星雲圖

但注意這是一個根據 CC Attribution 4.0 許可證提供各種行星紋理的網站,因此需要提供相應的版權信息。如果您將網站上線,請務必在頁面上的某個位置顯示這些版權信息。
隨後,在 Three.js 中加載貼圖,並將其賦值為場景背景。
同時可以通過 backgroundIntensity 調整背景亮度,使整體層次更柔和。
this.scene.background = this.resources.items.spaceTexture
this.scene.backgroundIntensity = 0.25

這一部分構成了整個 Hero Section 的核心視覺焦點。我們通過 Points 系統創建了包含星星和中心星環的大量粒子的螺旋星系。
乍一看,當前的實現方式似乎可以分為兩個部分,“中心的粒子圓環和外層的粒子群”,以及周圍緩慢旋轉的粒子群對嗎?但是仔細看“中心的粒子圓環和外層的粒子群”,似乎並沒有那麼強的割裂感,反而更像是粒子圓環帶動了整個粒子群一同旋轉,對吧?這是怎麼做到的?
其實這只是個非常巧妙的視覺錯覺,有點像小時候玩的萬花筒。如下圖
當我們放開控制器、稍微調整視角時,就會發現這個結構實際上只是個簡單的圓柱體。
通過透視營造出“浩瀚星海”的假象。
於是,現在你大概已經能想到幾種實現方式了:
MeshSurfaceSampler在多個圓柱體表面使用對幾何體平面進行採樣,以構建粒子位置;BufferGeometry 時,利用三角函數約束頂點分佈,使所有粒子落在圓環軌跡上。下面我來分享我在項目中採用的具體思路。
可以概括為以下幾個步驟:
在 setGalaxy() 方法中,我們為每個粒子生成位置數據:
// 生成在環形區域內的半徑
const minRadius = this.parameters.innerRadius * this.parameters.radius
const maxRadius = this.parameters.radius
const radius = minRadius + Math.random() * (maxRadius - minRadius)
// 計算螺旋角度和分支角度
const spinAngle = radius * this.parameters.spin
const branchAngle = (i % this.parameters.branches) / this.parameters.branches * Math.PI * 2
// 生成基礎位置
const x = Math.cos(branchAngle + spinAngle) * radius
const z = Math.sin(branchAngle + spinAngle) * radius
這裡的關鍵是:
spinAngle:根據半徑和旋轉參數 spin 計算螺旋角度,離中心越遠,旋轉角度越大branchAngle:將粒子均勻分佈到多個分支上,形成星系的旋臂結構為了讓粒子呈現出星環的效果,我們使用了圓環約束算法:
// 計算距離圓環中心的歸一化距離(0-1)
const ringCenter = (minRadius + maxRadius) * 0.5
const ringWidth = maxRadius - minRadius
const distanceToRingCenter = Math.abs(radius - ringCenter) / (ringWidth * 0.5)
// 使用帽形函數(反向拋物線)來約束隨機擾動
const ringConstraint = (1.0 - distanceToRingCenter ** this.parameters.ringFalloff) * this.parameters.constraintStrength
const effectiveRandomness = this.parameters.randomness * ringConstraint
這個算法的核心思想是:
ringFalloff 參數控制衰減曲線,constraintStrength 控制整體約束強度這樣就能形成一條清晰的星環帶,而不是均勻分佈的粒子。
為了增加星系的自然感,我們為每個粒子的Y軸添加了隨機偏移:
const randomY = effectiveRandomness * radius * (Math.random() < 0.5 ? 1 : -0.4) * Math.random() ** this.parameters.randomnessPower * 20
這裡可以看到我對於擾動約束存在兩種細分情況 Math.random() < 0.5 ? 1 : -0.4
可以理解為粒子 Y軸正向偏移時隨機偏移效果為 100%,負向偏移時隨機效果只有 40%
當然也可以把這個效果去掉,當前效果會變為下圖:
或許你有更大膽的創意?在中心部分放上一些特殊的星空元素?比如黑洞,或者一隻巨大的“外星之眼” 👁️讓整個場景更具神秘感。
到目前為止,我們已經定義了粒子在空間中的分佈關係,也確定了它們在星環結構中的約束邏輯。接下來要做的,就是讓這些粒子 動起來 —— 讓星環旋轉,從而帶動整個星系流動。
在這裡我們通過 頂點著色器(vertex shader) 來實現粒子的動態旋轉。相比在 JavaScript 層更新所有粒子坐標,使用 GPU 端的位移計算可以大幅降低性能消耗,並讓動畫更流暢。
核心邏輯是基於 粒子到中心的距離衰減旋轉速度:
這樣能讓整個星環在旋轉時呈現一種“內圈快、外圈慢”的自然渦旋感。
float angle = atan(modelPosition.x, modelPosition.z);
float distanceToCenter = length(modelPosition.y);
float offset = (1.0 / distanceToCenter) * uTime * 0.2;
angle += offset;
// 更新位置
modelPosition.x = cos(angle);
modelPosition.z = sin(angle);
計算到中心的距離:
distanceToCenter = length(modelPosition.y); 用於控制旋轉的衰減速率。
如果旋轉速度變化過大,可以通過以下方式做平滑限制:
distanceToCenter = clamp(distanceToCenter, 0.0, 10.0);
讓距離過近或過遠的粒子保持在合理的旋轉速率範圍內。
最終呈現出的效果,就是一個不斷旋轉、充滿空間層次感的 銀河渦旋:
這一部分是整個頁面中最具表現力的視覺元素 —— 三顆漂浮在太空中的星球。

而對於如何在 Threejs裡面顯示一顆星球,我想已經有太多技術文章來描述這一過程了。這裡我也推薦你去看 Threejs jounery 中 Earth Shader 章節。(這裡 B 站鏈接,但是有條件還是建議補補票,作者會在每個聖誕節給 discount )
這裡我主要描述當前頁面的星球實現與其餘教程的不同點。
星球渲染包含幾個關鍵部分:
放大星球可以看到表面存在明顯的地形起伏,這通常會讓人想到 MeshStandardMaterial 的 bumpMap 或 displacementMap。
但在本項目中,我並沒有直接使用標準物理材質,而是採用了 ShaderMaterial,並在頂點著色器中根據 法線貼圖 自行實現位移和光照控制。
這麼做的原因是為了後續自定義光照系統做鋪墊。
當然條條大路通羅馬,你也可以通過
這裡僅提供我的實現方案

首先我們先創建一個頂點足夠多的球體,再簡單的創建ShaderMaterial以及應用baseColor到球體上後有以下幾點需要注意
vertexShader假如沒有分配足夠多的頂點則會導致地形呈現“方塊感”:// 創建星球幾何體(增加細分以支持置換)
this.geometry = new THREE.IcosahedronGeometry(this.radius, 64, 64)
較少頂點情況:

在頂點著色器中,我們使用置換貼圖來改變頂點的位置,形成地形起伏:
float displacement = texture2D(uDisplacementMap, uv).r; // uniform 傳入 網站上下載好的貼圖即可
// 沿法線方向偏移頂點位置
vec3 displacedPosition = position + normal * displacement * uDisplacementScale;
uDisplacementScale 控制置換強度,數值越大,地形起伏越明顯。
現在你應該擁有了一個凹凸不平的星球
片元著色器負責計算最終的顏色,包含以下幾個關鍵步驟:
1. 法線貼圖處理
我們實現了完整的 TBN(切線-副切線-法線)矩陣計算,將法線貼圖從切線空間轉換到世界空間:
vec3 perturbNormal(vec3 normal, vec3 position, vec2 uv, sampler2D normalMap, float normalScale) {
  // 獲取法線貼圖值並轉換到 -1 到 1 範圍
  vec3 normalMapColor = texture2D(normalMap, uv).rgb;
  vec3 normalMapNormal = normalize(normalMapColor * 2.0 - 1.0);
  // 通過螢幕空間導數計算切線和副切線
  vec3 q1 = dFdx(position);
  vec3 q2 = dFdy(position);
  vec2 st1 = dFdx(uv);
  vec2 st2 = dFdy(uv);
  vec3 tangent = normalize(q1 * st2.t - q2 * st1.t);
  vec3 bitangent = normalize(-q1 * st2.s + q2 * st1.s);
  // 構建 TBN 矩陣
  mat3 tbn = mat3(tangent, bitangent, normal);
  // 混合原始法線和擾動後的法線
  vec3 perturbedNormal = normalize(mix(normal, tbn * normalMapNormal, normalScale));
  return perturbedNormal;
}
2. 環境光計算
環境光提供基礎照明,讓陰影區域也有可見度:
vec3 ambient = uAmbientLight * textureColor * uAmbientLightIntensity;
3. 點光源計算
點光源使用蘭伯特漫反射模型,並包含距離衰減:
// 計算光源方向
vec3 lightDirection = uPointLightPosition - vPosition;
float distance = length(lightDirection);
lightDirection = normalize(lightDirection);
// 距離平方衰減(避免近距離過度曝光)
float attenuation = 1.0 / (1.0 + 0.09 * distance + 0.032 * distance * distance);
// 蘭伯特漫反射
float lightIntensity = max(dot(normal, lightDirection), 0.0);
vec3 diffuse = uPointLightColor * textureColor * lightIntensity * uPointLightIntensity * attenuation;
4. 鏡面反射
我們還實現了簡單的鏡面高光,模擬金屬表面的反射:
// 菲涅爾效應
vec3 viewDirection = normalize(cameraPosition - vPosition);
float fresnel = pow(1.0 - max(dot(normal, viewDirection), 0.0), 2.0);
// 金屬度和粗糙度影響
vec3 metallic = mix(textureColor, vec3(1.0), uMetalness);
float roughnessFactor = 1.0 - uRoughness;
// 鏡面反射
vec3 reflectDirection = reflect(-lightDirection, normal);
float specular = pow(max(dot(viewDirection, reflectDirection), 0.0), 4.0 * roughnessFactor);
vec3 specularColor = uPointLightColor * specular * metallic * fresnel * attenuation;
最終顏色由環境光、漫反射和鏡面反射組合而成:
vec3 finalColor = ambient + diffuse + specularColor * 0.3;
效果如下:
這是一套很經典的著色模型(Blinn-Phong Reflectance Model),當然你也可以從我之前推薦的 games101 的第 7 節 第39 分開始看到相關內容。
放大後中央模糊?別急!這是採樣角度導致的走樣問題 📉

這看齊來是真的挺醜的,接下來我們需要設定一個特殊的texture屬性值來解決它。——Anisotropic Filtering各向異性過濾
聽起來有點熟悉對不對?
很多人在做比較大的場景時,會將地板貼圖的各向異性過濾值調高,因為這樣可以讓"讓貼圖在遠處或傾斜的角度下依然保持清晰,不會變糊"。

但為什麼會出現這種走樣的情況呢?
原因在於當三維表面與攝像機夾角較大時,螢幕上壹個像素在紋理空間中的採樣區域會被透視投影拉伸,形成壹個長條或椭圓形的採樣足跡。傳統過濾算法假設採樣區域為正方形,因此在這種情況下容易出現模糊或走樣。(這裡同樣也推薦您去看 games101 圖形學入門課程中對於走樣和採樣相關章節)