首页
/ three.js TSL 之 ComputeBuiltinNode:Compute 着色器内建值(Builtin)的完整指南

three.js TSL 之 ComputeBuiltinNode:Compute 着色器内建值(Builtin)的完整指南

2026-09-06 15:44:25作者:盛欣凯Ernestine

本文以 docs/pages/ComputeBuiltinNode.html.md 官方 API 文档为主体,结合 ComputeBuiltinNode.js 源码与 WGSL 节点构建器实现,系统讲解 three.js 中 Compute 作用域内建值节点 ComputeBuiltinNode 的构造方式、核心 API、TSL 内置常量(numWorkgroupsworkgroupIdglobalIdlocalIdsubgroupSize)以及它在 WGSL 代码生成与 WebGPU compute dispatch 中的底层工作原理,帮助你在 WebGPU compute 着色器中正确获取并运用当前 dispatch 的坐标、工作组与子组信息。

一、ComputeBuiltinNode 是什么

根据官方文档的定义:

ComputeBuiltinNode represents a compute-scope builtin value that expose information about the currently running dispatch and/or the device it is running on.

This node can only be used with a WebGPU backend.

ComputeBuiltinNode 表示一个compute 作用域的内建值(builtin),用于暴露"当前正在运行的 dispatch"以及"承载它运行的设备"的相关信息,例如工作组索引、全局调用编号、子组大小等。它对应 WebGPU/WGSL 中 @builtin() 修饰符定义的着色器内建变量。

文档同时明确了两个关键约束:

  1. 仅限 WebGPU 后端使用。在 WebGL 渲染路径下没有对应的 compute 内建概念,该节点只有在 WebGPURenderer 生成的 WGSL 代码中才有意义;
  2. 内建值与"着色阶段(shader stage)"强相关。从源码看,只有在 shaderStage === 'compute' 时,节点才会被生成为真正的内建变量引用(详见第四节的 generate() 实现)。

文档给出的继承链为 EventDispatcher → Node → ComputeBuiltinNode。源码中该类直接继承自 NodeComputeBuiltinNode.js),并声明了静态类型名:

class ComputeBuiltinNode extends Node {

	static get type() {

		return 'ComputeBuiltinNode';

	}
	// ...
}

static get type() 返回 'ComputeBuiltinNode',是 TSL 节点体系进行节点序列化/反序列化时识别类型的依据。

二、构造器与核心 API

2.1 构造器

new ComputeBuiltinNode( builtinName : string, nodeType : string )

builtinName:内建值名称,对应 WGSL 的 @builtin(...) 名称(如 workgroup_id)。 nodeType:节点类型(WGSL 类型字符串,如 uvec3uint)。

源码实现(ComputeBuiltinNode.js):

constructor( builtinName, nodeType ) {

	super( nodeType );                 // 类型直接透传给 Node 基类

	/**
	 * The built-in name.
	 * @private
	 * @type {string}
	 */
	this._builtinName = builtinName;   // 名称以私有字段保存
}

可以看到,nodeType 被透传给基类 Node 的构造器,而 builtinName 以私有字段 _builtinName 保存,后续通过 getter/setter 访问。

2.2 方法一览

官方文档列出的方法及其源码行为如下(均位于 src/nodes/gpgpu/ComputeBuiltinNode.js):

方法 说明 源码行为
generateNodeType( builder ) : string 重写自 Node#generateNodeType,节点类型直接由构造时的 nodeType 推导 直接返回 this.nodeType,与 builder 无关(L58-L62
getBuiltinName( builder ) : string 返回内建值名称 返回私有字段 this._builtinNameL84-L88
getHash( builder ) : string 重写自 Node#getHash,哈希直接由内建值名称派生 return this.getBuiltinName( builder )——同一内建名在整个节点图中天然唯一(L46-L50
hasBuiltin( builder ) : boolean 查询当前 NodeBuilder 是否已登记该内建值 委托给 builder.hasBuiltin( this._builtinName )L96-L100),供其他节点判断某个内建值是否可用
setBuiltinName( builtinName ) : ComputeBuiltinNode 设置内建值名称,返回 this 支持链式调用 写入 this._builtinNameL70-L76

getHash 的实现值得注意:普通节点的哈希往往需要结合 builder 上下文计算,而 ComputeBuiltinNode 的哈希就是内建值名称本身——因为 workgroupId 这样的内建值在整个着色器中语义唯一,无需额外上下文即可区分。

2.3 源码补充:generate 的跨阶段回退与序列化

官方文档未列出、但源码中存在两个对使用者很重要的方法:

generate( builder, output ) —— 决定该节点最终生成什么 WGSL 代码(L102-L118):

generate( builder, output ) {

	const builtinName = this.getBuiltinName( builder );
	const nodeType = this.getNodeType( builder );

	if ( builder.shaderStage === 'compute' ) {

		return builder.format( builtinName, nodeType, output );   // compute 阶段:输出内建值引用

	} else {

		warn( `ComputeBuiltinNode: Compute built-in value ${builtinName} can not be accessed in the ${builder.shaderStage} stage` );
		return builder.generateConst( nodeType );                  // 其他阶段:告警并回退为常量

	}

}

这解释了文档中"can only be used with a WebGPU backend"之外的另一层保护:当该节点被误用于 vertex/fragment 等阶段时,构建器不会直接崩溃,而是打印 ComputeBuiltinNode: Compute built-in value <name> can not be accessed in the <stage> stage 的警告,并生成一个该类型(nodeType)的零值常量。这对 TSL 代码跨阶段复用(例如同一段表达式被 vertex 与 compute 两端共享)提供了容错,但也意味着在非 compute 阶段你拿到的是无效常量而非真实内建值

serialize( data ) / deserialize( data )L120-L136):在 TSL 节点图序列化(供节点编辑器/节点图保存使用)时,额外持久化 global 标记与 _builtinName 两个字段,反序列化时原样恢复,保证节点图可完整往返。

三、TSL 内置常量:开箱即用的内建值

文档主体面向 API 使用者,而实际开发中你几乎不会手动 new ComputeBuiltinNode(...)——源码底部提供了一个 TSL 工厂函数与一组预构造的常用内建常量(ComputeBuiltinNode.js):

const computeBuiltin = ( name, nodeType ) => new ComputeBuiltinNode( name, nodeType );

基于它导出了以下从 three/tsl 直接可用的常量:

常量 内建名 类型 语义
numWorkgroups numWorkgroups uvec3 本次 compute dispatch 分发的工作组总数
workgroupId workgroupId uvec3 当前 compute 调用所属工作组的三维索引
globalId globalId uvec3 当前调用在三维全局网格中的位置(未线性化)
localId localId uvec3 当前调用在三维工作组网格中的位置(未线性化)
subgroupSize subgroupSize uint 设备相关变量,暴露当前调用的 subgroup 大小

注:这三个 uvec3 值均为"非线性化"的三维形式,需要一维调用索引时应使用 instanceIndex / invocationLocalIndex 等由 WorkgroupInfoNode 等节点派生的索引(见 WorkgroupInfoNode.js),官方示例正是这种组合用法(见第五节)。

3.1 使用 numWorkgroups 的示例

官方文档给出的示例(原文完整保留):

// Run 512 invocations/threads with a workgroup size of 128.
const computeFn = Fn(() => {

	// numWorkgroups.x = 4
	storageBuffer.element(0).assign(numWorkgroups.x)

}()).compute(512, [128]);

// Run 512 invocations/threads with the default workgroup size of 64.
const computeFn = Fn(() => {

	// numWorkgroups.x = 8
	storageBuffer.element(0).assign(numWorkgroups.x)

}()).compute(512);

这里体现了 dispatch 参数与 numWorkgroups 的对应关系:compute(512, [128]) 表示共 512 个线程、每组 128 个,故 numWorkgroups.x = 512 / 128 = 4;而 compute(512) 使用默认工作组大小 64,得到 numWorkgroups.x = 512 / 64 = 8。该常量适合在着色器内部校验/记录 dispatch 规模,避免硬编码线程数。

3.2 使用 workgroupId 的示例

官方文档的第二个示例展示按工作组奇偶分支处理:

// Execute 12 compute threads with a workgroup size of 3.
const computeFn = Fn( () => {

	If( workgroupId.x.mod( 2 ).equal( 0 ), () => {

		storageBuffer.element( instanceIndex ).assign( instanceIndex );

	} ).Else( () => {

		storageBuffer.element( instanceIndex ).assign( 0 );

	} );

} )().compute( 12, [ 3 ] );

// workgroupId.x =  [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3];
// Buffer Output =  [0, 1, 2, 0, 0, 0, 6, 7, 8, 0, 0, 0];

12 个线程按每组 3 个划分为 4 个工作组,workgroupId.x 在 0/1/2/3 组上为奇数、在 0/2 组上为偶数:偶数组内每个线程把自己的一维索引 instanceIndex 写入对应 buffer 元素,奇数组写入 0,最终输出正是注释中的 [0, 1, 2, 0, 0, 0, 6, 7, 8, 0, 0, 0]。这个例子很适合用来验证 dispatch 参数、工作组切分与内建值取值三者是否一致。

四、底层原理:WGSL 代码是如何生成的

ComputeBuiltinNode 只负责携带"名称 + 类型",真正把 @builtin(...) 声明写进 WGSL 源文件的是 WebGPU 后端的节点构建器 WGSLNodeBuilder.js

4.1 按阶段登记内建值

构建器内部维护一个"每个 shader stage 对应一个 builtin Map"的字典(WGSLNodeBuilder.js):

/**
 * A dictionary that holds for each shader stage a Map of builtins.
 */
this.builtins = {};

当任何节点需要一个内建值时,应通过 getBuiltin() 登记(L1442-L1469):

getBuiltin( name, property, type, shaderStage = this.shaderStage ) {

	const map = this.builtins[ shaderStage ] || ( this.builtins[ shaderStage ] = new Map() );

	if ( map.has( name ) === false ) {

		map.set( name, { name, property, type } );   // 同名内建值只登记一次,保证声明唯一

	}

	return property;
}

其 JSDoc 说明了设计意图:"This method should be used whenever builtins are required in nodes. The internal builtins data structure will make sure builtins are defined in the WGSL source." 即登记机制确保每个 WGSL 函数入口的内建变量声明恰好出现一次,不会产生重复声明。

对应的查询方法 hasBuiltin()L1478-L1482):

hasBuiltin( name, shaderStage = this.shaderStage ) {

	return ( this.builtins[ shaderStage ] !== undefined && this.builtins[ shaderStage ].has( name ) );

}

ComputeBuiltinNode.hasBuiltin( builder ) 正是把 this._builtinName 传给这里——节点层面"当前 builder 是否拥有某内建值"的判断,完全等价于"该内建值是否已在对应 stage 的 Map 中登记过"

4.2 生成 @builtin(...) 声明片段

生成阶段代码时,构建器会把已登记的内建值渲染为 WGSL 的变量声明。从源码结构看,getBuiltins( shaderStage ) 的输出形如(WGSLNodeBuilder.js):

for ( const { name, property, type } of builtins.values() ) {

	snippets.push( `@builtin( ${name} ) ${property} : ${type}` );

}

也就是说,workgroupId 这类常量最终在 compute 函数入口表现为类似 @builtin( workgroup_id ) <property> : uvec3 的声明,节点表达式中使用的变量名(property)与 WGSL 规范的内建名(name)由此一一绑定。这一"登记 → 声明 → 引用"的三步机制,正是 ComputeBuiltinNode 无需用户手写任何着色器代码就能拿到内建值的原理所在。

五、实战印证:BitonicSort 中的 workgroupId

仓库内一个典型的 compute 示例是基数/位序排序实现 examples/jsm/gpgpu/BitonicSort.js(对应示例页 examples/webgpu_compute_sort_bitonic.html)。它直接从 three/tsl 导入内建值与工作组信息节点:

import { Fn, uvec2, If, instancedArray, instanceIndex, invocationLocalIndex, Loop, workgroupArray, workgroupBarrier, workgroupId, uint, select, min, max } from 'three/tsl';

_getSwapLocal() 中,workgroupId.x 被用来计算每个工作组负责的全局数据偏移(BitonicSort.js):

const fnDef = Fn( () => {

	// Get ids of indices needed to populate workgroup local buffer.
	// Use .toVar() to prevent these values from being recalculated multiple times.
	const localOffset = uint( workgroupSize ).mul( 2 ).mul( workgroupId.x ).toVar();

	const localID1 = invocationLocalIndex.mul( 2 );
	const localID2 = invocationLocalIndex.mul( 2 ).add( 1 );

	localStorage.element( localID1 ).assign( dataBuffer.element( localOffset.add( localID1 ) ) );
	localStorage.element( localID2 ).assign( dataBuffer.element( localOffset.add( localID2 ) ) );

	// Ensure that all local data has been populated
	workgroupBarrier();
	// ...
} )().compute( this.dispatchSize, [ this.workgroupSize ] );

这段代码完整展示了内建值在真实算法中的用法:

  1. workgroupId.x(即 ComputeBuiltinNode)给出当前工作组的编号,乘上"每工作组处理的数据量 workgroupSize * 2"得到该工作组在数据 buffer 中的起始偏移 localOffset
  2. invocationLocalIndex 给出工作组内的一维线程编号,配合偏移定位到具体的两个相邻元素;
  3. 每个工作组先把自己的数据块搬入工作组局部内存(workgroupArray),workgroupBarrier() 同步后在局部空间完成一段位序排序,再写回——这正是"以工作组为单位的分块并行"的教科书式写法。

此外 BitonicSort 各计算函数均以 .compute( this.dispatchSize, [ this.workgroupSize ] ) 的形式声明 dispatch 规模,其中 workgroupSize 由构造参数钳制(Math.min( this.dispatchSize, options.workgroupSize ),默认 64,L122),与 3.1 节 numWorkgroups 示例中"线程数 / 工作组大小 = 工作组数"的换算完全一致。

六、使用要点与限制

综合文档与源码,使用 ComputeBuiltinNode 及其常量时需注意:

  1. 仅限 WebGPU 后端:节点文档明确声明只能在 WebGPU 后端使用;其代码生成依赖 WGSLNodeBuilder.js 的 builtin 登记/声明机制。
  2. 仅 compute 阶段取值有效generate() 在非 compute 阶段会发出警告并回退为零值常量(ComputeBuiltinNode.js)。如果你看到控制台出现 Compute built-in value ... can not be accessed in the ... stage,说明该表达式被放错了着色阶段。
  3. 优先使用 TSL 预导出常量numWorkgroupsworkgroupIdglobalIdlocalIdsubgroupSize 已在模块内用 computeBuiltin(...) 构造完毕(带 /*@__PURE__*/ 标注,便于 tree-shaking);仅当需要其他 WGSL compute 内建(如 subgroup_size 之外的子组索引类)时才手动 new ComputeBuiltinNode( builtinName, nodeType ) 或调用工厂函数。
  4. 哈希即名称getHash() 直接返回内建名,因此同一内建值在节点图中天然去重,不会产生重复变量;setBuiltinName() 可在序列化/反序列化后修正名称(serialize/deserialize 持久化 _builtinNameglobal 字段)。
  5. 三维值需配合索引派生globalId/localId 是三维形式,需要一维调用索引时请使用 instanceIndex(全局一维索引)、invocationLocalIndex(工作组内一维索引)等派生节点,BitonicSort.js 的组合用法可直接参考。

参考来源

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