首页 / Three.js 入门教程 / 光照模型与渲染原理:让灯光物理正确

Three.js 入门教程

光照模型与渲染原理:让灯光物理正确

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

Three.js光照模型PBR物理光照坎德拉色调映射

本节目标:理解 three.js 的光照是怎么算出来的,搞清楚 r185 的物理光照模式、光照强度单位(坎德拉、流明、勒克斯)和色调映射,能解释”为什么我的场景这么暗”。

上一章介绍了六种灯,这一章回答”灯到底是怎么算的”。很多人调灯光全凭手感,因为不理解背后的模型。搞懂本章的内容,布光会从”试错”变成”计算”。

光在实时渲染里被拆成两半

真实世界中,你看到的光由两部分组成:从光源直接射来的直接光,以及经周围物体反弹多次的间接光。反弹次数是无限的,计算机算不完。所以实时渲染把光照拆成两半:直接光用灯光精确计算,间接光用近似手段假装。

three.js 提供三种”装”间接光的手段,按成本从低到高:

  1. 环境光、半球光:最便宜,上一章的 AmbientLight 和 HemisphereLight 就是;
  2. 环境贴图(IBL,Image-Based Lighting):把四周环境预先拍进一张图,材质通过 envMap 采样,效果真实很多;
  3. 光照探针(LightProbe):在场景中放探测器记录光照,用来给动态物体补光。

实际项目里,环境光负责”亮起来”,IBL 负责”真实感”。

PBR:基于物理的材质模型

直接光打在物体表面怎么反光?r155 之后 three.js 全面采用 PBR(Physically Based Rendering,基于物理的渲染)模型,就是 MeshStandardMaterial、MeshPhysicalMaterial 用的那套算法。

PBR 的核心是能量守恒:入射光等于反射光加吸收光,总能量不会凭空增加。描述表面只需要两个参数:

  • roughness(粗糙度):0 是镜面,1 是完全粗糙的哑光;
  • metalness(金属度):0 是非金属,有漫反射也有高光;1 是金属,没有漫反射,只有高光。

表面越粗糙,高光越散、越暗;越光滑,高光越集中、越亮。金属表面反射周围环境的颜色,非金属表面的颜色来自自身。两个参数配合得当,一套材质在任何灯光下都说得通。这就是 PBR 最大的好处:换灯光不用重调材质。

物理光照:useLegacyLights 的来龙去脉

在 r155 之前,three.js 用的是老式光照(legacy lights):所有灯光的 intensity 都是无量纲的倍数,点光源也没有物理衰减,调光全靠手感。r155 把 WebGLRenderer.useLegacyLights 的默认值改成 false,同时标记弃用;r165 干脆把这个属性整个删掉了。

所以 r185 里根本不存在 useLegacyLights 这个开关——物理光照是唯一模式。看旧教程时要注意:老代码里 intensity: 1 的点光源,在 r185 下会暗到几乎看不见。这不是 bug,是光照模型变了。

物理光照模式下:

  • 点光源、聚光灯的 intensity 单位是坎德拉(candela,cd),光强按距离平方衰减(decay = 2);
  • 灯光还有 power 属性,单位流明(lumen,lm),改 power 会自动换算 intensity;
  • 勒克斯(lux)是照度单位:1 lux = 1 lm/m²,描述”落在物体表面的光有多少”;
  • 平行光不衰减,intensity 没有物理单位,纯观感值,常见 2~5;
  • 环境光、半球光的 intensity 也没有物理意义,只是亮度倍数。

平方反比衰减很残酷:一盏 100 cd 的灯,10 米外只剩 1 lux(100 除以 10 的平方),差不多是月光亮度;正午阳光约 10 万 lux。所以物理模式下点光源、聚光灯的强度经常要开到几百、几千,场景尺寸也要按”1 单位 = 1 米”来建,数值才对得上。点光源的衰减就是这条公式的除法:E = I / d²,距离翻倍,照度只剩四分之一。

Note

快速估算:想让 5 米外的物体有 40 lux 的照度,点光源强度就设 40 × 25 = 1000(照度 × 距离平方)。先按这个起步,再微调。

色彩管理:在线性空间里算,在 sRGB 里显示

颜色也要物理正确。r152 起 three.js 默认开启色彩管理:所有光照计算在线性空间进行,输出到屏幕前再转换到 sRGB。材质颜色、纹理贴图会被自动标记和转换,开发者一般不用管。

如果你发现贴图颜色和原图不一样,多半是色彩空间问题。渲染器的输出色彩空间由 outputColorSpace 控制,默认 SRGBColorSpace,正常不用改。

色调映射:把高动态范围压进屏幕

物理光照让场景亮度可以超过 1,比如直射阳光。但显示器只能显示 0~1 的亮度,直接截断会让亮部一片死白。色调映射(tone mapping)就是一条把高动态范围映射到低动态范围的曲线:压住高光,同时尽量保住暗部细节。

WebGLRenderer 有两个相关属性:

renderer.toneMapping = THREE.ACESFilmicToneMapping; // 默认 NoToneMapping
renderer.toneMappingExposure = 1;                    // 曝光,默认 1

常用模式对比:

  • NoToneMapping:默认,不做映射,亮度超过 1 直接变白;
  • ReinhardToneMapping:经典算法,简单稳定,但画面整体偏灰;
  • ACESFilmicToneMapping:电影工业标准曲线,高光柔和,最常用;
  • AgXToneMapping:r160 加入的新一代电影级曲线,色彩还原比 ACES 更准;
  • NeutralToneMapping:r162 加入的温和映射,适合需要”原汁原味”的产品展示;
  • LinearToneMapping、CineonToneMapping:另两种旧选项,现在很少用。
Tip

调光流程:先关色调映射(NoToneMapping)把灯光调好,再开 ACESFilmic 调曝光。两个变量分开调,心里有数。

环境贴图:更真实的间接光

环境光只能均匀提亮,金属材质没有环境可反射,看起来像塑料。环境贴图(IBL)解决这个问题:把四周环境存成一张图,材质自动从中采样反射和高光。

three.js 里最省事的做法是用 RoomEnvironment,一个程序生成的室内环境,不需要任何图片资源。环境贴图的生成与用法在第 14 章已讲过(RoomEnvironment + PMREMGenerator 那一套),这里只补充它与光照模型的关系。

scene.environment 设置后,场景里所有 PBR 材质都会把它当环境光来反射,金属度高的物体立刻”活”过来。

常见误区

  • 场景太暗就是灯不够多:先查强度单位。点光源强度 1 约等于一根火柴,从几百起步再看。
  • 灯光和材质一起调:PBR 的好处是两者独立。先固定材质只调灯,灯满意了再动材质,变量少才调得准。
  • 忘了色彩管理:纹理颜色发灰时,检查纹理的 colorSpace 是否 SRGBColorSpace、渲染器 outputColorSpace 是否正确。
  • 曝光当亮度用:toneMappingExposure 是全局曝光,会同时压暗亮部和暗部。局部太暗应该加灯,而不是猛拉曝光。

可交互的色调映射示例

下面的例子摆了 6 个材质球:横向是粗糙度 0.1、0.5、0.9,纵向是金属度 0 和 1。可以切换色调映射模式、拖动曝光滑块,直接看同一个场景在不同映射下的差别。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>18 色调映射演示</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="tone">
        <option value="none">NoToneMapping</option>
        <option value="linear">LinearToneMapping</option>
        <option value="reinhard">ReinhardToneMapping</option>
        <option value="aces" selected>ACESFilmicToneMapping</option>
        <option value="agx">AgXToneMapping</option>
        <option value="neutral">NeutralToneMapping</option>
      </select>
    </label>
    <label>
      曝光
      <input type="range" id="exposure" min="0.2" max="2" step="0.05" value="1" />
      <span id="exposureValue">1.0</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.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1;
  document.body.appendChild(renderer.domElement);

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x222233);
  const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
  camera.position.set(0, 3, 9);
  camera.lookAt(0, 0, 0);
  const controls = new OrbitControls(camera, renderer.domElement);

  // 灯光:一盏平行光 + 一盏环境光
  const dirLight = new THREE.DirectionalLight(0xffffff, 3);
  dirLight.position.set(4, 6, 4);
  scene.add(dirLight);
  scene.add(new THREE.AmbientLight(0xffffff, 0.3));

  // 地面
  const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(14, 14),
    new THREE.MeshStandardMaterial({ color: 0x555566, roughness: 0.8 })
  );
  floor.rotation.x = -Math.PI / 2;
  floor.position.y = -1;
  scene.add(floor);

  // 6 个材质球:粗糙度 0.1/0.5/0.9,金属度 0/1
  const roughnessValues = [0.1, 0.5, 0.9];
  const metalnessValues = [0, 1];
  roughnessValues.forEach((roughness, i) => {
    metalnessValues.forEach((metalness, j) => {
      const sphere = new THREE.Mesh(
        new THREE.SphereGeometry(0.9, 48, 48),
        new THREE.MeshStandardMaterial({ color: 0xcccccc, roughness, metalness })
      );
      sphere.position.set((i - 1) * 2.4, 0, (j - 0.5) * 2.4);
      scene.add(sphere);
    });
  });

  // 色调映射切换
  const toneMappingMap = {
    none: THREE.NoToneMapping,
    linear: THREE.LinearToneMapping,
    reinhard: THREE.ReinhardToneMapping,
    aces: THREE.ACESFilmicToneMapping,
    agx: THREE.AgXToneMapping,
    neutral: THREE.NeutralToneMapping,
  };
  document.getElementById('tone').addEventListener('change', (e) => {
    renderer.toneMapping = toneMappingMap[e.target.value];
  });
  document.getElementById('exposure').addEventListener('input', (e) => {
    renderer.toneMappingExposure = parseFloat(e.target.value);
    document.getElementById('exposureValue').textContent = e.target.value;
  });

  window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  });

  function animate() {
    requestAnimationFrame(animate);
    controls.update();
    renderer.render(scene, camera);
  }
  animate();
  </script>
</body>
</html>
Note

观察重点:切到 NoToneMapping 后,材质球正对灯光的高光处会变成纯白一片;切到 ACESFilmic 后,高光有了层次。这就是色调映射在起作用。

小结

本章把”光怎么算”讲完了:直接光加间接光近似,PBR 材质加物理光照、色彩管理、色调映射,共同构成 r185 的默认渲染管线。下一章给这套管线补上最后一个大件:阴影。