three.js 中 SphericalHarmonics3 解析:用 9 个系数编码光照的球谐函数实现
【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js
SphericalHarmonics3是 three.js 中用于表示三阶(L2)球谐函数(Spherical Harmonics,简称 SH)的核心数学类。它用 9 个Vector3系数把周围环境的光照信息压缩编码下来,LightProbe(光照探针)正是依靠它来描述“光在空间中如何分布”,从而让场景中的物体获得基于环境光场的漫反射全局光照。读完本文,你将理解这个类的完整 API、9 个系数按频段(band)组织的方式、getAt/getIrradianceAt两个核心采样方法的数学含义,以及它在 WebGL 渲染管线(uniform 上传、GLSL 着色器)和LightProbeGenerator(从立方体贴图拟合系数)中的完整调用链。
一、类定位与设计动机
官方文档对该类的定义是:“表示一个三阶球谐函数(SH),Light Probe 使用该类来编码光照信息”(见 docs/pages/SphericalHarmonics3.html.md)。其参考依据为两篇经典文献:
- 斯坦福大学的《Spherical Harmonic Lighting: The Gist of It》(graphics.stanford.edu 的 envmap 论文);
- Peter Shirley 等的《Radiance Evaluation with Spherical Harmonics》(Stupid SH 36 论文)。
这两个引用同样写在 src/math/SphericalHarmonics3.js 的类注释中。
球谐函数是一组正交基函数,可以把定义在球面上的函数(例如某个方向上的辐射亮度)展开成有限项的和。三阶(L2)展开只需要 9 个系数,因此它能以极小的存储成本近似低频率的环境光照——这正是漫反射光照(Lambertian)最需要的部分:漫反射反射率对高频细节不敏感,低频分量即可得到相当准确的辐照度。
LightProbe的类注释进一步说明了它的用途(见 src/lights/LightProbe.js):
- 光照探针不同于方向光、点光等“发射光”的光源,它本身不发射光,而是存储光穿过 3D 空间的信息;
- 探针通常由辐射度(radiance)环境贴图生成,
LightProbeGenerator类负责从立方体贴图或渲染目标创建探针,数据也可以来自 WebXR(例如 AR 场景对真实世界光照的响应); - three.js 当前实现的是漫反射光照探针,功能上等价于一张辐照度环境贴图(irradiance environment map)。
LightProbe构造函数接收一个SphericalHarmonics3实例作为 SH 数据,并持有其引用(src/lights/LightProbe.js#L31-L51):
import { SphericalHarmonics3, LightProbe } from 'three'; const sh = new SphericalHarmonics3(); const probe = new LightProbe( sh, 1 ); // 强度默认为 1 scene.add( probe );二、构造函数与核心属性
new SphericalHarmonics3()
文档中构造函数的说明只有一句话:“Constructs a new spherical harmonics.”。结合源码 src/math/SphericalHarmonics3.js#L15-L39,它实际做了两件事:
- 设置只读类型标志
this.isSphericalHarmonics3 = true,用于类型判断; - 初始化
this.coefficients为长度 9 的数组,每个元素都是一个全新的Vector3(初始值为 0)。
constructor() { this.isSphericalHarmonics3 = true; this.coefficients = []; for ( let i = 0; i < 9; i ++ ) { this.coefficients.push( new Vector3() ); } }.coefficients : Array<Vector3>
文档说明:“存放 (9) 个 SH 系数的数组”。每个系数是三维的(RGB 三分量各一个球谐展开),因此一个探针共携带 27 个标量数值。9 个系数按球谐频带的排布为:
| 索引 | 频带 | 基函数形式(由 getBasisAt 的系数可读出) |
|---|---|---|
| 0 | band 0(DC) | 常数0.282095 |
| 1 / 2 / 3 | band 1(一次) | 0.488603 * y、0.488603 * z、0.488603 * x |
| 4 | band 2(二次) | 1.092548 * x * y |
| 5 | band 2 | 1.092548 * y * z |
| 6 | band 2 | 0.315392 * (3 z² − 1) |
| 7 | band 2 | 1.092548 * x * z |
| 8 | band 2 | 0.546274 * (x² − y²) |
.isSphericalHarmonics3 : boolean (readonly)
文档说明:“该标志可用于类型判断,默认值为true”。单位测试 test/unit/src/math/SphericalHarmonics3.tests.js 中专门验证了这一点。
三、系数修改类方法
这一组方法负责初始化、复制与序列化 9 个系数。所有方法都返回this以支持链式调用(源码中每个方法均以return this结尾)。
.set( coefficients : Array<Vector3> )
文档:“按值拷贝的方式,把给定的 SH 系数设置到本实例上”。实现(src/math/SphericalHarmonics3.js#L48-L58)是对 9 个槽位逐一执行copy,注意它是深拷贝每个 Vector3 的分量值,而非替换引用:
set( coefficients ) { for ( let i = 0; i < 9; i ++ ) { this.coefficients[ i ].copy( coefficients[ i ] ); } return this; }.zero()
文档:“把所有 SH 系数置为0”。实现是对每个系数调用Vector3.set( 0, 0, 0 )(src/math/SphericalHarmonics3.js#L65-L75)。
.copy( sh ) 与 .clone()
.copy( sh ):文档“把给定球谐函数的值拷贝到本实例”,实现直接转发给set:return this.set( sh.coefficients )(src/math/SphericalHarmonics3.js#L250-L254);.clone():文档“返回一个从本实例拷贝值得新球谐函数”,实现为new this.constructor().copy( this ),用this.constructor而非硬编码类名,方便子类继承。
LightProbe.copy中对 SH 的深拷贝就是调用这条链路(src/lights/LightProbe.js#L53-L61)。
.fromArray( array, offset ) 与 .toArray( array, offset )
文档说明:
.fromArray( array : Array.<number>, offset : number ):从给定(扁平)数组设置系数,offset是开始拷贝的偏移量,默认0;.toArray( array : Array.<number>, offset : number ):返回扁平系数数组,或拷贝进提供的数组;array默认[],offset默认0。
源码中的关键细节是“展平步长为 3”(src/math/SphericalHarmonics3.js#L274-L308):
fromArray( array, offset = 0 ) { const coefficients = this.coefficients; for ( let i = 0; i < 9; i ++ ) { coefficients[ i ].fromArray( array, offset + ( i * 3 ) ); } return this; }也就是说一个完整的 SH 序列化为27 个连续 number:coefficients[0].x/y/z占 3 个,coefficients[1]占接下来 3 个,依此类推。这与ObjectLoader/Object3D.toJSON生态中“数组优先、扁平存储”的序列化风格一致——LightProbe.toJSON正是通过this.sh.toArray()把探针数据写入 JSON(src/lights/LightProbe.js#L63-L71),配合 src/loaders/ObjectLoader.js 中的反序列化即可完整保存/加载含探针的场景。
.equals( sh ) : boolean
文档:“若两个球谐函数相等返回true”。实现是逐系数调用Vector3.equals,任一不相等即短路返回false(src/math/SphericalHarmonics3.js#L228-L242)。由于使用的是精确相等比较,它适用于“同一对象拷贝后比对”或测试断言,而不适用于浮点误差容忍比较。
四、线性代数操作:add / addScaledSH / scale / lerp
这组方法把 SH 当作“27 维向量”做线性组合,全部逐系数在 9 个Vector3上循环实现:
| 方法 | 文档语义 | 底层实现 |
|---|---|---|
.add( sh ) | 把给定 SH 加到本实例 | 逐系数Vector3.add(L152-L162) |
.addScaledSH( sh, s ) | 一次完成scale+add,即this += s * sh | 逐系数addScaledVector(L172-L182) |
.scale( s ) | 按缩放因子缩放本 SH | 逐系数multiplyScalar(L190-L200) |
.lerp( sh, alpha ) | 以 alpha 因子在两个 SH 间线性插值 | 逐系数Vector3.lerp(L210-L220) |
其中.addScaledSH( sh, s )是渲染管线的关键路径:WebGL 端收集场景中所有LightProbe时,逐个探针执行
state.probe[ j ].addScaledVector( light.sh.coefficients[ j ], intensity );(见 src/renderers/webgl/WebGLLights.js#L282),即用intensity把每个探针的系数加权累加进 9 槽位的共享 uniform 数组——这意味着 multiple light probes 在着色器端是线性叠加生效的,与addScaledSH的语义完全一致。
典型用法:
const a = new SphericalHarmonics3(); const b = new SphericalHarmonics3(); a.set( b.coefficients ); // a 拷贝 b a.addScaledSH( b, 0.5 ); // a = a + 0.5 * b a.lerp( b, 0.25 ); // a 向 b 插值 25% a.scale( 2 ); // 整体加倍 a.zero(); // 清零 console.log( a.equals( new SphericalHarmonics3() ) ); // true五、两个核心采样方法:getAt 与 getIrradianceAt
这两个方法是文档 Methods 部分中唯一的“物理量求值”接口,也是理解整个类的关键。两者都要求入参normal为单位向量,把结果写入调用方提供的target(避免每次调用产生新对象,符合 three.js 的零分配风格)。
.getAt( normal, target ) —— 采样辐射亮度
文档:“返回给定法线方向上的 radiance(辐射亮度)”。源码(src/math/SphericalHarmonics3.js#L84-L109)是标准 L2 球谐求值,三个频带一目了然:
getAt( normal, target ) { const x = normal.x, y = normal.y, z = normal.z; const coeff = this.coefficients; // band 0 target.copy( coeff[ 0 ] ).multiplyScalar( 0.282095 ); // band 1 target.addScaledVector( coeff[ 1 ], 0.488603 * y ); target.addScaledVector( coeff[ 2 ], 0.488603 * z ); target.addScaledVector( coeff[ 3 ], 0.488603 * x ); // band 2 target.addScaledVector( coeff[ 4 ], 1.092548 * ( x * y ) ); target.addScaledVector( coeff[ 5 ], 1.092548 * ( y * z ) ); target.addScaledVector( coeff[ 6 ], 0.315392 * ( 3.0 * z * z - 1.0 ) ); target.addScaledVector( coeff[ 7 ], 1.092548 * ( x * z ) ); target.addScaledVector( coeff[ 8 ], 0.546274 * ( x * x - y * y ) ); return target; }九个常量0.282095、0.488603、1.092548、0.315392、0.546274正是球谐基函数的归一化系数:band 0 为常数项1/√(4π) ≈ 0.282095,band 1 各为√(3/4π)的近似0.488603,band 2 则对应四极项系数。系数索引 1/2/3 分别绑定 y、z、x 轴,索引 4–8 对应 xy、yz、z²、xz、(x²−y²) 这五个二次型方向。
.getIrradianceAt( normal, target ) —— 采样辐照度
文档:“返回给定法线方向上的 irradiance(radiance 与余弦瓣卷积的结果)”。这就是漫反射表面真正需要的量:对一个法线方向 n,把半球内所有入射光按cosθ加权积分。源码(src/math/SphericalHarmonics3.js#L119-L144 对应实现位于 L119–L144):
// band 0 target.copy( coeff[ 0 ] ).multiplyScalar( 0.886227 ); // π * 0.282095 // band 1 target.addScaledVector( coeff[ 1 ], 2.0 * 0.511664 * y ); // ( 2 * π / 3 ) * 0.488603 ... // band 2 target.addScaledVector( coeff[ 4 ], 2.0 * 0.429043 * x * y ); // ( π / 4 ) * 1.092548 target.addScaledVector( coeff[ 6 ], 0.743125 * z * z - 0.247708 ); // ( π / 4 ) * 0.315392 * 3 target.addScaledVector( coeff[ 8 ], 0.429043 * ( x * x - y * y ) ); // ( π / 4 ) * 0.546274与getAt相比,每个频带都乘上了“与余弦瓣卷积后”的解析系数(源码注释直接给出了推导式,如0.886227 = π * 0.282095),这正对应参考论文中“辐照度 = 辐射亮度经余弦卷积”的结论。
值得强调的一点:着色器端与 CPU 端完全同构。WebGL 光照代码中的 GLSL 函数shGetIrradianceAt使用了与getIrradianceAt完全相同的常数和频带结构(src/renderers/shaders/ShaderChunk/lights_pars_begin.glsl.js#L13-L36),注释里明确写着 “get the irradiance (radiance convolved with cosine lobe) at the point 'normal' on the unit sphere”,来源同样是斯坦福那篇 envmap 论文。这保证 JS 侧离线计算/调试得到的辐照度与 GPU 侧逐像素结果一致。
六、静态方法 getBasisAt:系数是如何“造”出来的
.getBasisAt( normal, shBasis )
文档:“计算给定法线向量处的 SH basis”。它把某个方向normal代入 9 个归一化基函数,把结果写入shBasis(长度 9 的扁平 number 数组)。实现(src/math/SphericalHarmonics3.js#L316-L337)与getAt中的常量一一对应:
static getBasisAt( normal, shBasis ) { const x = normal.x, y = normal.y, z = normal.z; // band 0 shBasis[ 0 ] = 0.282095; // band 1 shBasis[ 1 ] = 0.488603 * y; shBasis[ 2 ] = 0.488603 * z; shBasis[ 3 ] = 0.488603 * x; // band 2 shBasis[ 4 ] = 1.092548 * x * y; shBasis[ 5 ] = 1.092548 * y * z; shBasis[ 6 ] = 0.315392 * ( 3 * z * z - 1 ); shBasis[ 7 ] = 1.092548 * x * z; shBasis[ 8 ] = 0.546274 * ( x * x - y * y ); }它是积分(拟合)一侧的入口:球谐系数本质是对辐射亮度函数在各基函数上的投影积分c_j = ∫ L(dir) · basis_j(dir) dΩ,getBasisAt提供了被积函数中的基函数值。
仓库中最典型的应用者是 examples/jsm/lights/LightProbeGenerator.js,其fromCubeTexture静态方法把立方体贴图拟合为LightProbe,完整流程为(源码 examples/jsm/lights/LightProbeGenerator.js#L47-L141):
- 遍历 6 个面(
faceIndex0–5),逐像素读取颜色并按cubeTexture.colorSpace转线性空间(convertColorToLinear处理 sRGB → linear,见同文件 L312-L335); - 把像素映射为单位立方体上的坐标
coord(每个面一套轴映射公式),并计算该像素的积分权重weight = 4 / (√lengthSq · lengthSq)——这是立方体贴图参数化下立体角微元的近似; - 归一化得到方向
dir,调用SphericalHarmonics3.getBasisAt( dir, shBasis )求基函数值; - 按
shCoefficients[j] += shBasis[j] * color * weight累加到 9 个系数上; - 最后用
norm = 4π / totalWeight归一化全部系数,return new LightProbe( sh )。
同一文件还提供async fromCubeRenderTarget( renderer, cubeRenderTarget )版本,走readRenderTargetPixelsAsync读回 GPU 端渲染的立方体贴图(支持FloatType/HalfFloatType/UnsignedByteType三种数据格式),并处理 WebGL/WebGPU 坐标系统差异(flip因子)。两条路径共同印证了文档中“探针通常由辐射度环境贴图创建”的说法。
import { LightProbeGenerator } from 'three/addons/lights/LightProbeGenerator.js'; const probe = LightProbeGenerator.fromCubeTexture( cubeTexture ); scene.add( probe );七、从系数到像素:渲染管线中的完整链路
把SphericalHarmonics3放回渲染管线,可以看到一条清晰的调用链:
- 收集:每帧 src/renderers/webgl/WebGLLights.js 遍历场景中的
LightProbe,预分配的state.probe(9 个Vector3,L215)通过addScaledVector( light.sh.coefficients[ j ], intensity )(L282)把多个探针加权求和; - 上传:
lightProbe作为vec3 lightProbe[ 9 ]uniform 数组传入材质(uniform 声明见 src/renderers/shaders/UniformsLib.js#L123,GLSL 声明见 src/renderers/shaders/ShaderChunk/lights_pars_begin.glsl.js#L5-L9,由USE_LIGHT_PROBES宏控制编译); - 求值:在片元阶段,
getLightProbeIrradiance( lightProbe, normal )先把模型空间法线变换到世界空间,再调用与 JS 版同构的shGetIrradianceAt(src/renderers/shaders/ShaderChunk/lights_pars_begin.glsl.js#L38-L46),结果在 src/renderers/shaders/ShaderChunk/lights_fragment_begin.glsl.js#L230 处被加进irradiance,参与 PBR 光照计算。
这也解释了 API 的一个细节:为什么文档同时提供getAt(radiance)与getIrradianceAt(irradiance)——着色器只需要后者(漫反射探针等价于辐照度环境贴图),前者则可用于离线处理、调试或生成探针辅助可视化。仓库中的完整演示是 examples/webgl_lightprobes.html,标题即 “light probe volume”,页面说明使用 “Position-dependent diffuse global illumination via L2 SH probe grid” 在 Cornell box 场景中做基于 L2 SH 探针网格的位置相关漫反射 GI,配套实现为LightProbeGridWebGL/LightProbeGridHelperWebGL。
八、序列化与验证
- 保存/加载:
LightProbe.toJSON输出data.object.sh = this.sh.toArray()(27 个数的扁平数组,src/lights/LightProbe.js#L63-L71),配合ObjectLoader可无损往返——fromArray与toArray互为逆操作(步长 3 的偏移约定一致); - 单元测试:test/unit/src/math/SphericalHarmonics3.tests.js 用 QUnit 验证了实例化与
isSphericalHarmonics3标志;此外 test/unit/addons/tsl/TSL.Irradiance.tests.js 在 TSL(three Shading Language)辐照度节点测试中也引用了该类,说明 SH3 在 WebGPU/TSL 路径中同样是辐照度计算的基础数据结构。
九、小结
SphericalHarmonics3是 three.js 全局光照体系中承上启下的一环:
- 对上,它给
LightProbe、LightProbeGenerator、ObjectLoader序列化提供了一个紧凑(9 × Vector3 = 27 标量)、线性可组合(add/addScaledSH/scale/lerp)、可往返序列化的光照容器; - 对下,它的
getIrradianceAt语义与着色器端shGetIrradianceAt严格对齐,getBasisAt则支撑了从立方体贴图数值积分拟合系数的完整流程; - 所有系数按 band 0 / band 1 / band 2 组织,常量与经典球谐光照文献一一对应,CPU/GPU 双端同构。
实际开发中需要记住的三个前提:getAt/getIrradianceAt/getBasisAt均要求normal为单位向量;方法普遍采用“结果写入 target”的零分配风格,复用时注意不要传入仍在使用的向量;探针数据应保持线性色彩空间(LightProbeGenerator会自动做 sRGB → linear 转换),否则拟合出的系数会偏亮。
【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考