Three.js TSL BitcountNode:着色器位计数节点 countOneBits / countLeadingZeros / countTrailingZeros 详解
BitcountNode 是 Three.js TSL(Three Shading Language)节点图中的一个数学节点,用于在着色器中执行“位计数”运算:统计数值中连续 0 位或 1 位的数量。本文将基于 API 文档 docs/pages/BitcountNode.html.md 与核心实现 src/nodes/math/BitcountNode.js,完整覆盖其构造函数参数、类型体系、三大位计数算法的着色器级实现原理、单元测试的期望行为,以及它在仓库内 SSGI 后处理中的真实用法。
什么是 BitcountNode:继承关系与 API 骨架
按 API 文档的描述,BitcountNode 表示一个对一段着色器数据执行位计数运算的节点("This node represents an operation that counts the bits of a piece of shader data")。其继承链为:
EventDispatcher → Node → TempNode → MathNode → BitcountNode
即它最终派生自 src/nodes/math/MathNode.js 中的 MathNode,属于 TSL 数学节点家族的一部分,并通过 src/nodes/Nodes.js 统一导出(export { default as BitcountNode } from './math/BitcountNode.js')。
构造函数
文档定义的构造签名为:
new BitcountNode( method : 'countTrailingZeros' | 'countLeadingZeros' | 'countOneBits', aNode : Node )
参数含义与源码 src/nodes/math/BitcountNode.js#L26-L39 完全一致:
- method:方法名,只能是三个字符串常量之一:
'countTrailingZeros':统计从最低有效位(LSB)起连续 0 位的个数(即最低 1 位的下标);'countLeadingZeros':统计从最高有效位(MSB)起连续 0 位的个数;'countOneBits':统计数值中置位(为 1)的位总数,即 popcount。
- aNode:第一个(也是唯一的)输入节点,即被计数的值。
三个字符串常量在类上以静态 getter 形式暴露(src/nodes/math/BitcountNode.js#L397-L413),可作为类型安全的方式引用:
BitcountNode.COUNT_TRAILING_ZEROS // 'countTrailingZeros'
BitcountNode.COUNT_LEADING_ZEROS // 'countLeadingZeros'
BitcountNode.COUNT_ONE_BITS // 'countOneBits'
类型标识属性
.isBitcountNode : boolean(readonly):文档标注的默认值为true,用于类型测试。源码中在构造函数内通过this.isBitcountNode = true赋值(src/nodes/math/BitcountNode.js#L37),与 TSL 中其他节点的isXxxNode惯例一致,方便在节点图遍历中做instanceof之外的轻量类型判断。
三个 TSL 入口函数:countTrailingZeros / countLeadingZeros / countOneBits
实际开发中几乎不会直接 new BitcountNode(...),而是使用文件末尾通过 nodeProxyIntent 派生出的三个 TSL 函数(src/nodes/math/BitcountNode.js#L419-L453),它们都调用 setParameterLength( 1 ),即只接受一个输入参数:
import { countOneBits, countLeadingZeros, countTrailingZeros, uint } from 'three/tsl';
// 统计 0xF0(11110000b)中为 1 的位:结果为 4
const ones = countOneBits( uint( 0xF0 ) );
// 统计 8(1000b)的尾随零:结果为 3
const tz = countTrailingZeros( uint( 8 ) );
// 统计 0xF0 的前导零:结果为 24
const lz = countLeadingZeros( uint( 0xF0 ) );
三个函数的官方 JSDoc 语义(与 docs/TSL.md 所属的 TSL 文档体系一致):
| 函数 | 语义 | 返回类型 |
|---|---|---|
countTrailingZeros( x ) |
输入值从最低有效位起连续 0 位的数量,等价于最低 1 位的下标 | 与输入同类型 |
countLeadingZeros( x ) |
输入值从最高有效位起连续 0 位的数量 | 与输入同类型 |
countOneBits( x ) |
输入值中置位(1)的位总数 | 与输入同类型 |
需要特别注意:源码 JSDoc 中标注这三个函数 "Can only be used with WebGPURenderer and a WebGPU backend"(面向 WebGPURenderer 与 WebGPU 后端设计)。但从 setup() 的源码结构看,节点在非 WebGPU 后端上并没有直接失败,而是动态生成等价的 GLSL 函数来仿真这三个运算(见下文),这一回退路径在测试文件的注释中也被明确称为 "WebGL polyfill"(test/unit/addons/tsl/TSLBitOps.tests.js#L68-L70)。因此可以推断:WebGPU 路径使用硬件内建函数,WebGL 路径使用运行时生成的位运算仿真。
setup() 运行时生成机制:函数注册缓存与按类型分发
位计数节点的编译逻辑集中在 setup( builder ) 方法中(src/nodes/math/BitcountNode.js#L321-L395)。其工作流程可拆解为四步:
-
后端分流:
if ( renderer.backend.isWebGPUBackend )命中时直接return super.setup( builder ),把运算交给 WGSL 内建函数(count_ones、count_one_bits等),不生成任何额外着色器代码(src/nodes/math/BitcountNode.js#L327-L333)。 -
推导输入类型:通过 builder 获取
inputType(完整向量类型)、elementType(标量元素类型,如int/uint)与typeLength(分量数)。 -
两级函数注册缓存:模块级对象
registeredBitcountFunctions以两种 key 缓存已生成的函数,避免重复生成:- 基础分量函数:
${method}_base_${elementType},例如countOneBits_base_uint、countOneBits_base_int,由_createOneBitsBaseLayout/_createLeadingZerosBaseLayout/_createTrailingZerosBaseLayout分别创建; - 面向完整输入类型的主函数:
${method}_${inputType},例如countOneBits_uvec3,由_createMainLayout创建。 也就是说,同一method会按元素类型生成基础实现,再按向量长度生成向量包装,缓存后跨节点复用。
- 基础分量函数:
-
输出封装:最终生成一个
Fn包裹层,调用主函数处理aNode并返回结果(src/nodes/math/BitcountNode.js#L385-L393)。
输入/输出类型矩阵
_returnDataNode( inputType )(src/nodes/math/BitcountNode.js#L63-L117)实现了“同类型进、同类型出”的映射:
| 输入类型 | 输出类型 |
|---|---|
uint / int |
uint / int |
uvec2 / uvec3 / uvec4 |
uvec2 / uvec3 / uvec4 |
ivec2 / ivec3 / ivec4 |
ivec2 / ivec3 / ivec4 |
向量输入走分量级处理:_createMainLayout(src/nodes/math/BitcountNode.js#L282-L319)在 typeLength === 1 时直接返回标量基础函数结果;否则依次对 x、y、z、w 分量调用基础函数并组装回向量——即位计数对向量的语义是逐分量(component-wise)的。
对于 int 输入,_resolveElementType(src/nodes/math/BitcountNode.js#L49-L61)会先通过 bitcast( inputNode, 'uint' ) 把有符号整数按位重解释为无符号整数再计数,避免符号位干扰移位运算;uint 输入则原样直通。
三大 GLSL 位计数算法的源码解读(WebGL 回退路径)
非 WebGPU 后端下,节点会为每种元素类型动态生成一个 Fn 着色器函数,其算法实现是理解位计数节点最有价值的部分。
countOneBits:经典 popcount 位压缩
_createOneBitsBaseLayout(src/nodes/math/BitcountNode.js#L242-L269)实现了教科书式的 32 位 popcount,分三步把 32 个比特的 1 逐级压缩到低 8 位:
// 第 1 步:相邻 2 位分组求和(掩码 0101…)
v.assign( v.sub( v.shiftRight( uint( 1 ) ).bitAnd( uint( 0x55555555 ) ) ) );
// 第 2 步:4 位一组求和(掩码 0011…)
v.assign( v.bitAnd( uint( 0x33333333 ) ).add( v.shiftRight( uint( 2 ) ).bitAnd( uint( 0x33333333 ) ) ) );
// 第 3 步:8 位一组求和后,乘 0x1010101 把低字节和提升到最高字节,右移 24 位取出
const numBits = v.add( v.shiftRight( uint( 4 ) ) ).bitAnd( uint( 0xF0F0F0F ) )
.mul( uint( 0x1010101 ) ).shiftRight( uint( 24 ) );
三步之后最高字节即为 0–32 的置位数,整段运算只有加减、移位和位与,无任何分支,适合 GPU 并行执行。
countLeadingZeros:16/8/4/2/1 二分式扫描
_createLeadingZerosBaseLayout(src/nodes/math/BitcountNode.js#L170-L232)采用经典的“对半排除”法,用一个累加器 n 和一个工作副本 v:
// 输入为 0 时约定返回 32
If( value.equal( uint( 0 ) ), () => { return uint( 32 ); } );
If( v.shiftRight( 16 ).equal( 0 ) ) { n.addAssign( 16 ); v.shiftLeftAssign( 16 ); }
If( v.shiftRight( 24 ).equal( 0 ) ) { n.addAssign( 8 ); v.shiftLeftAssign( 8 ); }
If( v.shiftRight( 28 ).equal( 0 ) ) { n.addAssign( 4 ); v.shiftLeftAssign( 4 ); }
If( v.shiftRight( 30 ).equal( 0 ) ) { n.addAssign( 2 ); v.shiftLeftAssign( 2 ); }
If( v.shiftRight( 31 ).equal( 0 ) ) { n.addAssign( 1 ); }
含义是:若右移 16 位后为 0,说明高位 16 位全零,累加 16 位并把 v 左移 16 位重新对齐;随后以 8、4、2、1 位粒度重复该判断。最坏 5 次比较即可定位前导零个数,时间复杂度与位宽对数级相当。
countTrailingZeros:借浮点指数域直接读出 2 的幂次
_createTrailingZerosBaseLayout(src/nodes/math/BitcountNode.js#L127-L160)是最巧妙的一段,它利用 v & -v 只保留最低置位这一性质,再把结果借道 float 的指数域读出来:
If( value.equal( uint( 0 ) ), () => { return uint( 32 ); } );
const f = float( v.bitAnd( negate( v ) ) ); // 最低置位对应的 2 的幂
const uintBits = floatBitsToUint( f ); // 按位重解释为 uint
const numTrailingZeros = ( uintBits.shiftRight( 23 ) ).sub( 127 ); // 取指数域 - 127
原理:若最低置位在第 k 位,则 v & -v 等于 2^k;而 IEEE-754 单精度浮点 2^k 的位模式高 8 位正是偏置指数 k + 127,因此右移 23 位再减 127 就得到 k。这里依赖的 floatBitsToUint 是 src/nodes/math/BitcastNode.js#L136 中定义的 BitcastNode 便捷封装(new BitcastNode( value, 'uint', 'float' )),即纯粹的位模式重解释,不改变任何位。
零值的统一约定
两个“零计数”函数对 0 输入都显式特判返回 uint( 32 )(前导零:32 位全零即 32 个前导零;尾随零:无最低置位时约定为 32)。这一约定在单元测试中被明确验证(见下节),使用这些函数时必须牢记零输入不是“未定义行为”,而是固定返回 32。
单元测试:用独立参照系验证 GPU 行为
仓库中的 GPU 端测试 test/unit/addons/tsl/TSLBitOps.tests.js 为三个函数给出了可复现的期望值,且每个参照都刻意独立于被测节点本身:
import { uint, countLeadingZeros, countOneBits, countTrailingZeros } from 'three/tsl';
- countOneBits:与一段纯 JS 手写 popcount 循环对比——
countOneBits( uint( 0 ) )期望 0,countOneBits( uint( 0xF0 ) )期望 4,countOneBits( uint( 0xFFFFFFFF ) )期望 32(test/unit/addons/tsl/TSLBitOps.tests.js#L31-L47); - countLeadingZeros:以 JS 内建的
Math.clz32()为参照——countLeadingZeros( uint( 0 ) )期望 32、( uint( 1 ) )期望 31、( uint( 0xF0 ) )期望 24、( uint( 0xFFFFFFFF ) )期望 0(test/unit/addons/tsl/TSLBitOps.tests.js#L49-L61); - countTrailingZeros:与手写尾随零循环对比——
countTrailingZeros( uint( 0 ) )期望 32、( uint( 8 ) )期望 3、( uint( 0xF0 ) )期望 4、( uint( 1 ) )期望 0(test/unit/addons/tsl/TSLBitOps.tests.js#L63-L85)。
这些用例同时验证了两类事实:算法数值正确性,以及“0 输入返回 32”的边界约定在真实 GPU 上成立。
仓库中的真实应用:SSGI 的遮挡位域统计
BitcountNode 并不是一个孤立的实验性节点,examples/jsm/tsl/display/SSGINode.js(SSGI 屏幕空间全局光照 TSL 实现)就依赖 countOneBits 完成核心逻辑。SSGI 用一个 32 位位域 globalOccludedBitfield 记录某个切片内哪些角度区间已被遮挡,每一位代表一条采样射线对应的方位角区间:
// 新增被遮挡区间后,统计本次新遮挡了多少个区间
globalOccludedBitfield.assign( globalOccludedBitfield.bitOr( currentOccludedBitfield ) );
const numOccludedZones = countOneBits( currentOccludedBitfield ); // L521
// 环境光遮蔽度 = 被遮挡区间数 / 总采样数
ao.addAssign( float( countOneBits( globalOccludedBitfield ) ).div( float( MAX_RAY ) ) ); // L626
(见 examples/jsm/tsl/display/SSGINode.js#L521 与 examples/jsm/tsl/display/SSGINode.js#L626)
这个案例很好地展示了 countOneBits 的典型工程价值:GPU 上统计“32 位中有多少个 1”如果靠循环逐位判断代价高昂,而位压缩 popcount 只有常数次算术/位运算,且对 32 条采样的遮挡状态是逐像素并行计算的。类似的,countTrailingZeros / countLeadingZeros 在 GPU 端可用于确定位索引、构造前缀和、位域编码/解码等场景。
使用要点小结
- 入口:优先使用
three/tsl导出的countOneBits/countLeadingZeros/countTrailingZeros函数;new BitcountNode( method, aNode )与其等价,method 取值可引用静态常量BitcountNode.COUNT_ONE_BITS等(src/nodes/math/BitcountNode.js#L397-L413)。 - 类型:输入支持
uint/int及 2/3/4 分量向量;输出与输入同类型;向量语义为逐分量;int输入内部先bitcast为uint再计数。 - 后端行为:WebGPU 后端下编译为 WGSL 内建函数(零额外代码生成);其他后端由节点按
${method}_${type}缓存键动态生成 GLSL 仿真函数,其中countTrailingZeros借助floatBitsToUint(src/nodes/math/BitcastNode.js)的浮点指数域技巧实现。 - 边界约定:
countLeadingZeros(0)与countTrailingZeros(0)均约定返回 32。 - 类型测试:
.isBitcountNode === true(readonly)可用于节点图中的类型判断。
参考文档:docs/pages/BitcountNode.html.md;核心实现:src/nodes/math/BitcountNode.js;行为验证:test/unit/addons/tsl/TSLBitOps.tests.js;真实用例:examples/jsm/tsl/display/SSGINode.js。
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 StartedRust0625
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