案例 8.7自定义环境反射 Cubemap进阶
目标:不用天空盒,而是用自己生成的 Cubemap 作为环境反射(例如产品展示用的“摄影棚”环境、黄昏的天空),并让环境光、背景与反射保持一致。
在编辑器里的做法:导入一张 Cubemap(HDR 全景图在导入设置里选 Texture Shape = Cube),把 设为 Specular (Glossy Reflection),Unity 会为它生成按粗糙度预过滤的 mip 链;然后在 Lighting 窗口把 Environment Reflections 的 Source 改为 Custom 并指定它。在代码里,对应的是 RenderSettings.customReflectionTexture。
我们在 C# 里从一个环境函数直接生成 Cubemap,并自己完成预过滤:每一级 mip 先由 URP 的曲线反解出对应的粗糙度,再对每个纹素方向按 GGX 分布做重要性采样、按 N·L 加权平均(这正是 Split Sum 第一项的标准做法,假设 N = V = R):
/// 生成 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。

金属对环境的依赖远大于非金属:中图里镜面金属纽结几乎是全黑的,只有柔光箱的倒影——它没有漫反射,“周围有什么”就是它的全部外观。这也是为什么做产品展示、角色展示时,环境贴图的选择和直接光同等重要。中图金色球上略显斑驳的反射来自运行时预过滤的采样数有限(每纹素 160 个样本),正式资源应当使用离线工具或 Unity 导入器生成的高质量预过滤结果。
探针混合与盒体投影
反射探针(Reflection Probe)就是摆在场景里的一个“球形摄像机”,在它的位置拍一张 Cubemap。物体使用哪个探针,由物体与探针的影响盒(Box Size)的相交关系决定。两个探针重叠时,URP 的混合权重来自 CalculateProbeWeight:物体位置到盒子各个面的距离,除以 Blend Distance,取最小值——越靠近盒子中心权重越接近 1,越靠近边缘越接近 0。探针的 Importance 决定谁“优先”:重要性高的先拿权重,剩下的才给另一个;重要性相同时,体积较小的那个优先(更小的探针通常更“局部”,更准确)。
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。
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 的 控制的 _REFLECTION_PROBE_BOX_PROJECTION 关键字决定——两处都要打开。
本案例出自《URP 渲染实战》第 8 章「环境光、光照贴图与反射探针」。在章节中阅读上下文 →