three.js SMAANode 详解:基于 TSL 的 SMAA 亚像素形态抗锯齿后处理节点
SMAANode 是 three.js 基于 TSL(Three Shading Language)封装的后处理节点,用于在 WebGPU 渲染流程中施加 SMAA(Subpixel Morphological Anti-Aliasing,亚像素形态抗锯齿)效果。本文以 SMAANode 参考文档 为核心骨架,结合其源码实现与官方示例,完整讲解其引入方式、构造/方法/属性、内部三阶段渲染管线,以及如何在 RenderPipeline 中把它接入你的 WebGPU 渲染流程,并与 FXAA、传统 SMAAPass 做对比给出选型建议。
SMAANode 是什么
SMAANode 是一个专门做后处理抗锯齿的 TSL 节点(examples/jsm/tsl/display/SMAANode.js),底层采用的预设是 SMAA 1x Medium(含彩色边缘检测 color edge detection),算法参考自 iryoku 发布的 SMAA v2.8 标准实现。节点本身以 WebGPU + TSL(WebGL 2 对应实现见 SMAAPass 与 module-SMAAShader)为目标后端,因此只适用于基于 WebGPURenderer / three/webgpu 的渲染管线。
SMAA 属于形态学抗锯齿技术,通过三步图像分析找出并混合边缘附近的像素。相比 FXAA(参见 FXAANode 文档),SMAA 在保留更多图像细节的前提下通常能获得更好的抗锯齿结果,但计算开销也更大。因此如果你的目标是极致性能、对画质要求一般,可以优先选择 FXAA;若追求更好的边缘质量且 GPU 预算充足,则选择 SMAA。
在使用层面,有三个关键注意事项:
- 应用于 sRGB 转换之前:与 FXAA 不同,SMAA 是基于亮度和颜色差值做边缘检测的,因此它应在颜色被转换到 sRGB 色彩空间之前执行,否则色彩空间转换会破坏边缘信息,导致效果大打折扣。
- 节点在每帧渲染前自行执行多次全屏 pass,不会阻塞主渲染流程,适合直接挂到渲染管线的
outputNode上。 - 作为 addon 需要显式导入,不包含在 three 核心包中。
引入方式与继承关系
SMAANode 属于 addons,源码位于 examples/jsm/tsl/display/SMAANode.js。常见的引入方式是配合 importmap 中的 three/addons/ 别名:
import { smaa } from 'three/addons/tsl/display/SMAANode.js';
当从构建产物导入时,同样会走 three/addons 路径。在类的继承体系上,SMAANode 属于 TempNode 家族的展示类节点,其继承链为:
EventDispatcher → Node → TempNode → SMAANode
其中基类定义可参考 TempNode 文档、Node 文档。从源码看,类声明为 class SMAANode extends TempNode,构造时向基类传入输出类型 'vec4',表明该节点输出的是 RGBA 纹理颜色;同时静态 type 返回 'SMAANode'。
super( 'vec4' );
// ...
this.textureNode = textureNode;
this.updateBeforeType = NodeUpdateType.FRAME;
核心导出有两个:默认导出 SMAANode 类,以及一个 TSL 便捷函数 smaa():
export default SMAANode;
export const smaa = ( node ) => new SMAANode( convertToTexture( node ) );
convertToTexture 会把传入的任意节点统一转换成纹理节点(TextureNode),所以你既可以直接传 TextureNode,也可以传 pass() 得到的结果,见下文集成示例。
构造函数与核心属性
new SMAANode( textureNode : TextureNode )
构造一个 SMAA 后处理节点。唯一的构造参数 textureNode 代表效果的输入——通常是场景渲染结果对应的纹理节点(例如 pass(scene, camera) 的输出)。
.textureNode : TextureNode
保存输入纹理节点,等价于构造函数传入的参数,在 setup() 中被作为各 pass 的采样来源。
.updateBeforeType : string
由于该节点需要在 updateBefore() 中每帧渲染一次效果,源码中将该属性设置为 NodeUpdateType.FRAME,因此文档标注其默认值为 'frame'。它覆盖了 TempNode#updateBeforeType。
内部私有资源(从源码结构推断)
在构造函数内部,SMAANode 一次性创建了一整套用于多 pass 渲染的内部资源(均以 _ 前缀命名,属于私有成员):
| 资源 | 作用 |
|---|---|
_renderTargetEdges / _materialEdges |
第一个 pass“边缘检测”的输出渲染目标(HalfFloatType,无深度缓冲)与着色器 |
_renderTargetWeights / _materialWeights |
第二个 pass“混合权重计算”的输出与着色器 |
_renderTargetBlend / _materialBlend |
第三个 pass“混合”的输出与着色器,其纹理即最终结果 |
_areaTexture / _searchTexture |
SMAA 算法所需的 area 查找纹理与 search 纹理,均以 Base64 PNG 内嵌在源码中,通过 new Image() 异步解码加载(onload 后置 needsUpdate) |
_areaTextureUniform / _searchTextureUniform |
对应的 uniform 纹理节点,供 TSL 着色器采样 |
_invSize |
保存 1 / width、1 / height 的 uniform 向量,用于把像素坐标换算成 UV 偏移 |
三个渲染目标都采用 depthBuffer: false, type: HalfFloatType 创建并分别命名为 'SMAANode.edges'、'SMAANode.weights'、'SMAANode.blend'。area 纹理使用 LinearFilter + flipY = false(需要双线性过滤),search 纹理则使用 NearestFilter(按整数步进寻址)。最终结果纹理通过 passTexture( this, this._renderTargetBlend.texture ) 包装成一个 PassTextureNode 作为节点的输出(passTexture 定义见 PassNode.js)。
方法一览
SMAANode 对外暴露的方法与 TempNode 存在覆盖关系,用途如下:
| 方法签名 | 返回/行为 | 覆盖 |
|---|---|---|
dispose() |
释放内部渲染目标、area/search 纹理及三份节点材质的 GPU 资源,在效果不再需要时必须调用 | TempNode#dispose |
getTextureNode() : PassTextureNode |
返回代表效果最终结果的纹理节点 | — |
setSize( width, height ) |
更新 inverse 分辨率 uniform(1/width、1/height)并按尺寸重建三个渲染目标 |
— |
setup( builder ) : PassTextureNode |
组装效果的 TSL 代码,返回输出纹理节点 | TempNode#setup |
updateBefore( frame ) |
每帧渲染一次效果,先执行三趟 pass 再恢复渲染器状态 | TempNode#updateBefore |
三趟 Pass:edges → weights → blend 的内部实现
与标准 SMAA 一致,SMAANode 把一个抗锯齿周期拆成三个全屏 pass,全部在 setup() 中通过 TSL 的 Fn() 函数式 API 定义着色器(fragmentNode),分别赋给三个 NodeMaterial,随后在每帧 updateBefore() 里用共享的 QuadMesh 依次渲染到对应渲染目标(updateBefore 源码):
const size = renderer.getDrawingBufferSize( _size );
this.setSize( size.width, size.height );
renderer.setRenderTarget( this._renderTargetEdges ); // pass 1: edges
_quadMesh.material = this._materialEdges;
_quadMesh.render( renderer );
renderer.setRenderTarget( this._renderTargetWeights ); // pass 2: weights
// ...
renderer.setRenderTarget( this._renderTargetBlend ); // pass 3: blend
// ...
RendererUtils.restoreRendererState( renderer, _rendererState );
算法中固定的参数以局部常量定义在 setup 内,与 SMAA 1x Medium 预设对应:
const SMAA_THRESHOLD = 0.1; // 边缘检测的对比度阈值
const SMAA_MAX_SEARCH_STEPS = 8; // 最大搜索步数
const SMAA_AREATEX_MAX_DISTANCE = 16; // area 纹理最大距离
const SMAA_AREATEX_PIXEL_SIZE = vec2( 1 / 160, 1 / 560 );
const SMAA_AREATEX_SUBTEX_SIZE = ( 1 / 7 );
第一阶段:边缘检测(edges)
SMAAEdgeDetection 计算当前像素与上/下/左/右邻域的颜色差值,取 RGB 三个通道的最大值作为 delta;用 SMAA_THRESHOLD(0.1)做 step() 得到候选边缘掩码。当四周无边缘时直接 discard(源码 dot( edges, vec2(1.0,1.0) ).equal( 0 ).discard())。随后引入局部对比度自适应:将 2 像素外的二阶邻域 delta 与一阶邻域最大值比较,只有超过 0.5 * maxDelta 的边缘才保留,从而避免大面积渐变区域被误判为锯齿边缘。输出为编码了横向/纵向边缘信息的 vec4。
第二阶段:计算混合权重(weights)
SMAAWeights 是算法中最复杂的部分。它先判断当前像素四周哪个方向存在边缘(边缘纹理的 r 通道代表南北方向、g 通道代表东西方向),然后沿四个方向执行有界搜索:
SMAASearchXLeft/SMAASearchXRight:沿水平方向,通过Loop(按注释,原 C 版的while循环在移植时改成了等价的for循环)配合If ... Break()实现最多SMAA_MAX_SEARCH_STEPS步的搜索,寻找穿越边缘的像素距离;SMAASearchYUp/SMAASearchYDown:沿垂直方向执行同样的搜索;SMAASearchLength:用 search 纹理精确修正搜索步进偏差;SMAAArea:把边缘两端斜率e1/e2与亚像素偏移映射到 area 纹理中的预计算覆盖区域。
由于 area 纹理按二次曲线压缩存储了图案距离信息,代码中对距离取了 sqrt( abs( d ) ) 再采样(见源码注释)。最终每个像素输出一组 weights,表示在四种可能穿越该像素的斜线中,应该向哪个方向、按多大比例混合。
第三阶段:混合(blend)
SMAABlend 对权重进行空间重采样(从当前像素与相邻像素读取 xz、g、a 分量,实现四方向权重的交叉采样),然后按“横轴 vs 纵轴”取权重绝对值更大的那条边缘方向,用 mix() 在原始颜色 C 与取相反方向的邻域颜色 Cop 之间插值(插值系数 s 取该方向的绝对权重),从而让边缘像素的颜色过渡趋向平滑,达到亚像素级抗锯齿效果。若总权重小于 1e-5,则直接输出原始采样值,不做任何改动。
三个材质共享同一份 builder 上下文(context( builder.getSharedContext() )),保证三趟 pass 的着色器变量与 uniform 布局一致。
在 WebGPU 渲染管线中的接入示例
仓库提供了完整可运行的官方示例 examples/webgpu_postprocessing_smaa.html。其核心接入逻辑如下(importmap 将 three/webgpu、three/tsl、three/addons/ 分别映射到构建产物与 ./jsm/):
import * as THREE from 'three/webgpu';
import { pass } from 'three/tsl';
import { smaa } from 'three/addons/tsl/display/SMAANode.js';
renderer = new THREE.WebGPURenderer();
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.setAnimationLoop( animate );
// ...创建 scene、camera、网格与纹理...
// post processing
renderPipeline = new THREE.RenderPipeline( renderer );
const scenePass = pass( scene, camera ).toInspector( 'Color' );
const smaaPass = smaa( scenePass );
renderPipeline.outputNode = smaaPass;
要点解析:
pass( scene, camera )(见 PassNode.js)先把场景渲染成一个色彩纹理节点(PassTextureNode);smaa( scenePass )经由convertToTexture自动包装后构造SMAANode,并把结果纹理直接赋给renderPipeline.outputNode;- 每帧只需调用
renderPipeline.render(),SMAANode 会靠自身updateBefore在帧渲染前完成 SMAA 的三趟 pass; - 示例还演示了如何通过 Inspector 的 GUI 动态开关 SMAA(
enabled变化时在scenePass与smaaPass之间切换outputNode并置renderPipeline.needsUpdate = true); - 窗口尺寸变化时重设 renderer 尺寸即可,
updateBefore()中会通过renderer.getDrawingBufferSize()自动获取当前绘制缓冲大小并同步调用setSize()。
需要再次强调:SMAA 的输入必须是线性色彩空间(sRGB 转换之前)的图像。在示例场景中贴图使用 texture.colorSpace = THREE.SRGBColorSpace,颜色输出到输出节点时遵循渲染器色彩管线;把 smaa 放在管线末端、由渲染器在输出阶段统一做 sRGB 编码,正是文档强调该节点“应放在 sRGB 转换之前”的落地方式。
示例运行效果可参考仓库内置截屏 examples/screenshots/webgpu_postprocessing_smaa.jpg:黑色背景上左侧为白色线框立方体、右侧为砖墙纹理立方体,两者边角线条在 SMAA 处理后平滑无明显锯齿。
资源清理与注意事项
当 SMAA 效果不再需要(例如切换管线、销毁场景或组件卸载)时,应调用节点的 dispose(),它会依次释放:
dispose() {
this._renderTargetEdges.dispose();
this._renderTargetWeights.dispose();
this._renderTargetBlend.dispose();
this._areaTexture.dispose();
this._searchTexture.dispose();
this._materialEdges.dispose();
this._materialWeights.dispose();
this._materialBlend.dispose();
}
以下是使用 SMAANode 时需要留意的几个工程要点:
- 多 pass 开销:SMAA 每帧要执行 3 次全屏绘制(edge / weights / blend),额外采样次数显著高于 FXAA(FXAA 通常为单 pass),在移动端或高分辨率(如 4K)下需评估性能预算;
- 内部异步纹理加载:area/search 纹理通过 HTML
Image以 Base64 数据异步解码,解码完成后内部会把对应纹理标记needsUpdate = true,从源码结构看这一过程在首个updateBefore前可能尚未完成,生产环境中首个有效帧前的行为建议以实际后端的纹理就绪机制为准; - 尺寸自适应:
setSize会被updateBefore依据 drawing buffer 自动调用,无需在resize事件中手动维护渲染目标尺寸; - 输入节点类型:构造参数会被
convertToTexture统一转成纹理节点,传pass()结果或普通纹理节点均可,但输入应处于合适色彩空间(线性)以保证边缘检测正确; - 弃用 WebGL 版本对照:若你的项目仍在 WebGL 2 后端上运行,应使用传统后处理中的 SMAAPass(配合 SMAA 着色器模块),它与
examples/jsm/tsl/display/SMAANode.js共享同一套 SMAA 算法与预设参数,但 API 形态完全不同。
小结
SMAANode 把业界标准的 SMAA 1x Medium(彩色边缘检测)算法完整迁移到了 three.js 的 TSL / WebGPU 节点体系中:通过 edges → weights → blend 三次全屏 pass 与内嵌的 area/search 查找纹理实现亚像素边缘混合,效果优于 FXAA 但开销更高。接入 RenderPipeline 只需三步:pass(scene, camera) 得到场景纹理、smaa(场景纹理) 得到效果节点、再赋给 outputNode。牢记“在 sRGB 转换前使用、按需 dispose、评估多 pass 性能”三个要点,即可在 WebGPU 项目里获得稳定可用的高质量抗锯齿输出。
相关深度阅读:SMAANode 参考文档、源码实现 examples/jsm/tsl/display/SMAANode.js、WebGPU 官方示例 examples/webgpu_postprocessing_smaa.html、PassNode / pass / passTexture 定义。
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 StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00