摘要:《Rain Rust 的 2D 光追渲染管线》该文介绍了一个基于JFA算法的2D光线追踪渲染管线,包含光源绘制、自发光物体标记和跳转洪水算法(JFA)等核心阶段,实现了动态光影效果,在RTX 5070ti和M4 GPU上均能达到120FPS。

前言

Rain Rust 是作者的毕业设计作品代号, 以下也以Rain Rust作为作品的名称.

Rain Rust 是一款以精确动作、解密、探索为主要玩法的2d平台跳跃游戏.

游戏中玩家将探索一个被弃用的神秘工厂, 其中存在着各式由一种特殊物质“耦合蜜”驱动的机械造物, 而玩家可以利用自身的能力去控制它们运作.

而玩家的探索会将自己置身于一场更大的阴谋当中.

本项目开源在GitHub, 你可以点击这里进行访问, 欢迎留下一个小星星.

画面效果

你可以观看这个视频来了解画面效果.

由于平台原因, 此视频可能无法观看, 你也可以点击这个链接到笔者的bilibili账号中观看视频.

对于不想观看视频的读者, 也提供了一些游戏截图来了解效果

PixPin_2026-04-19_16-47-58

PixPin_2026-04-19_16-48-48

PixPin_2026-04-19_16-49-47

PixPin_2026-04-19_16-50-40

PixPin_2026-03-25_23-58-48

优点

  • 这是光追
  • 很cool的画面风格
  • 完全动态的世界

缺点

  • 这是光追
  • jfa算法生成的sdf with true sdf间有偏差, 导致比如物体角落难以采样到光源
  • [TODO] 在镜头移动时, 光场会剧烈抖动
  • [TODO] 暂时不支持半透明物体

性能

测试环境1: 120 fps

  • 系统: windows

  • GPU: RTX 5070ti desktop

  • 分辨率: 4k

  • sample count: 256

  • resolution scale: 1

测试环境2: 120 fps

  • 系统: macOS
  • GPU: M4
  • 分辨率: 1080p
  • sample count: 128
  • resolution scale: 1

管线概览

Pass Overview

这是此项目的Render grpah viewer.

其中绿框部分为 Rain Rust 的部分.

PixPin_2026-04-19_17-35-01

Stage 1: 绘制光源

  • 绘制的所有原始光源, 用于生成光场图

PixPin_2026-04-19_17-29-05

Stage 2: 绘制自发光物体

绘制所有自发光物体, 用于标记哪些像素不需要收光场影响

PixPin_2026-04-19_17-36-41

Stage 3: JFA

使用 Stage 1 的Light Sorce Map 跑一遍JFA算法

PixPin_2026-04-19_17-36-56

PixPin_2026-04-19_17-40-30

Stage 4: JFA 生成 SDF

用 JFA 算法的结果生成 SDF

PixPin_2026-04-19_17-45-30

Stage 5: RayTracing 生成光场图

根据 SDF 以及 Light Source Map 来计算光场图

PixPin_2026-04-19_17-46-57

Stage 6: Composition

组合一系列计算结果, 生成最终结果

PixPin_2026-04-19_17-47-43

具体细节

管线假设

  • 所有sprite的纹理都是自发光贴图
  • 光线衰减并不遵从平方反比定律, 而是GTR (Generalized Trowbridge-Reitz)函数

管线切入点

  • 在渲染完urp的所有半透明物体之后

PixPin_2026-04-19_18-04-21

为什么需要Stage 2

  • 由于所有sprite的纹理都是自发光贴图
  • 所以对于sprite不需要再受光照影响
  • 因此在Stage 2中, 绘制了一张深度图, 用于Stage 6(合成)的深度测试
  • 即如果是自发光像素, 直接过, 否则就查询光场进行着色

光场图缩放以优化性能

  • 对于光场图, 我们完全可以降低分辨率来优化性能
  • 而且这并不会对画面产生太大影响

JFA算法

可以参考笔者的这篇文章

这里直接引用原文:

Algorithm

首先我们有一张 NNN*N种子图

比如下图:

image

其中有颜色的地方为种子, 没有的则是’未定义’

随着算法迭代, 最终整张图的想读都会被’定义’

伪代码如下:

对于每个步长 k{N2,N4,,1}k \in \{ \frac{N}{2}, \frac{N}{4}, \dots, 1 \}, 执行一次 JFA

遍历(x,y)(x,y)处的每一个像素pp

  对于每一个在$(x+i,y+j)$处的像素$q$ ( $i, j \in \{-k, 0, k\}$)

  	如果$p$未定义且$q$着色

  		将$p$的颜色更改为$q$的颜色

  	如果$p$着色且$q$着色

  		$p$的颜色使用 `min(dist(p,s),dist(q,s'))`, 其中, $s$ 和 $s'$ 分别是 $p$ 和 $q$ 的种子颜色

PixPin_2026-01-08_02-53-23

JFA to SDF

我们让JFA种子图中的每个像素存储当前位置的UV坐标,那么最终我们就得到了一张存储了离该像素最近的“物体像素”的 UV 坐标的纹理

那么, 轻易可以得到:
SDF=distance(当前像素坐标,最近物体像素坐标)SDF = distance(当前像素坐标, 最近物体像素坐标)

当然, 这里没有考虑屏幕比例

image

Ray Tracing 2D

将三维空间的路径追踪降低为二维, 只需要将原本的向球(或者半球)采样变成向圆采样即可

伪代码如:

for (float f = 0.; f < _Samples; f++)
	{
    	const float t = f / _Samples * float(3.1415926 * 2.0);
        result += Trace(i.uv, float2(cos(t), sin(t)) / _Aspect.xy);
    }

而对于Trace()函数, 由于我们已经拥有了一张SDF, 我们使用Ray Marching 的方式计算结果

float3 Trace(const float2 uv, const float2 dir) // Ray Marching
{
    float2 uvPos = uv; // 当前采样坐标

    // 若起始点已在光源上, 直接返回颜色
    const float4 color = tex2D(_ColorTex, uv).rgba;
    if (color.a > 0)
        return color.rgb / color.a;
    
    // 步进
    uvPos += dir * tex2D(_DistTex, uvPos).rr;
    if (NotUVSpace(uvPos))
        return _AmbientColor;
                
    [unroll]
    for (int n = 1; n < STEPS; n++)
    {
        const float4 color = tex2D(_ColorTex, uvPos).rgba;
        if (color.a > 0)
        {
            // 使用 GTR 衰减
            float attenuation = GTRAttenuation((uv - uvPos) * _Aspect.xy, _LightFalloffAlpha * color.a, _LightFalloffGamma);
            return color.rgb * attenuation;
        }

        uvPos += dir * tex2D(_DistTex, uvPos).rr;
        if (NotUVSpace(uvPos))
            return _AmbientColor;
    }
    
    return _AmbientColor;
}

Composition

PixPin_2026-04-19_17-47-43

我们有以下输入:

  • Urp绘制结果: 最底层的背景
  • Light Map: 光场图
  • Emissive: 自发光图
  • Emissive Depth: 自发光深度图

我们的混合方案伪代码如下:

if (hasEmissive)
{
    // 直接使用 Emissive 的结果 (与背景进行 Alpha 混合以保证透明物体正确渲染)
    finalColor = lerp(background.rgb, receiver.rgb, receiver.a);
}
else
{
    // 没有记录的地方: 混合光照结果和 main 的结果
    #if defined(LIGHTING_BLEND_ADDITIVE)
    finalColor = background.rgb + lighting.rgb;
    #elif defined(LIGHTING_BLEND_ALPHABLEND)
    finalColor = lerp(background.rgb, lighting.rgb, lighting.a);
    #elif defined(LIGHTING_BLEND_MULTIPLY)
    finalColor = background.rgb * lighting.rgb;
    #elif defined(LIGHTING_BLEND_SCREEN)
    finalColor = 1.0 - (1.0 - background.rgb) * (1.0 - lighting.rgb);
    #elif defined(LIGHTING_BLEND_OVERLAY)
    finalColor = (background.rgb < 0.5) ? (2.0 * background.rgb * lighting.rgb) : (1.0 - 2.0 * (1.0 - background.rgb) * (1.0 - lighting.rgb));
    #else
    finalColor = background.rgb + lighting.rgb;
    #endif
}

return float4(finalColor, background.a);

时间复杂度

定义:

  • SS: 光线采样数
  • WW: 光场图宽度
  • HH: 光场图高度
  • DD: 迭代深度
  • Costintersect{Cost}_{intersect}: 相交检测开销

与传统路径追踪对比:

RainRust Path Tracing
时间复杂度 O(SWH)O(S \cdot W \cdot H) O(SWHDCostintersect)O(S \cdot W \cdot H \cdot D \cdot \text{Cost}_{intersect})
迭代深度(DD) 1 DD 次反弹
相交检测开销Costintersect{Cost}_{intersect} O(1)O(1) (SDF 纹理采样) O(logN)O(\log N) (BVH 等加速结构) 或 O(N)O(N)
预处理开销 JFA 生成 SDF (O(WHlog(max(W,H)))O(WH \log(\max(W,H)))) 需要构建加速结构 (如 BVH) 其中:
- 中值划分 (Median Split): O(NlogN)O(N \log N)
- SAH 启发式 (Surface Area Heuristic): O(NlogN)O(N \log N)O(Nlog2N)O(N \log^2 N)
- 线性 BVH (LBVH): O(NlogN)O(N \log N)
- KD-Tree: O(Nlog2N)O(N \log^2 N)O(NlogN)O(N \log N)
- 均匀网格 (Uniform Grid): O(N+G)O(N + G)