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。

相关推荐
red润11 小时前
THREE.Ray 和 THREE.Raycaster 用途和功能
three.js
YAY_tyy21 小时前
Three.js 开发实战教程(五):外部 3D 模型加载与优化实战
前端·javascript·3d·three.js
入秋2 天前
Three.js 实战之电子围栏可根据模型自动生成
前端·前端框架·three.js
答案answer3 天前
three.js着色器(Shader)实现数字孪生项目中常见的特效
前端·three.js
KallkaGo8 天前
threejs复刻原神渲染(三)
前端·webgl·three.js
刘皇叔code9 天前
如何给Three.js中ExtrudeGeometry的不同面设置不同材质
webgl·three.js
vivo互联网技术10 天前
拥抱新一代 Web 3D 引擎,Three.js 项目快速升级 Galacean 指南
前端·three.js
你真的可爱呀14 天前
5.Three.js 学习(基础+实践)
学习·three.js