T.TAO
返回博客
/8 min read/Graphics Engine

Metal #15 反射与折射

#ComputerGraphics#GraphicsEngine#Metal

这篇笔记主要写一下镜面反射和折射的几种实现路线,以及每一种各自能做到什么、做不到什么。

立方体贴图与基于图像的光照

最基础的环境反射是立方体贴图:六张面向六个方向的图,用一个方向向量就能采样。

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 的优点是能反射场景里的动态物体,而且不需要额外渲染场景。缺点是它有几类结构性的失败,不是调参能解决的:

  1. 屏幕外的东西无法反射。 你脚下的地面反射不出你身后的墙。标准做法是在屏幕边缘淡出 SSR,回退到立方体贴图。
  2. 被遮挡的表面无法反射。 深度缓冲只记录了最靠前的表面,一个物体的背面在缓冲里根本不存在。
  3. 厚度是猜的。 上面那个 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

下一篇先讲动画。

本系列文章

Metal
  1. 01Metal #0 Swift 回顾
  2. 02Metal #1 初始化
  3. 03Metal #2 渲染管线
  4. 04Metal #3 顶点函数
  5. 05Metal #4 片元函数
  6. 06Metal #5 纹理
  7. 07Metal #6 摄像机与交互
  8. 08Metal #7 光照
  9. 09Metal #8 材质
  10. 10Metal #9 渲染通道
  11. 11Metal #10 阴影
  12. 12Metal #11 延迟渲染
  13. 13Metal #12 粒子系统
  14. 14Metal #13 曲面细分
  15. 15Metal #14 后处理
  16. 16Metal #15 反射与折射
  17. 17Metal #16 动画
  18. 18Metal #17 光线追踪(一)渲染算法
  19. 19Metal #18 光线追踪(二)阴影与光照
  20. 20Metal #19 光线追踪(三)性能优化
  21. 21Metal #21 [附录] 计算着色器
  22. 22Metal #22 [附录] SwiftUI 中的 Metal