首页
/ three.js ProjectorLightNode 深度解析:TSL 投影灯矩形衰减的源码原理与 WebGPU 实战

three.js ProjectorLightNode 深度解析:TSL 投影灯矩形衰减的源码原理与 WebGPU 实战

2026-09-07 15:19:18作者:邵娇湘

导读

本文以 three.js 节点系统(Node/TSL)中的 ProjectorLightNode 为研究对象,完整讲解"投影灯(Projector Light)"这一特殊光源在着色器节点层是如何把聚光灯(SpotLight)改造成"投射画面/图案"的矩形光锥的。读者将掌握该节点的类继承位置、构造函数、getSpotAttenuation 投影衰减的逐行数学原理、逐帧 update 的 aspect 推导逻辑,并能在 WebGPURenderer 中通过 ProjectorLight + colorNode/map 复现程序化焦散、视频投影与贴图投影三种实战效果。

three.js WebGPU 投影灯示例效果(模型与地面上的矩形光斑投射)


一、投影灯是什么:从 SpotLight 到 ProjectorLightNode

投影灯的核心需求是:让一束光像投影仪一样,把一张图片、一段视频或一个程序化函数"成像"在被照射的表面上。普通聚光灯只能产生圆形光锥与圆形半影衰减,无法描述画面内容;投影灯则在聚光灯的光锥几何基础上,把光照范围约束为一个随投影矩阵而定的矩形区域,并把贴图/颜色函数的内容映射进这个区域,从而在被照物体上形成清晰的投射画面。

在 three.js 中该能力拆分为两层实现:

  • 光源对象层ProjectorLight(继承 SpotLight),负责承载 colorNodemapaspect 等属性,见 src/lights/webgpu/ProjectorLight.js。其文档注释明确指出"Can only be used with WebGPURenderer"(仅可用于 WebGPURenderer),目前只从 WebGPU 构建入口导出,见 src/Three.WebGPU.js
  • 着色器节点层:即本文主角 ProjectorLightNode(继承 SpotLightNode),负责把"矩形投影区域 + 半影软化"写进光照着色器,见 src/nodes/lighting/ProjectorLightNode.js

完整的类继承链

原文档给出的继承关系为:

EventDispatcher → Node → LightingNode → AnalyticLightNode → SpotLightNode → ProjectorLightNode

这条链说明:ProjectorLightNode 本质是一棵"光照着色器节点树"。每一层分别在 EventDispatcherNodeLightingNodeAnalyticLightNodeSpotLightNode 的文档中展开。其中 SpotLightNode源码)已经为聚光灯建立了 coneCosNodepenumbraCosNodecutoffDistanceNodedecayExponentNode 等与 renderGroup 绑定的 uniform 节点,并在 setupDirect 中实现了"圆形角度衰减 × 距离衰减 × 投影贴图"的完整直接光照管线(见 SpotLightNode.js#L119-L164);ProjectorLightNode 只需"重写投影衰减那一步",这正是它几乎不新增任何公开属性的原因。


二、构造函数

new ProjectorLightNode()

与原文档描述一致,构造函数本身不接受显式参数、不定义新的公开属性。它完全复用父类 SpotLightNode 的构造逻辑(把 light 交给 AnalyticLightNode,并初始化若干 renderGroup uniform),并仅通过静态 type getter 标记自身类型为 'ProjectorLightNode'(源码 ProjectorLightNode.js#L24-L28)。实际使用中,你通常不会手动 new 它,而是直接创建 ProjectorLight 光源交给渲染器,由节点库自动映射生成对应节点。


三、自动绑定机制:光照对象如何被映射为节点

从源码结构看,three.js 通过"节点库(NodeLibrary)"建立 光源类 ↔ 光源节点类 的映射关系。ProjectorLightNode 被注册在两处 WebGPU 节点库中:

这意味着:当你在 WebGPURenderer 场景中添加一个 new THREE.ProjectorLight(...) 时,渲染器会依据映射自动选用 ProjectorLightNode 参与光照计算,无需手动接线。同时 src/nodes/Nodes.js#L121ProjectorLightNode 作为公开节点导出,ProjectorLight 则仅由 WebGPU 构建产物导出(Three.WebGPU.jsThree.WebGPU.Nodes.js)。

使用前提ProjectorLight/ProjectorLightNode 目前面向 WebGPU 渲染管线(WebGPURenderer + three/webgpu 构建),使用前请确认你的渲染环境支持 WebGPU。


四、核心方法 getSpotAttenuation:矩形投影衰减的逐行剖析

原文档对该方法的定义为:

getSpotAttenuation( builder : NodeBuilder ) : Node<float> Overwrites the default implementation to compute projection attenuation.(重写默认实现以计算投影衰减。)

  • builder:The node builder(节点构建器);
  • OverridesSpotLightNode#getSpotAttenuation(在 SpotLightNode 文档中有该方法的原型);
  • Returns:The spot attenuation(点衰减系数)。

也就是说,父类 SpotLightNode#getSpotAttenuationsmoothstep( coneCosNode, penumbraCosNode, angleCosine ) 生成一个"随角度余弦平滑过渡的圆形半影";而 ProjectorLightNode 的版本完全放弃这一模式,改为基于阴影投影矩阵 + 盒状有向距离场(Box SDF) 计算"矩形、带软化边缘"的投影衰减。完整实现见 ProjectorLightNode.js,其数学流程可分四步:

1. 把世界坐标变换到光源裁剪空间

const spotLightCoord = lightShadowMatrix( this.light ).mul( builder.context.positionWorld || positionWorld );

lightShadowMatrix( light ) 返回一个 renderGroup 下的 mat4 uniform,其值来自光源阴影系统的 light.shadow.matrix。关键的边界处理位于 src/nodes/accessors/Lights.js:该 uniform 在每帧 onRenderUpdate 时,如果光源没有开启阴影light.castShadow !== true)或渲染器的 shadowMap 被关闭,仍会主动调用 light.shadow.updateMatrices( light ) 强制刷新矩阵。注释对此解释得很清楚——"normally, shadow matrices are updated in ShadowNode. However, if the shadow matrix is used outside of shadow rendering (like in ProjectorLightNode), the shadow matrix still requires an update"(阴影矩阵通常由 ShadowNode 更新;但当它在阴影渲染之外被使用(如 ProjectorLightNode 中)时,仍必须更新)。因此投影灯不需要开启阴影也可以获得完整的投影矩阵

2. 用 w 的符号剔除"背投"(back-projection)

If( spotLightCoord.w.greaterThan( 0 ), () => { ... } );

投影矩阵是透视变换,w 分量即为视空间(此处即光源视角下)的深度。位于光源后方的片元其 w 为负,若不加判断直接除以 w 做透视除法,会把贴图"反向投到身后",产生鬼影。w > 0 的判断从数学上保证只有真正处于光源视锥正前方的表面才会参与投影衰减计算。

3. 透视除法后与单位盒子求距离(Box SDF)

const projectionUV = spotLightCoord.xyz.div( spotLightCoord.w );
const boxDist = sdBox( projectionUV.xy.sub( vec2( 0.5 ) ), vec2( 0.5 ) );
  • 先做透视除法把坐标归一化到光源裁剪体的 [-1,1]^3 范围,再取 xy 作为投影平面坐标;
  • 平移到中心 (0.5, 0.5)、半边长 0.5,即把该坐标放入"中心在 0.5、边长 1"的单位矩形坐标系,再调用文件顶部定义的盒式有向距离场函数 sdBox(见 ProjectorLightNode.js):
const sdBox = Fn( ( [ p, b ] ) => {
	const d = p.abs().sub( b );
	return length( max( d, 0.0 ) ).add( min( max( d.x, d.y ), 0.0 ) );
} );

这是经典的矩形 SDF 写法:片元在矩形内部boxDist < 0,在矩形外部boxDist > 0,边缘附近连续变化,正好可用来制造平滑的投影边界。

4. 映射为带软边的 0~1 衰减系数

const angleFactor = div( - 1.0, sub( 1.0, acos( penumbraCos ) ).sub( 1.0 ) );
attenuation.assign( saturate( boxDist.mul( - 2.0 ).mul( angleFactor ) ) );

两行代码完成软化:

  • acos( penumbraCos ) 把父类保存的半影余弦反解回"半影角度",进而构造比例因子 angleFactor(整理后近似为 1 / acos(penumbraCos),等价于随 angle × (1 - penumbra) 变化)。它控制投影矩形边缘从"全亮"过渡到"全暗"的带宽;
  • boxDist * (-2) * angleFactor:矩形内部(负距离)得到正值、外部得到负值,最后 saturate 钳制到 [0,1]。于是矩形内部为 1(完整照亮)、外部为 0(不照亮),两者之间根据 angleFactor 形成可调的平滑过渡带——这正是真实投影镜头边缘柔化效果的着色器等价实现。

关键点:这一步彻底替换了父类基于 smoothstep 的圆形锥角衰减。投影区域的"矩形轮廓 + 软化边缘"完全由本方法决定,而投影的"画面内容"则交由父类 SpotLightNode#setupDirect 中的 light.colorNode / light.map 路径叠加(详见本文第六节)。


五、update():半影钳制与投影画面纵横比

虽然原文档没有单列 update 方法,但它在 ProjectorLightNode.js 中是决定衰减形状的关键:

this.penumbraCosNode.value = Math.min( Math.cos( light.angle * ( 1 - light.penumbra ) ), .99999 );

if ( light.aspect === null ) {
	let aspect = 1;
	if ( light.map !== null ) {
		aspect = light.map.width / light.map.height;
	}
	light.shadow.aspect = aspect;
} else {
	light.shadow.aspect = light.aspect;
}

两个细节值得展开:

  1. 半影余弦钳制Math.min(..., .99999) 防止当 penumbra 接近 1(此时 cos(0) ≈ 1)时 acos 因精度越界或分母趋零而产生数值不稳定,保证 getSpotAttenuation 中的 angleFactor 始终有限。penumbra = 1 对应"接近硬边"的锐利投影。
  2. 纵横比推导:投影画面是矩形,若画面宽高比与阴影相机不匹配,投射出的图像会被拉伸变形。update 中若用户未显式设置 ProjectorLight.aspect(保持 null),则自动取 light.map(纹理/视频)的 width / height 作为纵横比并写入 light.shadow.aspect;否则用用户显式指定的值。

该值最终作用于阴影相机投影:SpotLightShadow#updateMatrices 中按 (mapSize.width / mapSize.height) * aspect 计算实际相机纵横比(见 src/lights/SpotLightShadow.js),从而保证投影矩形与画面内容比例一致。这意味着即使只做"画面投影、不开阴影",该纵横比也会经由第四节所述的矩阵强制更新路径生效。


六、实战:在 WebGPURenderer 中使用 ProjectorLight

以仓库中的官方示例 examples/webgpu_lights_projector.html 为蓝本(其页面副标题即为 "Projector light with procedural caustics, video and texture projection"),演示三种投影源:

基础搭建与程序化焦散投影

把程序化 TSL 函数赋给 light.colorNode,即可"投出"由噪声函数实时演算的焦散图案:

import * as THREE from 'three/webgpu';
import { Fn, color, mx_worley_noise_float, time } from 'three/tsl';

const causticEffect = Fn( ( [ projectorUV ] ) => {
	const waterLayer0 = mx_worley_noise_float( projectorUV.mul( 10 ).add( time ) ).pow( 2 );
	return waterLayer0.mul( color( 0x5abcd8 ) ).mul( 2 );
} );

const projectorLight = new THREE.ProjectorLight( 0xffffff, 100 );
projectorLight.colorNode = causticEffect;
projectorLight.position.set( 2.5, 5, 2.5 );
projectorLight.angle = Math.PI / 6;
projectorLight.penumbra = 1;      // 半影 [0,1],1 接近硬边
projectorLight.decay = 2;         // 距离衰减指数
projectorLight.distance = 0;      // 0 = 无限远
projectorLight.castShadow = true;
projectorLight.shadow.mapSize.width  = 1024;
projectorLight.shadow.mapSize.height = 1024;
projectorLight.shadow.camera.near = 1;
projectorLight.shadow.camera.far  = 10;
scene.add( projectorLight );

说明:ProjectorLight 构造参数与 SpotLight 一致——color(默认 0xffffff)、intensity(默认 1,单位为坎德拉 cd)、distance(默认 0,0 表示不限距)、angle(默认 Math.PI/3,最大散射角上限 Math.PI/2)、penumbra(默认 0,取值范围 [0,1])、decay(默认 2),另外新增独有的 aspect 属性(默认 nullnull 表示使用贴图纵横比),完整参数见 src/lights/webgpu/ProjectorLight.js

视频与贴图投影

colorNode 置空并改赋 map 即可切换为视频/静态贴图投影(对应示例 GUI 的 procedural / video / texture 三档):

projectorLight.colorNode = null;

// 视频投影
const video = document.getElementById( 'video' );   // 页面内 <video>
const videoTexture = new THREE.VideoTexture( video );
videoTexture.colorSpace = THREE.SRGBColorSpace;
projectorLight.map = videoTexture;
projectorLight.shadow.focus = 0.46;

// 或静态贴图投影
const mapTexture = new THREE.TextureLoader().load( 'textures/colors.png' );
mapTexture.minFilter = THREE.LinearFilter;
mapTexture.magFilter = THREE.LinearFilter;
mapTexture.generateMipmaps = false;
mapTexture.colorSpace = THREE.SRGBColorSpace;
projectorLight.map = mapTexture;
projectorLight.shadow.focus = 1;

这些属性在父类 SpotLightNode#setupDirect 中被消费(src/nodes/lighting/SpotLightNode.js):当 light.colorNode 存在时用 light.colorNode( lightCoord ) 求值颜色函数;否则回退到对 light.maptexture(...) 采样,并通过 getLightCoord(内部即 lightProjectionUV,复用 lightShadowMatrix 做投影坐标换算,见 src/nodes/accessors/Lights.js)取得投影 UV,再以 inSpotLightMap 判断片元是否处于投影画面内。也就是说:画面内容由 SpotLightNode 的贴图管线负责,而"只照亮矩形区域内"的几何约束由 ProjectorLightNode 重写的 getSpotAttenuation 负责,两者叠加后才是完整的投影灯效果。

运行方式

该示例需通过本地静态服务器访问(例如在仓库根目录执行 npx serve . 或使用项目自带工具),然后在支持 WebGPU 的浏览器(Chrome/Edge 等)中打开 examples/webgpu_lights_projector.html;页面内可借助 renderer.inspector 生成的 GUI 实时切换投影类型、调整颜色、强度、距离、角度、半影、衰减与阴影 focus。


七、要点小结与源码索引

关注点 结论
类定位 SpotLightNode 的投影专用子类,继承链 EventDispatcher → Node → LightingNode → AnalyticLightNode → SpotLightNode → ProjectorLightNode
构造 无参数、无自有属性,type'ProjectorLightNode'
核心方法 getSpotAttenuation(builder):用投影矩阵 + w>0 背投剔除 + 单位盒 sdBox + saturate 生成矩形软边衰减
逐帧更新 update():钳制半影余弦至 0.99999;由 map 宽高比或显式 aspect 推导 light.shadow.aspect
适用范围 面向 WebGPU 渲染管线;ProjectorLight 仅在 three/webgpu 构建中导出
内容来源 colorNode(程序化 TSL)或 map(视频/贴图),由父类 setupDirect 合成

深度阅读推荐:

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388