首页
/ three.js TSL FXAANode 详解:使用 fxaa() 在 WebGPU 渲染管线中实现快速近似抗锯齿

three.js TSL FXAANode 详解:使用 fxaa() 在 WebGPU 渲染管线中实现快速近似抗锯齿

2026-09-06 18:48:00作者:翟萌耘Ralph

FXAANode 是 three.js 在 TSL(Three Shading Language)节点系统中提供的后处理节点,用于以极低成本实现 FXAA(Fast Approximate Anti-Aliasing,快速近似抗锯齿)。它主要服务于 WebGPU 渲染路径:配合 RenderPipelinerenderOutput() 使用,让开发者用一行 fxaa(pass) 把完整的 FXAA 算法接入渲染输出的末端。读完本文,你将掌握 FXAANode 的导入与构造方式、textureNodeupdateBeforeType 等关键成员的行为、其每帧运行的底层实现原理,以及如何在真实示例场景中把它组装进渲染管线,并理清它与 WebGL 路径中 FXAAPass 的对应关系。

FXAA 属于"后处理型抗锯齿":它不依赖 GPU 的多重采样(MSAA),而是对已经渲染完成的整幅图像做边缘检测与像素混合,因此具有极低的性能开销、适合作为渲染管线的收尾步骤。FXAANode 把原本以 GLSL 编写的经典 FXAA 着色器改写为基于 TSL 的节点图实现,从而能无缝挂接在 TSL 的渲染输出链上。

FXAANode 的基本定位与继承关系

FXAANode 文档 与源码 examples/jsm/tsl/display/FXAANode.js 可以看到它的继承链为:

EventDispatcher → Node → TempNode → FXAANode
  • Node:TSL 节点体系的基类,承担节点图的编译与调度;
  • TempNode:负责在最终着色器中生成临时求值结果;
  • FXAANode:在二者基础上,将 FXAA 的完整算法封装成一个后处理节点。

该节点本身不做任何场景渲染,它的输入是一张已渲染好的纹理(以 TextureNode 形式表示),输出是完成抗锯齿后的颜色。需要特别强调的是其色彩空间前提:FXAA 需要 sRGB(gamma 空间)输入,因此色调映射(tone mapping)与颜色空间转换必须发生在抗锯齿之前。这一点是 FXAANode 正确使用的第一原则,稍后会在"渲染管线中的组装位置"一节具体演示。

导入方式

FXAANode 属于 addons(附加组件),不会被 three/webgpu 的核心包默认导出,必须显式导入:

import { fxaa } from 'three/addons/tsl/display/FXAANode.js';

文件路径对应的正是源码模块 examples/jsm/tsl/display/FXAANode.js(npm 包结构中映射为 three/addons/tsl/display/FXAANode.js)。该模块共导出两样东西:

  1. FXAANode(默认导出)——类本身,可手动 new
  2. fxaa(命名导出)——推荐使用的 TSL 便捷工厂函数。

构造函数与 fxaa() 便捷函数

new FXAANode( textureNode )

const node = new FXAANode( textureNode );

参数 textureNode:类型为 TextureNode,代表 FXAA 效果的输入(即待抗锯齿的画面纹理节点)。

在实际使用中,直接向构造函数传入原始输出节点即可,因为工厂函数内部会做一次规范化。源码 examples/jsm/tsl/display/FXAANode.js 尾部给出了推荐用法:

export const fxaa = ( node ) => new FXAANode( convertToTexture( node ) );

这里通过 convertToTexture() 把任意"节点求值结果"统一转成可采样纹理,因此日常 TSL 编程中几乎不需要手动 new FXAANode,直接使用函数式写法:

import { fxaa } from 'three/addons/tsl/display/FXAANode.js';

const fxaaPass = fxaa( outputPass );   // outputPass 可以是任意后处理输出节点

fxaa() 返回一个 FXAANode 实例,其构造器内部执行 super( 'vec4' )(源码构造函数第 24–34 行),声明该节点的输出类型为四分量颜色。

属性成员

.textureNode : TextureNode

保存 FXAA 效果的输入纹理节点,与构造函数传入值一致。它是 FXAANode 在 updateBefore() 中读取画面尺寸、以及在 setup() 中执行采样与边缘处理的唯一数据来源。

.updateBeforeType : string

类型为字符串,取值恒为 NodeUpdateType.FRAME(即 'frame'),默认值即 'frame'。含义是:节点会在每一帧渲染前执行 updateBefore(),用于更新其内部 uniform。该常量定义于节点核心 src/nodes/core/constants.js,其中 FRAME 的注释明确说明"update 方法按帧执行"。FXAANode 选择按帧更新,是因为渲染目标(RenderTarget)尺寸可能随窗口/相机变化,必须每帧把最新的"逆分辨率"写入 uniform。

从源码结构还可以看到它持有一个私有 uniform:

this._invSize = uniform( new Vector2() );   // 逆分辨率:vec2(1/width, 1/height)

_invSize 正是把真实像素偏移(如 ±1 个像素、1.5 个像素)换算成 UV 坐标步长所需的缩放因子,整个 FXAA 的边缘搜索都依赖它。

重写自 TempNode 的说明

updateBeforeType 与下文的方法均覆盖自父类 TempNode,相关基础约定可参考 TempNode 文档

方法:setup() 与 updateBefore()

.updateBefore( frame : NodeFrame )

每帧执行一次,用于同步 uniform。源码实现非常精简(examples/jsm/tsl/display/FXAANode.js):

updateBefore( /* frame */ ) {

    const map = this.textureNode.value;
    this._invSize.value.set( 1 / map.image.width, 1 / map.image.height );

}

也就是读取输入纹理图像的实际宽高,计算其倒数(逆分辨率)写入 _invSize。当后处理缓冲被 resize 时,这里保证下一帧的 FXAA 边缘搜索仍以正确的物理像素步长进行。

.setup( builder : NodeBuilder )

这是 FXAANode 的核心,负责把整个 FXAA 算法构造成 TSL 节点图,返回一个 ShaderCallNodeInternal。源码中约从第 73 行开始,包含约 270 行 TSL 代码,完整翻译了经典 FXAA 算法的每一个环节。其内部逻辑可以划分为以下几层。

1. 采样前处理与 UV 获取

const textureNode = this.textureNode.bias( - 100 );
const uvNode = textureNode.uvNode || uv();
  • 对输入纹理施加一个极负的 bias(-100):从实现意图看,这是让纹理采样始终落在最高分辨率层级(等效于关闭 mipmap 模糊),保证后处理边缘检测读到的是一帧锐利的颜色值;
  • UV 优先取纹理节点自带的 uvNode,否则回退到当前片元默认 uv()

2. 算法常量(全部为 TSL float 硬编码)

常量 取值 作用
EDGE_STEP_COUNT 6 沿边缘向两个方向搜索的迭代次数
EDGE_GUESS 8.0 到达边缘后"外推猜步"的最大像素数
EDGE_STEPS [1.0, 1.5, 2.0, 2.0, 2.0, 4.0] 每次迭代的像素步长数组(非均匀步长)
_ContrastThreshold 0.0312 对比度下界,低于它视为平坦区域直接跳过
_RelativeThreshold 0.063 相对阈值,按最亮邻域亮度缩放跳过判定
_SubpixelBlending 1.0 亚像素混合强度系数

其中 EDGE_STEP_COUNT = 6EDGE_GUESS = 8.0 等常量与 WebGL 路径的 GLSL 版本 examples/jsm/shaders/FXAAShader.js#define EDGE_STEP_COUNT 6#define EDGE_GUESS 8.0 一一对应,说明两套实现(WebGL Shader 与 WebGPU TSL)遵循同一套算法参数。注意:这些阈值在节点内是写死的,TSL API 目前不暴露参数化入口,如需调节强度需直接修改常量(本仓库只读,可 fork 后自行调整)。

3. 亮度采样函数族

  • Sample( uv ):对输入纹理在指定 UV 采样;
  • SampleLuminance( uv ):按人眼感知权重 dot(rgb, vec3(0.3, 0.59, 0.11)) 把颜色折算成亮度;
  • SampleLuminanceOffset( texSize, uv, uOffset, vOffset ):借助 _invSize 实现"当前 UV + N 个像素"的偏移采样。

4. 邻域采样与"是否跳过像素"判定

SampleLuminanceNeighborhood 会读取中心 m 以及上、下、左、右、四个对角线共 9 个位置的亮度,并计算:

highest = max(n,e,s,w,m)
lowest  = min(n,e,s,w,m)
contrast = highest - lowest

ShouldSkipPixel 则判断:若 contrast < max(0.0312, 0.063 * highest),说明当前像素附近缺乏可感知的边缘,直接返回原始采样,这是 FXAA 高性能的关键——大多数平坦像素根本不进入昂贵的长程搜索。

5. 边缘方向检测(水平 vs 垂直)

DetermineEdge 分别累计水平方向与垂直方向的二阶差分绝对值,比较两者大小判断边缘朝向;随后取边缘两侧梯度较大的那一侧作为主方向,并换算出一个"每步移动量" pixelStep(按水平/垂直分别取 texSize.xtexSize.y)。

6. 沿边缘的迭代式端点搜索

DetermineEdgeBlendFactor 先从当前像素向边缘正、反两个方向出发:

  • EDGE_STEPS 数组的步长做最多 6 次迭代,每次比较采样点亮度与"边缘平均亮度"的差值是否超过 gradient * 0.25 的阈值,找到边缘端点;
  • 若 6 步内仍未触及阈值(pAtEnd.not()),则额外外推 EDGE_GUESS = 8.0 个像素;
  • 最后依据两侧最短距离与符号计算出介于 0~0.5 的 blendFactor

7. 亚像素混合与最终输出

DeterminePixelBlendFactor 对 3×3 邻域做 12 点加权平均(2*(n+e+s+w) + (ne+nw+se+sw) 除以 12),得到该像素与邻域平均的差异,再经 smoothstep 平方得到亚像素混合量。FXAANode 最终取 max(pixelBlend, edgeBlend) 作为混合系数,把 finalUv 沿边缘垂直方向偏移 pixelStep * blendFactor 后重新采样输出——这正是 FXAA"把边缘颜色向过渡区中心拉拢"的核心手法。

整个流程被封装进一个名为 FxaaPixelShader 的 TSL 函数(vec4 FxaaPixelShader(vec2 uv, vec2 texSize)),最后由主函数 fxaa()ApplyFXAA( uvNode, this._invSize ) 的方式调用。

真实示例:在 RenderPipeline 中启用 FXAA

仓库提供了可直接运行的官方示例 examples/webgpu_postprocessing_fxaa.html,其中完整展示了 FXAANode 的接入方式,核心片段如下:

import * as THREE from 'three/webgpu';
import { pass, renderOutput } from 'three/tsl';
import { fxaa } from 'three/addons/tsl/display/FXAANode.js';

// 后处理管线
renderPipeline = new THREE.RenderPipeline( renderer );

// 关闭渲染器默认的输出颜色变换(色调映射与输出色彩空间)
// 改由 renderOutput() 显式控制顺序
renderPipeline.outputColorTransform = false;

// 场景渲染通道
const scenePass = pass( scene, camera );

// 先做色调映射 + sRGB 色彩空间转换
const outputPass = renderOutput( scenePass );

// FXAA 必须在 sRGB 颜色空间计算(位于色调映射和色彩空间转换之后)
const fxaaPass = fxaa( outputPass );
renderPipeline.outputNode = fxaaPass;

这段代码值得逐行理解:

  1. renderPipeline.outputColorTransform = false:把"色调映射 + 输出色彩空间"从渲染器默认收尾中剥离(相关机制可参考 RenderPipeline 文档);
  2. renderOutput( scenePass ):TSL 内建函数,在当前节点链中显式应用渲染器的输出设置,产出 sRGB 画面。fxaaTSL 函数参考 中被定义为 "Creates a FXAA anti-aliasing effect",与 renderOutput 等一同列在显示/后处理函数表中;
  3. fxaa( outputPass ):把已经完成色彩空间转换的图像接入 FXAANode——这正是"FXAA 需要 sRGB 输入"这一文档约束的实现方式;
  4. renderPipeline.outputNode = fxaaPass:把 FXAA 结果指定为管线最终输出。

示例中还提供了开关逻辑:需要关闭 FXAA 时,把 renderPipeline.outputNode 切回 outputPass 并置 renderPipeline.needsUpdate = true 即可,这展示了 TSL 后处理"节点可随时替换"的灵活性。

three.js WebGPU 的 FXAA 后处理官方示例运行截图,场景为大量随机排列的平面着色四面体,用于展示边缘抗锯齿效果

与 WebGL 路径 FXAAPass 的对应关系

若你此前使用的是 WebGL 渲染器,会发现 FXAA 存在"双轨"实现,二者算法同源但宿主不同:

维度 WebGL 路径 WebGPU / TSL 路径
载体 EffectComposer + Pass RenderPipeline + 输出节点
实现模块 examples/jsm/postprocessing/FXAAPass.js examples/jsm/tsl/display/FXAANode.js
着色器 GLSL examples/jsm/shaders/FXAAShader.js TSL 节点图(setup() 内联构建)
分辨率处理 setSize() 更新 material.uniforms['resolution'] updateBefore() 更新 _invSize uniform

WebGL 的 FXAAPass 继承自 ShaderPass,其 setSize 同样写入逆分辨率 (1/width, 1/height),可见"以逆分辨率作为像素步长换算因子"是两套实现的共同设计。可对比官方示例 examples/webgl_postprocessing_fxaa.html 与 WebGPU 版本,体会从"EffectComposer.addPass"到"outputNode 赋值"的 API 演变。除 FXAA 外,仓库还提供了更高质量但开销更大的 SMAA 方案,可查阅 SMAANode 文档 作为横向对比。

使用注意事项小结

  1. 色彩空间顺序不可颠倒:FXAA 的亮度阈值针对感知(sRGB)亮度设计,必须在 renderOutput() 完成色调映射与 sRGB 转换之后再接入,否则边缘检测结果会偏差甚至失效;
  2. 输入纹理需真实存在尺寸updateBefore() 依赖 textureNode.value.image.width/height 计算逆分辨率,因此请勿在纹理尚未加载/创建完成时启用该节点;
  3. 均匀调参入口:节点内部阈值(对比度 0.0312、相对阈值 0.063、亚像素 1.0)与步长均为写死常量,若默认强度不适合你的项目画面,需基于源码自行派生或修改(本仓库只读,请复制到自有工程后调整);
  4. 与多采样方案的关系:FXAA 无法消除闪烁性锯齿(shimmer),它擅长的是平滑斜边阶梯与减少高对比边缘的"锯齿感",在性能预算紧张的场景(如 WebGPU 移动端)常作为 MSAA 之外的轻量补充方案。

扩展阅读

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