本文主要從以下幾個方面進行講述:
建立沒有光照效果的立方體;擴充套件lambert材質,建立有光照效果的立方體;適用人群:對THREE.js和glsl有基本瞭解的人。
建立沒有光照效果的立方體本示例會建立一個前後左右面是純色,上下面是貼圖的立方體。該部分的內容主要包括以下部分:
建立bufferGeometry;自定義shaderMaterial,在shaderMaterial裡面判斷是用純色還是貼圖;建立mesh。建立bufferGeometry因為想更深入的瞭解THREE.js的實現原理,所以這塊沒有直接使用BoxBufferGeometry,而是自己定義頂點資訊:
const geometry = new THREE.BufferGeometry()const position = [ // 每個面兩個三角形,每個三角形三個頂點,每個頂點三個座標值,所以一個三角形是3*3=9個值,一個面是3*3*2=18個值 -1, -1, 1, 1, -1, 1, 1, 1, 1, // front face 1, 1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1, 1, -1, -1, 1, 1, -1, // right face 1, 1, -1, 1, 1, 1, 1, -1, 1, 1, -1, -1, -1, -1, -1, -1, 1, -1, // back face -1, 1, -1, 1, 1, -1, 1, -1, -1, -1, -1, -1, -1, -1, 1, -1, 1, 1, // left face -1, 1, 1, -1, 1, -1, -1, -1, -1, -1, 1, 1, 1, 1, 1, 1, 1, -1, // top face 1, 1, -1, -1, 1, -1, -1, 1, 1, 1, -1, 1, -1, -1, 1, -1, -1, -1, // bottom face -1, -1, -1, 1, -1, -1, 1, -1, 1]// 定義了一個長寬高都是2的立方體,所以上面xyz的座標要麼是1,要麼是-1geometry.setAttribute('position', new THREE.BufferAttribute(Float32Array.from(position), 3))
然後,給每個頂點新增顏色資訊,每個頂點既可以是純色也可以是貼圖,純色需要rgb三個分量,貼圖需要uv兩個分量,所以每個頂點至少需要三個分量來表示。
那麼,如何判斷這個頂點是純色還是貼圖呢?我們當然可以再使用一個數組來表示。但是注意到上面貼圖只需要兩個分量,那麼我們就可以利用第三個分量來判斷。glsl語言裡面rgb色值的範圍是0-1,所以我們可以使用這個範圍之外的值表示這是一個貼圖。
那取什麼值呢?我們這個立方體定義了上下面是貼圖,也就是貼圖不只一個,那麼這個值還要能推匯出是第幾個貼圖。我這裡設定了一個textureBaseIndex為2的變數。
const colors = []const textureBaseIndex = 2for (let i = 0; i < 12; i++) { switch (i) { case 0: // front color case 1: colors.push(1, 0, 0, 1, 0, 0, 1, 0, 0) // 紅 break case 2: // right color case 3: colors.push(0, 1, 0, 0, 1, 0, 0, 1, 0) // 綠 break case 4: // back color case 5: colors.push(0, 0, 1, 0, 0, 1, 0, 0, 1) // 藍 break; case 6: // left color case 7: colors.push(1, 1, 0, 1, 1, 0, 1, 1, 0) // 黃 break case 8: // top texture uv,前兩個分量表示uv,第三個分量表示取第幾個紋理,在紋理實際索引值的基礎上加上textureBaseIndex colors.push(0, 0, textureBaseIndex + 0, 1, 0, textureBaseIndex + 0, 1, 1, textureBaseIndex + 0) break case 9: colors.push(1, 1, textureBaseIndex + 0, 0, 1, textureBaseIndex + 0, 0, 0, textureBaseIndex + 0) break case 10: // bottom texture uv,前兩個分量表示uv,第三個分量表示取第幾個紋理,在紋理實際索引值的基礎上加上textureBaseIndex colors.push(1, 1, textureBaseIndex + 1, 0, 1, textureBaseIndex + 1, 0, 0, textureBaseIndex + 1) break case 11: colors.push(0, 0, textureBaseIndex + 1, 1, 0, textureBaseIndex + 1, 1, 1, textureBaseIndex + 1) break }}geometry.setAttribute('color', new THREE.BufferAttribute(Float32Array.from(colors), 3))
自定義shanderMaterial頂點著色器的程式碼比較簡單,把color屬性透過varying變數vColor傳給片元著色器:
function getVertexShader () { return ` attribute vec3 color; varying vec3 vColor; void main () { vColor = color; gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); } `}
接下來是片元著色器,主要有以下幾點:
透過vColor.z判斷是純色還是貼圖;把貼圖資訊透過sampler2D陣列傳入,然後在根據vColor.z獲取陣列下標的時候,前面在生成下標的時候加了一個textureBaseIndex,所以用的時候得先減去;透過下標獲取sampler2D陣列中的某一項的時候,不能直接使用textures[index],glsl要求[]裡面的內容必須是Integral constant expression,所以使用了一個generateSwitch函式動態生成一系列if程式碼;完整程式碼如下:
function getFragmentShader (textureLength, textureBaseIndex) { function generateSwitch () { let str = '' for (let i = 0; i < textureLength; i++) { str += `${str.length ? 'else' : ''} if (index == ${i}) { gl_FragColor = texture2D(textures[${i}], vec2(vColor.x, vColor.y)); } ` } return str } return ` ${textureLength ? ` uniform sampler2D textures[${textureLength}]; ` : ''} varying vec3 vColor; void main () { ${textureLength ? ` if (vColor.z <= 1.0) { gl_FragColor = vec4(vColor, 1.0); } else { int index = int(vColor.z) - ${textureBaseIndex}; ${generateSwitch()} }` : ` gl_FragColor = vec4(vColor, 1.0); ` } } `}
生成自定義材質:
const textures = [ new THREE.TextureLoader().load('./textures/colors.png'), // 頂面貼圖 new THREE.TextureLoader().load('./textures/colors.png') // 底面貼圖]const material = new THREE.ShaderMaterial({ uniforms: { textures: { value: textures } // 片元著色器中會使用 }, vertexShader: getVertexShader(), fragmentShader: getFragmentShader(textures.length, textureBaseIndex)})
建立mesh
這步就比較簡單了,建立一個mesh,並新增到場景中:
const mesh = new THREE.Mesh(geometry, material)scene.add(mesh)
這樣,立方體就建立好了。本例使用了基本的WebGLRenderer,Scene,PerspectiveCamera,沒有特殊處理,這裡就不再寫了。實現效果截圖如下:front/right/top面效果截圖
back/left/bottom面效果截圖
擴充套件lambert材質,建立有光照效果的立方體我的實際應用場景中的物體是lambert材質,也就是MeshLambertMaterial。所以,下面的例項程式碼以擴充套件lamert材質的光照效果為例。要想使用該實現方案,最好研究下THREE.js的原始碼。
THREE.js裡面預先定義了一系列材質,MeshLambertMaterial材質就是其中之一。這部分程式碼在src/renderers/shaders資料夾下面,ShaderLib.js裡面是材質的入口,比如MeshLambertMaterial:
const ShaderLib = { lambert: { uniforms: mergeUniforms( [ // uniform變數 UniformsLib.common, UniformsLib.specularmap, UniformsLib.envmap, UniformsLib.aomap, UniformsLib.lightmap, UniformsLib.emissivemap, UniformsLib.fog, UniformsLib.lights, { emissive: { value: new Color( 0x000000 ) } } ] ), vertexShader: ShaderChunk.meshlambert_vert, // 頂點著色器程式碼 fragmentShader: ShaderChunk.meshlambert_frag // 片元著色器程式碼 },}
ShaderChunk和ShaderLib資料夾下面就是實際的著色器程式碼,區別是ShaderLib是THREE.js給我們直接使用的,ShaderChunk是更細粒度的程式碼。ShderLib裡面的不同材質有很多共有的程式碼,所以這個共有的程式碼就提取成一個個ShaderChunk,達到複用的目的。一個材質是由多個ShaderChunk生成的。我們可以開啟ShaderLib/meshlambert_vert.glsl.js檔案,會發現裡面有很多#include語句,這些語句最後會被替換為實際的ShaderChunk裡面的片段。
我們看到shaders資料夾下面只是定義了材質的結構以及glsl程式碼片段,那麼,完整效果的程式碼是在哪生成的呢?src/renderers/webgl/WebGLProgram.js檔案。
列一下這個檔案我瞭解的一些知識點:
首先根據我們建立材質時的引數,定義一些#define變數,新增在著色器程式碼的前面;解析ShaderLib裡面的程式碼,把#include語句替換為實際程式碼,參見resolveIncludes函式;更重要的是,ShaderLib裡面預定義的一些材質,掛在了THREE變數上,這樣我們就可以獲得原始程式碼,並透過修改部分glsl程式碼達到擴充套件材質的目的。
比如,上面的那個例子,首先改造一下頂點著色器:
在預設的lambert頂點著色器程式碼前面新增屬性變數和varying變數;在main函數里面給varying變數賦值;具體插在原始main函式的哪一行看你的需求;function getVertexShader () { let shader = ` attribute vec3 color; varying vec3 vColor; ` + THREE.ShaderLib.lambert.vertexShader const index = shader.indexOf('#include <uv_vertex>') shader = shader.slice(0, index) + ` vColor = color; ` + shader.slice(index) return shader}
片元著色器的改造如下:
在預設的lambert片元著色器程式碼前面新增uniform變數和varying變數;在main函數里面插入我們的程式碼,插入位置我選在了#include <color_fragment>後面,因為這個程式碼片段和我現在的修改做了類似的事情,所以插在這個位置是可以的。注意,此時就不是直接給gl_FragColor賦值了,而是把效果加在diffuseColor變數上。實際開發的時候,具體修改哪個值就得參考THREE.js原始碼了。function getFragmentShader (textureLength, textureBaseIndex) { function generateSwitch () { let str = '' for (let i = 0; i < textureLength; i++) { str += `${str.length ? 'else' : ''} if (index == ${i}) { diffuseColor *= texture2D(textures[${i}], vec2(vColor.x, vColor.y)); } ` } return str } let shader = ` uniform sampler2D textures[${textureLength}]; varying vec3 vColor; ` + THREE.ShaderLib.lambert.fragmentShader const index = shader.indexOf('#include <color_fragment>') shader = shader.slice(0, index) + ` ${textureLength ? ` if (vColor.z <= 1.0) { diffuseColor.rgb *= vColor; } else { int index = int(vColor.z) - ${textureBaseIndex}; ${generateSwitch()} }` : ` diffuseColor.rgb *= vColor; ` } ` + shader.slice(index) return shader}
然後,建立著色器:
修改一下uniform變數,把lambert預設的uniform變數也新增進去;新增lights引數為true,否則程式碼報錯;THREE原始碼預設diffuse是0xeeeeee,覆蓋一下,修改為0xffffff;const material = new THREE.ShaderMaterial({ uniforms: THREE.UniformsUtils.merge([ THREE.ShaderLib.lambert.uniforms, { textures: { value: textures } }, { diffuse: { value: new THREE.Color(0xffffff) } } ]), vertexShader: getVertexShader(), fragmentShader: getFragmentShader(textures.length, textureBaseIndex), lights: true})
這個時候重新整理頁面,會發現是一個黑色的立方體,這是因為我們還沒有新增光源:
const light = new THREE.DirectionalLight( 0xffffff ); // 平行光light.position.set( 1, 1, 1 );scene.add( light );const ambient = new THREE.AmbientLight(0xffffff, 0.7); // 環境光scene.add(ambient)
之所以新增兩個光源是因為發現:
環境光不受幾何物體法線影響;平行光受幾何物體法線影響;新增上述程式碼後,如果把環境光註釋掉,會發現材質還是黑色的,這是因為上面建立的geometry沒有法線資訊,所以需要使用下面的方法新增一下法線資訊:
geometry.computeVertexNormals()
最終效果截圖如下:front/right/top面效果截圖,同時受平行光和環境光影響
back/left/bottom面效果截圖,不在平行光照射範圍內,只受環境光影響