这篇笔记主要写一下最基础的光照模型,以及在 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 反射与折射》里会接触到。
下一篇讲材质,把这些参数组织起来。