GameTech/URP 渲染实战

案例 8.5动手烘焙:用 C# 算一张最简光照贴图进阶

目标:光照贴图到底存了什么?与其看 Lighting 窗口里的结果,不如自己算一张。我们写一个只有几十行的“迷你烘焙器”:场景是一块地面加两个球,只计算天空光的遮蔽(一次弹射),结果存成一张线性 HDR 贴图,再用一个简单的 Shader 当作间接漫反射使用。

烘焙器对每个纹素做的事情与 Progressive Lightmapper 的核心完全一样:从纹素位置向上半球发射余弦加权分布的光线,看不到天空(被球挡住)的方向贡献 0,能看到的贡献该方向的天空颜色,最后求平均。由于余弦加权采样的概率密度恰好是 cosθ/π,平均值直接就是 URP 约定下的 bakedGI(辐照度/π),与 SampleSH 的含义相同,可以无缝替换。

Assets/Cookbook/Ch08/ManualLightmapBaker.cs(节选:bake)
/// size:地面边长(米),地面是中心在原点、朝 +Y 的正方形,uv(0,0) 对应 (-size/2, 0, -size/2)
/// spheres:遮挡球(xyz = 球心,w = 半径)
/// 返回值:每个纹素存的是 URP 约定下的间接漫反射 bakedGI(= 辐照度 / π),线性空间
public static Texture2D BakeFloor(int resolution, float size, Vector4[] spheres, Func<Vector3, Color> sky, int samples = 256)
{
    var tex = new Texture2D(resolution, resolution, TextureFormat.RGBAHalf, false, true)
    {
        name = "ManualLightmap", wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Bilinear, hideFlags = HideFlags.DontSave
    };
    var px = new Color[resolution * resolution];
    int sq = Mathf.Max(1, Mathf.RoundToInt(Mathf.Sqrt(samples)));
    for (int y = 0; y < resolution; y++)
        for (int x = 0; x < resolution; x++)
        {
            var p = new Vector3(((x + 0.5f) / resolution - 0.5f) * size, 0.001f, ((y + 0.5f) / resolution - 0.5f) * size);
            Color sum = Color.black;
            for (int i = 0; i < sq; i++)
                for (int j = 0; j < sq; j++)
                {
                    // 余弦加权的半球方向(法线 = +Y),pdf 与 cosθ/π 抵消 → 结果是可见天空的简单平均
                    float u1 = (i + 0.5f) / sq, u2 = (j + 0.5f) / sq;
                    float r = Mathf.Sqrt(u1), phi = 2f * Mathf.PI * u2;
                    var d = new Vector3(r * Mathf.Cos(phi), Mathf.Sqrt(1f - u1), r * Mathf.Sin(phi));
                    if (!Occluded(p, d, spheres)) sum += sky(d);
                }
            px[y * resolution + x] = sum / (sq * sq);
        }
    tex.SetPixels(px);
    tex.Apply(false, false);
    return tex;
}

// 光线与球求交:只要有一个球挡住就算被遮挡(不考虑球表面的二次反弹)
static bool Occluded(Vector3 o, Vector3 d, Vector4[] spheres)
{
    foreach (var s in spheres)
    {
        Vector3 oc = o - (Vector3)s;
        float b = Vector3.Dot(oc, d);
        float c = Vector3.Dot(oc, oc) - s.w * s.w;
        float h = b * b - c;
        if (h >= 0 && -b - Mathf.Sqrt(h) > 0) return true;
    }
    return false;
}

Shader 很简单:间接漫反射从“光照贴图”取,直接光仍然实时计算——这正是 Baked Indirect 模式的分工。

Assets/Cookbook/Ch08/ManualLightmap.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    half3 N = normalize(i.normalWS);
    half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv.xy).rgb * _BaseColor.rgb;
    half3 baked = SAMPLE_TEXTURE2D(_BakedGI, sampler_BakedGI, i.uv.zw).rgb;
    if (_Mode > 1.5) return half4(baked, 1);          // 直接看光照贴图里存了什么

    // 间接漫反射:光照贴图(含天空遮蔽) 或 球谐(处处一样,没有遮蔽)
    half3 indirect = _Mode > 0.5 ? baked : SampleSH(N);
    // 直接光:实时主光 + 实时阴影
    Light mainLight = GetMainLight(TransformWorldToShadowCoord(i.positionWS));
    half3 direct = mainLight.color * saturate(dot(N, mainLight.direction)) * mainLight.shadowAttenuation;
    return half4(albedo * (indirect + direct), 1);
}
void main()
{
    vec3 N = normalize(normalWS);
    vec3 albedo = texture(_BaseMap, uv.xy).rgb * _BaseColor.rgb;
    vec3 baked = texture(_BakedGI, uv.zw).rgb;
    if (_Mode > 1.5) { fragColor = vec4(baked, 1.0); return; }   // 直接看光照贴图里存了什么

    // 间接漫反射:光照贴图(含天空遮蔽) 或 球谐(处处一样,没有遮蔽)
    vec3 indirect = _Mode > 0.5 ? baked : SampleSH(N);
    // 直接光:实时主光 + 实时阴影
    Light mainLight = GetMainLight(TransformWorldToShadowCoord(positionWS));
    vec3 direct = mainLight.color * saturate(dot(N, mainLight.direction)) * mainLight.shadowAttenuation;
    fragColor = vec4(albedo * (indirect + direct), 1.0);
}
图 8-8三格使用同一片天空。左:地面的间接光来自 SH(处处相同,只有实时阴影);中:直接显示烘焙结果,球体周围出现柔和的天空遮蔽,越靠近接触点越暗;右:烘焙的间接光 + 实时主光,球的底部与地面接触处多了一圈自然的“压暗”,物体“坐”在了地面上。

对比左右两图,差别集中在球与地面接触的区域。左图里球像是浮在地面上,这是只有 SH 环境光的场景最常见的观感问题;右图多出的那圈暗部,就是光照贴图的价值。真实的烘焙器还要做很多事:多次弹射(地面的光再反弹到球的底部)、从 Meta Pass 读取每个表面的反照率决定反弹光的颜色、降噪、处理 UV 接缝处的扩边(Dilation),以及按平台编码。它们是工程上的复杂度,原理就是这几十行代码。

注意这个案例是教学演示:我们没有通过 LightmapSettings 把贴图交给引擎,而是用一个自定义贴图属性直接采样。真实项目里请使用 Unity 的烘焙器;如果需要在运行时切换整套光照贴图(例如白天/夜晚两套烘焙结果),可以研究 LightmapSettings.lightmaps 与 Renderer.lightmapIndex、lightmapScaleOffset,此时贴图必须符合项目的光照贴图编码。