three.js TSL 深入解析 ClippingNode:default、hardware 与 alphaToCoverage 三种裁剪模式的源码原理
ClippingNode 是 three.js 节点材质(TSL)体系中专门负责“裁剪面(Clipping Plane)”计算的节点,由 NodeMaterial 在着色器编译阶段自动实例化,用于把场景中的裁剪平面转换为顶点/片元着色器逻辑:既可以通过 GPU 硬件裁剪加速,也可以退化为着色器内 discard,还能启用 alpha-to-coverage 对裁剪边缘做抗锯齿。读完本文,你将理解 new ClippingNode(scope) 三种取值 'default' | 'hardware' | 'alphaToCoverage' 各自的代码生成路径、intersectionPlanes 与 unionPlanes 的语义差异,以及硬件裁剪在 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
文档将 intersectionPlanes 与 unionPlanes 都描述为 Array.<Vector4>。它们的语义差异来自 ClippingContext:
unionPlanes(ClippingContext.js#L94-L99):任意一个平面越界即被裁剪,对应常规“任一裁剪面生效”的行为;intersectionPlanes(ClippingContext.js#L87-L92):只有当所有平面同时越界时才裁剪,即多个裁剪面定义出的“交集区域”之外的部分被保留,常用于挖孔式裁剪。
clipIntersection 属性(ClippingContext.js#L43-L49)控制裁剪组使用交集还是并集语义,update() 方法据此把 ClippingGroup.clippingPlanes 追加到对应数组中。
每个 Vector4 的坐标格式由 projectPlanes() 确定(ClippingContext.js#L129-L147):平面先经视图矩阵投影到观察空间,然后 xyz = -normal、w = 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();
}
} )();
}
其要点:
- union 平面:只要某点满足
dot(p, plane.xyz) > plane.w(即越过任一平面)就discard()该片元;this.hardwareClipping === false是保护条件——若硬件裁剪已接管 union 平面,这里不再重复判断; - intersection 平面:用一个
clipped布尔量累乘所有平面的越界条件(.and( clipped )),只有全部越界时才 discard,恰好实现“交集”语义; - 平面数组通过
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)” 的落点。两条后端的具体实现分别是:
- WebGL:
getClipDistance()返回'gl_ClipDistance',enableHardwareClipping(planeCount)声明out float gl_ClipDistance[ planeCount ]并启用GL_ANGLE_clip_cull_distance扩展(GLSLNodeBuilder.js#L1415-L1419、GLSLNodeBuilder.js#L1486-L1492); - WebGPU:
getClipDistance()返回'varyings.hw_clip_distances',对应 WGSL 的 clip-distance varying(WGSLNodeBuilder.js#L1659-L1663)。
并非任何设备都能走这条路径。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 ) );
} );
关键步骤:
distanceToPlane为点到平面的带符号距离(观察空间);distanceGradient = fwidth(distanceToPlane) / 2:取屏幕相邻像素间距离变化量的半幅,作为 smoothstep 过渡带宽度,保证软边约为 1 像素宽,与屏幕分辨率自适应;clipOpacity.mulAssign( smoothstep(-g, g, d) ):多个 union 平面的透明度相乘,任一平面靠近边缘都会拉低 alpha;- intersection 平面单独计算
intersectionClipOpacity(每平面取.oneMinus()表示“该平面内部”的占比),最后clipOpacity.mulAssign( intersectionClipOpacity.oneMinus() )合并; - 结尾两行
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;
}
从源码结构看,完整的调用链可以归纳为:
- 若没有裁剪上下文或没有任何裁剪平面,直接返回
null,不生成 ClippingNode; - 若用户把
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; - 否则入栈
clipping()(default 模式); - 硬件裁剪在更早的顶点阶段由
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 / intersectionPlanes 与 builder.hardwareClipping 标志协同工作,开发者通常只需在 NodeMaterial 上设置 alphaToCoverage 等属性,即可让渲染器自动选择最优的裁剪路径。
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 StartedRust0624
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