Raymarching: Building an SDF Renderer from Scratch
SDF Raymarching 的宿主 C++ 程序不加载任何 3D 模型,只画一个覆盖全屏的三角形(B17 详述技巧)。这个三角形唯一的作用是触发 GPU 为每个像素执行 fragment shader:
全屏三角形 (1 个 primitive)
↓ 光栅化
每像素 fragment shader 独立运行
↓
每个像素:算射线 → Sphere Tracing 求交 → 算光照 → 输出颜色
整个 3D 场景——几何、相机、光照、阴影——全部封装在一个 fragment shader 函数里。没有 vertex buffer、没有 mesh、没有材质贴图。
GPU 是 SIMD(单指令多数据)架构:数百万像素同时执行同一份 shader 代码,每个像素用不同的 (x, y) 坐标算自己的射线。这就是为什么 raymarching 能实时跑:
对比光栅化(三角形):需要顶点着色、图元装配、光栅化、深度测试等流水线,像素间有依赖(z-buffer)。Raymarching 把这些全跳过——直接在像素级算几何,结构极简。
传统渲染的几何来自顶点(triangle mesh),通过 vertex shader 变换位置。SDF 的几何来自距离函数 ,是解析定义的——给定任意点 ,函数返回距离。所以:
这是 SDF 的核心权衡:用计算换存储。简单场景快,复杂场景(很多物体)每像素要算很多 SDF,可能比光栅化慢。SDF 适合程序化、有机、可微的场景;三角形适合离散、预先建模的场景。
C++ 宿主:上传 uniform (resolution, time, mouse)
↓
Vertex shader:3 个顶点 (gl_VertexID) → 全屏三角形
↓
Fragment shader (每像素):
1. 计算 uv 坐标 → 相机射线方向 (B16)
2. Sphere Tracing 沿射线步进求交 (B09)
3. 算法线 (B11) → 光照 (B12, B13)
4. 软阴影 (B14) + AO (B15) 调制
5. 后处理:AA / 雾 / Gamma (B18)
↓
帧缓冲 → 显示
每帧重复,iTime 让相机和几何动画推进。这就是 SDF 渲染器的完整架构——极简但强大。
SDF 是一个标量场 ,输入空间任意点 ,输出该点到最近表面的有符号距离:
“有符号”承担两个角色:绝对值给距离,符号给内外。零等值面 就是几何体的表面——这是整个 Raymarching 几何的”隐式方程”。
传统光栅化(三角形)预先离散化了表面:每个三角形是表面的一小块。Raymarching 用解析的距离函数描述整个空间——表面不需要离散化,求交靠”步进”。
SDF 的关键性质是 1-Lipschitz:距离函数的梯度模长 (距离变化不超过位移)。这给出一个安全步长保证——从任意点 出发,沿任意方向走 的距离,绝不会穿过任何表面(因为至少在 半径内没有表面)。
形式化:对任意 ,(三角不等式)。等号取到当且仅当 沿最近表面方向。所以以 为球心、 为半径的球完全在体内或体外,没有表面穿过它。
这就是 Sphere Tracing(B09)能工作的数学根基:每步跳 距离是最大安全步长,既快又保证不漏交。
给定 ,表面是 的零等值面。法线是 的梯度方向:
所以 SDF 不只定义几何,还隐式给出法线(B11 用这个性质算光照),这是它相对三角形网格的一大优势——无需顶点法线插值。
很多人把任意隐式函数(如 )当 SDF 用——它是球面的隐式方程,但不是距离函数(在球外, 是距离的平方而非距离)。
后果:Sphere Tracing 的”安全步长”保证失效。 比真距离增长更快(平方关系),步进 可能穿过表面,导致漏交或几何畸变。所以构造 SDF 时必须保证 (至少 ≤1,under-relaxed 可接受;>1 危险)。
d < EPS)和法线方向会反。球的 SDF 是所有距离函数里最简单的:
float sdSphere(vec3 p, float r) {
return length(p) - r; // ‖p‖ - r
}
球心在原点、半径 的球面方程 。球外点到球面的最近距离是 (沿径向往球心走),球内点是 (往外走)。统一成有符号形式:
梯度的模:,模长 1(除原点外)。满足 1-Lipschitz,是真 SDF。
要把球心从原点移到 ,不重写函数,只需传入平移后的坐标:
float d = sdSphere(p - center, radius);
为什么?因为”点 到球心 的距离” = “点 到原点的距离”:
这个”坐标偏移即平移”是 SDF 的通用模式——所有几何体的位置、旋转都通过预处理坐标实现,SDF 函数本身永远假设标准位置(球心在原点、盒子轴对齐):
// 旋转:用旋转矩阵的转置(逆)变换坐标
vec3 q = rotate(transpose(R), p - center);
float d = sdBox(q, halfSize);
旋转 把物体从标准姿态转到世界姿态,逆变换 (旋转矩阵正交)把世界点转回标准姿态——于是可以套用标准 SDF。这是 SDF 表达任意姿态物体的统一手法。
SDF 的”坐标预处理”模式让场景组合非常自然:
float map(vec3 p) {
float d1 = sdSphere(p - vec3(0,1,0), 1.0); // 球 1 在 (0,1,0)
float d2 = sdBox(p - vec3(3,0,0), vec3(1)); // 盒子在 (3,0,0)
return min(d1, d2); // 并集
}
每个物体在 map() 里占一行,位置靠 p - center 表达,组合靠 min/max(B08)。这种”声明式”的场景描述是 SDF shader 可读性高的根源——也方便热重载改参数(B17)。
length(p) 在 处梯度未定义(除以零)。球心恰好是查询点时返回 0(表面),数值上不爆炸(开方保护),但梯度计算(B11 法线)要避免精确零向量——通常加极小 。Rtranspose*(p-c)),不是反过来。矩阵乘法不可交换,搞反了物体姿态完全错。轴对齐盒子(AABB,Axis-Aligned Bounding Box)的 SDF 是 SDF 技巧的典型范例:
float sdBox(vec3 p, vec3 b) { // b: 半边长 (half extents)
vec3 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}
短短两行包含了两个深刻技巧:对称性折叠和内外分区。
盒子关于三个坐标面对称。点 和 到盒子的距离完全相同(镜像)。所以 q = abs(p) - b 把任意点的查询归约到第一象限的等价点:
的每个分量表示该轴方向上超出盒子的量(负值表示在盒子内部该方向)。
点可能在盒子外或内,距离定义不同:
外部点(某个 ):到盒子的最近点是某个角点或棱,距离是 正分量的欧氏长度:
max(q, 0) 把负分量(盒子内方向)截零,只留正分量(盒子外方向),求欧氏长度就是到最近角/棱/面的距离。
内部点(所有 ):到最近面的距离是 最大分量(最不负的那个,即最近的面):
内部 SDF 应为负,所以取这个 max(负值)。
length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
两项里始终只有一个非零,相加正好切换。这是非常精巧的写法——避免了分支(if),GPU 上分支会破坏 SIMD 并行。
现代 GPU 是 SIMD(单指令多数据):相邻像素(线程)同时执行相同指令。if (在外) ... else ... 会让两条分支都被执行(分支发散 branch divergence),浪费一半算力。max/min/abs 是无分支运算,所有线程走同一路径——这是 shader 性能优化的核心套路。
外部点的 SDF 梯度:,模长 1 ✓。内部点的梯度沿轴方向(最近面的法向),模长也是 1 ✓。所以 sdBox 是真 SDF,可以放心用 Sphere Tracing。
b 是半边长不是全边长。很多 SDF 库约定 b 是半边长(half extents),所以边长 的盒子要传 b/2。误用全边长会让盒子大一倍。sdBox 假设盒子沿坐标轴。要做任意朝向的盒子,得用旋转矩阵预处理坐标(B05):sdBox(R^T (p - center), b)。q = abs(p) - b 是个中间量,若用它算别的(如法线)容易出错。法线从 SDF 梯度算(B11),不直接用 q。圆环(torus)绕 y 轴旋转一圈形成,有旋转对称性。SDF 巧妙地把 3D 问题降到 2D:
// 主半径 R(环中心到管中心),管半径 r(管粗细)
float sdTorus(vec3 p, vec2 R_r) {
float R = R_r.x, r = R_r.y;
vec2 q = vec2(length(p.xz) - R, p.y); // 降维:xz 平面 → 1D,配 y → 2D
return length(q) - r; // 在 2D 截面里做圆 SDF
}
圆环是”以 y 轴为中心轴、主半径 、管半径 “。点 到环面最近点的几何关系:
这两个量构成一个2D 截面坐标 ——这是点在”穿过环轴和该点”的纵截面上的坐标。在这个 2D 截面里,环面就是一个圆心在原点、半径 的圆(管子的横截面)。
3D SDF → 2D 圆 SDF,降了一维。这是构造复杂 SDF 的核心思路:识别对称性,把高维问题降到低维。
圆环有连续的旋转对称性(绕 y 轴任意旋转不变)。这意味着点到环面的最近点只依赖点到环轴的水平距离和垂直距离,不依赖方位角。所以 xz 两个分量可以合并成一个”水平距离” ,问题从 3D 降到 2D。
数学上: 是环面的真距离函数,梯度模长 1(验证:外法向沿径向 + 垂直方向,归一化后模 1)。可以放心做 Sphere Tracing。
很多复杂 SDF 都用降维:
通用模式:找到物体的对称轴/对称面,把垂直于对称方向的维度用 length() 合并,降到该对称方向上的低维问题。
p.xz 的 length 是水平距离,不是到原点距离。length(p) 是 3D 距离,length(p.xz) 才是 xz 平面的水平距离。混淆会让环的位置和大小全错。vec2(R, r) 的顺序要和函数定义一致。颠倒会让环变成”细管大环”或”粗管小环”。多个 SDF 通过三个布尔运算组合,对应三个简单的 min/max:
float opUnion(float d1, float d2) { return min(d1, d2); } // 并集 ∪
float opIntersect(float d1, float d2) { return max(d1, d2); } // 交集 ∩
float opSubtract(float d1, float d2) { return max(d1, -d2); } // 差集 d1 - d2
回忆 SDF 的符号:正=外、负=内。两个物体的并集是”任一物体内”的区域:
“任一为负”= “min 为负”,所以 ✓。
交集是”两个都在内”:
“两个都负” = “max 为负”,所以 ✓。
差集(A 减去 B)是”A 内且 B 外”。B 外是 ,等价于 (取反 B 把”外”变成”内”),然后求 A 和(反 B)的交集:
把 B 翻转(取负)再求交,就是”挖洞”操作。
min/max 是保距运算:若 都是 1-Lipschitz(),它们的 min/max 也是。
证明: 等于其中一个的梯度(哪个更小取哪个),模长 ≤1。所以组合后的 SDF 仍是合法距离场,Sphere Tracing 的安全步长保证成立。
shader 里 25 个物体各有不同材质,组合时除了距离还要传递”是哪个物体”:
// vec2 的 .x = 距离,.y = 材质 ID
vec2 opU(vec2 d1, vec2 d2) {
return (d1.x < d2.x) ? d1 : d2; // 哪个更近,材质跟着传递
}
每次 opU 比较 .x(距离),近的胜出,.y(材质 ID)一起带过去。最终 map() 返回的 vec2 里,.y 告诉 shader 这个像素看到的是什么材质——用于选光照参数、颜色、纹理。
裸 min/max 在物体相交处产生尖锐折线(C¹ 不连续,法线突变)。要柔和过渡用平滑 min(smooth minimum):
控制平滑半径。代价:在过渡区 SDF 偏离真距离(梯度 <1),需要 under-step 或保守步进。多数 demo shader 接受这个近似换取视觉柔和。
opSubtract(A, B) 是 A 减 B,不是 B 减 A。max(d1, -d2) 里 d2 被挖掉。搞反了变成 B 挖 A。min/max 不连续处法线跳变。两个 SDF 在交线处梯度不同,min 切换时梯度突变,光照会出现硬边。要么接受(折线美学),要么用 smin 平滑。smin、非标准 SDF(如 length(p)-r 的近似版本)组合后梯度 <1,Sphere Tracing 仍能工作但步进偏保守(实际步长 < 真安全步长),不会漏交只是慢一点。Raymarching 的核心算法,由 John Hart 1996 提出。从相机沿射线方向步进,每步跳”到最近表面的距离”,保证不穿表面:
float rayMarch(vec3 ro, vec3 rd) { // ro: 射线起点, rd: 单位方向
float t = 0.0; // 已走距离
for (int i = 0; i < MAX_STEPS; i++) { // 上限 70
vec3 p = ro + t * rd; // 当前点
float d = map(p).x; // 到最近表面的有符号距离
if (d < EPS) return t; // 收敛:到达表面
if (t > MAX_DIST) break; // 太远放弃(背景)
t += d; // 跳 d 距离(最大安全步长)
}
return -1.0; // 未命中
}
由 SDF 的 1-Lipschitz 性质(B04):以当前点 为球心、 为半径的球内绝无表面。所以沿射线方向走 距离(即 t += d)一定不会跨过任何表面。
形式化:设 (沿射线走 ),则 。由 Lipschitz:
等等,这看上去不”安全”。更精确的论证:球 内所有点 满足 (同号),所以没有零等值面穿过这个球。沿射线走 距离,终点仍在球内(在边界上),所以没穿表面 ✓。
每次步进后 单调增( 在外部)。收敛速度依赖几何:
这是 Sphere Tracing 的固有性能瓶颈——掠射和远处物体吃掉大部分迭代预算。优化手段:BVH 加速(B10)、自适应步长、early-exit。
#define MAX_STEPS 70
#define EPS 0.001
#define MAX_DIST 100.0
MAX_STEPS 太小:远处/掠射射线没收敛就退出,产生黑洞或几何缺失。太大:性能浪费(每像素 70 次 map 调用,map 又含 25 个 SDF)。70 是经验平衡。EPS 太大:表面”提前”判定命中,距离偏大,细节被磨平。太小:收敛慢(需要更多步才让 ),且浮点精度噪声(数值抖动)。 在浮点精度和速度间的折中。rd 单位向量保证 t += d 的物理意义( 是真实距离)。不归一化会让步进和距离量级错乱。t += d 不要写成 t += step_size。固定步长 march 是”ray marching”的另一种用法(如体积渲染),但不是 Sphere Tracing——前者可能穿表面,后者保证不穿。SDF 场景必须用距离场步进。map() 调用是性能热点。一次 rayMarch 调用最多 70 次 map,每次 map 又算 25 个 SDF。这意味着单像素最多 次 SDF 评估——这就是为什么需要 BVH(B10)剪枝。map() 是整个场景的总入口:输入点 ,返回 vec2(distance, materialID)。25 个几何体逐个算 SDF 会很贵,所以用层次包围盒(Bounding Volume Hierarchy, BVH)剪枝。
vec2 map(vec3 p) {
vec2 res = vec2(MAX, -1);
res = opU(res, vec2(sdSphere(p - c1, r1), 1)); // 物体 1
res = opU(res, vec2(sdBox(p - c2, b2), 2)); // 物体 2
// ... 25 个物体
return res;
}
每步 Sphere Tracing 调一次 map(),每次算 25 个 SDF。70 步 × 25 = 1750 次 SDF 评估/像素。全屏 1920×1080 像素,每帧要 次 SDF 调用——完全跑不动实时。
把 25 个物体按空间分 5 组,每组外面套一个轴对齐包围盒(AABB):
vec2 map(vec3 p) {
vec2 res = vec2(MAX, -1);
// 组 1:5 个物体,包围盒中心 c1、半边长 h1
if (sdBox(p - c1, h1) < res.x) { // 先测包围盒距离
res = opU(res, objectGroup1(p)); // 仅在可能命中时才进组
}
if (sdBox(p - c2, h2) < res.x) {
res = opU(res, objectGroup2(p));
}
// ... 5 组
return res;
}
关键剪枝条件:sdBox(p - ci, hi) < res.x——若包围盒到 的距离已经大于当前最近距离 res.x,那么组内任何物体都不会比 res.x 更近(包围盒是组内物体距离的下界),整组直接跳过。
一次 map() 调用通常只进 1–2 个组,算的 SDF 数从 25 降到 5–10,性能提升数倍。
AABB 包围住一组物体,意味着这组所有物体的表面都在盒子内或边界上。点 到盒子的有符号距离 是”到盒子表面”的距离;组内任意物体表面到 的距离 ≥ (因为物体表面在盒子内部,从 到物体表面必经过盒子表面或已经在盒内)。
所以 (每个物体)。若 (当前最近),则组内所有 ,没有物体能更新 res,整组可跳。
AABB 剪枝对空间分散的场景效果好(远处组直接跳过)。但对密集重叠的场景(多个物体挤在一起)效果有限——所有组的包围盒都重叠,每个点都要进多个组。这是 SDF 加速的固有难点:
if 分支开销),靠手工分组优化。高级方案:SDF 八叉树(distance field octree)、GPU 上的 SDF grid——但 shader 实现复杂,demo 里手工分组仍是主流。
sdBox 包围盒本身也是 SDF 调用。剪枝测试不是免费的,5 个组的包围盒测试是 5 次 sdBox。若组太小(只装 1-2 个物体),测试开销可能超过省下的 SDF——分组粒度要权衡。SDF 场景没有顶点法线(没有顶点!),法线从距离场的梯度提取:
几何直觉:SDF 增长最快的方向就是”远离表面最快”的方向,即外法向。梯度 指向这个方向,归一化后就是单位法线。
shader 里用前向差分估梯度,最经济的方案是四面体采样(tetrahedron technique),只需 4 次 SDF 查询:
vec3 calcNormal(vec3 p) {
const float eps = 0.0005;
const vec2 h = vec2(1.0, -1.0);
// 4 个四面体顶点方向(避免 6 次中心差分)
vec3 n = normalize(
h.xyy * map(p + h.xyy * eps).x + // (+,-,-)
h.yyx * map(p + h.yyx * eps).x + // (-,-,+)
h.yxy * map(p + h.yxy * eps).x + // (-,+,-)
h.xxx * map(p + h.xxx * eps).x // (+,+,+)
);
return n;
}
4 个偏移方向 构成正四面体,求加权 SDF 值的合成就近似梯度。比朴素的 6 面中心差分(±x, ±y, ±z 共 6 次)少 2 次查询,且数值稳定性相当。
差分法估计梯度有两类误差,eps 在两者间权衡:
eps = 0.0005 是 截断误差()和浮点噪声()的平衡点。物体尺度接近 1 时这个值合适;场景尺度差很多要相应缩放。
// 不要展开成 4 次显式 map() 调用
for (int i=0; i<4; i++) {
n += h[i] * map(p + h[i]*eps).x; // 循环调用
}
map() 函数体很大(25 个 SDF + BVH)。如果手写 4 次显式调用,编译器可能内联 4 份 map()——shader 二进制膨胀几倍,指令缓存(I-cache)压力暴增,性能反而下降。用 for 循环(且 map 不标 inline)让编译器复用同一份 map 代码,4 次调用走同一指令路径。
这是 shader 优化的反直觉点:循环有时比展开快,因为大函数被多次复制会破坏 I-cache。
法线是所有光照计算(漫反射、高光、Fresnel)的输入。法线噪声直接表现成表面颗粒感或闪烁。来源:
min/max 切换 → 梯度不连续,硬边。调试法线时常用”法线可视化”:直接把 n*0.5+0.5 作为颜色输出,应该看到平滑的彩色渐变;出现锯齿或斑块说明 SDF 或采样有问题。
eps 要随场景尺度调整。物体大(如 100 单位)时 eps=0.0005 太小,相对精度不足;物体小(如 0.01)时太大,过度平滑。常写成 eps = 0.0005 * scale。min 切换处梯度突变,法线出现折线。接受(折线美学)或用 smin 平滑(B08)。SDF 场景的光照模型和传统光栅化一样,只是法线来源不同(梯度,B11)。三个分量:
表面均匀散射光,强度正比于法线与光方向夹角的余弦:
float diff = max(dot(n, l), 0.0);
vec3 diffuse = diff * lightColor * k_diffuse;
max(..., 0) 把背光面()截零,避免负光。这是余弦定律:单位面积接收的光通量 (光斜照时摊薄)。
注意:这是没有除以 π 的工程形式(B05 smallpt2 讨论过物理正确的 BRDF)。实时 demo 常省 π 为简单调参,物理不严格但视觉够用。
镜面反射高光,模拟光滑表面的”亮斑”。Blinn-Phong 用半向量 (光方向和视线方向的角平分线):
vec3 h = normalize(l + v);
float spec = pow(max(dot(n, h), 0.0), shininess);
vec3 specular = spec * lightColor * k_specular;
指数 (shininess)控制高光锐度: 大高光小而锐(金属、玻璃), 小高光大而糊(塑料、纸张)。
为什么用半向量而不是反射方向:Blinn-Phong 比 Phong(用反射方向 和视线 的夹角)更接近真实物理,且只需算一次归一化()而非反射()。半向量与法线夹角正好是 Phong 角度的一半,所以 Blinn-Phong 的 要比 Phong 大约 4 倍才能匹配。
掠射角时反射增强(看玻璃窗斜着更亮)。Schlick 近似(smallpt2 B15 用过):
float fresnel = pow(1.0 - max(dot(n, v), 0.0), 5.0);
vec3 F = mix(F0, vec3(1.0), fresnel); // F0 是正入射反射率
是正入射(垂直看)的反射率,绝缘体约 0.04(玻璃),金属更高(如铜 0.95)。mix(F0, 1, fresnel) 在掠射角插值到 1(全反射),实现”边缘更亮”。
vec3 shade(vec3 n, vec3 l, vec3 v, vec3 albedo, vec3 F0, float shininess) {
vec3 h = normalize(l + v);
vec3 F = mix(F0, vec3(1.0), pow(1.0 - max(dot(n,v),0.0), 5.0));
vec3 diffuse = max(dot(n,l),0.0) * albedo * lightColor;
vec3 specular = pow(max(dot(n,h),0.0), shininess) * F * lightColor;
return diffuse + specular;
}
这是 Blinn-Phong + Fresnel 的经典组合,2000 年代几乎所有实时渲染都用。现代 PBR(physically based rendering)用更精确的 Cook-Torrance BRDF(GGX 分布、Smith 几何),但视觉提升相对复杂度增量不大,demo shader 仍常用 Blinn-Phong。
dot 结果要 max(.,0)。背光面 会让漫反射变负,加上高光后颜色失真。永远 clamp 到非负。pow(., shininess) 在底数为 0 时未定义()。pow(0, s) 在 GLSL 返回 0 但数值上有风险。先 max(dot, 0) 再 pow。单一直射光会让背光面全黑、缺乏氛围感。shader 用四个光源叠加,近似全局光照(Global Illumination, GI)的主要特征:
vec3 render(vec3 ro, vec3 rd) {
// ... rayMarch 求交得到 p, n, material
vec3 col = vec3(0);
// 1. 太阳光:直射,带阴影
vec3 sundir = normalize(vec3(0.8, 0.4, 0.2));
float dif = max(dot(n, sundir), 0.0);
float sha = softShadow(p + n*0.01, sundir); // B14 软阴影
col += dif * sha * sunColor * materialColor;
// 2. 天空光:半球环境光,受 AO 调制
float sky = clamp(0.5 + 0.5*n.y, 0.0, 1.0); // 朝上多,朝下少
float occ = calcAO(p, n); // B15 AO
col += sky * occ * skyColor * materialColor;
// 3. 背光:从太阳反方向补光
vec3 backdir = -sundir;
float bac = clamp(dot(n, backdir), 0.0, 1.0) * 0.5;
col += bac * backColor * materialColor;
// 4. SSS 近似:边缘通透(次表面散射)
float sss = clamp(pow(clamp(dot(rd, -sundir)+1, 0, 1)/2, 2.0), 0, 1) * 0.3;
col += sss * sunColor * materialColor;
return col;
}
主直射光源,方向 sundir 固定。漫反射(B12)+ 软阴影(B14)+ 高光。贡献最大,决定整体明暗。dif * sha 让阴影区域的太阳光为零。
模拟天空散射的环境光,半球形:
float sky = clamp(0.5 + 0.5*n.y, 0.0, 1.0);
法线朝上(,朝天空)的部分多收天空光,朝下(朝地面)的少——模拟”天空从上方照下来”。乘以 AO(角落遮挡天空光)让缝隙变暗。这是半球光照(hemisphere lighting)的简化:上半球一种颜色(天蓝),下半球另一种(地面灰),用法线插值。
防止背光面全黑的补光,方向是太阳的反方向。强度低(系数 0.5),作用是让阴影面也有微弱照明,模拟”地面反射的太阳光”。电影摄影叫补光(fill light),让暗部细节可见。
次表面散射模拟皮肤、蜡、玉石等半透明材质——光从背面透过来,边缘发亮:
float sss = pow(clamp(dot(rd, -sundir)+1, 0, 1)/2, 2.0);
当视线方向 rd 和太阳反方向 -sundir 接近(即视线顺着光从背面看物体),边缘(薄处)透光强。这是真实 SSS 的廉价近似(真 SSS 需要光线在物体内部弹射)。
单一光源的渲染方程 很难实时解。多光源合成把它离散化:
每个光源代表一个方向的入射光,叠加近似积分。“天空光”是上半球连续光的离散,“背光”是地面反射光的离散。这种经验分解不严格物理但视觉好,是实时渲染几十年的标准做法。现代 PBR 用 IBL(image-based lighting,用环境贴图采样)更精确,但四光源方案在 SDF demo 里性能与视觉平衡最好。
软阴影(soft shadow)让物体真正”落”在地面上,没有阴影时漂浮感强。Inigo Quilez 提出的 SDF 软阴影算法,是 Sphere Tracing 的巧妙复用——沿光线步进时顺手算阴影。
沿光源方向步进,每步记录 SDF 值 与已走距离 的比值:
几何含义:从当前点 (在光线上距起点 )到最近遮挡物的距离是 ,则 近似”遮挡物对光源的张角”(小角度近似 ):
取整条光线上的最小张角作为阴影浓度:
float softShadow(vec3 ro, vec3 rd, float k) {
float res = 1.0; // 1 = 无阴影
float t = 0.01; // 起点(避免自阴影)
for (int i = 0; i < SHADOW_STEPS; i++) {
float h = map(ro + rd*t).x;
if (h < 0.001) return 0.0; // 直接命中遮挡物:硬阴影核心
res = min(res, k * h / t); // 记录最小张角
t += clamp(h, 0.02, 0.5); // 步进(夹紧防止过大过小)
}
return clamp(res, 0.0, 1.0);
}
k 是”光线锐度”参数: 大阴影硬(接近硬阴影), 小阴影软(边缘大范围过渡)。
设光源在 (射线起点),遮挡物在 (距 为 ),最近遮挡面到光线的垂直距离是 。从光源看遮挡物,张角约:
(小角度近似)。阴影浓度正比于这个张角——张角越大遮得越严实。
这个近似忽略了遮挡物的真实形状(只看最近距离),但在视觉上效果很好,且只多花几次 map 调用(沿光线步进的副产物)。
阴影值 res 是 连续量,不是非黑即白:
res 接近 1,无阴影。这就是”软”阴影的本质——硬阴影算法(如 shadow mapping)需要专门滤波才能软化,SDF 软阴影天生柔和。
t += clamp(h, 0.02, 0.5):
clamp 牺牲一点精度换取步数可控。这是阴影算法相对主 rayMarch(B09,纯 t += d)的差异——阴影允许漏一些精度,主求交必须精确。
t = 0.01 起点不能从 0 开始。从光源(起点)开始 会除以 0,且光源附近的物体自阴影。0.01 偏移避开这个区域。k 不要太大。 退化成硬阴影(res 要么 0 要么 1),失去柔和性; 阴影太散(整片暗)。SHADOW_STEPS < MAX_STEPS(如 32 vs 70)省性能。环境光遮蔽(Ambient Occlusion, AO)模拟角落、缝隙、接触面变暗——这些区域被周围几何遮挡,接收到的环境光少。SDF 场景里 AO 沿法线方向采样几次就出来,几乎免费。
在表面点 ,沿法线 向外采样几个点 ,比较”期望距离”(采样点到 的距离 )和”实际 SDF 值” :
float calcAO(vec3 p, vec3 n) {
float occ = 0.0;
float sca = 1.0;
for (int i = 0; i < AO_SAMPLES; i++) {
float h = 0.01 + 0.12 * float(i) / float(AO_SAMPLES); // 步长递增
float d = map(p + n * h).x; // 该采样点的 SDF
occ += (h - d) * sca; // 差值累积
sca *= 0.95; // 衰减权重
}
return clamp(1.0 - 1.5 * occ, 0.0, 1.0);
}
理想情况下,从 沿法线走 距离,到达的点应在”开阔空间”(SDF 值 ≥ ,因为离 表面 远)。若实际 SDF 值 ,说明在 距离内遇到了其他表面——周围有遮挡。
差值 越大,遮挡越严重。多次采样累积,按距离衰减(远处的遮挡影响小,sca *= 0.95),最后归一化到 。
真正的 AO 需要积分半球上方的可见性(每个方向是否被遮挡):
是可见性函数(被挡为 0)。完整积分贵,但沿法线方向采样几次的近似抓住了主要信号:法线方向的 SDF 距离反映”头顶有多开阔”。这是 SDF 的红利——其他表示(三角形网格)需要专门的 ray casting 算 AO,SDF 直接查距离场。
物体和地面接触的地方 AO 最强——法线方向(朝上)采样时,邻近的地表/自身几何让 远小于 ,差值大。这模拟了”物体底部被自身遮挡”的真实效果,让物体视觉上”贴”在地面而非漂浮。这就是 AO 对比图里角落变暗的来源。
p + n*h 的 起点不能为 0(p 在表面,,差值 没意义),加 偏移避开表面。1.5)要按场景调。太强(系数大)角落全黑,太弱看不出效果。每个像素独立算一条射线,方向由相机参数决定。LookAt 相机用三个正交向量定义朝向:
mat3 setCamera(vec3 eye, vec3 target, float roll) {
vec3 w = normalize(target - eye); // forward(朝目标)
vec3 u = normalize(cross(w, vec3(0,1,0))); // right(与 up 叉积)
vec3 v = cross(w, u); // 真正的 up(再叉积)
return mat3(u, v, w); // 列向量组成相机基矩阵
}
任意相机姿态可以由 forward 向量 + 一个”up 提示”完全决定:
这构造一个右手正交基 ,模长都为 1,互相正交。组成的 矩阵 是旋转矩阵()。
每个像素 对应一条射线。把像素坐标映射到”虚拟像平面”上的归一化坐标(带视场角 FOV):
vec2 uv = (gl_FragCoord.xy * 2.0 - resolution) / resolution.y; // [-aspect, aspect] × [-1, 1]
vec3 rd = normalize(uv.x * u + uv.y * v + focal * w); // 像素方向
vec3 ro = eye; // 射线起点
uv 把像素映射到归一化像平面(除以 resolution.y 让纵向范围 ,横向按宽高比扩展)。focal 控制视场角(焦距),大 = 长焦窄视野,小 = 广角。uv.x*u + uv.y*v + focal*w 是像平面上的点在相机基下的坐标,归一化后是射线方向。这就是每个像素射线的来源——和 smallpt1 的相机射线生成数学同构。
float t = iTime * 0.3; // 时间相关
vec3 eye = vec3(sin(t)*3, 1.5, cos(t)*3); // 绕场景圆周
mat3 cam = setCamera(eye, vec3(0,0,0), 0.0); // 看向原点
iTime 是 shader 的内置 uniform(时间),让 eye 沿圆周运动,target 固定在原点——相机绕场景转。每帧 eye 变化 → 相机基变化 → 所有像素射线方向变化 → 渲染出不同视角。
cross(w, up_world) 当 时为 (叉积退化),归一化出 NaN。所以相机不能正好朝正上方或正下方。工程上要么约束 pitch 角(避开 ±90°),要么动态切换 up 提示(如接近垂直时改用 )。
这与 smallpt2 B07 的 ONB 构造面临同样问题,解法也类似——避免与 forward 共线的 up 向量。
cross(w, up) 给的是”右手系 right”,cross(up, w) 给反方向。搞反了相机左右镜像,画面像”翻转”。uv 的 aspect ratio 处理。除以 resolution.y 而非 resolution.x 是为了让纵向范围 固定,横向按宽高比延伸——这样 FOV 在纵向是固定的,横向随屏幕宽高比变化。反过来会让纵向 FOV 随屏幕变,体验不一致。roll 参数通常固定为 0(无滚转)。若要支持相机倾斜(飞行模拟),用 roll 绕 旋转 。C++ 宿主程序不做任何 3D 计算,只做 4 件事触发 GPU 跑 shader:
int main() {
glfwInit(); // 1. 初始化 GLFW + OpenGL 上下文
GLFWwindow* w = glfwCreateWindow(1280, 720, "SDF", NULL, NULL);
glfwMakeContextCurrent(w);
glewInit();
GLuint prog = compileShader("fragment.glsl"); // 编译+链接 shader
while (!glfwWindowShouldClose(w)) {
glUseProgram(prog);
// 3. 上传 uniform(实时数据)
glUniform2f(glGetUniformLocation(prog, "resolution"), 1280, 720);
glUniform1f(glGetUniformLocation(prog, "time"), glfwGetTime());
glUniform2f(glGetUniformLocation(prog, "mouse"), mx, my);
glDrawArrays(GL_TRIANGLES, 0, 3); // 2. 画全屏三角形(无需顶点缓冲)
glfwSwapBuffers();
glfwPollEvents();
maybeHotReload(&prog); // 4. 检测 shader 变化热重载
}
}
传统做法要建一个 VBO(顶点缓冲对象)存矩形的 4 个顶点,配两个三角形覆盖屏幕。全屏三角形 trick 更省事:直接画一个大三角形,顶点超出屏幕范围,光栅化时屏幕内的部分自然覆盖整个窗口:
// vertex shader
vec2 positions[3] = vec2[3](
vec2(-1,-1), vec2(3,-1), vec2(-1, 3)
);
void main() {
gl_Position = vec4(positions[gl_VertexID], 0, 1);
}
三个顶点 形成的三角形覆盖了 的范围,屏幕 完全在内。光栅化裁掉屏幕外的部分,剩下一个填满屏幕的三角形。
关键:gl_VertexID。这是 vertex shader 的内置变量,标识当前是第几个顶点(0/1/2)。用它直接生成位置,不需要任何 VBO/VAO——一个 glDrawArrays(GL_TRIANGLES, 0, 3) 就能画。
一个三角形 vs 两个三角形(传统 quad):
对于 fragment shader 完全接管像素颜色的场景(SDF 渲染),顶点数据没意义——只要覆盖屏幕即可,所以这个 trick 在 demo 场景里流行。
glUniform* 是 CPU 向 GPU 传数据的标准方式。SDF shader 需要的实时输入:
resolution:窗口大小(计算像素坐标必需)。time:动画(相机运动、几何动画)。mouse:交互(鼠标控制相机或参数)。每帧上传一次(CPU → GPU),fragment shader 对所有像素可见。热重载(hot reload)让开发者改 shader 文件后立刻看到效果——节省反复编译 C++ 的时间,是 shader 开发体验的关键。
gl_VertexID 需要 OpenGL 3.3+ 或 GLSL 330+。旧版 OpenGL 没有,得回退到 VBO 方案。glDeleteProgram 旧的,否则 GPU 内存泄漏(长期运行崩溃)。glfwGetTime() 单位是秒。shader 里时间动画(iTime)依赖这个,跨平台一致。不要用 clock()(平台依赖)。走完光照,最终颜色输出前还有三步后处理,缺一不可。
每个像素只采一条射线,物体边缘会出现锯齿(aliased staircase)——像素要么完全在物体内要么完全在外,没有过渡。超采样在每个像素内多条射线取平均:
vec3 col = vec3(0);
const int AA = 2; // 2x2 = 4 samples per pixel
for (int y = 0; y < AA; y++)
for (int x = 0; x < AA; x++) {
vec2 offset = (vec2(x, y) + 0.5) / float(AA) - 0.5; // 子像素位置
vec2 uv = (gl_FragCoord.xy + offset) * 2.0 - resolution;
col += render(uv / resolution.y);
}
col /= float(AA * AA); // 平均
个样本,边缘像素部分在内部分在外,平均后产生过渡色——这就是抗锯齿。代价:fragment shader 跑 4 次,性能 1/4。AA 宏控制质量,低端硬件可降到 1(关闭)。
数学上这是盒式滤波(box filter)的蒙特卡洛实现,把高于奈奎斯特频率(Nyquist)的高频信号平均掉,避免混叠。更高级的有 FXAA(后处理)、TAA(时间累积),但 SSAA 在 SDF demo 里最简单。
远处物体淡入背景色,模拟大气散射(空气中的水汽、尘埃散射光,远处对比度下降):
col = mix(col, fogColor, 1.0 - exp(-t*t*t * fogDensity));
距离 越大雾越浓,按指数衰减:
是经验选择(比 或 衰减更快),让远处快速消失而近处几乎无雾。雾的作用:
光照计算在线性空间(颜色线性叠加符合物理),但显示器假设输入是 gamma 2.2 编码(暗部细节更多)。输出前要做伽马校正:
col = pow(col, vec3(1.0 / 2.2)); // 线性 → sRGB
不做这步会怎样?画面偏暗,颜色不饱和——因为显示器会把输入值进一步压暗(它假设你给的是编码后的值,会按 解码,于是 )。1/2.2 的预校正抵消显示器的解码,让最终看到的颜色等于线性计算结果。
更严格的是 sRGB 传递函数(分段函数,2.4 指数 + 线性段),但 pow(1/2.2) 是工程近似,视觉差异很小。
| 缺失 | 症状 |
|---|---|
| 无 AA | 物体边缘锯齿,远处闪烁(高频率细节混叠) |
| 无雾 | 远处硬截断,深度感差,远处瑕疵暴露 |
| 无 Gamma | 画面偏暗,颜色不准(特别是中间调) |
三者是实时渲染的”最低卫生标准”——不做的 demo 看起来”业余”,做了立刻专业感提升。
pow(1/2.2) 是编码传递函数,和 sRGB 色彩空间(含原色定义、白点)是两回事。完整 sRGB 还要矩阵变换色度。pow(x, 2.2) 转回线性再参与光照,否则光照在非线性空间计算出错。SDF demo 没纹理所以无此问题。exp(-t^3) 不要写成 exp(-t)^3。前者是 ,后者是 ,衰减曲线完全不同。