three.js SMAANode 详解:基于 TSL 的 SMAA 亚像素形态抗锯齿后处理节点
【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js
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 定义。
【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考