一. 背景
Threejs 给我们封装的阴影虽然简单易用,但太过上层,不够灵活。 之前在预研厘米秀新形象时,需要把 Unity 中的着色器代码“翻译”到 Threejs 的自定义材质中。我们发现新形象在 Unity 中定义的第一个 PASS 依赖于阴影,也就是说我们需要必须先拿到阴影数据,才能再对后续的 PASS 进行“翻译”。但是 Threejs 并不会给我们提供阴影信息。另外,就算真的可以通过各种操作拿到 Threejs 生成的阴影纹理,我们也还是需要在自定义材质中通过着色器来理解以及消费它。 综上,干脆在 Threejs 上自己实现一套阴影,足够灵活,也能自己把握细节。
二. 阴影是如何产生的
在自然界中,一个不自发光的物体要被看见,是需要光源照射的。由于光是沿直线传播的,当光线被某些物体(图中橘色物体)遮挡后,那些本来有颜色的区域(点C)因为没有照射而变回黑色,这些区域就是阴影。
三. 如何用 ShadowMap 生成阴影
理论上,在绘制点的颜色时,只要判断该点有没有被“遮挡”,就知道是否要绘制成阴影。而判断“遮挡”的方案有很多,最常用的就是 ShadowMap。 我们只要知道该点与光源的连线上,有没有比它离光源更近的点存在。其中点与光源的距离,在 ShadowMap 中就是深度。具体的做法是:
- (1) 生成深度纹理图:所谓深度纹理图,就是每个位置的最小深度。我们站在光源的位置,按照光线传播的视角,观察场景,计算场景中的物体距离光源的距离(也就是该视角下的深度),并记录各个位置上的最小值,从而获得一张深度纹理。
- (2) 使用深度纹理图:对于世界中的某个点 p,我们要先得到它在光源视角下的深度,再和深度纹理图中对应的深度进行比较,就可以判定它是否在阴影中了。
ps: 更多关于 ShadowMap 生成阴影的原理以及阴影质量的逐步优化可以看我的另一篇文章《3D世界中的阴影—ShadowMap原理解析》
四. 落地代码
下面的 demo 代码,都是用来实现以上这个场景的阴影效果。其中蓝青色的正方体代表光源位置。
1. 每一帧的渲染流程
我们在设备上看到的图像,都是一帧一帧绘制出来的。在这个 demo 中每一帧的渲染,包括以下两个部分:
- (1) 离屏渲染:将相机移动到光源处,渲染场景到缓冲区,拿到 shadow map。
- (2) 切换渲染目标,将场景渲染到屏幕上。其中场景中的正方体和地板,都要配上自定义材质来使用 shadow map。
下面是渲染流程的相关代码:
import { OrbitControls } from "./assets/orbitcontrols.js";
let renderer, stats, camera, camera4SM;
let scene, bufferScene, bufferTexture;
const domElement = document.getElementById("canvas-frame");
// 帧数检测
function initStatus() { // ... }
// 初始化 render
function initThree() { // ... }
// 初始化场景
function initScene() {
// 需要绘制到屏幕的场景
scene = new THREE.Scene();
const axisHelper = new THREE.AxesHelper(100);
scene.add(axisHelper);
// 离屏缓冲区
bufferScene = new THREE.Scene();
bufferTexture = new THREE.WebGLRenderTarget(
domElement.clientWidth,
domElement.clientHeight
);
bufferTexture.depthBuffer = true;
bufferTexture.depthTexture = new THREE.DepthTexture();
}
// 初始化相机
function initCamera() {
const width = domElement.clientWidth;
const height = domElement.clientHeight;
camera = new THREE.PerspectiveCamera(45, width / height, 1, 10000);
camera.position.set(50, 50, 200);
camera.lookAt(scene.position);
camera.aspect = width / height;
camera.updateProjectionMatrix();
scene.add(camera);
// 光源处的相机,用于生成 shadow map
camera4SM = new THREE.OrthographicCamera(-100, 100, 100, -100, 1, 70);
camera4SM.position.set(20, 50, 0);
camera4SM.lookAt(bufferScene.position);
camera4SM.aspect = width / height;
camera4SM.updateProjectionMatrix();
bufferScene.add(camera4SM);
}
// 初始化光源
function initLight() { // ...}
// 每一帧调用的渲染函数
function render() {
// 渲染到目标缓冲区
renderer.setClearColor("rgb(255,255,255)", 1.0);
renderer.setRenderTarget(bufferTexture);
renderer.render(bufferScene, camera4SM);
// 渲染到屏幕
renderer.setClearColor("rgb(150,150,150)", 1.0);
renderer.setRenderTarget(null);
renderer.render(scene, camera);
stats.update();
requestAnimationFrame(render);
}
function start() {
initStatus();
initThree();
initScene();
initCamera();
initLight();
initObject();
render();
}
start();
2. 生成深度纹理图 - shadow map
使用离屏渲染,将深度图这一帧缓存起来。
(1) 新加一个自定义材质,用来记录深度
function initObject() {
// add object in buffer scene
const getSMMaterial = new THREE.ShaderMaterial({
uniforms: {
projectionMatrixSM: { value: camera4SM.projectionMatrix },
},
vertexShader: document.getElementById("vertexShaderSM").textContent,
fragmentShader: document.getElementById("fragmentShaderSM").textContent,
});
// ...
}
相关的着色器代码: 具体的做法就是把投影之后的片元深度值,写到 rgb 中的 r 值。这就是为啥我们的 shadow map 是偏红色的~
<script id="vertexShaderSM" type="x-shader/x-vertex">
uniform mat4 projectionMatrixSM;
void main(){
gl_Position = projectionMatrixSM * modelViewMatrix * vec4( position, 1.0 );
}
</script>
<script id="fragmentShaderSM" type="x-shader/x-fragment">
void main() {
gl_FragColor = vec4(gl_FragCoord.z, 0.0, 0.0, 0.0); // 将片元的深度值写入r值
}
</script>
(2) 把使用该材质的正方体和地板放到离屏渲染的场景里
function initObject() {
// ...
const groundGeo = new THREE.BoxGeometry(40, 40, 1);
const groundInBuffer = new THREE.Mesh(groundGeo, getSMMaterial);
groundInBuffer.rotation.x = Math.PI / 2;
groundInBuffer.name = "groundPlane";
bufferScene.add(groundInBuffer);
const cubeGeo = new THREE.BoxGeometry(20, 20, 20);
const cubeInBuffer = new THREE.Mesh(cubeGeo, getSMMaterial);
cubeInBuffer.position.y += 10;
cubeInBuffer.name = "cubeInBuffer";
bufferScene.add(cubeInBuffer);
// ...
}
(3) 用光源处的相机渲染场景到缓存里
function render() {
// 渲染到目标缓冲区
renderer.setClearColor("rgb(255,255,255)", 1.0);
renderer.setRenderTarget(bufferTexture);
renderer.render(bufferScene, camera4SM);
// ...
}
3. 使用深度纹理图生成阴影
(1) 新加一个自定义材质,用来使用深度图生成阴影
function initObject() {
// add object in buffer scene
// ...
// add object in screen scene
const useSM4CubeMat = new THREE.ShaderMaterial({
// attributes 居然传不进去
uniforms: {
modelViewMatrixSM: { value: cubeInBuffer.modelViewMatrix },
projectionMatrixSM: { value: camera4SM.projectionMatrix },
depthTexture: { value: bufferTexture.texture },
color: { value: new THREE.Vector3(0, 1, 0) },
},
vertexShader: document.getElementById("vertexShader").textContent,
fragmentShader: document.getElementById("fragmentShader").textContent,
});
const useSM4GroundMat = new THREE.ShaderMaterial({
uniforms: {
modelViewMatrixSM: { value: groundInBuffer.modelViewMatrix },
projectionMatrixSM: { value: camera4SM.projectionMatrix },
depthTexture: { value: bufferTexture.texture },
color: { value: new THREE.Vector3(1, 1, 1) },
},
vertexShader: document.getElementById("vertexShader").textContent,
fragmentShader: document.getElementById("fragmentShader").textContent,
});
}
相关的着色器代码:
- 先做归一化:mvp 矩阵处理完的坐标还会被自动转化成裁剪空间的坐标,范围在 [0, 1] 区间,所以这里也要做归一化。
- 获取深度:拿到深度纹理中对应坐标存储的数据。
- 判断片元属否在阴影中:如果该片元的深度大于shadow map 存储的对应深度,则表示相同位置下,有比该点距离光源更近的点存在,被遮挡了。
<script id="vertexShader" type="x-shader/x-vertex">
uniform mat4 modelViewMatrixSM;
uniform mat4 projectionMatrixSM;
varying vec4 result;
void main(){
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
result = projectionMatrixSM * modelViewMatrixSM * vec4( position, 1.0 );
}
</script>
<script id="fragmentShader" type="x-shader/x-fragment">
uniform sampler2D depthTexture;
uniform vec3 color;
varying vec4 result;
void main() {
vec3 shadowCoord = (result.xyz / result.w) / 2.0 + 0.5; // 归一化
vec4 rgbaDepth = texture2D(depthTexture, shadowCoord.xy);
float depth = rgbaDepth.r; // 拿到深度纹理中对应坐标存储的深度
float visibility = (shadowCoord.z > depth + 0.3) ? 0.0 : 1.0; // 判断片元是否在阴影中
vec4 v_Color = vec4(color, 1.0);
gl_FragColor = vec4(v_Color.rgb * visibility, v_Color.a);
}
</script>
(2) 把使用该材质的正方体和地板放到屏幕渲染的场景里
function initObject() {
// ...
const cubeBufGeo = new THREE.BufferGeometry();
cubeBufGeo.fromGeometry(cubeGeo);
const cubeInScreen = new THREE.Mesh(cubeBufGeo, useSM4CubeMat);
cubeInScreen.position.y += 10;
cubeInScreen.name = "cubeInScreen";
scene.add(cubeInScreen);
const planeInScreen = new THREE.Mesh(groundGeo, useSM4GroundMat);
planeInScreen.rotation.x = Math.PI / 2;
planeInScreen.name = "planeInScreen";
scene.add(planeInScreen);
// 展示 shadow map
//...
}
(3) 渲染场景到屏幕上
// 每一帧调用的渲染函数
function render() {
// 渲染到目标缓冲区
// ...
// 渲染到屏幕
renderer.setClearColor("rgb(150,150,150)", 1.0);
renderer.setRenderTarget(null);
renderer.render(scene, camera);
stats.update();
requestAnimationFrame(render);
}
五. 最终效果
六. 附录
- 相关代码:github.com/Zack921/vis…
- 3D世界中的阴影—ShadowMap原理解析: juejin.cn/post/694007…