前言
在一个Three.js的场景中,我们经常会添加Light,最常用的如:DirectionalLight、PointLight、SpotLight。这些Light都属于直接光照(Direct Illumination),即光源(如太阳、灯具等)发出的光线直接照射到物体表面所产生的光照效果。在真实世界中,除了直接光照,还有间接光照(Indirect Illumination),即光线经物体表面反射、折射或散射后,间接照亮其他物体的光照效果。全局光照(Global Illumination)就是将直接光照和间接光照两者结合产生的光照效果。能够在场景中模拟间接光照,可以在很大程度上增加场景渲染的真实感。
对于模拟间接光照,Three.js提供了一些解决方案。最简单的是AmbientLight,它为整个场景提供一个基础的光照亮度。AmbientLight的缺点很明显,它假定整个场景中每个点的间接光照都一致,都是相同的一个环境光,这并不真实,仅仅能避免出现场景中某个区域完全处于黑暗中。
另一种方案是使用envMap,为某个object预计算一张记录光照信息的环境贴图,在渲染时每个像素点从这张纹理上采样获得光照信息。这个方案优点很明显,光照信息是预先计算好的,而且可以预先计算好不同分辨率大小的纹理,将其分为不同的层级,根据不同的粗糙度(roughness)选择不同层级的纹理进行采样。所以envMap不但可以模拟间接光照的漫反射(diffuse),还能模拟镜面反射(Specular)。但是envMap的缺点就是纹理的存储开销比较大。
接下来就是本文要介绍的LightProbe。LightProbe也是通过预计算的方式将环境中的光照信息存储起来,但是存储的信息比envMap少很多,在Three.js中只需要9个三维向量,即9个Vector3对象。但相比envMap可以模拟镜面反射,LightProbe只用来模拟漫反射。
LightProbe的原理简介
接下来对LightProbe的原理进行简单介绍。LightProbe的数学基础的球面谐波函数(Spherical Harmonics,SH),简称球谐函数。本文不涉及过多的数学推导,强烈推荐对球谐函数的详细推导感兴趣的同学可以看看知乎上tkstar大佬的文章,写的非常通俗和详细。
球谐光照------球谐函数
球谐光照------辐照度照明
渲染方程
既然讨论渲染话题,自然得从大名鼎鼎的渲染方程开始,这里给不了解的同学简单介绍下:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> L o ( p , ω o ) = L e ( p , ω o ) + ∫ Ω f r ( p , ω i , ω o ) L i ( p , ω i ) ( n ⋅ ω i ) d ω i L_o(p,ω_o)=L_e(p,ω_o)+\int_Ωf_r(p,ω_i,ω_o)L_i(p,ω_i)(n\cdotω_i)dω_i </math>Lo(p,ωo)=Le(p,ωo)+∫Ωfr(p,ωi,ωo)Li(p,ωi)(n⋅ωi)dωi
先简单介绍两个概念:
radiance,翻译为辐射亮度,指单位投影面积、单位立体角上的辐射功率。
irradiance:翻译为辐照度,指单位面积表面接收到的总辐射功率。
<math xmlns="http://www.w3.org/1998/Math/MathML"> p p </math>p:代表被渲染的点。
<math xmlns="http://www.w3.org/1998/Math/MathML"> ω o , ω i ω_o,ω_i </math>ωo,ωi:分别代表出射的方向和入射的方向,这里的方向可以由球面坐标系的极角θ和方位角φ表示,也可以由直角坐标系下的一个向量(x,y,z)表示。
<math xmlns="http://www.w3.org/1998/Math/MathML"> L o L_o </math>Lo:从点 <math xmlns="http://www.w3.org/1998/Math/MathML"> p p </math>p沿方向 <math xmlns="http://www.w3.org/1998/Math/MathML"> ω o ω_o </math>ωo出射的radiance。
<math xmlns="http://www.w3.org/1998/Math/MathML"> L e L_e </math>Le:点 <math xmlns="http://www.w3.org/1998/Math/MathML"> p p </math>p自身沿方向 <math xmlns="http://www.w3.org/1998/Math/MathML"> ω o ω_o </math>ωo发射出去的光的radiance,由于本文中渲染的物体不能自发光,所以本文不涉及这个量。
<math xmlns="http://www.w3.org/1998/Math/MathML"> L i L_i </math>Li:沿方向 <math xmlns="http://www.w3.org/1998/Math/MathML"> ω i ω_i </math>ωi入射到点 <math xmlns="http://www.w3.org/1998/Math/MathML"> p p </math>p上的radiance。
<math xmlns="http://www.w3.org/1998/Math/MathML"> f r ( p , ω i , ω o ) f_r(p,ω_i,ω_o) </math>fr(p,ωi,ωo):BRDF项,本文对此不做详细解释,只需要知道BRDF项和材质本身的光学特性有关,决定了材质如何反射光,对于漫反射来说,来自周围四面八方的入射光(当然除了物体背面)都可以对同一个反射方向有贡献,即来自物体正面的整个半球上的所有 <math xmlns="http://www.w3.org/1998/Math/MathML"> ω i ω_i </math>ωi方向的入射光都对一个出射方向 <math xmlns="http://www.w3.org/1998/Math/MathML"> ω o ω_o </math>ωo上的辐射亮度 <math xmlns="http://www.w3.org/1998/Math/MathML"> L o L_o </math>Lo有贡献。但对于镜面反射来说,只有少数方向的入射光对 <math xmlns="http://www.w3.org/1998/Math/MathML"> ω o ω_o </math>ωo方向有贡献,所以我们在生活中经常能够在镜面反射的物体的某一个出射方向 <math xmlns="http://www.w3.org/1998/Math/MathML"> ω o ω_o </math>ωo上看到反射出的高光,其他方向又看不到反射的光。
<math xmlns="http://www.w3.org/1998/Math/MathML"> ( n ⋅ ω i ) (n\cdotω_i) </math>(n⋅ωi):平面的法线向量与入射方向的点乘,即法线方向与入射方向的夹角的余弦值cosθ,越是正对着平面光照越强,对反射的贡献也就越大。
积分项:对所有可能的入射方向在整个球面上进行积分,就是将四面八方的入射到点 <math xmlns="http://www.w3.org/1998/Math/MathML"> p p </math>p的光的贡献都加起来,点 <math xmlns="http://www.w3.org/1998/Math/MathML"> p p </math>p出射的radiance是这些光共同作用的结果。
对于漫反射来说,BRDF项可以看做是一个常数,这就使得渲染方程的积分式满足了一定条件,可以近似的用以下方式拆分:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ∫ Ω f ( x ) g ( x ) d x ≈ ∫ Ω G f ( x ) d x ∫ Ω G d x ⋅ ∫ Ω g ( x ) d x \int_Ωf(x)g(x)dx\approx\frac{\int_{Ω_G}f(x)dx}{\int_{Ω_G}dx}\cdot\int_Ωg(x)dx </math>∫Ωf(x)g(x)dx≈∫ΩGdx∫ΩGf(x)dx⋅∫Ωg(x)dx
其中, <math xmlns="http://www.w3.org/1998/Math/MathML"> Ω G Ω_G </math>ΩG代表 <math xmlns="http://www.w3.org/1998/Math/MathML"> g ( x ) g(x) </math>g(x)的支撑集,即 <math xmlns="http://www.w3.org/1998/Math/MathML"> g ( x ) g(x) </math>g(x)非零的自变量的取值范围。
类似的渲染方程可以做如下拆分:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ∫ Ω f r ( p , ω i , ω o ) L i ( p , ω i ) ( n ⋅ ω i ) d ω i ≈ ∫ Ω L i ( p , ω i ) ( n ⋅ ω i ) d ω i ⋅ ∫ Ω f r ( p , ω i , ω o ) d x ∫ Ω d x \int_Ωf_r(p,ω_i,ω_o)L_i(p,ω_i)(n\cdotω_i)dω_i\approx\int_ΩL_i(p,ω_i)(n\cdotω_i)dω_i\cdot\frac{\int_Ωf_r(p,ω_i,ω_o)dx}{\int_Ωdx} </math>∫Ωfr(p,ωi,ωo)Li(p,ωi)(n⋅ωi)dωi≈∫ΩLi(p,ωi)(n⋅ωi)dωi⋅∫Ωdx∫Ωfr(p,ωi,ωo)dx
上文中提到过,对于漫反射来说,乘号右边的BRDF项是一个常数,那么对BRDF项进行积分然后再归一化的得到的值仍然是一个常数,所以我们暂且不去管它。我们这里重点关注乘号左边的一项:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ∫ Ω L i ( p , ω i ) ( n ⋅ ω i ) d ω i \int_ΩL_i(p,ω_i)(n\cdotω_i)dω_i </math>∫ΩLi(p,ωi)(n⋅ωi)dωi
我们可以看到,这个积分式可以看做是某一个点 <math xmlns="http://www.w3.org/1998/Math/MathML"> p p </math>p接收的整个球面上所有入射光照的radiance总和,也就是 <math xmlns="http://www.w3.org/1998/Math/MathML"> p p </math>p接收的irradiance。仔细分析,这是一个以 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( n ⋅ ω i ) (n\cdotω_i) </math>(n⋅ωi)为核函数的卷积,卷积后就得到了一个关于点 <math xmlns="http://www.w3.org/1998/Math/MathML"> p p </math>p所在平面法线 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n的函数。可以记作:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> E ( n ) = ∫ Ω L i ( p , ω i ) ( n ⋅ ω i ) d ω i E(n)=\int_ΩL_i(p,ω_i)(n\cdotω_i)dω_i </math>E(n)=∫ΩLi(p,ωi)(n⋅ωi)dωi
那么,关键的问题就变成了,如何计算这个函数。
球面谐波函数
终于可以请出惊为天人又令人恐惧的球面谐波函数了,数学原理详细推导请大家去看上文中提到的tkstar大佬的文章。简单来说,球面谐波函数是一组关于球面上立体角的基函数,可以把上文中所说的 <math xmlns="http://www.w3.org/1998/Math/MathML"> E ( n ) E(n) </math>E(n)通过一定程度的近似,写成每个基函数各自乘以一个系数然后求和的形式。在Three.js中选择了球谐函数中的前3阶一共9个基函数。也就是说,只要能提前计算出这9个基函数的系数,就能通过这组基函数线性组合表示球面上所有方向上的irradiance。那么对于要具体渲染的某个点 <math xmlns="http://www.w3.org/1998/Math/MathML"> p p </math>p来说,只要知道这个点的平面法线,就能通过这个函数求出该点接收到的irradiance。
可以看看Three.js的源码,这样能理解的更直观:
glsl
// get the irradiance (radiance convolved with cosine lobe) at the point 'normal' on the unit sphere
// source: https://graphics.stanford.edu/papers/envmap/envmap.pdf
vec3 shGetIrradianceAt( in vec3 normal, in vec3 shCoefficients[ 9 ] ) {
// normal is assumed to have unit length
float x = normal.x, y = normal.y, z = normal.z;
// band 0
vec3 result = shCoefficients[ 0 ] * 0.886227;
// band 1
result += shCoefficients[ 1 ] * 2.0 * 0.511664 * y;
result += shCoefficients[ 2 ] * 2.0 * 0.511664 * z;
result += shCoefficients[ 3 ] * 2.0 * 0.511664 * x;
// band 2
result += shCoefficients[ 4 ] * 2.0 * 0.429043 * x * y;
result += shCoefficients[ 5 ] * 2.0 * 0.429043 * y * z;
result += shCoefficients[ 6 ] * ( 0.743125 * z * z - 0.247708 );
result += shCoefficients[ 7 ] * 2.0 * 0.429043 * x * z;
result += shCoefficients[ 8 ] * 0.429043 * ( x * x - y * y );
return result;
}
Three.js中的LightProbe
LightProbe就是通过预计算的方式,提前计算场景中各个方向的光照辐照度,光照辐照度可以表示为一个以方向为自变量的函数,这个方向既可以用(极角θ,方位角φ)表示,也可以用法向方向表示。依据上文的介绍,我们知道这个函数可以用球面谐波函数的基函数的线性组合来表示。那么只需要预计算出球面谐波函数的9个系数的值,就可以在渲染时非常高效的算出任意方向上的光照辐照度。
对于LightProbe的预计算来说,Three.js提供了LightProbeGenerator类,这个类中有两个方法:fromCubeTexture:通过一组六面体纹理(CubeTexture)来计算场景中的LightProbe。 fromCubeRenderTarget:在场景中定义一个CubeCamera,这个Camera放在场景中,将上、下、左、右、前、后六个方向都渲染到RenderTarget上,然后通过RenderTarget的纹理来计算LightProbe。
js
class LightProbeGenerator {
/**
* Creates a light probe from the given (radiance) environment map.
* The method expects that the environment map is represented as a cube texture.
*
* @param {CubeTexture} cubeTexture - The environment map.
* @return {LightProbe} The created light probe.
*/
static fromCubeTexture( cubeTexture );
/**
* Creates a light probe from the given (radiance) environment map.
* The method expects that the environment map is represented as a cube render target.
*
* The cube render target must be in RGBA so `cubeRenderTarget.texture.format` must be
* set to {@link RGBAFormat}.
*
* @async
* @param {WebGPURenderer|WebGLRenderer} renderer - The renderer.
* @param {CubeRenderTarget|WebGLCubeRenderTarget} cubeRenderTarget - The environment map.
* @return {Promise<LightProbe>} A Promise that resolves with the created light probe.
*/
static async fromCubeRenderTarget( renderer, cubeRenderTarget );
}
但是Three.js的LightProbe有一定的局限性,它把LightProbe当做一种场景中的光源类型,可以确切的说是当做专门用来描述漫反射物体的间接光照的光源。但是,一个LightProbe加入场景后,这个场景中任意位置物体都将接收到这个LightProbe的光照。这与真实世界尤其是场景中光照条件变化比较剧烈的情况,有比较大的差距,比如一个场景中有一堵墙,墙的两侧物体接收到的间接光照显然会有很大的差别。
此外,Three.js在渲染时,凡是可以接受间接光照的材质,如MeshStandardMaterial、MeshPhysicalMaterial、MeshLambertMaterial、MeshPhongMaterial等都会受到LightProbe,这也不够灵活,我们实际开发中有时并不想让所有的物体都受到LightProbe的影响。
在场景放置多个LightProbe
想要利用LightProbe达到更加真实的渲染效果,参考其他引擎的做法,场景中不同位置的间接光照是不一样的,应该在场景中多个位置分别放置LightProbe,可以在光照变化不大的区域放的比较稀疏,在光照变化剧烈的地方放的比较稠密。场景中的物体根据距离各个LightProbe的远近,决定具体使用哪个LightProbe。也可以对LightProbe的intensity进行插值。
下面是我写的一个demo,供参考。场景中有两个球状网格体,作为对照,一个球接收LightProbe的光照,一个球不接收。在两个球的下方和后方,是两个长方形的平面网格体,平面网格体各处的颜色不同。沿着直线方向放置一系列的LightProbe,然后将这些LightProbe和要接收LightProbe的球加入到probeScene中,其他的物体加入到scene中,这样就可以做到只有probeScene中的球接收到LightProbe的光照。这个球在场景中沿着直线移动,实时计算球与各个LightProbe的距离,将距离球比较近的两个LightProbe的intensity设置为非0,具体数值设为 <math xmlns="http://www.w3.org/1998/Math/MathML"> 距离 / 10 距离/10 </math>距离/10,其它LightProbe的intensity设为0。这样就有了球的间接光照动态变化的效果了。
js
import { useEffect, useRef } from "react"
import * as THREE from 'three';
import { OrbitControls, LightProbeGenerator } from 'three/addons';
let camera: THREE.PerspectiveCamera;
let scene: THREE.Scene;
let probeScene: THREE.Scene;
let renderer: THREE.WebGLRenderer | null;
let controls: OrbitControls;
let requestId: number
let sphere1: THREE.Mesh
function ThreeContainer() {
const threeContainer = useRef<HTMLInputElement>(null);
useEffect(() => {
if (threeContainer.current) {
//scene包含不接收LightProbe的mesh,probeScene包含接收LightProbe的小球
probeScene = new THREE.Scene();
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(75, threeContainer.current.clientWidth / threeContainer.current.clientHeight, 0.1, 1000);
camera.position.set(0, 50, 150);
camera.lookAt(0, 20, 20);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(threeContainer.current.clientWidth, threeContainer.current.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.autoClear = false
controls = new OrbitControls(camera, renderer.domElement);
controls.target = new THREE.Vector3(0, 0, 0);
threeContainer.current.appendChild(renderer.domElement);
//给两个scene都添加一个默认的直射光
const directionalIntensity = 2
const directionalLight = new THREE.DirectionalLight(0xffffff, directionalIntensity);
directionalLight.position.set(40, 40, 0);
scene.add(directionalLight);
const probeDirectionalLight = directionalLight.clone()
probeScene.add(probeDirectionalLight)
//场景中下方的平面mesh
const floorGeometry1 = new THREE.PlaneGeometry(400, 40)
const floorMaterial1 = new THREE.ShaderMaterial({
vertexShader: `
varying vec2 vUv;
void main(){
vUv = uv;
gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);
}`,
fragmentShader: `
varying vec2 vUv;
void main(){
gl_FragColor=vec4(pow(vec3(vUv.x,(1.-vUv.x),0.),vec3(1./2.2)),1.);
}`,
side: THREE.DoubleSide,
});
const floorMesh1 = new THREE.Mesh(floorGeometry1, floorMaterial1)
floorMesh1.rotation.x = -(Math.PI / 2)
floorMesh1.position.y = -20
scene.add(floorMesh1);
//场景中后方的平面mesh
const floorGeometry2 = new THREE.PlaneGeometry(400, 40)
const floorMaterial2 = new THREE.ShaderMaterial({
vertexShader: `
varying vec2 vUv;
void main(){
vUv = uv;
gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);
}`,
fragmentShader: `
varying vec2 vUv;
void main(){
gl_FragColor=vec4(pow(vec3(0.,(1.-vUv.x),vUv.x),vec3(1./2.2)),1.);
}`,
side: THREE.DoubleSide,
});
const floorMesh2 = new THREE.Mesh(floorGeometry2, floorMaterial2)
floorMesh2.position.z = -20
scene.add(floorMesh2);
//添加两个小球,作为对照一个接收LightProbe,一个不接收
//添加完LightProbes再添加小球,确保小球不会对LightProbes的系数值造成影响
addLightProbes().then(() => {
const sphereGeometry = new THREE.SphereGeometry(10);
const sphereMaterial = new THREE.MeshStandardMaterial({
color: 0xffffff,
});
const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
sphere.position.set(-190, 0, 0)
scene.add(sphere);
const sphereGeometry1 = new THREE.SphereGeometry(10);
const sphereMaterial1 = new THREE.MeshStandardMaterial({
color: 0xffffff,
});
sphere1 = new THREE.Mesh(sphereGeometry1, sphereMaterial1);
sphere1.position.set(190, 0, 0)
probeScene.add(sphere1);
})
requestId = requestAnimationFrame(animate);
}
return () => {
destroyRenderer()
};
}, [])
const lightProbeMap: Map<number, THREE.LightProbe> = new Map<number, THREE.LightProbe>()
//每个10个单位添加一个LightProbe,初始化将最右侧的intensity设为1,其它设为0
function addLightProbes() {
const promiseList = []
for (let x = -200; x <= 200; x = x + 10) {
const cubeRenderTarget = new THREE.WebGLCubeRenderTarget(512);
const cubeCamera = new THREE.CubeCamera(0.1, 1000, cubeRenderTarget);
cubeCamera.position.set(x, 0, 0)
cubeCamera.update(renderer as THREE.WebGLRenderer, scene);
promiseList.push(new Promise<void>((resolve) => {
if (renderer) {
LightProbeGenerator.fromCubeRenderTarget(renderer as THREE.WebGLRenderer, cubeRenderTarget).then(probe => {
if (x === 190) {
probe.intensity = 1
} else {
probe.intensity = 0
}
lightProbeMap.set(x, probe)
probeScene.add(probe)
resolve()
}
).catch(err => {
console.log(err)
})
}
}))
}
return Promise.all(promiseList)
}
//实时变动小球的位置,并且根据位置设置不同LightProbe的intensity
function spherePositionChange(time: number) {
if (sphere1) {
const onePeriodTime = time % 20000
let sphereX
if (onePeriodTime < 10000) {
sphereX = 200 - (onePeriodTime / 10000) * 400
} else {
sphereX = -200 + (onePeriodTime / 10000 - 1) * 400
}
sphere1.position.x = sphereX
lightProbeMap.forEach((probe, x) => {
const distance = Math.abs(sphereX - x)
if (distance > 10) {
probe.intensity = 0
} else {
probe.intensity = 1 - distance / 10
}
})
}
}
function destroyRenderer() {
if (requestId) {
window.cancelAnimationFrame(requestId)
}
if (renderer) {
renderer.domElement.remove();
renderer.dispose();
renderer.forceContextLoss();
}
}
function animate(time: number) {
renderer?.clear()
renderer?.render(scene, camera);
spherePositionChange(time)
renderer?.render(probeScene, camera);
if (controls) {
controls.update()
}
requestAnimationFrame(animate);
}
return (
<div ref={threeContainer} className="w-[100%] h-[100%]" id="three-container"></div>
)
}
export default ThreeContainer
从截图上可以看到,接收LightProbe的小球随着位置变化间接光照的效果也跟着变化。



这个demo只是一个简单场景的示例,真实的开发时构建的场景肯定复杂的多,不是在一条直线上放置LightProbe这么简单了。比如将场景空间划分为四面体的立体网格,在网格的顶点上布置LightProbe。可以动态的计算物体属于哪一个网格,根据物体在网格中的重心坐标来计算LightProbe的intensity。