从 renderer.render() 到地面上的阴影:Three.js 阴影渲染全流程源码解析
本文基于 Three.js r129(0.129.0) 源码分析。不同版本的目录或局部实现可能变化,但 Shadow Mapping 的核心流程一致。
一个球体、一个平面、一盏平行光。我们只写了几行配置:
js
renderer.shadowMap.enabled = true;
light.castShadow = true;
sphere.castShadow = true;
plane.receiveShadow = true;
然后调用一次:
js
renderer.render(scene, camera);
地面上就出现了球的影子。
这几行代码背后,Three.js 实际上至少从两个视角绘制了场景:先从光源视角生成一张"深度照片",再从主相机视角正式渲染,并在 Shader 中查询每个片元是否被挡住。本文以"球体 + 平面 + 平行光"为最小场景,沿着 r129 源码把这条链路完整走一遍。
一、先搭一个最小阴影场景
下面的代码可以直接放进一个 ES Module 页面。为了让光照与阴影都容易观察,球体和平面使用 MeshStandardMaterial。
js
import * as THREE from './build/three.module.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x20242b);
const camera = new THREE.PerspectiveCamera(
45,
window.innerWidth / window.innerHeight,
0.1,
100
);
camera.position.set(7, 6, 9);
camera.lookAt(0, 1, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
// 少量环境光只用于看清未被平行光照亮的区域
scene.add(new THREE.AmbientLight(0xffffff, 0.18));
const light = new THREE.DirectionalLight(0xffffff, 1.2);
light.position.set(5, 8, 4);
light.castShadow = true;
scene.add(light);
scene.add(light.target);
// 平行光的阴影相机是正交相机;范围必须覆盖球和平面上的投影区域
light.shadow.camera.left = -6;
light.shadow.camera.right = 6;
light.shadow.camera.top = 6;
light.shadow.camera.bottom = -6;
light.shadow.camera.near = 0.5;
light.shadow.camera.far = 30;
light.shadow.mapSize.set(1024, 1024);
light.shadow.bias = -0.0005;
light.shadow.normalBias = 0.02;
const sphere = new THREE.Mesh(
new THREE.SphereGeometry(1.2, 48, 32),
new THREE.MeshStandardMaterial({ color: 0x4f8cff, roughness: 0.65 })
);
sphere.position.y = 1.2;
sphere.castShadow = true;
scene.add(sphere);
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(14, 14),
new THREE.MeshStandardMaterial({ color: 0xd8d8d8, roughness: 0.9 })
);
plane.rotation.x = -Math.PI / 2;
plane.receiveShadow = true;
scene.add(plane);
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
这里有四个不同层级的开关:
| 配置 | 控制什么 |
|---|---|
renderer.shadowMap.enabled |
渲染器是否执行阴影贴图阶段 |
light.castShadow |
这盏灯是否进入投影灯光列表 |
sphere.castShadow |
球体是否被绘制进阴影贴图 |
plane.receiveShadow |
正式渲染平面时,是否让直射光乘以阴影系数 |
它们并不是同一个开关的四种写法,而是分别作用于流水线的四个位置。
二、Shadow Mapping 到底在做什么
Three.js 的实时阴影使用 Shadow Mapping。它可以概括为两次渲染:
- 阴影阶段:把灯光当成相机,从灯光视角渲染场景,保存每个像素离灯光最近的深度,得到 Shadow Map。
- 主渲染阶段:从用户相机渲染。把当前片元变换到灯光坐标系,查询 Shadow Map;如果当前深度比图中记录的最近深度更远,说明它与灯光之间存在遮挡物。
关键点是:阴影不是一个贴在地面上的黑色模型 。Shadow Map 保存的是遮挡关系,最终阴影只是光照计算中一个 0~1 的可见性系数。
三、入口:WebGLRenderer.render() 如何组织一帧
入口位于 src/renderers/WebGLRenderer.js。省略 XR、裁剪和透明物体等旁支后,主干可以简化成:
js
this.render = function ( scene, camera ) {
if ( scene.autoUpdate === true ) scene.updateMatrixWorld();
if ( camera.parent === null ) camera.updateMatrixWorld();
currentRenderState.init();
currentRenderList.init();
projectObject( scene, camera, 0, _this.sortObjects );
currentRenderList.finish();
const shadowsArray = currentRenderState.state.shadowsArray;
shadowMap.render( shadowsArray, scene, camera );
currentRenderState.setupLights();
currentRenderState.setupLightsView( camera );
renderObjects( opaqueObjects, scene, camera );
renderObjects( transparentObjects, scene, camera );
};
顺序非常重要:先生成 Shadow Map,再设置灯光 uniforms,最后正式绘制场景。
1. 更新世界矩阵
scene.updateMatrixWorld() 会把球体、平面、灯光和 light.target 的局部变换更新为世界矩阵。之后生成灯光相机、计算裁剪体和顶点坐标,都依赖这些矩阵。
如果手动关闭 scene.autoUpdate,却没有自行更新矩阵,阴影可能会停在旧位置。
2. projectObject() 收集物体和灯光
projectObject() 递归访问场景树。对于 Mesh,它会做层级、可见性和视锥判断,并把物体放进 opaque、transmissive 或 transparent 渲染列表。
遇到灯光时,渲染状态会收集灯光;如果灯光还满足 castShadow === true,则调用 currentRenderState.pushShadow(object),进入 shadowsArray。所以:
js
light.castShadow = false;
意味着这盏灯不会传给 WebGLShadowMap.render(),自然也不会生成 Shadow Map。
四、第一遍绘制:从平行光视角生成 Shadow Map
阴影阶段的核心在 src/renderers/webgl/WebGLShadowMap.js:
js
this.render = function ( lights, scene, camera ) {
if ( scope.enabled === false ) return;
if ( scope.autoUpdate === false && scope.needsUpdate === false ) return;
if ( lights.length === 0 ) return;
for ( let i = 0; i < lights.length; i ++ ) {
const light = lights[ i ];
const shadow = light.shadow;
// 创建或复用 Shadow Map 对应的 RenderTarget
if ( shadow.map === null ) {
shadow.map = new WebGLRenderTarget(/* ... */);
}
_renderer.setRenderTarget( shadow.map );
_renderer.clear();
shadow.updateMatrices( light, 0 );
renderObject( scene, camera, shadow.camera, light, this.type );
}
};
注意,此处传给内部 renderObject() 的相机是 shadow.camera,不是页面中创建的 PerspectiveCamera。
1. 为什么平行光使用正交相机
DirectionalLight 构造时会创建 DirectionalLightShadow。源码中它给基类传入的是:
js
new OrthographicCamera( -5, 5, 5, -5, 0.5, 500 )
平行光的光线方向彼此平行,不存在"越远越发散"的透视关系,因此用正交投影表达最自然。light.shadow.camera.left/right/top/bottom 决定阴影相机覆盖的长方体范围;范围之外既不会写入阴影贴图,也不会得到正确阴影。
这也解释了一个常见误区:增大 mapSize 只会提高纹理分辨率,不会扩大阴影覆盖范围;覆盖范围由阴影相机决定。
2. LightShadow.updateMatrices() 做了什么
src/lights/LightShadow.js 中主要做三件事:
js
shadowCamera.position.copy(lightWorldPosition);
shadowCamera.lookAt(light.target的世界坐标);
shadowCamera.updateMatrixWorld();
// 用于阴影阶段的视锥裁剪
projScreenMatrix = projectionMatrix * matrixWorldInverse;
frustum.setFromProjectionMatrix(projScreenMatrix);
// 世界坐标 -> 灯光裁剪坐标 -> [0, 1] 纹理坐标
shadowMatrix = biasMatrix
* shadowCamera.projectionMatrix
* shadowCamera.matrixWorldInverse;
其中源码里的 biasMatrix 是:
text
0.5 0 0 0.5
0 0.5 0 0.5
0 0 0.5 0.5
0 0 0 1
灯光投影后的 NDC 坐标范围是 [-1, 1],纹理 UV 和深度范围是 [0, 1]。这个矩阵完成缩放和平移:
text
shadowCoord = Bias × LightProjection × LightView × worldPosition
3. 哪些物体会写入 Shadow Map
阴影阶段会递归遍历场景,但 r129 的普通 PCF 阴影只绘制满足以下条件的 Mesh、Line 或 Points:
js
object.castShadow &&
(!object.frustumCulled || shadowFrustum.intersectsObject(object))
因此示例中的球体进入阴影贴图,只有 receiveShadow 的平面不会进入普通 PCF 阴影贴图。VSM 是一个例外:实现还会绘制接收阴影的物体,以支持它的概率分布与模糊处理。
4. 为什么不用物体原来的材质
生成 Shadow Map 时,我们只关心"离灯光多远",不需要球体的蓝色、粗糙度或金属度。getDepthMaterial() 会选择 MeshDepthMaterial;点光源因深度定义不同,会选择 MeshDistanceMaterial。
最后仍然通过底层的:
js
renderer.renderBufferDirect(
shadowCamera,
null,
geometry,
depthMaterial,
object,
group
);
提交顶点、索引和阴影材质,执行真正的 WebGL draw call。
对普通方向光阴影,深度被打包进 RGBA 颜色纹理。后面的 Shader 使用 unpackRGBAToDepth() 还原它。透明裁切、位移贴图、蒙皮或形变等几何效果也必须在深度材质中得到一致处理,否则屏幕里的轮廓和影子轮廓会对不上。复杂自定义顶点变形时,可以使用 customDepthMaterial。
五、阴影数据如何进入正式材质
Shadow Map 生成后,WebGLRenderer 调用 currentRenderState.setupLights()。WebGLLights.js 把每盏投影平行光的数据整理进数组:
js
state.directionalShadowMap[i] = light.shadow.map.texture;
state.directionalShadowMatrix[i] = light.shadow.matrix;
state.directionalShadow[i] = {
shadowBias: shadow.bias,
shadowNormalBias: shadow.normalBias,
shadowRadius: shadow.radius,
shadowMapSize: shadow.mapSize
};
创建或更新材质程序时,WebGLRenderer.js 再把这些数组连接到 Shader uniforms:
js
uniforms.directionalShadowMap.value = lights.state.directionalShadowMap;
uniforms.directionalShadowMatrix.value = lights.state.directionalShadowMatrix;
uniforms.directionalLightShadows.value = lights.state.directionalShadow;
WebGLPrograms.js 还根据阴影灯数量生成 NUM_DIR_LIGHT_SHADOWS,并根据 renderer.shadowMap.type 生成类似 SHADOWMAP_TYPE_PCF_SOFT 的编译宏。这意味着切换阴影算法或改变投影灯数量,可能触发 Shader 程序变体的重新编译。
六、第二遍绘制:主相机下判断片元是否处在阴影中
正式阶段按 opaque、transmissive、transparent 的顺序绘制渲染列表。这里仍会执行常规的模型矩阵、视图矩阵、投影矩阵、材质和光照计算;阴影逻辑作为 ShaderChunk 被拼进支持光照的内置材质。
1. 顶点着色器计算灯光空间坐标
shadowmap_vertex.glsl.js 的核心代码是:
glsl
shadowWorldPosition = worldPosition
+ vec4(shadowWorldNormal * shadowNormalBias, 0.0);
vDirectionalShadowCoord[i] =
directionalShadowMatrix[i] * shadowWorldPosition;
directionalShadowMatrix 就是上一节得到的:
text
Bias × LightProjection × LightView
所以 vDirectionalShadowCoord 同时携带了 Shadow Map 的 UV 和待比较的深度。它经 varying 插值后进入片元着色器。
这里的 normalBias 会沿表面法线把采样位置稍微推出表面,常用于缓解斜面上的条纹状阴影痤疮(shadow acne)。
2. 片元着色器做深度比较
shadowmap_pars_fragment.glsl.js 中的 getShadow() 可以简化为:
glsl
float getShadow(..., vec4 shadowCoord) {
shadowCoord.xyz /= shadowCoord.w;
shadowCoord.z += shadowBias;
if (shadowCoord 在阴影相机范围内) {
float closestDepth = unpackRGBAToDepth(
texture2D(shadowMap, shadowCoord.xy)
);
return step(shadowCoord.z, closestDepth);
}
return 1.0;
}
设 Shadow Map 中记录的深度为 d_map,当前片元到灯光的深度为 d_fragment:
text
d_fragment <= d_map:片元是灯光看见的最近表面,返回 1(有光)
d_fragment > d_map:前面有更近的遮挡物,返回 0(阴影)
示例中,平面上落在球体后方的片元会发现:Shadow Map 对应位置保存的是球表面的更小深度,因此它们被判定为阴影。
3. 阴影最终乘在哪里
在 lights_fragment_begin.glsl.js 中,平行光先按方向、法线等计算直射辐照度,然后执行:
glsl
directLight.color *=
receiveShadow
? getShadow(directionalShadowMap[i], ..., vDirectionalShadowCoord[i])
: 1.0;
RE_Direct(directLight, geometry, material, reflectedLight);
这段代码揭示两个常被忽略的事实:
receiveShadow不是决定物体是否进入 Shadow Map,而是决定正式着色时是否使用阴影系数;r129 中它作为 uniform 由object.receiveShadow更新。- 阴影主要削弱这盏灯的直射光,并不会自动抹掉环境光、半球光、环境贴图或材质自发光。因此阴影区通常不是纯黑色。
七、PCFSoftShadowMap 为什么让边缘变软
最简单的 BasicShadowMap 只采样一次,结果接近非黑即白,锯齿明显。PCF(Percentage-Closer Filtering)的思路不是先过滤深度值,而是在邻域内多次做"是否被遮挡"的比较,再对比较结果求平均。
r129 中:
BasicShadowMap:单点深度比较;PCFShadowMap:对周围多个位置比较并平均;源码中这一分支使用 17 次比较;PCFSoftShadowMap:结合 texel 网格与双线性混合,得到更平滑的变化;VSMShadowMap:存储深度分布的矩并进行两遍模糊,再用概率上界估算可见性。
所以 PCF 的"软"本质上是采样过滤,并不是真实面积光源产生的物理半影。提高 mapSize 会减少像素化,但也可能让相同采样半径在世界空间里显得更窄。
八、bias、normalBias 和常见阴影瑕疵
Shadow Mapping 比较的是离散纹理中的有限精度深度,因此通常会遇到两类相反的问题。
Shadow acne:表面出现条纹或噪点
同一个表面在 Shadow Map 与主相机下的采样位置不同,量化误差可能让它错误地认为"自己挡住了自己"。可尝试:
js
light.shadow.bias = -0.0005;
light.shadow.normalBias = 0.02;
在 r129 的 getShadow() 中执行的是 shadowCoord.z += shadowBias,所以合适的正负号应以当前版本和场景实测为准;本文示例使用轻微负值。斜面问题通常优先微调 normalBias。
Peter Panning:影子与物体底部脱离
偏移过大会把比较位置推得太远,阴影看起来像飘离物体。这不是继续增大偏移能解决的问题,应回调 bias/normalBias,并同时优化阴影相机范围与精度。
一个更可靠的调参顺序是:
- 先让阴影相机刚好包住需要投影的区域;
- 再选择合适的
mapSize; - 最后只用尽可能小的
bias和normalBias消除瑕疵。
九、为什么阴影模糊、消失或完全不出现
1. 四个开关少了一个
检查:
js
renderer.shadowMap.enabled === true
light.castShadow === true
sphere.castShadow === true
plane.receiveShadow === true
此外,接收阴影的材质必须参与光照计算;MeshBasicMaterial 不计算灯光,不适合演示普通灯光阴影。
2. 阴影相机没有覆盖目标
使用辅助器直接观察:
js
scene.add(new THREE.CameraHelper(light.shadow.camera));
球体和需要接收影子的地面区域必须同时落在正交阴影相机的盒子内,near/far 也必须覆盖它们。
3. 阴影有明显方块
阴影世界空间精度大致取决于:
text
单位世界长度对应 texel 数 ≈ mapSize / 阴影相机覆盖宽度
因此,把相机范围从 10 扩成 100,会让同样的 1024 贴图精度下降约 10 倍。优先收紧范围,再考虑从 1024 提升到 2048;盲目堆分辨率会增加显存和渲染成本。
4. 物体移动了,影子不更新
默认 shadowMap.autoUpdate = true。静态场景可以关闭自动更新来节省性能:
js
renderer.shadowMap.autoUpdate = false;
但场景变化后需要请求一次刷新:
js
renderer.shadowMap.needsUpdate = true;
灯光的 shadow 对象也有自己的 autoUpdate 与 needsUpdate 检查。
十、阴影的性能成本怎么算
Shadow Mapping 的代价不是"打开一个 Shader 开关"这么简单。每盏投影灯都要额外渲染可能投影的几何体。
对于本文的一盏平行光,可以粗略理解为:
text
一遍阴影深度绘制 + 一遍主场景绘制
如果有三盏投影平行光,投影几何体可能额外画三遍。点光源要覆盖六个方向,r129 把六个视口组织进一张二维纹理图集,但仍需要从六个方向绘制,成本更高。
常用优化策略包括:
- 只让真正需要的灯开启
castShadow; - 只让关键模型开启
castShadow; - 收紧阴影相机范围,避免无效绘制并提高单位面积精度;
- 静态场景关闭
autoUpdate,变化时再设置needsUpdate; - 控制
mapSize,不要默认所有灯都使用超高分辨率; - 远景或大量静态物体可考虑烘焙阴影、光照贴图或简单 blob shadow。
十一、把整条源码调用链串起来
最后用一条调用链收束全文:
text
renderer.render(scene, camera)
│
├─ scene.updateMatrixWorld()
├─ projectObject(scene, camera)
│ ├─ Mesh → 主渲染列表
│ └─ Light + castShadow → shadowsArray
│
├─ WebGLShadowMap.render(shadowsArray, scene, camera)
│ ├─ 创建/复用 light.shadow.map
│ ├─ light.shadow.updateMatrices(light)
│ │ ├─ 更新灯光相机
│ │ ├─ 建立灯光视锥
│ │ └─ 计算 Bias × LightProjection × LightView
│ └─ 遍历 castShadow 物体
│ ├─ 选择 MeshDepthMaterial
│ └─ renderBufferDirect(..., shadow.camera, ...)
│
├─ setupLights()
│ └─ 整理 Shadow Map、shadowMatrix、bias、mapSize 等 uniforms
│
└─ renderObjects(..., mainCamera)
├─ 顶点 Shader:世界坐标 → Shadow Map 坐标
├─ 片元 Shader:当前深度与 Shadow Map 深度比较
├─ PCF / PCFSoft / VSM 过滤
└─ 直射光颜色 × 阴影可见性系数
结语
现在再看开头的四行配置,它们分别对应了一条清晰的源码链路:渲染器决定是否执行阴影 Pass,灯光决定是否创建灯光视角的 Shadow Map,球体决定是否写入这张深度图,平面决定正式着色时是否读取它。
Three.js 帮我们隐藏了 RenderTarget、灯光相机、深度材质、矩阵变换、uniform 上传和 Shader 采样,但核心算法仍然是那句朴素的问题:
从灯光看过去,在到达当前片元之前,是否已经看见了更近的东西?
理解这个问题,也就理解了 Three.js 实时阴影的主干。之后无论调阴影范围、解决 acne、选择过滤方式,还是做性能优化,都不再只是试参数,而是在定位流水线中的具体一环。
源码索引(Three.js r129)
package.json:本文所用版本号0.129.0src/renderers/WebGLRenderer.js:一帧的组织、阴影 Pass 调用、材质 uniform 绑定src/renderers/webgl/WebGLRenderStates.js:灯光与投影灯列表src/renderers/webgl/WebGLShadowMap.js:Shadow Map 创建与深度阶段绘制src/renderers/webgl/WebGLLights.js:阴影纹理、矩阵和参数的整理src/lights/LightShadow.js:灯光相机与 shadow matrixsrc/lights/DirectionalLightShadow.js:平行光的正交阴影相机src/renderers/shaders/ShaderChunk/shadowmap_vertex.glsl.js:生成阴影坐标src/renderers/shaders/ShaderChunk/shadowmap_pars_fragment.glsl.js:深度比较与过滤src/renderers/shaders/ShaderChunk/lights_fragment_begin.glsl.js:把阴影系数作用于直射光