首页
/ Three.js TSL 深入解析:BufferAttributeNode 让顶点数据在节点层面定义并贯通 Compute Shader

Three.js TSL 深入解析:BufferAttributeNode 让顶点数据在节点层面定义并贯通 Compute Shader

2026-09-06 12:06:50作者:范靓好Udolf

本文基于 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 属性(如 colorNodepositionNode)。

这种做法在数据由 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 ) );

这里注意两点:

  1. bufferAttribute(...) 是 TSL 函数(定义在 src/nodes/accessors/BufferAttributeNode.js#L401),返回的是一个 BufferAttributeNode,而不是 GPU 端的 storage buffer 节点。它的作用是把数据挂到顶点着色器可读取的 attribute 上。
  2. 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 属性数据,支持 BufferAttributeInterleavedBufferTypedArray 三种形态 必填
bufferType 缓冲区类型,如 'vec3';为 null 时从 BufferAttribute 自动推断 null
bufferStride 步长(stride),用于交织数据或多分量拆分的场景 0
bufferOffset 在缓冲区内的分量偏移 0

构造器中有一个关键判断值得注意:只有当传入的是 itemSize <= 4BufferAttribute 时,才会直接把它挂为 this.attribute 并继承其 usageisInstancedBufferAttribute 状态:

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

其中 itemSizebuilder.getTypeLength( type ) 从节点类型得出,stride 在未显式指定时回退为 itemSizeconst stride = this.bufferStride || itemSize)。这解释了为什么 bufferStride/bufferOffsetmat3/mat4 拆分场景至关重要。

.generateNodeType( builder ):类型推断

由于节点类型直接从 BufferAttribute 推断,该方法被覆写:当 bufferTypenull 时调用 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'typenullarray.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.htmlexamples/webgpu_tsl_vfx_linkedparticles.html

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