iT邦幫忙

2022 iThome 鐵人賽

DAY 18
0
Software Development

30天成為鍵盤麥可貝:前端視覺特效開發實戰系列 第 18

Day18: three.js GIS系統開發實戰:成為網頁特效的鹿丸!影子模仿術:陰影的終極原理

  • 分享至 

  • xImage
  •  

陰影集合了3D視覺特效中相當核心、基礎、深奧的一面。它很好實作但又不好抓Bug,且時不時出現「怪怪的」地方。本篇將介紹陰影,他的強大就像鹿丸的影子模仿數一樣。閱讀本篇之後,你將成為網頁視覺特效的鹿丸!

https://ithelp.ithome.com.tw/upload/images/20221017/201425059KhFml9pjQ.png
圖片來源

本篇透過3D模型介紹陰影。如此一來,我們可以得到3D模型的作品,也能同時釐清陰影的實作以及其原理。

本篇介紹:

  1. 陰影的實作
  2. 陰影的本質
  3. 渲染問題解決方法
  4. Self-Shadow Aliasing問題
  5. Bias修正
  6. 各種光源所產生的陰影差異

陰影的實作

陰影的實作:準備程式碼

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();

CodePen

https://ithelp.ithome.com.tw/upload/images/20221003/20142505opA3i3mVbA.png

https://codepen.io/umas-sunavan/pen/ZEoRWov?editors=1010

陰影的實作:開啟Shader渲染

基本上,整個陰影的實作只要做三件事即可:

  1. 在renderer開啟shadow功能。
  2. 光能在物件上蒙上陰影
  3. 物體可以產生陰影(且同時還能在物件上蒙上陰影)

這三個缺一不可。我們從第一個「在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://ithelp.ithome.com.tw/upload/images/20221003/201425052NvHuAhI6o.png

這樣就完成了!原來那麼簡單嗎?

https://codepen.io/umas-sunavan/pen/ExLRKLp?editors=1010

依照經驗,陰影其實相當麻煩,很容易出現各式各樣「怪怪的東西」,如果不熟悉陰影的底層,那很難找到問題。身為前端視覺特效工程師,熟悉底層的運作邏輯就變得相當重要。

陰影的實作:渲染問題

舉例來說:

  1. 為什麼高度差不多的Mesh會有髒髒的表面?

    https://ithelp.ithome.com.tw/upload/images/20221003/20142505HBCM395pDZ.png

  2. 為什麼騎樓的柱子有奇怪的鋸齒狀?

    https://ithelp.ithome.com.tw/upload/images/20221003/20142505cvATuFPkto.png

  3. 為什麼地板破圖(shadow acne)?

    https://ithelp.ithome.com.tw/upload/images/20221003/20142505elxAq3MBSm.png

    圖片來源

  4. 為什麼會Mesh跟陰影相黏的地方會漏光?

    https://ithelp.ithome.com.tw/upload/images/20221004/20142505L9gvRuaSjT.png

  5. 為什麼陰影會一格一格的?而且那麼粗糙?(Shadows Leaks或稱Light Leak,目前所查稱呼不明確)

    https://ithelp.ithome.com.tw/upload/images/20221003/20142505G7a8xfOKKG.png

  6. 為什麼陰影呈現鋸齒狀?(陰影渲染演算法問題)

    https://ithelp.ithome.com.tw/upload/images/20221003/20142505RCll9bRTXL.png

以上情況相當頻繁,如果我們只會開關陰影的話,會很難解決這些問題。

以下我從原理解釋陰影,然後帶到這些問題的解決方法。

陰影的本質

如果從未有人實作過陰影,而你就是要在發明陰影技術的人,你該怎麼找出影子呢?

如果你回顧光那篇,你可能還會記得之所以會有亮度,是因為每一個面都在計算自己的法線單位向量跟其向光的單位向量是否多接近。

https://ithelp.ithome.com.tw/upload/images/20221003/20142505OhOoWmtI7j.png

  1. 光會投射在物體上,物體上的面都有法線
  2. 就等同於物體上有「向光向量」投向光源
  3. 向光向量跟法線向量若都轉成單位向量(長度為一單位的向量)的話
  4. 就可以計算每一個面的其兩向量的相近度。以內積計算,落在1~-1範圍內。截1~0區間作為亮度

陰影的本質:深度材質

在這個過程中,在物體上的每一個面都會跑一遍。你會想說:既然面都會找光源向量了,那就看它向光的過程中,檢查是否有遇到其他物件就好了!

https://ithelp.ithome.com.tw/upload/images/20221003/20142505pnkl6E4bR3.png

圖片中,我們的物體被熊熊擋住了,所以向量光沒有到達光源,那麼沒到達光的面,就是陰影。這樣的方式就邏輯就可以把陰影找出來。

但是,我要怎麼知道熊熊擋在途中?如果熊熊前面還有一個猴猴,那到底要先算猴猴還是先算熊熊?

這樣的順序是個很大的議題。而發明陰影技術的人很聰明,他直接在光源的位置,幫受光的範圍拍一張照片。

https://ithelp.ithome.com.tw/upload/images/20221004/20142505uCCNo3agFR.png

拍了一張照之後,就可以知道光源打到的第一層「表面」在哪些地方。以上面的圖來說,熊熊就被光源打到,熊熊背後的物體就沒有被光源打到。

光打到「表面」之後,就計算它跟表面的距離有多遠。在0~1的範圍計算數值。離光源越接近的表面其數值越小,越遠的表面其數值最大。

https://ithelp.ithome.com.tw/upload/images/20221004/20142505wLVXZ7ryxw.png

就像下圖,如果把0~1範圍變成0~255的灰階時,就可以得到一張照片。我們可以看到,最接近的方塊最黑,數值大約在0.1,最遠的天空,數值大約為1。

https://ithelp.ithome.com.tw/upload/images/20221004/201425053sSknlwddK.png
圖片來源

這張材質圖看起來像起霧一樣,只有畫面深度的差別。所以就被命名為深度材質(depth texture),每一個像素上的灰階數值,就被稱作「深度(depth)」了。這張照片將以材質圖存起來給下一步使用。

陰影的本質:深度的上下界範圍

剛剛提到,雖然最遠的是天空,數值為1,但不會是無限遠。回顧camera種類,主要有兩種:PerspectiveCameraOrthographicCamera,都有nearfar,而這成為了0~1的上下界範圍。near為0,far為1。

https://ithelp.ithome.com.tw/upload/images/20221003/20142505nkb8REtsfq.png

陰影的本質:計算陰影

拍了一張材質圖之後,就是shader的工作。我們再提一次:WebGL有program,program包含vertexShader以及fragmentShader,前者每一個錨點執行一次(單純的立方體就執行八次),將所需要的資料傳送給fragmentShader。fragmentShader每一個像素執行一次,將RGB資料傳送到螢幕上,一幀可能會執行兩百萬次(1920*1280的話)。

https://ithelp.ithome.com.tw/upload/images/20221003/20142505wxU93XSytv.png

在fragmentShader每一個像素執行一次時,WebGL就一手捧著這張我們先前拍到的深度材質圖,一手檢查每一個要渲染的像素位在哪一個物體上。

我們以下圖來說好了,假設熊熊的背後有一個像素要確認自己是不是一個陰影,像素要渲染的位置在紫色的箭頭指的地方。

https://ithelp.ithome.com.tw/upload/images/20221003/20142505wgUPqDdWoM.png

我們先暫時把材質圖放著不管,我們需要先計算該像素的「深度」。深度的計算方法,就是計算它離光源有多遠。

https://ithelp.ithome.com.tw/upload/images/20221003/20142505OdRSaMkdnF.png

假設他離光源的距離是0.5好了。接下來我們打開另一隻手捧的深度材質,觀察該像素的位置在材質圖上的深度有多少。

前面我們提到深度材質,就是光打下來照到的第一層表面。下圖是深度材質圖示意:

https://ithelp.ithome.com.tw/upload/images/20221004/20142505Ft0V7jGIAs.png
圖片來源

以我們舉例的像素來說,就是熊熊的皮膚表面,也就是0.1。

https://ithelp.ithome.com.tw/upload/images/20221003/20142505b8CpPktsWz.png

於是fragmentShader察覺到該像素數值是0.5,而深度材質的數值是0.1。相比之下,就知道0.5比0.1還要遠,那麼它就是陰影。

https://ithelp.ithome.com.tw/upload/images/20221003/20142505HrEnVmdIhg.png

這就是陰影產生的原理。有了這個原理,WebGL目前為止不必去搞清楚物件的順序也可以產生陰影,渲染的效率也可以非常快速。

陰影的本質:castShadowreceiveShadow由來

這也是為什麼,three.js會提供每一個Mesh物件兩個屬性:castShadowreceiveShadow ,因為castShadow用來確定該物件要渲染在深度材質上。

https://ithelp.ithome.com.tw/upload/images/20221003/20142505vJo8oY31y1.png

receiveShadow代表所有位於物件上的像素是否要比對深度材質中的深度,確定自己為陰影之後透過陰影公式計算是否要蒙上陰影。

https://ithelp.ithome.com.tw/upload/images/20221003/20142505Rxl7TbGeRO.png

所以,以後當有人問你問題,你就很好回答了:

  • 拿掉熊熊但保留後面的形狀的receiveShadow,效能會不會增加?
    • 幾乎不會,因為鏡頭還是要照一張材質圖,一手比對深度材質圖的數值,一手計算像素的深度。不會因為把熊熊關掉,就會省一個步驟。
  • 如果receiveShadow不開,效能會不會增加?
    • 會,雖然一手比對深度材質圖,但像素深度不用計算,所以效率會增加。

渲染問題解決方法

雖然釐清了原理,但我們還沒有破解渲染問題。

我們先切入一個問題點來帶出原理:為什麼高度差不多的Mesh會有髒髒的表面?

https://ithelp.ithome.com.tw/upload/images/20221003/20142505eD7CRmCq5f.png

那些髒髒的東西是陰影。但為什麼會有髒髒的東西呢?

我們回顧一下剛剛所說的,WebGL一手捧著深度材質圖,一手計算像素的深度。一個是材質圖,一個是像素。

身為材質圖,它是有大小的,以下面這張圖來說,它可能256x256,也可能512x512,也可能2048x2048。

https://ithelp.ithome.com.tw/upload/images/20221003/201425055iTI3ZCBiP.png

它一旦拍攝下來,投影到物件上面,往往都會比fragmentShader的解析度還要差。

深度材質圖很少比fragmentShader計算的解析度好的,因為fragmentShader是螢幕像素一顆一顆渲染,但它深度材質圖它有固定大小。下圖你就可以依稀看到該陰影身為材質圖,陰影會出現的鋸齒狀。

https://ithelp.ithome.com.tw/upload/images/20221003/20142505Jq5jLCnavy.png

所以說:webGL一手捧的深度材質圖解析度很差,另一手計算的像素解析度好。這兩種合在一起計算,就一定會有誤差。

以下圖來說,假設一顆球照在平面上。而且陰影的材質圖奇低,只有十像素寬。這時照下來的陰影解析度就非常低。

https://ithelp.ithome.com.tw/upload/images/20221003/20142505N7NSa6v3ZL.png

圖中有一個紅色的深度材質,如果在這個平面下包含10個像素,那麼這十個像素所用的材質深度都是同一個數值。髒髒的就是因為這個問題造成的。

Self-Shadow Aliasing問題

延伸上一個問題,它不僅出現在兩個物體上,還可能出現在同一個物體上,甚至同一個平面上。

用前一張圖,把球拿掉之後,用力放大看。假設黑白點點為像素大小,而有一個深度材質(綠色)照在平面。這時候,有些黑白點(代表螢幕上所呈現的每顆像素)位在材質圖比較前面的位置,計算深度之後比深度材質的0.5還要小,有些則比較大。這時候就會產生黑白黑白的狀況。

換句話說,一個像素的材質圖區域範圍內(綠色箭頭所指),可能有六個像素要渲染到顯示器,其中有三個像素比較深度之後,被誤認成是陰影了。

https://ithelp.ithome.com.tw/upload/images/20221003/20142505PPGRAeYmtH.png

這也解釋為什麼柱子有奇怪的鋸齒狀,地板會破圖。

https://ithelp.ithome.com.tw/upload/images/20221003/201425057V1WjWzv9T.png

https://ithelp.ithome.com.tw/upload/images/20221003/20142505CXioO2CJtZ.png

而這個現象有一個名稱,那就是Shadow Acne。

Bias修正

為了解決上述問題,出現了Bias修正功能,只要減去一點點數值,就可以修正上述問題。

// light可以是PointLightShadow、DirectionalLightShadow或SpotLightShadow
light.shadow.bias = -0.0001

下面是內縮的效果:

Untitled

它使用特別的公式,使得材質圖可以用特別的方式內縮。

https://ithelp.ithome.com.tw/upload/images/20221003/2014250502llVOWQJL.png

圖片來源

這個方法雖然可以解決問題,然而如果Bias數值給太多,會導致陰影跟物體相連的地方分離,使得畫面上看起來被裁減了一樣。而這也導致下圖陰影出現漏光的現象。

https://ithelp.ithome.com.tw/upload/images/20221004/20142505737KS9uGTN.png

由於篇幅問題,我們不討論公式推導,如果有興趣可以查看這篇文章

延伸的陰影技術:Shadow Map, PCF, PCSS

Shadow Map

到目前為止,我們只解釋到最基本的陰影技術,人稱「Shadow Map」。

renderer.shadowMap.type = THREE.BasicShadowMap

https://ithelp.ithome.com.tw/upload/images/20221004/20142505cfmJ4QfxRg.png

事實上還有很多種陰影技術,例如PCF Shadow:

PCF Map(Percentage-Closer Filtering)

這是three.js預設的陰影渲染方式。簡單來說,就是取樣周圍的像素,得到一個平均值。藉此,陰影的邊界不會那麼模糊。

renderer.shadowMap.type = THREE.PCFShadowMap

https://ithelp.ithome.com.tw/upload/images/20221004/20142505YMJN4uMQXz.png

而這有特別的公式:

https://ithelp.ithome.com.tw/upload/images/20221016/20142505nSOQY0yaeq.png

圖片來源

因為篇幅關係,如果有興趣的人可以前往查看文章

PCF Map SoftShadow(Percentage-Closer Filtering)

基於PCF公式,所使用的更模糊版本。

renderer.shadowMap.type = THREE.PCFSoftShadowMap

https://ithelp.ithome.com.tw/upload/images/20221004/20142505yBcXXVx8zy.png

VSM(Variance Shadow Map)

基於PCF使用數學公式計算出來的另一種陰影處理。

renderer.shadowMap.type = THREE.VSMShadowMap

因為篇幅關係,如果有興趣的人可以前往查看文章

各種光源所產生的陰影差異

上面有提到,每一個光源都會拍一張照,代表深度材質圖。

事實上,重要的光源物件都會有光源陰影物件,裡面又有鏡頭專門拍照。

  • LightLightShadowCamera

而這架構存在於DirectionalLightPointLightSpotLight

拍照這件事情可就複雜了。如果還記得「Day15: three.js 前端3D視覺特效開發實戰——3D儀表板:圓餅圖」那篇,你可能還記得我們還分成兩種主要的鏡頭:

https://ithelp.ithome.com.tw/upload/images/20221003/201425058OcxAZewfp.png

那到底不同的光是用什麼鏡頭呢?

看上去點光跟聚光燈一樣都是用perspectiveCamera去處理。但問題是:身為一個點光,其照射的範圍是360度的。但是其內部的鏡頭如果要照射360度的深度材質,那麼解析度會相當差。

Untitled

圖片來源

為了解決這個問題,PointLightShadow使用了六個PerspectiveCamera,上下左右前後各照一張,當自己是google街景車一樣。

https://ithelp.ithome.com.tw/upload/images/20221004/20142505clisKoFQll.png

所以說,使用PointLight需要照射的材質圖,相當於可以抵上六個DirectionalLight。如果要考慮效能的話,那點光就是必須慎重使用的光源。

小結

本篇是我跌跌撞撞的開發經驗,加上參考其他文章彙整出來有關陰影的技術問題。

事實上,內容還不只這樣,我們還沒有提到渲染技術、不同的貼圖取樣方式影響材質圖以及Frustum的概念、Depth Buffer、Depth Testing等等相當多的內容。為了留接下來這幾天的篇幅給Shader,我就點到為止,如果有興趣的人可以繼續研究。


上一篇
Day17: three.js GIS系統開發實戰:鄉鎮市區GIS系統:SVG、GeoJson的應用
下一篇
Day19: three.js 前端3D鏡面特效開發實戰:梅利奧達斯的全反射!—智慧工廠檢視器
系列文
30天成為鍵盤麥可貝:前端視覺特效開發實戰31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言