GameTech/URP 渲染实战

案例 8.7自定义环境反射 Cubemap进阶

目标:不用天空盒,而是用自己生成的 Cubemap 作为环境反射(例如产品展示用的“摄影棚”环境、黄昏的天空),并让环境光、背景与反射保持一致。

在编辑器里的做法:导入一张 Cubemap(HDR 全景图在导入设置里选 Texture Shape = Cube),把 Convolution Type 设为 Specular (Glossy Reflection),Unity 会为它生成按粗糙度预过滤的 mip 链;然后在 Lighting 窗口把 Environment Reflections 的 Source 改为 Custom 并指定它。在代码里,对应的是 RenderSettings.customReflectionTexture。

我们在 C# 里从一个环境函数直接生成 Cubemap,并自己完成预过滤:每一级 mip 先由 URP 的曲线反解出对应的粗糙度,再对每个纹素方向按 GGX 分布做重要性采样、按 N·L 加权平均(这正是 Split Sum 第一项的标准做法,假设 N = V = R):

Assets/Cookbook/Ch08/EnvironmentTools.cs(节选:cubemap)
/// 生成 HDR Cubemap,并按 URP 的“粗糙度 → mip”曲线对每一级 mip 做 GGX 预过滤(split-sum 的第一项)。
public static Cubemap BakeCubemap(Func<Vector3, Color> radiance, int size = 128, int samplesPerTexel = 64)
{
    var cube = new Cubemap(size, TextureFormat.RGBAHalf, true) { name = "Ch08Env", hideFlags = HideFlags.DontSave };
    int mipCount = cube.mipmapCount;
    for (int mip = 0; mip < mipCount; mip++)
    {
        int s = Mathf.Max(1, size >> mip);
        // URP: mip = r·(1.7 - 0.7r)·6,反解出这一级对应的感知粗糙度 r
        float t = Mathf.Min(mip / 6f, 1f);
        float perceptual = (1.7f - Mathf.Sqrt(2.89f - 2.8f * t)) / 1.4f;
        float alpha = Mathf.Max(perceptual * perceptual, 1e-4f);
        var px = new Color[s * s];
        for (int f = 0; f < 6; f++)
        {
            for (int y = 0; y < s; y++)
                for (int x = 0; x < s; x++)
                {
                    Vector3 n = FaceDirection((CubemapFace)f, (x + 0.5f) / s * 2f - 1f, (y + 0.5f) / s * 2f - 1f);
                    px[y * s + x] = mip == 0 ? radiance(n) : PrefilterGGX(radiance, n, alpha, samplesPerTexel);
                }
            cube.SetPixels(px, (CubemapFace)f, mip);
        }
    }
    cube.Apply(false, false);
    return cube;
}

// 预过滤假设 N = V = R:以 N 为中心按 GGX 分布重要性采样半程向量 H,反射出 L,按 N·L 加权平均
static Color PrefilterGGX(Func<Vector3, Color> radiance, Vector3 n, float alpha, int count)
{
    Vector3 up = Mathf.Abs(n.y) < 0.999f ? Vector3.up : Vector3.right;
    Vector3 tx = Vector3.Normalize(Vector3.Cross(up, n));
    Vector3 ty = Vector3.Cross(n, tx);
    Color sum = Color.black; float wsum = 0;
    for (int i = 0; i < count; i++)
    {
        // Hammersley 低差异序列
        float u1 = (i + 0.5f) / count;
        uint bits = (uint)i;
        bits = (bits << 16) | (bits >> 16);
        bits = ((bits & 0x55555555u) << 1) | ((bits & 0xAAAAAAAAu) >> 1);
        bits = ((bits & 0x33333333u) << 2) | ((bits & 0xCCCCCCCCu) >> 2);
        bits = ((bits & 0x0F0F0F0Fu) << 4) | ((bits & 0xF0F0F0F0u) >> 4);
        bits = ((bits & 0x00FF00FFu) << 8) | ((bits & 0xFF00FF00u) >> 8);
        float u2 = bits * 2.3283064365386963e-10f;
        // GGX 的 cosθh 采样公式
        float phi = 2f * Mathf.PI * u2;
        float cosT = Mathf.Sqrt((1f - u1) / (1f + (alpha * alpha - 1f) * u1));
        float sinT = Mathf.Sqrt(1f - cosT * cosT);
        Vector3 h = tx * (sinT * Mathf.Cos(phi)) + ty * (sinT * Mathf.Sin(phi)) + n * cosT;
        Vector3 l = 2f * Vector3.Dot(n, h) * h - n;
        float ndl = Vector3.Dot(n, l);
        if (ndl <= 0) continue;
        sum += radiance(l) * ndl; wsum += ndl;
    }
    return wsum > 0 ? sum / wsum : Color.black;
}

/// Cubemap 第 face 面上 (u,v)∈[-1,1] 的像素对应的方向(与 Unity 的面朝向约定一致)
public static Vector3 FaceDirection(CubemapFace face, float u, float v)
{
    Vector3 d;
    switch (face)
    {
        case CubemapFace.PositiveX: d = new Vector3(1, -v, -u); break;
        case CubemapFace.NegativeX: d = new Vector3(-1, -v, u); break;
        case CubemapFace.PositiveY: d = new Vector3(u, 1, v); break;
        case CubemapFace.NegativeY: d = new Vector3(u, -1, -v); break;
        case CubemapFace.PositiveZ: d = new Vector3(u, -v, 1); break;
        default: d = new Vector3(-u, -v, -1); break;
    }
    return d.normalized;
}

注意 Cubemap.Apply(false, …) 的第一个参数是 false:如果让 Unity 自动生成 mip,它只会做简单的盒式降采样,各级 mip 的模糊程度与 GGX 波瓣不匹配,粗糙材质会显得“太亮太锐”。拍摄代码里把同一个环境函数同时用于三处:背景天空盒、RenderSettings.ambientProbe(案例 8.1 的投影)、RenderSettings.customReflectionTexture。

图 8-11同一组材质(镜面金属纽结、粗糙金色金属球、光滑红色塑料球)放在三种环境中。左:拍摄环境默认的程序化天空;中:自制摄影棚,深色背景里两块柔光箱在纽结上留下清晰的长条高光;右:黄昏天空,所有反射都染上了暖色。

金属对环境的依赖远大于非金属:中图里镜面金属纽结几乎是全黑的,只有柔光箱的倒影——它没有漫反射,“周围有什么”就是它的全部外观。这也是为什么做产品展示、角色展示时,环境贴图的选择和直接光同等重要。中图金色球上略显斑驳的反射来自运行时预过滤的采样数有限(每纹素 160 个样本),正式资源应当使用离线工具或 Unity 导入器生成的高质量预过滤结果。

探针混合与盒体投影

反射探针(Reflection Probe)就是摆在场景里的一个“球形摄像机”,在它的位置拍一张 Cubemap。物体使用哪个探针,由物体与探针的影响盒(Box Size)的相交关系决定。两个探针重叠时,URP 的混合权重来自 CalculateProbeWeight:物体位置到盒子各个面的距离,除以 Blend Distance,取最小值——越靠近盒子中心权重越接近 1,越靠近边缘越接近 0。探针的 Importance 决定谁“优先”:重要性高的先拿权重,剩下的才给另一个;重要性相同时,体积较小的那个优先(更小的探针通常更“局部”,更准确)。

摘自 URP 14 ShaderLibrary/GlobalIllumination.hlsl
float CalculateProbeWeight(float3 positionWS, float4 probeBoxMin, float4 probeBoxMax)
{
    float blendDistance = probeBoxMax.w;
    float3 weightDir = min(positionWS - probeBoxMin.xyz, probeBoxMax.xyz - positionWS) / blendDistance;
    return saturate(min(weightDir.x, min(weightDir.y, weightDir.z)));
}

探针还有一个更本质的问题:Cubemap 是在探针中心拍的,它默认环境“无限远”。对天空来说没问题,但对室内的墙壁就错了——站在房间角落看地板上墙的倒影,与站在房间中心看到的倒影位置不同。盒体投影(Box Projection)用一个简单的几何修正解决它:假设环境是探针影响盒的六个面,从着色点沿反射方向射出,求它打到盒子的哪一点 H,然后用“从探针中心看向 H”的方向去采样 Cubemap。

探针影响盒(假设环境就在盒子的六个面上) 探针中心 C 着色点 P(地板) 反射方向 R H:R 真正打到的点 不投影:从 C 沿 R 看→ 看到错误的位置 盒体投影:采样方向 = H − C
图 8-12盒体投影的几何意义(侧视图)。不投影时,Cubemap 按“从探针中心沿 R 看”取值,看到的是 C 正上方偏右的天花板;盒体投影先求出 R 与盒子的交点 H,再用 H − C 作为采样方向。
摘自 URP 14 ShaderLibrary/GlobalIllumination.hlsl
half3 BoxProjectedCubemapDirection(half3 reflectionWS, float3 positionWS, float4 cubemapPositionWS, float4 boxMin, float4 boxMax)
{
    // Is this probe using box projection?
    if (cubemapPositionWS.w > 0.0f)
    {
        float3 boxMinMax = (reflectionWS > 0.0f) ? boxMax.xyz : boxMin.xyz;
        half3 rbMinMax = half3(boxMinMax - positionWS) / reflectionWS;
        half fa = half(min(min(rbMinMax.x, rbMinMax.y), rbMinMax.z));
        half3 worldPos = half3(positionWS - cubemapPositionWS.xyz);
        half3 result = worldPos + reflectionWS * fa;
        return result;
    }
    else
    {
        return reflectionWS;
    }
}

这是经典的“射线与轴对齐盒求交”:沿 R 的每个分量,看它朝正方向就和 boxMax 求交、朝负方向就和 boxMin 求交,得到三个距离,取最小的那个(最先碰到的面)。worldPos + R × fa 是交点相对探针中心的向量,即新的采样方向。cubemapPositionWS.w 的符号表示该探针是否勾选了 Box Projection;而 Shader 里是否编译这段代码,由 URP Asset 的 Lighting › Reflection Probes › Box Projection 控制的 _REFLECTION_PROBE_BOX_PROJECTION 关键字决定——两处都要打开。