three.js ProjectorLightNode 深度解析:TSL 投影灯矩形衰减的源码原理与 WebGPU 实战
导读
本文以 three.js 节点系统(Node/TSL)中的 ProjectorLightNode 为研究对象,完整讲解"投影灯(Projector Light)"这一特殊光源在着色器节点层是如何把聚光灯(SpotLight)改造成"投射画面/图案"的矩形光锥的。读者将掌握该节点的类继承位置、构造函数、getSpotAttenuation 投影衰减的逐行数学原理、逐帧 update 的 aspect 推导逻辑,并能在 WebGPURenderer 中通过 ProjectorLight + colorNode/map 复现程序化焦散、视频投影与贴图投影三种实战效果。
一、投影灯是什么:从 SpotLight 到 ProjectorLightNode
投影灯的核心需求是:让一束光像投影仪一样,把一张图片、一段视频或一个程序化函数"成像"在被照射的表面上。普通聚光灯只能产生圆形光锥与圆形半影衰减,无法描述画面内容;投影灯则在聚光灯的光锥几何基础上,把光照范围约束为一个随投影矩阵而定的矩形区域,并把贴图/颜色函数的内容映射进这个区域,从而在被照物体上形成清晰的投射画面。
在 three.js 中该能力拆分为两层实现:
- 光源对象层:
ProjectorLight(继承SpotLight),负责承载colorNode、map、aspect等属性,见 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 本质是一棵"光照着色器节点树"。每一层分别在 EventDispatcher、Node、LightingNode、AnalyticLightNode、SpotLightNode 的文档中展开。其中 SpotLightNode(源码)已经为聚光灯建立了 coneCosNode、penumbraCosNode、cutoffDistanceNode、decayExponentNode 等与 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 节点库中:
- src/renderers/webgpu/nodes/BasicNodeLibrary.js:基础库,
this.addLight( ProjectorLightNode, ProjectorLight ); - src/renderers/webgpu/nodes/StandardNodeLibrary.js:标准库,同样注册该映射。
这意味着:当你在 WebGPURenderer 场景中添加一个 new THREE.ProjectorLight(...) 时,渲染器会依据映射自动选用 ProjectorLightNode 参与光照计算,无需手动接线。同时 src/nodes/Nodes.js#L121 把 ProjectorLightNode 作为公开节点导出,ProjectorLight 则仅由 WebGPU 构建产物导出(Three.WebGPU.js 与 Three.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(节点构建器);
- Overrides:
SpotLightNode#getSpotAttenuation(在 SpotLightNode 文档中有该方法的原型);- Returns:The spot attenuation(点衰减系数)。
也就是说,父类 SpotLightNode#getSpotAttenuation 用 smoothstep( 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;
}
两个细节值得展开:
- 半影余弦钳制:
Math.min(..., .99999)防止当penumbra接近 1(此时cos(0) ≈ 1)时acos因精度越界或分母趋零而产生数值不稳定,保证getSpotAttenuation中的angleFactor始终有限。penumbra = 1对应"接近硬边"的锐利投影。 - 纵横比推导:投影画面是矩形,若画面宽高比与阴影相机不匹配,投射出的图像会被拉伸变形。
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属性(默认null,null表示使用贴图纵横比),完整参数见 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.map 的 texture(...) 采样,并通过 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 合成 |
深度阅读推荐:
- 节点实现:src/nodes/lighting/ProjectorLightNode.js
- 父类聚光灯节点:src/nodes/lighting/SpotLightNode.js
- 光源对象:src/lights/webgpu/ProjectorLight.js
- 阴影纵横比传播:src/lights/SpotLightShadow.js
- 投影矩阵 uniform:src/nodes/accessors/Lights.js
- 节点库注册:src/renderers/webgpu/nodes/BasicNodeLibrary.js、src/renderers/webgpu/nodes/StandardNodeLibrary.js
- 官方示例:examples/webgpu_lights_projector.html
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
