首页
/ three.js SMAANode 详解:基于 TSL 的 SMAA 亚像素形态抗锯齿后处理节点

three.js SMAANode 详解:基于 TSL 的 SMAA 亚像素形态抗锯齿后处理节点

2026-09-07 14:50:16作者:尤辰城Agatha

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 对应实现见 SMAAPassmodule-SMAAShader)为目标后端,因此只适用于基于 WebGPURenderer / three/webgpu 的渲染管线。

SMAA 属于形态学抗锯齿技术,通过三步图像分析找出并混合边缘附近的像素。相比 FXAA(参见 FXAANode 文档),SMAA 在保留更多图像细节的前提下通常能获得更好的抗锯齿结果,但计算开销也更大。因此如果你的目标是极致性能、对画质要求一般,可以优先选择 FXAA;若追求更好的边缘质量且 GPU 预算充足,则选择 SMAA。

在使用层面,有三个关键注意事项:

  1. 应用于 sRGB 转换之前:与 FXAA 不同,SMAA 是基于亮度和颜色差值做边缘检测的,因此它应在颜色被转换到 sRGB 色彩空间之前执行,否则色彩空间转换会破坏边缘信息,导致效果大打折扣。
  2. 节点在每帧渲染前自行执行多次全屏 pass,不会阻塞主渲染流程,适合直接挂到渲染管线的 outputNode 上。
  3. 作为 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 / width1 / 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/width1/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 对权重进行空间重采样(从当前像素与相邻像素读取 xzga 分量,实现四方向权重的交叉采样),然后按“横轴 vs 纵轴”取权重绝对值更大的那条边缘方向,用 mix() 在原始颜色 C 与取相反方向的邻域颜色 Cop 之间插值(插值系数 s 取该方向的绝对权重),从而让边缘像素的颜色过渡趋向平滑,达到亚像素级抗锯齿效果。若总权重小于 1e-5,则直接输出原始采样值,不做任何改动。

三个材质共享同一份 builder 上下文(context( builder.getSharedContext() )),保证三趟 pass 的着色器变量与 uniform 布局一致。

在 WebGPU 渲染管线中的接入示例

仓库提供了完整可运行的官方示例 examples/webgpu_postprocessing_smaa.html。其核心接入逻辑如下(importmap 将 three/webgputhree/tslthree/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 变化时在 scenePasssmaaPass 之间切换 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 时需要留意的几个工程要点:

  1. 多 pass 开销:SMAA 每帧要执行 3 次全屏绘制(edge / weights / blend),额外采样次数显著高于 FXAA(FXAA 通常为单 pass),在移动端或高分辨率(如 4K)下需评估性能预算;
  2. 内部异步纹理加载:area/search 纹理通过 HTML Image 以 Base64 数据异步解码,解码完成后内部会把对应纹理标记 needsUpdate = true,从源码结构看这一过程在首个 updateBefore 前可能尚未完成,生产环境中首个有效帧前的行为建议以实际后端的纹理就绪机制为准;
  3. 尺寸自适应setSize 会被 updateBefore 依据 drawing buffer 自动调用,无需在 resize 事件中手动维护渲染目标尺寸;
  4. 输入节点类型:构造参数会被 convertToTexture 统一转成纹理节点,传 pass() 结果或普通纹理节点均可,但输入应处于合适色彩空间(线性)以保证边缘检测正确;
  5. 弃用 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.jsWebGPU 官方示例 examples/webgpu_postprocessing_smaa.htmlPassNode / pass / passTexture 定义

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
898
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
921
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
391