首页 / Three.js 入门教程 / 阴影系统:让物体稳稳落地

Three.js 入门教程

阴影系统:让物体稳稳落地

本教程共 40 篇 · 第 19 篇 · 更新于 2026-08-14 · 约 9 分钟阅读

Three.js阴影阴影贴图ShadowMapPCFbias

本节目标:学会开启和调优 three.js 阴影,理解阴影贴图的工作方式,能独立排查阴影闪烁、悬浮、漏光等常见问题。

没有阴影的 3D 场景像悬浮在空中的贴图。这一章把阴影系统讲透:怎么开、怎么调、出问题怎么修。

阴影是怎么来的

先建立一个认知:three.js 的阴影不是”算”出来的光线遮挡,而是一种叫阴影贴图(shadow mapping)的近似技术,分两步:

  1. 从灯光的视角,把场景渲染成一张只有深度信息的图,叫阴影贴图;
  2. 正常渲染时,把每个像素的深度和贴图比较:比贴图更深,说明被挡住了,画阴影。

所以阴影质量受两个硬指标限制:贴图分辨率(像素有限)和深度精度(深度值有误差)。后面所有”调阴影”的操作,本质上都是在和这两个限制搏斗。这也是为什么阴影永远不可能像光线追踪那样精确:它本质上是”从灯的角度拍一张照片”。

三步开启阴影

阴影默认全关,因为渲染阴影贴图很贵。开启需要三个地方配合:

  1. 渲染器开总开关:
renderer.shadowMap.enabled = true;
  1. 灯光开 castShadow(只有直接光能投阴影,环境光不行):
dirLight.castShadow = true;
  1. 物体声明投、收阴影:
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 提质量。