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

Metal #7 光照

#ComputerGraphics#GraphicsEngine#Metal

这篇笔记主要写一下最基础的光照模型,以及在 Metal 里怎么组织多个光源。

Lambert 漫反射

理想漫反射表面向各个方向均匀散射入射光。到达表面的能量正比于入射方向与法线夹角的余弦——这就是 Lambert 余弦定律

MSLfloat3 n = normalize(in.normalWS);
float3 l = normalize(-light.direction);     // 指向光源
float  ndotl = saturate(dot(n, l));
float3 diffuse = baseColor * light.color * ndotl;

saturate 而不是 max(dot, 0) 只是习惯,两者等价。关键是必须钳制:背面的 ndotl 是负数,不处理就会得到负的颜色,在 HDR 管线里会一路传播成黑斑。

Phong 与 Blinn-Phong 高光

Phong 模型用反射向量与视线的夹角算高光:

MSLfloat3 r = reflect(-l, n);
float  spec = pow(saturate(dot(r, v)), shininess);

Blinn-Phong 用半程向量(light 和 view 的角平分线)代替反射向量:

MSLfloat3 h = normalize(l + v);
float  spec = pow(saturate(dot(n, h)), shininess * 4);

Blinn-Phong 更常用,有两个原因。第一,normalize(l + v)reflect 便宜一点。第二,也是更重要的:当光源和视线都接近掠射时,Phong 的 dot(r, v) 会提前变成负数,高光被硬生生截断,形成一条锐利的边。Blinn-Phong 没有这个问题,高光形状在掠射角下更接近实际测量。

shininess 指数在两个模型间不等价,经验上 Blinn 需要 Phong 的 2~4 倍才能得到相似大小的高光。

三种光源

C// 共享头文件里
typedef enum { LightTypeDirectional, LightTypePoint, LightTypeSpot } LightType;

typedef struct {
    vector_float3 position;      // point / spot
    vector_float3 direction;     // directional / spot
    vector_float3 color;
    float         intensity;
    float         range;         // point / spot
    float         innerCone;     // spot,余弦值
    float         outerCone;
    uint          type;
} Light;

方向光没有位置,只有方向,光线处处平行。太阳、月亮用它。没有衰减。

点光源从一点向所有方向发光,强度随距离衰减。

聚光灯是点光源加一个锥形限制。锥体边缘要用平滑过渡,否则边界会有硬锯齿:

MSLfloat  cosAngle = dot(normalize(fragToLight), normalize(-light.direction));
float  cone     = smoothstep(light.outerCone, light.innerCone, cosAngle);

注意 smoothstep 的两个边界是反的(outer 在前),因为余弦值随角度增大而减小。

衰减

物理上正确的衰减是平方反比

MSLfloat d    = length(light.position - in.positionWS);
float atten = 1.0 / (d * d);

但直接用它有两个实际问题:靠近光源时 1/d² 会趋于无穷;而且理论上光永远照不到零,每个光源都要遍历全场景。

实用的做法是加一个窗口函数,让光强在 range 处平滑地归零:

MSLfloat d      = length(light.position - in.positionWS);
float ratio  = d / light.range;
float window = saturate(1.0 - ratio * ratio * ratio * ratio);
float atten  = window * window / (d * d + 1.0);

+ 1.0 避免了近处的奇点,四次方的窗口保证了在 range 处值和导数都连续——不连续的导数在移动时会表现为可见的亮度跳变。

有了明确的 range,剔除也变得可能:CPU 或 compute shader 可以先判断光源的影响球是否与物体(或屏幕分块)相交,不相交就完全跳过。这是所有分块/分簇渲染的基础。

多光源循环放在哪里

最直接的写法是在片元函数里遍历所有光源:

MSLfragment float4 fragment_lit(VertexOut in [[stage_in]],
                             constant Light *lights [[buffer(13)]],
                             constant uint  &lightCount [[buffer(14)]])
{
    float3 n = normalize(in.normalWS);
    float3 v = normalize(cameraPosition - in.positionWS);
    float3 color = ambient * baseColor;

    for (uint i = 0; i < lightCount; i++) {
        color += shade(lights[i], n, v, in.positionWS, baseColor);
    }
    return float4(color, 1);
}

这叫单遍前向渲染(single-pass forward)。它简单、支持透明物体和 MSAA,但代价是 O(物体数 × 光源数):每个片元都要遍历所有光源,即使绝大多数光源离它很远。几十个光源以后就撑不住了。

其它几条路:

  • 多遍前向:每个光源一个 pass,用加法混合叠加。老 API 的做法,现在几乎不用——几何被重复处理 N 次。
  • 延迟渲染:先把几何属性写进 G-Buffer,再在屏幕空间对每个光源着色。复杂度变成 O(物体数 + 光源数 × 覆盖像素)。见《Metal #11 延迟渲染》
  • 分块/分簇前向(Forward+):用一个 compute pass 把光源分配到屏幕分块(或视锥体的三维簇)里,片元只遍历自己所在分块的光源列表。保留了前向渲染对透明和 MSAA 的支持,同时把光源数降到每分块可控的范围。在 Apple Silicon 的 TBDR 架构上,这条路特别自然,因为硬件本来就在按分块工作。

环境光与下一步

上面代码里那个 ambient * baseColor 是最粗糙的近似:假设有均匀的环境光从各个方向来。它让暗部不至于全黑,但完全是假的——真实的环境光会随方向变化。

改进的第一步是 hemisphere lighting:按法线朝上还是朝下,在天空色和地面色之间插值:

MSLfloat  up      = n.y * 0.5 + 0.5;
float3 ambient = mix(groundColor, skyColor, up);

一行代码,但效果比常数环境光好得多。再往上就是 irradiance map 和球谐光照,属于基于图像的光照(IBL)的范畴,我们在《Metal #15 反射与折射》里会接触到。

下一篇讲材质,把这些参数组织起来。

本系列文章

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