这篇笔记主要写一下镜面反射和折射的几种实现路线,以及每一种各自能做到什么、做不到什么。
立方体贴图与基于图像的光照
最基础的环境反射是立方体贴图:六张面向六个方向的图,用一个方向向量就能采样。
MSLfloat3 r = reflect(-viewDir, normal);
float3 env = environmentMap.sample(cubeSampler, r).rgb;
Metal 里创建立方体贴图:
Swiftlet d = MTLTextureDescriptor.textureCubeDescriptor(
pixelFormat: .rgba16Float, size: 512, mipmapped: true)
d.usage = [.shaderRead, .renderTarget]
对于 PBR,直接采样反射方向只对完全光滑的表面正确。粗糙表面反射的是一个锥形范围内的环境,需要对环境图做预卷积。这就是 IBL(基于图像的光照)的分裂求和近似,把积分拆成两部分:
1. 预过滤环境图。 把立方体贴图的每一级 mip 用不同粗糙度的 GGX 分布卷积一遍。mip 0 是原图(roughness 0),最后一级几乎全模糊(roughness 1)。运行时按粗糙度选 mip:
MSLfloat lod = roughness * float(prefilteredMap.get_num_mip_levels() - 1);
float3 prefiltered = prefilteredMap.sample(s, r, level(lod)).rgb;
2. BRDF 积分查找表。 一张与环境无关的二维表,横轴 NdotV、纵轴 roughness,存的是菲涅尔的缩放和偏移。可以离线算好,所有场景共用:
MSLfloat2 ab = brdfLUT.sample(s, float2(NdotV, roughness)).rg;
float3 specular = prefiltered * (F0 * ab.x + ab.y);
漫反射部分用另一张预积分的 irradiance map——把环境图按余弦加权半球积分,得到一张很小(32×32 就够)的辐照度立方图。也可以用球谐系数(9 个 float3)表示,更省。
这一整套是离线渲染器里环境光积分的实时近似,效果好、成本固定,是现在所有 PBR 引擎的标配。
平面反射
对于水面、镜子这类平面,可以直接把摄像机沿平面镜像,再渲染一遍场景。
Swiftfunc mirrorMatrix(plane: SIMD4<Float>) -> float4x4 {
let n = plane.xyz, d = plane.w
return float4x4(
SIMD4(1 - 2*n.x*n.x, -2*n.y*n.x, -2*n.z*n.x, 0),
SIMD4( -2*n.x*n.y, 1 - 2*n.y*n.y, -2*n.z*n.y, 0),
SIMD4( -2*n.x*n.z, -2*n.y*n.z, 1 - 2*n.z*n.z, 0),
SIMD4( -2*n.x*d, -2*n.y*d, -2*n.z*d, 1)
)
}
结果完全正确、没有任何近似,但代价是整个场景要渲染两遍。所以平面反射只适合平面数量很少、且确实重要的情况(一面镜子、一片湖)。
镜像会翻转三角形的绕序,记得同时翻转 setFrontFacingWinding,否则背面剔除会把所有东西剔掉。另外需要用斜裁剪平面(oblique near plane)把平面之下的几何裁掉,避免它们出现在反射里。
屏幕空间反射
SSR 用已经渲染好的深度和颜色缓冲,在屏幕空间做光线步进:
MSLfloat3 rayStep(float3 origin, float3 dir, texture2d<float> depthTex, constant Params &p) {
float3 pos = origin;
for (uint i = 0; i < p.maxSteps; i++) {
pos += dir * p.stepSize;
float2 uv = projectToUV(pos, p.projection);
if (any(uv < 0.0) || any(uv > 1.0)) break; // 出屏幕,失败
float sceneDepth = linearDepth(depthTex.sample(s, uv).r, p);
float delta = pos.z - sceneDepth;
if (delta > 0.0 && delta < p.thickness) {
return binarySearchRefine(pos, dir, p); // 命中,二分细化
}
}
return float3(-1); // 未命中
}
SSR 的优点是能反射场景里的动态物体,而且不需要额外渲染场景。缺点是它有几类结构性的失败,不是调参能解决的:
- 屏幕外的东西无法反射。 你脚下的地面反射不出你身后的墙。标准做法是在屏幕边缘淡出 SSR,回退到立方体贴图。
- 被遮挡的表面无法反射。 深度缓冲只记录了最靠前的表面,一个物体的背面在缓冲里根本不存在。
- 厚度是猜的。 上面那个
thickness参数是在假设"深度缓冲里的表面有多厚"。猜小了会穿过物体,猜大了会产生拉长的伪影。
所以 SSR 从来不是单独使用的,它总是作为立方体贴图之上的一层增强:SSR 命中就用 SSR 的结果,没命中就用环境图。
粗糙表面的 SSR 需要多条射线,代价很高。实用的做法是每像素只打一条抖动过的射线,然后用时序(TAA)和空间滤波把噪声去掉。
折射
折射的方向由斯涅尔定律决定,MSL 直接提供:
MSLfloat3 refracted = refract(-viewDir, normal, eta); // eta = n1 / n2
refract 在发生全内反射时返回零向量(从密介质射向疏介质、入射角超过临界角),这种情况需要退化成反射:
MSLfloat3 t = refract(-v, n, eta);
float3 dir = (length_squared(t) < 1e-6) ? reflect(-v, n) : t;
常见的折射率:水 1.33,玻璃 1.5,钻石 2.42。
实时渲染里最常见的折射近似是屏幕空间抓取:把折射方向投影成一个屏幕空间的偏移,用这个偏移去采样背景颜色。
MSLfloat2 offset = refracted.xy * strength / in.position.w; // 除以 w:远处偏移更小
float3 behind = sceneColor.sample(s, screenUV + offset).rgb;
这在物理上是错的(它假设背景在一个平面上),但对玻璃、水面、热浪这类效果完全够用,而且成本只有一次纹理采样。
色散(chromatic dispersion)可以通过对 RGB 三个通道用略微不同的折射率来模拟:
MSLfloat3 refr;
refr.r = sampleRefracted(eta - dispersion).r;
refr.g = sampleRefracted(eta).g;
refr.b = sampleRefracted(eta + dispersion).b;
三次采样换来彩虹边缘,在宝石和棱镜上效果明显。
硬件光线追踪
上面所有技术都是近似,而且每一种的失效情形都很具体。Metal 提供的硬件加速光线追踪可以直接解决它们——反射就是从表面发一条射线,没有屏幕空间的限制,也不需要预烘焙环境图。
代价是性能。即使有硬件 BVH 遍历,每像素一条反射射线加上着色,也远比采样一张立方体贴图昂贵。现在的实际做法是混合:主要反射用光追,粗糙表面和远处用 IBL,然后用去噪器把稀疏的光追结果补全。
关于光线追踪,接下来三篇会详细展开:#16 动画之后的#17、#18、#19。
下一篇先讲动画。