阴影系统:让物体稳稳落地
本教程共 40 篇 · 第 19 篇 · 更新于 2026-08-14 · 约 9 分钟阅读
本节目标:学会开启和调优 three.js 阴影,理解阴影贴图的工作方式,能独立排查阴影闪烁、悬浮、漏光等常见问题。
没有阴影的 3D 场景像悬浮在空中的贴图。这一章把阴影系统讲透:怎么开、怎么调、出问题怎么修。
阴影是怎么来的
先建立一个认知:three.js 的阴影不是”算”出来的光线遮挡,而是一种叫阴影贴图(shadow mapping)的近似技术,分两步:
- 从灯光的视角,把场景渲染成一张只有深度信息的图,叫阴影贴图;
- 正常渲染时,把每个像素的深度和贴图比较:比贴图更深,说明被挡住了,画阴影。
所以阴影质量受两个硬指标限制:贴图分辨率(像素有限)和深度精度(深度值有误差)。后面所有”调阴影”的操作,本质上都是在和这两个限制搏斗。这也是为什么阴影永远不可能像光线追踪那样精确:它本质上是”从灯的角度拍一张照片”。
三步开启阴影
阴影默认全关,因为渲染阴影贴图很贵。开启需要三个地方配合:
- 渲染器开总开关:
renderer.shadowMap.enabled = true;
- 灯光开 castShadow(只有直接光能投阴影,环境光不行):
dirLight.castShadow = true;
- 物体声明投、收阴影:
box.castShadow = true; // 投阴影
floor.receiveShadow = true; // 收阴影
三步缺一不可。castShadow 和 receiveShadow 默认都是 false,不设置就什么都没有。
Note只有受光材质需要这些设置。MeshBasicMaterial 不受光,但也可以设 receiveShadow 接收阴影。
阴影贴图类型
renderer.shadowMap.type 决定阴影边缘的滤波方式,四种可选:
- BasicShadowMap:不做滤波,最快,锯齿最明显;
- PCFShadowMap:默认值,百分比邻近滤波,边缘柔和一点;
- PCFSoftShadowMap:边缘更软,质量好,性能略低,最常用;
- VSMShadowMap:方差阴影贴图,边缘非常平滑,但有两个坑:容易漏光,而且所有接收阴影的物体也会自动投阴影。
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
阴影贴图分辨率:mapSize
灯光阴影的清晰度由 shadow.mapSize 决定,默认 512×512,必须是 2 的幂:
dirLight.shadow.mapSize.set(2048, 2048);
1024 够小场景用,2048 是常见选择,4096 只在必要时开。分辨率翻倍,显存和计算量翻四倍,别盲目拉满。
阴影相机:把灯光视角框起来
每盏投阴影的灯都有一个 shadow.camera,代表它渲染深度图的取景范围。范围必须刚好罩住要出阴影的区域:
- 平行光:正交相机,用 left、right、top、bottom、near、far 定义长方体;
- 聚光灯:透视相机,用 near、far、fov 定义锥体;
- 点光源:立方体贴图,向六个方向各渲染一次,最贵。
dirLight.shadow.camera.left = -8;
dirLight.shadow.camera.right = 8;
dirLight.shadow.camera.top = 8;
dirLight.shadow.camera.bottom = -8;
dirLight.shadow.camera.near = 0.5;
dirLight.shadow.camera.far = 30;
范围越大,同样的分辨率摊得越稀,阴影越糊。范围没罩住的地方干脆没有阴影。建议把范围调到”刚好包住物体”。
Tip调试阴影相机范围:把 shadow.camera 塞进 CameraHelper,就能在场景里看到取景框:
scene.add(new THREE.CameraHelper(dirLight.shadow.camera));
bias 和 normalBias:和精度误差搏斗
阴影痤疮(shadow acne)是最常见的阴影问题:物体表面出现密密麻麻的黑色麻点。原因是深度图精度有限,表面自身的深度和贴图里的深度有细微误差,导致表面”自己挡住自己”。
解决办法是给深度比较加偏移:
dirLight.shadow.bias = 0.0005; // 沿光线方向偏移
dirLight.shadow.normalBias = 0.05; // 沿表面法线偏移
- bias:把比较深度往”更亮”的方向推一点点,值域在 0.0001~0.001 量级;
- normalBias:沿法线偏移,对曲面、大场景特别有效,0.01~0.1 常见。
偏移加过头会得另一种病:阴影悬浮(peter panning),阴影和物体脱离,像飘在空中。所以 bias 的原则是”刚好够用,越小越好”。曲面痤疮优先试 normalBias,它的副作用比 bias 小。
Tip调参顺序:normalBias 从 0.02 起步,不够再加 bias。两个都别加到让阴影明显偏移的程度。
其他实用参数
shadow.radius:阴影边缘模糊半径,大于 1 时边缘变虚(BasicShadowMap 下无效);shadow.intensity:阴影浓度,0~1,默认 1,调小可以得到淡阴影;shadow.autoUpdate:默认 true,静态场景可以关掉省性能。
常见问题排查
| 现象 | 原因 | 对策 |
|---|---|---|
| 表面麻点、闪烁 | 阴影痤疮 | 加 normalBias、bias |
| 阴影脱离物体 | bias 过大 | 减小 bias |
| 边缘锯齿明显 | 滤波弱或分辨率低 | PCFSoftShadowMap + 提高 mapSize |
| 阴影破碎、漏光 | VSM 或贴图精度不足 | 换 PCF 系、提高 mapSize |
| 完全没阴影 | 三步没配齐、范围没罩住、材质不受光 | 逐一检查 |
性能提醒:每一盏开 castShadow 的灯,每帧都要额外渲染一次深度图(点光源是六次)。移动端场景里,带阴影的灯控制在 1~2 盏。
可调参数的阴影示例
下面的示例开好阴影,还带了三个控件:阴影类型、bias、normalBias。把 bias 拉大你会看到悬浮,把 normalBias 归零你会看到麻点,这就是最直观的”病理实验”。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>19 阴影系统演示</title>
<style>
body { margin: 0; overflow: hidden; background: #222; }
#controls {
position: fixed; top: 12px; left: 12px; z-index: 1;
color: #fff; font: 13px/2 sans-serif;
background: rgba(0,0,0,.6); padding: 10px 14px; border-radius: 6px;
}
#controls label { display: block; }
</style>
</head>
<body>
<div id="controls">
<label>
阴影类型
<select id="shadowType">
<option value="basic">BasicShadowMap</option>
<option value="pcf" selected>PCFShadowMap</option>
<option value="pcfsoft">PCFSoftShadowMap</option>
<option value="vsm">VSMShadowMap</option>
</select>
</label>
<label>
bias:
<input type="range" id="bias" min="-0.005" max="0.005" step="0.0001" value="0.0002" />
<span id="biasValue">0.0002</span>
</label>
<label>
normalBias:
<input type="range" id="normalBias" min="0" max="0.1" step="0.005" value="0.02" />
<span id="normalBiasValue">0.02</span>
</label>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.185.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.185.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true; // 1. 渲染器开阴影
renderer.shadowMap.type = THREE.PCFShadowMap;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x223344);
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(8, 6, 10);
camera.lookAt(0, 0, 0);
const controls = new OrbitControls(camera, renderer.domElement);
// 灯光
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
const dirLight = new THREE.DirectionalLight(0xffffff, 3);
dirLight.position.set(5, 8, 4);
dirLight.castShadow = true; // 2. 灯光投阴影
dirLight.shadow.mapSize.set(2048, 2048);
dirLight.shadow.camera.left = -8;
dirLight.shadow.camera.right = 8;
dirLight.shadow.camera.top = 8;
dirLight.shadow.camera.bottom = -8;
dirLight.shadow.camera.near = 0.5;
dirLight.shadow.camera.far = 30;
scene.add(dirLight);
// 地面:收阴影
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(16, 16),
new THREE.MeshStandardMaterial({ color: 0x888899, roughness: 0.9 })
);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true; // 3. 物体收阴影
scene.add(floor);
// 箱子:投阴影,缓慢旋转让阴影动起来
const box = new THREE.Mesh(
new THREE.BoxGeometry(1.6, 1.6, 1.6),
new THREE.MeshStandardMaterial({ color: 0xff8844, roughness: 0.5 })
);
box.position.set(-1.5, 1.6, 0);
box.castShadow = true;
scene.add(box);
// 球:投阴影,上下浮动
const sphere = new THREE.Mesh(
new THREE.SphereGeometry(0.9, 32, 32),
new THREE.MeshStandardMaterial({ color: 0x44cc88, roughness: 0.3, metalness: 0.2 })
);
sphere.position.set(2, 1.8, 0.5);
sphere.castShadow = true;
scene.add(sphere);
// 墙:又投又收
const wall = new THREE.Mesh(
new THREE.BoxGeometry(8, 3, 0.3),
new THREE.MeshStandardMaterial({ color: 0x5566aa, roughness: 0.8 })
);
wall.position.set(0, 1.5, -4);
wall.castShadow = true;
wall.receiveShadow = true;
scene.add(wall);
// 控件
const shadowTypeMap = {
basic: THREE.BasicShadowMap,
pcf: THREE.PCFShadowMap,
pcfsoft: THREE.PCFSoftShadowMap,
vsm: THREE.VSMShadowMap,
};
document.getElementById('shadowType').addEventListener('change', (e) => {
renderer.shadowMap.type = shadowTypeMap[e.target.value];
});
document.getElementById('bias').addEventListener('input', (e) => {
dirLight.shadow.bias = parseFloat(e.target.value);
document.getElementById('biasValue').textContent = e.target.value;
});
document.getElementById('normalBias').addEventListener('input', (e) => {
dirLight.shadow.normalBias = parseFloat(e.target.value);
document.getElementById('normalBiasValue').textContent = e.target.value;
});
function animate() {
requestAnimationFrame(animate);
box.rotation.y += 0.01;
sphere.position.y = 1.8 + Math.sin(Date.now() * 0.001) * 0.6;
controls.update();
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>
Note示例里故意用大范围阴影相机加 2048 分辨率。你可以把阴影相机范围改小,观察阴影立刻变清晰——这就是”范围刚好包住物体”的意义。
两个容易被忽略的坑
透明物体默认也会投阴影,整块”玻璃”会留下一个实心黑影。想让透明物体不投阴影,需要把它从投阴影的物体里排除,或者用 ShadowMaterial 之类的专用方案。
双面材质(side: THREE.DoubleSide)投阴影时默认只按正面计算,背面可能漏光。可以显式指定材质 shadowSide 来修正:
material.shadowSide = THREE.DoubleSide; // 或 FrontSide / BackSide
透明加阴影是坑最多的组合,遇到”影子和物体对不上”时,先往这两个方向排查。
小结
阴影等于三步开关加贴图分辨率加深度偏移。开起来不难,调好看需要耐心。记住排查顺序:先确认三步都开了,再调 normalBias 治痤疮,最后用 mapSize 和 PCFSoft 提质量。