首页
/ three.js TSL 深入解析 ClippingNode:default、hardware 与 alphaToCoverage 三种裁剪模式的源码原理

three.js TSL 深入解析 ClippingNode:default、hardware 与 alphaToCoverage 三种裁剪模式的源码原理

2026-09-06 14:13:25作者:董宙帆

ClippingNode 是 three.js 节点材质(TSL)体系中专门负责“裁剪面(Clipping Plane)”计算的节点,由 NodeMaterial 在着色器编译阶段自动实例化,用于把场景中的裁剪平面转换为顶点/片元着色器逻辑:既可以通过 GPU 硬件裁剪加速,也可以退化为着色器内 discard,还能启用 alpha-to-coverage 对裁剪边缘做抗锯齿。读完本文,你将理解 new ClippingNode(scope) 三种取值 'default' | 'hardware' | 'alphaToCoverage' 各自的代码生成路径、intersectionPlanesunionPlanes 的语义差异,以及硬件裁剪在 WebGL(GL_ANGLE_clip_cull_distance)与 WebGPU(clip-distances)两条后端下的启用条件与 8 平面限制。

定位:ClippingNode 在渲染管线中的位置

官方 API 文档(docs/pages/ClippingNode.html)对该节点的定义是:

This node is used in NodeMaterial to setup the clipping which can happen hardware-accelerated (if supported) and optionally use alpha-to-coverage for anti-aliasing clipped edges.

(该节点在 NodeMaterial 中用于设置裁剪,裁剪可以硬件加速(若设备支持),并可选使用 alpha-to-coverage 对裁剪边缘抗锯齿。)

从源码结构看,它的继承链为 EventDispatcher → Node → ClippingNode,实现位于 src/nodes/accessors/ClippingNode.js。它本身不持有裁剪平面数据——平面来自渲染器维护的“裁剪上下文”(builder.clippingContext,见 ClippingContext),ClippingNode 只负责根据选定的 scope 生成对应的着色器逻辑。

类上定义了三个静态常量,即三种 scope 的规范取值(ClippingNode.js#L224-L226):

常量 取值 行为
ClippingNode.DEFAULT 'default' 着色器内逐平面判断,越界片元直接 discard
ClippingNode.HARDWARE 'hardware' 将平面距离写入 GPU 内建裁剪距离变量,由光栅化器硬件裁剪
ClippingNode.ALPHA_TO_COVERAGE 'alphaToCoverage' 用 fwidth + smoothstep 计算软边透明度,配合多重采样抗锯齿

文件末尾还导出了三个 TSL 工厂函数,是各条路径的入口(ClippingNode.js#L237-L255):

  • clipping()new ClippingNode(),即 default 模式;
  • clippingAlpha()new ClippingNode(ClippingNode.ALPHA_TO_COVERAGE)
  • hardwareClipping()new ClippingNode(ClippingNode.HARDWARE)

三者经由 src/Three.TSL.js 一并导出,可直接在 TSL 表达式中使用。

构造函数与 scope 属性

官方文档定义的构造签名为:

new ClippingNode( scope : 'default' | 'hardware' | 'alphaToCoverage' )

scope:节点的 scope。与其他节点一样,所选 scope 会影响节点的行为和生成的代码类型。默认值为 'default'

对照源码(ClippingNode.js#L33-L45),构造函数签名实际为:

constructor( scope = ClippingNode.DEFAULT ) {
    super();
    this.scope = scope;
}

this.scope 是文档中列出的唯一公开属性,类型为 'default' | 'hardware' | 'alphaToCoverage',默认 'default'setup(builder) 方法会依据该值分派到三条不同的代码生成路径(ClippingNode.js#L53-L76):

setup( builder ) {

    super.setup( builder );

    const clippingContext = builder.clippingContext;
    const { intersectionPlanes, unionPlanes } = clippingContext;

    this.hardwareClipping = builder.hardwareClipping;

    if ( this.scope === ClippingNode.ALPHA_TO_COVERAGE ) {

        return this.setupAlphaToCoverage( intersectionPlanes, unionPlanes );

    } else if ( this.scope === ClippingNode.HARDWARE ) {

        return this.setupHardwareClipping( unionPlanes, builder );

    } else {

        return this.setupDefault( intersectionPlanes, unionPlanes );

    }
}

注意两点:裁剪平面从 builder.clippingContext 解构而来;this.hardwareClipping 缓存自 builder.hardwareClipping 标志,该标志由 NodeMaterial 在决定是否启用硬件裁剪时写入(见下文),default 与 alphaToCoverage 两条路径都会读取它来避免与硬件裁剪重复计算。

平面数据格式:unionPlanes 与 intersectionPlanes

文档将 intersectionPlanesunionPlanes 都描述为 Array.<Vector4>。它们的语义差异来自 ClippingContext

  • unionPlanesClippingContext.js#L94-L99):任意一个平面越界即被裁剪,对应常规“任一裁剪面生效”的行为;
  • intersectionPlanesClippingContext.js#L87-L92):只有当所有平面同时越界时才裁剪,即多个裁剪面定义出的“交集区域”之外的部分被保留,常用于挖孔式裁剪。

clipIntersection 属性(ClippingContext.js#L43-L49)控制裁剪组使用交集还是并集语义,update() 方法据此把 ClippingGroup.clippingPlanes 追加到对应数组中。

每个 Vector4 的坐标格式由 projectPlanes() 确定(ClippingContext.js#L129-L147):平面先经视图矩阵投影到观察空间,然后 xyz = -normalw = constant。因此在着色器内距离函数统一写作:

distanceToPlane = positionView.dot( plane.xyz ).negate().add( plane.w )

-dot(p, -n) + c = dot(p, n) + c,也就是点相对于裁剪平面的带符号距离(ClippingNode.js#L104)。这一约定是理解下述三种模式代码生成逻辑的前提。

模式一:default —— 着色器内逐平面 discard

setupDefault(intersectionPlanes, unionPlanes) 对应文档描述的 “Setups the default clipping”,生成的 TSL 逻辑为(ClippingNode.js#L150-L189):

setupDefault( intersectionPlanes, unionPlanes ) {

    return Fn( () => {

        const numUnionPlanes = unionPlanes.length;

        if ( this.hardwareClipping === false && numUnionPlanes > 0 ) {

            const clippingPlanes = uniformArray( unionPlanes ).setGroup( renderGroup );

            Loop( numUnionPlanes, ( { i } ) => {

                const plane = clippingPlanes.element( i );
                positionView.dot( plane.xyz ).greaterThan( plane.w ).discard();

            } );

        }

        const numIntersectionPlanes = intersectionPlanes.length;

        if ( numIntersectionPlanes > 0 ) {

            const clippingPlanes = uniformArray( intersectionPlanes ).setGroup( renderGroup );
            const clipped = bool( true ).toVar( 'clipped' );

            Loop( numIntersectionPlanes, ( { i } ) => {

                const plane = clippingPlanes.element( i );
                clipped.assign( positionView.dot( plane.xyz ).greaterThan( plane.w ).and( clipped ) );

            } );

            clipped.discard();

        }

    } )();

}

其要点:

  1. union 平面:只要某点满足 dot(p, plane.xyz) > plane.w(即越过任一平面)就 discard() 该片元;this.hardwareClipping === false 是保护条件——若硬件裁剪已接管 union 平面,这里不再重复判断;
  2. intersection 平面:用一个 clipped 布尔量累乘所有平面的越界条件(.and( clipped )),只有全部越界时才 discard,恰好实现“交集”语义;
  3. 平面数组通过 uniformArray(...).setGroup( renderGroup ) 注入,并归入 renderGroup 组,与渲染器其他每帧更新的 uniform 同组上传,保证平面每帧可动态变化。

模式二:hardware —— 交给光栅化器加速

setupHardwareClipping(unionPlanes, builder) 对应文档中的 “Setups hardware clipping”(ClippingNode.js#L198-L220):

setupHardwareClipping( unionPlanes, builder ) {

    const numUnionPlanes = unionPlanes.length;

    builder.enableHardwareClipping( numUnionPlanes );

    return Fn( () => {

        const clippingPlanes = uniformArray( unionPlanes ).setGroup( renderGroup );
        const hw_clip_distances = builtin( builder.getClipDistance() );

        Loop( numUnionPlanes, ( { i } ) => {

            const plane = clippingPlanes.element( i );

            const distance = positionView.dot( plane.xyz ).sub( plane.w ).negate();
            hw_clip_distances.element( i ).assign( distance );

        } );

    } )();

}

这条路径的语义与 default 不同:顶点着色器不再做 discard,而是把每个平面的带符号距离写入 GPU 的内建裁剪距离变量hw_clip_distances),由光栅化阶段硬件丢弃越界三角形——这正是文档所说 “hardware-accelerated (if supported)” 的落点。两条后端的具体实现分别是:

并非任何设备都能走这条路径。NodeMaterial 中的 setupHardwareClipping(builder)NodeMaterial.js#L650-L670)给出了完整的启用判据:

setupHardwareClipping( builder ) {

    builder.hardwareClipping = false;

    if ( builder.clippingContext === null ) return;

    const candidateCount = builder.clippingContext.unionPlanes.length;

    // 8 planes supported by WebGL ANGLE_clip_cull_distance and WebGPU clip-distances

    if ( candidateCount > 0 && candidateCount <= 8 && builder.isAvailable( 'clipDistance' ) ) {

        builder.stack.addToStack( hardwareClipping() );

        builder.hardwareClipping = true;

    }
}

即必须同时满足:裁剪上下文存在、union 平面数量在 1~8 之间(源码注释明确 WebGL 的 ANGLE_clip_cull_distance 与 WebGPU clip-distances 均支持 8 个平面)、且当前设备探测到 clipDistance 特性可用(builder.isAvailable( 'clipDistance' ))。满足后调用 hardwareClipping() 工厂函数入栈,并把 builder.hardwareClipping 置为 true——这个标志随后被 default / alphaToCoverage 路径读取,用于跳过软件侧的 union 平面重复计算,三种模式由此形成一个协调的整体。

模式三:alphaToCoverage —— 裁剪边缘抗锯齿

setupAlphaToCoverage(intersectionPlanes, unionPlanes) 对应文档中的 “Setups alpha to coverage”(ClippingNode.js#L85-L141)。它的思路是用平面的距离梯度对裁剪边做软过渡,把结果乘进材质 alpha,让多重采样抗锯齿接管边缘:

Loop( numUnionPlanes, ( { i } ) => {

    const plane = clippingPlanes.element( i );

    distanceToPlane.assign( positionView.dot( plane.xyz ).negate().add( plane.w ) );
    distanceGradient.assign( distanceToPlane.fwidth().div( 2.0 ) );

    clipOpacity.mulAssign( smoothstep( distanceGradient.negate(), distanceGradient, distanceToPlane ) );

} );

关键步骤:

  1. distanceToPlane 为点到平面的带符号距离(观察空间);
  2. distanceGradient = fwidth(distanceToPlane) / 2:取屏幕相邻像素间距离变化量的半幅,作为 smoothstep 过渡带宽度,保证软边约为 1 像素宽,与屏幕分辨率自适应;
  3. clipOpacity.mulAssign( smoothstep(-g, g, d) ):多个 union 平面的透明度相乘,任一平面靠近边缘都会拉低 alpha;
  4. intersection 平面单独计算 intersectionClipOpacity(每平面取 .oneMinus() 表示“该平面内部”的占比),最后 clipOpacity.mulAssign( intersectionClipOpacity.oneMinus() ) 合并;
  5. 结尾两行 diffuseColor.a.mulAssign( clipOpacity )diffuseColor.a.equal( 0.0 ).discard():把裁剪透明度写回 diffuseColor 的 alpha 通道,alpha 为 0 的片元才 discard。

这与 default 模式的硬 discard 形成对比:alphaToCoverage 在裁剪边界附近保留半透明片元,配合 MSAA 得到平滑边缘。

与 NodeMaterial 的集成:何时生成哪个节点

文档中所有方法签名都标注“由 NodeMaterial 驱动”,这一点在 NodeMaterial.setupClipping 中得到印证:

setupClipping( builder ) {

    if ( builder.clippingContext === null ) return null;

    const { unionPlanes, intersectionPlanes } = builder.clippingContext;

    let result = null;

    if ( unionPlanes.length > 0 || intersectionPlanes.length > 0 ) {

        const samples = builder.renderer.currentSamples;

        if ( this.alphaToCoverage && samples > 1 ) {

            // to be added to flow when the color/alpha value has been determined
            result = clippingAlpha();

        } else {

            builder.stack.addToStack( clipping() );

        }

    }

    return result;
}

从源码结构看,完整的调用链可以归纳为:

  1. 若没有裁剪上下文或没有任何裁剪平面,直接返回 null,不生成 ClippingNode;
  2. 若用户把 Material.alphaToCoverage(基类 Material.js#L409 中默认 false)设为 true,当前渲染目标开启多重采样(builder.renderer.currentSamples > 1),则生成 clippingAlpha()——注意它被延迟到颜色/alpha 确定之后才接入 flow(源码注释 “to be added to flow when the color/alpha value has been determined”),因为该模式必须修改 diffuseColor.a
  3. 否则入栈 clipping()(default 模式);
  4. 硬件裁剪在更早的顶点阶段由 setupHardwareClipping(builder) 决定(见上一节),成功后 builder.hardwareClipping 为 true,使 default / alphaToCoverage 内部跳过 union 平面的软件判断。

实操:在 NodeMaterial 上启用裁剪抗锯齿

结合上述集成逻辑,一个最小的用法示例如下(平面本身由渲染场景的裁剪上下文提供,如通过 ClippingGroup 挂载的裁剪平面,最终汇入 clippingContext.unionPlanes / intersectionPlanes):

import * as THREE from 'three';

// 假设 positionWorld / NodeMaterial 等 TSL 成员已从 three 导出
const material = new NodeMaterial();

material.fragmentNode = positionWorld; // 你的 TSL 片元节点

// 开启 alpha-to-coverage 裁剪边缘抗锯齿;
// 需要渲染目标开启多重采样(samples > 1)才会真正生效,
// 否则 NodeMaterial 会回退到默认的 discard 裁剪
material.alphaToCoverage = true;

mesh.material = material;

从源码可以推断出几条实用约束,均与本文前面分析的判据一致:

  • 不设置任何裁剪平面时,clippingContext 为空或平面数组为空,着色器中不会出现裁剪代码,alphaToCoverage 对裁剪无副作用;
  • alphaToCoverage = true 但渲染目标 samples === 1 时,自动回退到 default 的 discard 路径;
  • 当 union 平面数量 ≤ 8 且设备支持 clipDistance 时,union 平面交由硬件裁剪,软件路径仅保留 intersection 平面的判断;
  • 平面数量超过 8 时无法整体走硬件路径,会退回软件逐平面逻辑。

方法与属性速查(对齐官方文档)

成员 签名 说明 源码位置
new ClippingNode( scope ) scope : 'default' | 'hardware' | 'alphaToCoverage' 构造裁剪节点,默认 'default' ClippingNode.js#L33-L45
.scope 属性 决定生成哪类裁剪代码 ClippingNode.js#L43
.setup( builder ) builder : NodeBuilder → Node 按 scope 分派到三条路径,覆盖 Node#setup ClippingNode.js#L53-L76
.setupAlphaToCoverage( intersectionPlanes, unionPlanes ) 两个 Array.<Vector4> 用 fwidth/smoothstep 软边并写回 diffuseColor.a ClippingNode.js#L85-L141
.setupDefault( intersectionPlanes, unionPlanes ) 两个 Array.<Vector4> union 平面任一越界即 discard;intersection 平面全部越界才 discard ClippingNode.js#L150-L189
.setupHardwareClipping( unionPlanes, builder ) Array.<Vector4>, NodeBuilder 计算平面距离并写入 gl_ClipDistance / hw_clip_distances 内建变量 ClippingNode.js#L198-L220

小结

ClippingNode 是 three.js TSL 中“裁剪”这一渲染语义的统一入口:'default' 提供通用的着色器内 discard 兜底,'hardware' 在设备支持 ANGLE_clip_cull_distance(WebGL)或 clip-distances(WebGPU)且平面数不超过 8 时把 union 平面交给硬件光栅化器裁剪,'alphaToCoverage' 则在多重采样可用时以 1 像素级的 smoothstep 软边替代硬 discard,消除裁剪边缘锯齿。三种模式通过 builder.clippingContext 提供的 unionPlanes / intersectionPlanesbuilder.hardwareClipping 标志协同工作,开发者通常只需在 NodeMaterial 上设置 alphaToCoverage 等属性,即可让渲染器自动选择最优的裁剪路径。

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