在传统光线追踪里,我们通过解方程直接算出光线和几何体相交的精确坐标。但在某些场景中(比如云雾等体积数据,或者极其复杂的数学隐式曲面),方程根本解不出来。 Ray Marching 的核心思想是:既然算不出交点,那就沿着光线的方向每次往前走一小步,每走一步,就停下来“四处张望”一下,检查当前所在的位置是否满足了某种条件(比如进入了物体内部,或者积累了足够的雾气浓度)。 如果没有,就继续往前走,直到命中目标或超出视距


Ray Marching 实现步骤

实现一个最基础的、固定步长的 Ray Marching,分为以下四个步骤:

1. 射线生成

这一步是一切基于光线的渲染的基础。从相机位置向屏幕的每一个像素发射一条射线。

HLSL
float3 ro = float3(0.0, 0.0, -5.0);
float3 rd = normalize(float3(uv.x, uv.y, 1.0));
点击展开查看更多
  • ro (Ray Origin): 射线的起点(即相机位置)。
  • rd (Ray Direction): 射线的方向(标准化向量)。

2. 定义空间属性

我们需要一个函数来描述 3D 空间中任意一点的属性。在最通用的情况下,这个属性可以是一个布尔值(是否在物体内部)或一个密度值(当前位置云的厚度)。

HLSL
// 检查点 p 是否在一个半径为 1 的球体内部
bool isInsideObject(float3 p) {
    return (p.x*p.x + p.y*p.y + p.z*p.z) < 1.0; 
}
点击展开查看更多

3. 核心循环:步进

这是通用 Ray Marching 的运作方式:设定一个固定的步长 $\Delta t$,沿着方向 rd 不断累加并测试。

HLSL
#define MAX_STEPS 100 // 最大步进次数
#define STEP_SIZE 0.1 // 每次盲目向前迈进的固定步长

float rayMarch(float3 ro, float3 rd) {
	float t = 0.0;           // 光线走过的总距离
	bool hit = false;
	
	for(int i = 0; i < MAX_STEPS; i++) {
	    float3 p = ro + rd * t; // 计算当前光线到达的探测点
	    
	    if(isInsideObject(p)) {
	        hit = true;       // 发现进入了物体!
	        break;            // 停止步进
	    }
	    
	    t += STEP_SIZE;        // 盲目向前走一步
	}
}
点击展开查看更多

4. 结算与着色

如果光线检测到碰撞,就可以根据当前碰撞点进行着色;如果是渲染体积云,则在这个循环中不断累加颜色的不透明度(Alpha)。

HLSL
if (hit) {
	// 进行着色
}
点击展开查看更多

Ray Marching 的痛点

这种“固定步长”的通用解法,在实际工程中会遇到非常致命的矛盾:

  • 痛点 1:步长过大导致“穿模”与漏检 如果 stepSize 设置得比较大(为了跑得快),光线很可能会在一步之间,直接跨过了一个较薄的物体,导致屏幕上该物体完全消失。
  • 痛点 2:步长过小导致性能崩盘 如果为了不漏掉细节,把 stepSize 设置得极小,在广袤的空旷空间中,光线需要执行成千上万次 for 循环,仅仅是为了探测“空气”,这会让 GPU 性能瞬间崩溃(帧率跌至个位数)。
  • 痛点 3:交点不精确 当检测到 hit = true 时,光线其实已经陷入物体内部了,拿到的交点 p 不是精确的表面,这会导致光照计算(如法线和阴影)出现严重的锯齿和阶梯状瑕疵。

基于 SDF 的 Ray Marching

为了解决上述步长的痛点,图形学界引入了 SDF(Signed Distance Field,有向距离场)Sphere Tracing(球体追踪) 算法。 不再让场景返回“是否在内部”,而是让场景返回当前点距离最近表面的最短距离,一旦有了这个距离,光线就不需要“盲走”固定步长了。

场景建模:编写 SDF

所有的几何体都不由顶点组成,而是由数学公式定义。我们需要一个全局的 map(float3 p) 函数,输入一个空间点,返回该点到场景的最短距离。

HLSL
float sdSphere(float3 p, float s) {
  return length(p) - s; // 球体的 SDF
}

float map(float3 p) {
    float sphereDist = sdSphere(p - float3(0, 1, 0), 1.0);
    float planeDist = p.y; // 地面的 SDF
    return min(sphereDist, planeDist); // 用 min() 组合物体
}
点击展开查看更多

核心循环:步进

光线以 SDF 返回的安全距离为半径,向前“步进”,只要步长等于 SDF 的返回值,光线就绝对不会穿透物体。

HLSL
#define MAX_STEPS 100 // 最大步进次数
#define MAX_DIST 100.0 // 最大距离
#define SURF_DIST 0.001 // 最小距离阈值

float rayMarch(float3 ro, float3 rd) {
    float dO = 0.0; // 射线已行进的总距离
    
    for(int i = 0; i < MAX_STEPS; i++) {
        float3 p = ro + rd * dO; // 当前探测点 $p = ro + rd \cdot t$
        float dS = map(p);     // 探测点到场景的最近距离
        dO += dS;              // 向前迈进
        
        // 如果距离小于阈值(击中表面),或超出最大距离(未击中),则跳出循环
        if(dS < SURF_DIST || dO > MAX_DIST) break;
    }
    return dO;
}
点击展开查看更多

法线计算

没有多边形,如何获取表面法线来计算光照?答案是利用 SDF 的梯度。通过在击中点的 X、Y、Z 方向上做极其微小的偏移(有限差分法),观察距离场的变化速率,即可得到法线向量。

HLSL
float3 getNormal(float3 p) {
    float d = map(p);
    float2 e = float2(0.001, 0); // 极小偏移量
    float3 n = d - float3(
        map(p - e.xyy),
        map(p - e.yxy),
        map(p - e.yyx)
    );
    return normalize(n);
}
点击展开查看更多

版权声明

作者: Cheyne Xie

链接: https://chaim.eu.org/posts/cef0ac95/

许可证: CC BY-NC-SA 4.0

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. Please attribute the source, use non-commercially, and maintain the same license.

开始搜索

输入关键词搜索文章内容

↑↓
ESC
⌘K 快捷键