Three.js中LightProbe的应用

前言

在一个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。

相关推荐
Mintopia18 小时前
Three.js 顶点与颜色点的装配艺术:从像素到彩虹的底层之旅
前端·javascript·three.js
爱看书的小沐1 天前
【小沐杂货铺】基于Three.JS绘制汽车展示Car(WebGL、vue、react、autoshow、提供全部源代码)
汽车·vue3·react·webgl·three.js·opengl·autoshow
Mintopia2 天前
Three.js 中三角形到四边形的顶点变换:一场几何的华丽变身
前端·javascript·three.js
Mintopia3 天前
Three.js 中的噪声与图形变换:一场数字世界的舞蹈
前端·javascript·three.js
中国黄金Gold3 天前
Three.js OrbitControls:实现鼠标左键直接平移场景
three.js
三年三月4 天前
021-顶点法线与反射原理
javascript·three.js
Mintopia4 天前
Three.js 中正切函数在相机视野里的那些事儿
前端·javascript·three.js
Mintopia5 天前
Three.js 中的 Color 对象:玩转色彩的魔法方块
前端·javascript·three.js
答案—answer6 天前
three.js编辑器2.0版本
javascript·three.js·three.js 编辑器·three.js性能优化·three.js模型编辑·three.js 粒子特效·three.js加载模型
Coffeeee6 天前
Threejs粒子动效之龙卷风
前端·three.js·动效