首页
/ Three.js TSL BitcountNode:着色器位计数节点 countOneBits / countLeadingZeros / countTrailingZeros 详解

Three.js TSL BitcountNode:着色器位计数节点 countOneBits / countLeadingZeros / countTrailingZeros 详解

2026-09-06 14:15:44作者:幸俭卉

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)。其工作流程可拆解为四步:

  1. 后端分流if ( renderer.backend.isWebGPUBackend ) 命中时直接 return super.setup( builder ),把运算交给 WGSL 内建函数(count_onescount_one_bits 等),不生成任何额外着色器代码(src/nodes/math/BitcountNode.js#L327-L333)。

  2. 推导输入类型:通过 builder 获取 inputType(完整向量类型)、elementType(标量元素类型,如 int/uint)与 typeLength(分量数)。

  3. 两级函数注册缓存:模块级对象 registeredBitcountFunctions 以两种 key 缓存已生成的函数,避免重复生成:

    • 基础分量函数:${method}_base_${elementType},例如 countOneBits_base_uintcountOneBits_base_int,由 _createOneBitsBaseLayout / _createLeadingZerosBaseLayout / _createTrailingZerosBaseLayout 分别创建;
    • 面向完整输入类型的主函数:${method}_${inputType},例如 countOneBits_uvec3,由 _createMainLayout 创建。 也就是说,同一 method 会按元素类型生成基础实现,再按向量长度生成向量包装,缓存后跨节点复用。
  4. 输出封装:最终生成一个 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

向量输入走分量级处理:_createMainLayoutsrc/nodes/math/BitcountNode.js#L282-L319)在 typeLength === 1 时直接返回标量基础函数结果;否则依次对 xyzw 分量调用基础函数并组装回向量——即位计数对向量的语义是逐分量(component-wise)的。

对于 int 输入,_resolveElementTypesrc/nodes/math/BitcountNode.js#L49-L61)会先通过 bitcast( inputNode, 'uint' ) 把有符号整数按位重解释为无符号整数再计数,避免符号位干扰移位运算;uint 输入则原样直通。

三大 GLSL 位计数算法的源码解读(WebGL 回退路径)

非 WebGPU 后端下,节点会为每种元素类型动态生成一个 Fn 着色器函数,其算法实现是理解位计数节点最有价值的部分。

countOneBits:经典 popcount 位压缩

_createOneBitsBaseLayoutsrc/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 二分式扫描

_createLeadingZerosBaseLayoutsrc/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 的幂次

_createTrailingZerosBaseLayoutsrc/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。这里依赖的 floatBitsToUintsrc/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#L521examples/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 输入内部先 bitcastuint 再计数。
  • 后端行为:WebGPU 后端下编译为 WGSL 内建函数(零额外代码生成);其他后端由节点按 ${method}_${type} 缓存键动态生成 GLSL 仿真函数,其中 countTrailingZeros 借助 floatBitsToUintsrc/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

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