光照模型与渲染原理:让灯光物理正确
本教程共 40 篇 · 第 18 篇 · 更新于 2026-08-14 · 约 9 分钟阅读
本节目标:理解 three.js 的光照是怎么算出来的,搞清楚 r185 的物理光照模式、光照强度单位(坎德拉、流明、勒克斯)和色调映射,能解释”为什么我的场景这么暗”。
上一章介绍了六种灯,这一章回答”灯到底是怎么算的”。很多人调灯光全凭手感,因为不理解背后的模型。搞懂本章的内容,布光会从”试错”变成”计算”。
光在实时渲染里被拆成两半
真实世界中,你看到的光由两部分组成:从光源直接射来的直接光,以及经周围物体反弹多次的间接光。反弹次数是无限的,计算机算不完。所以实时渲染把光照拆成两半:直接光用灯光精确计算,间接光用近似手段假装。
three.js 提供三种”装”间接光的手段,按成本从低到高:
- 环境光、半球光:最便宜,上一章的 AmbientLight 和 HemisphereLight 就是;
- 环境贴图(IBL,Image-Based Lighting):把四周环境预先拍进一张图,材质通过
envMap采样,效果真实很多; - 光照探针(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 的默认渲染管线。下一章给这套管线补上最后一个大件:阴影。