Three.js TSL 深入解析:BufferAttributeNode 让顶点数据在节点层面定义并贯通 Compute Shader
本文基于 three.js 官方 API 文档中的 BufferAttributeNode 页面,系统讲解 TSL(Three Shading Language)中“节点级顶点属性”的设计动机、完整参数与 API 参考,并结合 src/nodes/accessors/BufferAttributeNode.js 源码说明其类型推断、Hash 共享、varying 提升等底层机制,帮助读者把 CPU 端数组甚至 Compute Shader 产出的存储缓冲区,无缝接入材质着色器作为逐顶点数据消费。
背景:从几何体属性到节点属性
在传统 three.js 工作流中,顶点属性只能定义在几何体(geometry)层面:调用 geometry.setAttribute('color', attribute),着色器再通过内建 attribute 读取。而引入 TSL 后,BufferAttributeNode(继承链:EventDispatcher → Node → InputNode → BufferAttributeNode)允许把顶点数据直接定义在节点层面——你可以把一个普通的 Float32BufferAttribute 甚至裸的 TypedArray 包装成节点,赋值给材质的任意 Node 属性(如 colorNode、positionNode)。
这种做法在数据由 Compute Shader 生成的场景下价值最大:几何体上可以根本没有这个属性,取而代之的是 GPU 存储缓冲区(storage buffer),一行 positionBuffer.toAttribute() 就能把它转换回顶点属性节点,直接喂给顶点着色器。这也是仓库中多个 webgpu_compute_* 粒子示例的核心手法。
快速上手:节点级顶点属性
官方文档给出的第一个示例是在 PlaneGeometry 上逐顶点生成红色:
const geometry = new THREE.PlaneGeometry();
const positionAttribute = geometry.getAttribute( 'position' );
const colors = [];
for ( let i = 0; i < position.count; i ++ ) {
colors.push( 1, 0, 0 );
}
material.colorNode = bufferAttribute( new THREE.Float32BufferAttribute( colors, 3 ) );
这里注意两点:
bufferAttribute(...)是 TSL 函数(定义在 src/nodes/accessors/BufferAttributeNode.js#L401),返回的是一个BufferAttributeNode,而不是 GPU 端的 storage buffer 节点。它的作用是把数据挂到顶点着色器可读取的 attribute 上。colors数组长度必须与position.count对应的顶点数一致(3 分量/顶点),类型默认从itemSize推断(vec3)。
第二个示例则展示了 Compute Shader 场景,把存储缓冲区转换回属性节点:
material.positionNode = positionBuffer.toAttribute();
仓库中的真实用法印证了这一模式,例如 examples/webgpu_compute_particles.html:
material.positionNode = positions.toAttribute();
以及 examples/webgpu_compute_particles_rain.html 中把它参与普通 TSL 运算:
rainMaterial.positionNode = positionGeometry.add( positionBuffer.toAttribute() );
在 examples/webgpu_tsl_vfx_linkedparticles.html 中还能看到取单个分量、结合 storage() 的写法:
const life = particlePositions.toAttribute().w;
linksMaterial.opacityNode = storage( linksColorsSBA, 'vec4', linksColorsSBA.count ).toAttribute().w;
构造参数
new BufferAttributeNode( value, bufferType, bufferStride, bufferOffset ) 的完整参数如下(默认值来自 构造器源码):
| 参数 | 说明 | 默认值 |
|---|---|---|
value |
属性数据,支持 BufferAttribute、InterleavedBuffer、TypedArray 三种形态 |
必填 |
bufferType |
缓冲区类型,如 'vec3';为 null 时从 BufferAttribute 自动推断 |
null |
bufferStride |
步长(stride),用于交织数据或多分量拆分的场景 | 0 |
bufferOffset |
在缓冲区内的分量偏移 | 0 |
构造器中有一个关键判断值得注意:只有当传入的是 itemSize <= 4 的 BufferAttribute 时,才会直接把它挂为 this.attribute 并继承其 usage、isInstancedBufferAttribute 状态:
if ( value && value.isBufferAttribute === true && value.itemSize <= 4 ) {
this.attribute = value;
this.usage = value.usage;
this.instanced = value.isInstancedBufferAttribute;
}
也就是说,vec4 以内的常规属性走“直接引用”快路径;而 itemSize 为 9(mat3)或 16(mat4)的数据则不满足条件,会在 setup() 阶段按 stride/offset 规则拆分成多个交织属性(见下文 TSL 函数家族一节)。
属性(Properties)完整参考
| 属性 | 类型/默认值 | 说明 |
|---|---|---|
.attribute |
BufferAttribute,默认 null |
对属性对象的引用。直接传 BufferAttribute 构造时即为其本身;否则由 setup() 创建 |
.bufferOffset |
number,默认 0 |
缓冲区分量偏移 |
.bufferStride |
number,默认 0 |
缓冲区步长 |
.bufferType |
string,默认 null |
如 'vec3';null 时运行时从属性推断 |
.global |
boolean,默认 true |
覆盖了 InputNode#global 的默认行为(见 InputNode 文档)。为 true 意味着该节点在节点编译图中全局共享,同一份属性数据被多个材质/多次构建引用时只注册一次 |
.instanced |
boolean,默认 false |
是否为实例化属性 |
.isBufferNode |
boolean (readonly),固定 true |
类型测试标志 |
.usage |
number,默认 StaticDrawUsage |
GPU 缓冲使用方式;若计划每帧更新属性数据,应通过 .setUsage() 设置为 THREE.DynamicDrawUsage |
global = true 与下文 getHash() 的“共享哈希”设计是配套的:正因为多个节点实例可能引用同一份数据,BufferAttributeNode 刻意让哈希在 stride === 0 && offset === 0 时全局共享,从而保证着色器侧 attribute 只生成一次(源码见 getHash 实现)。
方法与代码生成流程
.setup( builder ):按需创建内部属性
setup() 的行为取决于传入 value 的形态(源码):
value.isInterleavedBuffer === true:直接使用该InterleavedBuffer;value.isBufferAttribute === true:取value.array包装成InterleavedBuffer;- 其他情况:把裸
TypedArray包装成InterleavedBuffer。
包装过程通过文件顶部的模块级 WeakMap _bufferLib 缓存,同一数组只会创建一个 InterleavedBuffer。最终统一构造成:
const bufferAttribute = new InterleavedBufferAttribute( buffer, itemSize, offset );
buffer.setUsage( this.usage );
this.attribute = bufferAttribute;
this.attribute.isInstancedBufferAttribute = this.instanced; // @TODO: Add a possible: InstancedInterleavedBufferAttribute
其中 itemSize 由 builder.getTypeLength( type ) 从节点类型得出,stride 在未显式指定时回退为 itemSize(const stride = this.bufferStride || itemSize)。这解释了为什么 bufferStride/bufferOffset 对 mat3/mat4 拆分场景至关重要。
.generateNodeType( builder ):类型推断
由于节点类型直接从 BufferAttribute 推断,该方法被覆写:当 bufferType 为 null 时调用 builder.getTypeFromAttribute( this.attribute ) 并缓存结果(源码)。所以 Float32BufferAttribute(colors, 3) 会被推断为 vec3。
.generate( builder ):顶点阶段直读,片元阶段走 varying
代码生成逻辑(源码)区分着色器阶段:
- vertex / compute 阶段:直接把该节点注册为 buffer attribute,生成属性名并作为输出表达式——这是数据真正被逐顶点读取的地方;
- 其他阶段(如 fragment):自动构造一个
varying( this, varyingName )节点并调用其build(),即把顶点数据提升为 varying 传递到片元着色器。若指定了nodeName,varying 会命名为nodeName + 'Varying'。
这意味着同一个 bufferAttribute(...) 表达式在片元着色器里被引用时,TSL 会自动处理顶点→片元的插值传递,而不需要手写 varying。
.getHash( builder ):共享数据的共享哈希
如前所述,stride === 0 && offset === 0 时哈希取自 builder.globalCache 中按 value 键存的第一个节点实例的 id;否则退回本节点自身的 id。这保证同一份 BufferAttribute 被多个节点包装时,编译产物中 attribute 声明与注册不会重复。
.getInputType( builder )
覆写默认实现,固定返回字符串 'bufferAttribute',供节点编译器识别输入类别(源码)。
链式方法 .setUsage( value ) / .setInstanced( value )
两者均返回 this 以支持链式调用。setUsage() 除了修改节点自身,还会把 usage 同步到已挂载的 this.attribute 上,保证 GL 侧缓冲使用标志一致(源码);setInstanced() 则用于构造时无法感知实例化状态(如裸 TypedArray)的情形。
TSL 函数家族与 toAttribute()
文件底部定义了四个 TSL 工厂函数,都是对内部 createBufferAttribute() 的薄封装:
| TSL 函数 | 用途 |
|---|---|
bufferAttribute( array, type, stride, offset ) |
标准用法,静态使用 |
dynamicBufferAttribute( array, type, stride, offset ) |
预设 DynamicDrawUsage,适用于每帧更新的属性 |
instancedBufferAttribute( array, type, stride, offset ) |
预设实例化属性(StaticDrawUsage) |
instancedDynamicBufferAttribute( array, type, stride, offset ) |
实例化 + DynamicDrawUsage 的组合 |
内部函数 createBufferAttribute()(源码)对矩阵数据做了特殊处理:
type === 'mat3'或type为null且array.itemSize === 9:用三个BufferAttributeNode( array, 'vec3', 9, 0|3|6 )拼成mat3(...);type === 'mat4'或itemSize === 16:用四个BufferAttributeNode( array, 'vec4', 16, 0|4|8|12 )拼成mat4(...)。
也就是说,矩阵类型的属性不是单个 attribute,而是按 stride 9/16、offset 0/3/6(或 0/4/8/12)拆成多列,由 TSL 侧再组合——这正是构造参数 bufferStride/bufferOffset 存在的原因。
此外,文件通过 addMethodChaining 为存储缓冲区节点追加了 toAttribute() 方法(源码):
addMethodChaining( 'toAttribute', ( bufferNode ) => bufferAttribute( bufferNode.value, bufferNode.bufferType ) );
它把 BufferNode(compute 侧 storage buffer)的底层数据与类型取出,包装为 bufferAttribute(...) 属性节点。这一句桥接代码就是 Compute Shader 粒子示例中 positions.toAttribute() 能成立的根源。
底层机制:NodeBuilder 如何注册节点级属性
在 generate() 中真正调用的是 builder.getBufferAttributeFromNode( this, nodeType, nodeName )。查看 src/nodes/core/NodeBuilder.js#L1983-L2009 可以看到其实现:按节点取缓存数据,未注册过则生成 NodeAttribute( name, type, node )(默认命名 nodeAttribute + index),压入 this.bufferAttributes 数组,供后续渲染管线统一注册到 GPU 网格状态中。这也从侧面说明了 global = true 的必要性:不同材质、不同着色器阶段共享同一 NodeBuilder 全局缓存时,同一份数据只会在 bufferAttributes 中出现一次。
适用前提与小结
BufferAttributeNode属于 TSL 节点系统,需要 WebGL/WebGPU 的节点渲染路径;示例中的positionNode用法出现在webgpu_*与部分webgl_*GPU 驱动示例中(如 examples/webgl_interactive_cubes_gpu.html);- 每帧更新的数据请显式使用
dynamicBufferAttribute()或.setUsage( THREE.DynamicDrawUsage ),否则 GL 侧缓冲按静态分配,频繁updateRange会带来额外开销; - 数据规模必须与渲染的顶点/实例数匹配:
itemSize > 4的属性不要期望“直接引用”快路径,而应依赖setup()的交织拆分与 stride 配置。
参考路径:API 文档 docs/pages/BufferAttributeNode.html.md(HTML 版 docs/pages/BufferAttributeNode.html)、核心实现 src/nodes/accessors/BufferAttributeNode.js、构建器 src/nodes/core/NodeBuilder.js、实战示例 examples/webgpu_compute_particles.html 与 examples/webgpu_tsl_vfx_linkedparticles.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 StartedRust0624
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