陰影集合了3D視覺特效中相當核心、基礎、深奧的一面。它很好實作但又不好抓Bug,且時不時出現「怪怪的」地方。本篇將介紹陰影,他的強大就像鹿丸的影子模仿數一樣。閱讀本篇之後,你將成為網頁視覺特效的鹿丸!
本篇透過3D模型介紹陰影。如此一來,我們可以得到3D模型的作品,也能同時釐清陰影的實作以及其原理。
本篇介紹:
import * as THREE from 'three';
import { OrbitControls } from 'https://unpkg.com/three@latest/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'https://unpkg.com/three@latest/examples/jsm/loaders/GLTFLoader';
import { RectAreaLightHelper } from 'https://unpkg.com/three@latest/examples/jsm/helpers/RectAreaLightHelper.js';
import { RectAreaLightUniformsLib } from 'https://unpkg.com/three@latest/examples/jsm/lights/RectAreaLightUniformsLib.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(20, window.innerWidth / window.innerHeight, 0.1, 100);
camera.zoom = 0.4
camera.updateProjectionMatrix();
camera.position.set(5, 5, 10)
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
renderer.toneMapping = THREE.ACESFilmicToneMapping
const sphereGeometry = new THREE.SphereGeometry(50, 30, 30)
const planeMaterial = new THREE.MeshStandardMaterial({ side: THREE.BackSide, color: 0xcceeff })
const sphere = new THREE.Mesh(sphereGeometry, planeMaterial)
sphere.position.set(0, 0, 0)
scene.add(sphere)
new GLTFLoader().load('https://storage.googleapis.com/umas_public_assets/michaelBay/file.gltf', gltf => {
gltf.scene.traverse(object => {
if (object.isMesh) {
object.material.roughness = 1
object.material.metalness = 0
object.material.transparent = false
}
})
scene.add(gltf.scene)
})
// 聚光燈
const addSpotLight = () => {
const spotLight = new THREE.SpotLight(0xffffff, 1);
spotLight.position.set(3, 3, 0);
scene.add(spotLight);
}
// 新增環境光
const addAmbientLight = () => {
const light = new THREE.AmbientLight(0xffffff, 1)
scene.add(light)
}
const control = new OrbitControls(camera, renderer.domElement);
control.target.set(0, 2, 3)
control.update()
addSpotLight()
addAmbientLight()
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
https://codepen.io/umas-sunavan/pen/ZEoRWov?editors=1010
基本上,整個陰影的實作只要做三件事即可:
這三個缺一不可。我們從第一個「在renderer開啟shadow功能。」開始:
const renderer = new THREE.WebGLRenderer({ antialias: true });
// 加上這行程式碼
renderer.shadowMap.enabled = true
先找到光,然後將其屬性castShadow
設定成true
,即可蒙上陰影到物件
// 聚光燈
const addSpotLight = () => {
const spotLight = new THREE.SpotLight(0xffffff, 1);
// 加上這行程式碼來蒙上陰影到物件
spotLight.castShadow = true;
}
為什麼蒙上了陰影卻沒有任何陰影跑出來?因為我們還需要設定物件能產生陰影。繼續看:
receiveShadow
可以產生陰影,加它在你要的Mesh
上面,即可生效。
// 載入的物件
new GLTFLoader().load('https://storage.googleapis.com/umas_public_assets/michaelBay/file.gltf', gltf => {
// 遞迴模型場景中每一個3D的物件
gltf.scene.traverse(object => {
if (object.isMesh) {
object.material.roughness = 1
object.material.metalness = 0
object.material.transparent = false
// 加上這行程式碼能蒙上陰影到物件
object.castShadow = true
// 加上這行程式碼產生陰影
object.receiveShadow = true
}
})
scene.add(gltf.scene)
})
由於物體本身可能產生陰影,蒙在自己本身,所以也需要加上castShadow
。
這樣就完成了!原來那麼簡單嗎?
https://codepen.io/umas-sunavan/pen/ExLRKLp?editors=1010
依照經驗,陰影其實相當麻煩,很容易出現各式各樣「怪怪的東西」,如果不熟悉陰影的底層,那很難找到問題。身為前端視覺特效工程師,熟悉底層的運作邏輯就變得相當重要。
舉例來說:
為什麼高度差不多的Mesh會有髒髒的表面?
為什麼騎樓的柱子有奇怪的鋸齒狀?
為什麼地板破圖(shadow acne)?
為什麼會Mesh跟陰影相黏的地方會漏光?
為什麼陰影會一格一格的?而且那麼粗糙?(Shadows Leaks或稱Light Leak,目前所查稱呼不明確)
為什麼陰影呈現鋸齒狀?(陰影渲染演算法問題)
以上情況相當頻繁,如果我們只會開關陰影的話,會很難解決這些問題。
以下我從原理解釋陰影,然後帶到這些問題的解決方法。
如果從未有人實作過陰影,而你就是要在發明陰影技術的人,你該怎麼找出影子呢?
如果你回顧光那篇,你可能還會記得之所以會有亮度,是因為每一個面都在計算自己的法線單位向量跟其向光的單位向量是否多接近。
在這個過程中,在物體上的每一個面都會跑一遍。你會想說:既然面都會找光源向量了,那就看它向光的過程中,檢查是否有遇到其他物件就好了!
圖片中,我們的物體被熊熊擋住了,所以向量光沒有到達光源,那麼沒到達光的面,就是陰影。這樣的方式就邏輯就可以把陰影找出來。
但是,我要怎麼知道熊熊擋在途中?如果熊熊前面還有一個猴猴,那到底要先算猴猴還是先算熊熊?
這樣的順序是個很大的議題。而發明陰影技術的人很聰明,他直接在光源的位置,幫受光的範圍拍一張照片。
拍了一張照之後,就可以知道光源打到的第一層「表面」在哪些地方。以上面的圖來說,熊熊就被光源打到,熊熊背後的物體就沒有被光源打到。
光打到「表面」之後,就計算它跟表面的距離有多遠。在0~1的範圍計算數值。離光源越接近的表面其數值越小,越遠的表面其數值最大。
就像下圖,如果把0~1範圍變成0~255的灰階時,就可以得到一張照片。我們可以看到,最接近的方塊最黑,數值大約在0.1,最遠的天空,數值大約為1。
這張材質圖看起來像起霧一樣,只有畫面深度的差別。所以就被命名為深度材質(depth texture),每一個像素上的灰階數值,就被稱作「深度(depth)」了。這張照片將以材質圖存起來給下一步使用。
剛剛提到,雖然最遠的是天空,數值為1,但不會是無限遠。回顧camera種類,主要有兩種:PerspectiveCamera
、OrthographicCamera
,都有near
跟far
,而這成為了0~1的上下界範圍。near
為0,far
為1。
拍了一張材質圖之後,就是shader的工作。我們再提一次:WebGL有program,program包含vertexShader以及fragmentShader,前者每一個錨點執行一次(單純的立方體就執行八次),將所需要的資料傳送給fragmentShader。fragmentShader每一個像素執行一次,將RGB資料傳送到螢幕上,一幀可能會執行兩百萬次(1920*1280的話)。
在fragmentShader每一個像素執行一次時,WebGL就一手捧著這張我們先前拍到的深度材質圖,一手檢查每一個要渲染的像素位在哪一個物體上。
我們以下圖來說好了,假設熊熊的背後有一個像素要確認自己是不是一個陰影,像素要渲染的位置在紫色的箭頭指的地方。
我們先暫時把材質圖放著不管,我們需要先計算該像素的「深度」。深度的計算方法,就是計算它離光源有多遠。
假設他離光源的距離是0.5好了。接下來我們打開另一隻手捧的深度材質,觀察該像素的位置在材質圖上的深度有多少。
前面我們提到深度材質,就是光打下來照到的第一層表面。下圖是深度材質圖示意:
以我們舉例的像素來說,就是熊熊的皮膚表面,也就是0.1。
於是fragmentShader察覺到該像素數值是0.5,而深度材質的數值是0.1。相比之下,就知道0.5比0.1還要遠,那麼它就是陰影。
這就是陰影產生的原理。有了這個原理,WebGL目前為止不必去搞清楚物件的順序也可以產生陰影,渲染的效率也可以非常快速。
castShadow
跟receiveShadow
由來這也是為什麼,three.js會提供每一個Mesh
物件兩個屬性:castShadow
跟receiveShadow
,因為castShadow
用來確定該物件要渲染在深度材質上。
而receiveShadow
代表所有位於物件上的像素是否要比對深度材質中的深度,確定自己為陰影之後透過陰影公式計算是否要蒙上陰影。
所以,以後當有人問你問題,你就很好回答了:
receiveShadow
,效能會不會增加?
receiveShadow
不開,效能會不會增加?
雖然釐清了原理,但我們還沒有破解渲染問題。
我們先切入一個問題點來帶出原理:為什麼高度差不多的Mesh會有髒髒的表面?
那些髒髒的東西是陰影。但為什麼會有髒髒的東西呢?
我們回顧一下剛剛所說的,WebGL一手捧著深度材質圖,一手計算像素的深度。一個是材質圖,一個是像素。
身為材質圖,它是有大小的,以下面這張圖來說,它可能256x256,也可能512x512,也可能2048x2048。
它一旦拍攝下來,投影到物件上面,往往都會比fragmentShader的解析度還要差。
深度材質圖很少比fragmentShader計算的解析度好的,因為fragmentShader是螢幕像素一顆一顆渲染,但它深度材質圖它有固定大小。下圖你就可以依稀看到該陰影身為材質圖,陰影會出現的鋸齒狀。
所以說:webGL一手捧的深度材質圖解析度很差,另一手計算的像素解析度好。這兩種合在一起計算,就一定會有誤差。
以下圖來說,假設一顆球照在平面上。而且陰影的材質圖奇低,只有十像素寬。這時照下來的陰影解析度就非常低。
圖中有一個紅色的深度材質,如果在這個平面下包含10個像素,那麼這十個像素所用的材質深度都是同一個數值。髒髒的就是因為這個問題造成的。
延伸上一個問題,它不僅出現在兩個物體上,還可能出現在同一個物體上,甚至同一個平面上。
用前一張圖,把球拿掉之後,用力放大看。假設黑白點點為像素大小,而有一個深度材質(綠色)照在平面。這時候,有些黑白點(代表螢幕上所呈現的每顆像素)位在材質圖比較前面的位置,計算深度之後比深度材質的0.5還要小,有些則比較大。這時候就會產生黑白黑白的狀況。
換句話說,一個像素的材質圖區域範圍內(綠色箭頭所指),可能有六個像素要渲染到顯示器,其中有三個像素比較深度之後,被誤認成是陰影了。
這也解釋為什麼柱子有奇怪的鋸齒狀,地板會破圖。
而這個現象有一個名稱,那就是Shadow Acne。
為了解決上述問題,出現了Bias修正功能,只要減去一點點數值,就可以修正上述問題。
// light可以是PointLightShadow、DirectionalLightShadow或SpotLightShadow
light.shadow.bias = -0.0001
下面是內縮的效果:
它使用特別的公式,使得材質圖可以用特別的方式內縮。
這個方法雖然可以解決問題,然而如果Bias數值給太多,會導致陰影跟物體相連的地方分離,使得畫面上看起來被裁減了一樣。而這也導致下圖陰影出現漏光的現象。
由於篇幅問題,我們不討論公式推導,如果有興趣可以查看這篇文章。
到目前為止,我們只解釋到最基本的陰影技術,人稱「Shadow Map」。
renderer.shadowMap.type = THREE.BasicShadowMap
事實上還有很多種陰影技術,例如PCF Shadow:
這是three.js預設的陰影渲染方式。簡單來說,就是取樣周圍的像素,得到一個平均值。藉此,陰影的邊界不會那麼模糊。
renderer.shadowMap.type = THREE.PCFShadowMap
而這有特別的公式:
因為篇幅關係,如果有興趣的人可以前往查看文章
基於PCF公式,所使用的更模糊版本。
renderer.shadowMap.type = THREE.PCFSoftShadowMap
基於PCF使用數學公式計算出來的另一種陰影處理。
renderer.shadowMap.type = THREE.VSMShadowMap
因為篇幅關係,如果有興趣的人可以前往查看文章
上面有提到,每一個光源都會拍一張照,代表深度材質圖。
事實上,重要的光源物件都會有光源陰影物件,裡面又有鏡頭專門拍照。
Light
→ LightShadow
→ Camera
而這架構存在於DirectionalLight
、PointLight
、SpotLight
。
拍照這件事情可就複雜了。如果還記得「Day15: three.js 前端3D視覺特效開發實戰——3D儀表板:圓餅圖」那篇,你可能還記得我們還分成兩種主要的鏡頭:
那到底不同的光是用什麼鏡頭呢?
平行光的Shadow平行光需設定投影範圍,它是OrthographicCamera
使用perspectiveCamera捕捉陰影
使用perspectiveCamera捕捉陰影
看上去點光跟聚光燈一樣都是用perspectiveCamera去處理。但問題是:身為一個點光,其照射的範圍是360度的。但是其內部的鏡頭如果要照射360度的深度材質,那麼解析度會相當差。
為了解決這個問題,PointLightShadow
使用了六個PerspectiveCamera
,上下左右前後各照一張,當自己是google街景車一樣。
所以說,使用PointLight需要照射的材質圖,相當於可以抵上六個DirectionalLight。如果要考慮效能的話,那點光就是必須慎重使用的光源。
本篇是我跌跌撞撞的開發經驗,加上參考其他文章彙整出來有關陰影的技術問題。
事實上,內容還不只這樣,我們還沒有提到渲染技術、不同的貼圖取樣方式影響材質圖以及Frustum的概念、Depth Buffer、Depth Testing等等相當多的內容。為了留接下來這幾天的篇幅給Shader,我就點到為止,如果有興趣的人可以繼續研究。