URP 渲染实战 / 第 20 章 地形与植被 / 案例 20.2
案例 20.2三平面映射 Triplanar入门
目标:让贴图在任意朝向的表面上都不拉伸,并理解它的混合权重和代价。
三平面映射(Triplanar Mapping)的思路很朴素:既然单方向投影只在“正对投影方向”的表面上好看,那就沿世界的 X、Y、Z 三个轴各投影一次,再按表面法线决定每个像素主要用哪一张。X 方向的投影用 (z, y) 当 UV,Y 方向用 (x, z),Z 方向用 (x, y)。法线越接近某个轴,那个轴的投影就越“正”,权重也就越大:
归一化保证三个权重之和为 1,亮度不会在过渡区忽明忽暗;指数 k(代码里的 _Sharpness)控制过渡带的宽窄。k = 1 时,45° 斜面上两张投影各占一半,两套图案叠在一起很容易看出“重影”;k 越大,过渡越窄,大部分区域只用一张投影,代价是过渡处更硬。URP 的 core 库里也有一个 ComputeTriplanarWeights(CommonMaterial.hlsl),它先减去 0.2 再取三次方,效果相当于一个固定的较硬设置。
Assets/Cookbook/Ch20/TerrainCommon.hlsl(节选:triplanar)
// 三个投影方向的混合权重:|N| 的 sharpness 次方再归一化。 // sharpness 越大,过渡带越窄;1 时过渡最宽,45° 斜面上能看到两张图叠在一起。 float3 TriplanarWeights(float3 normalWS, float sharpness) { float3 w = pow(abs(normalWS), sharpness); return w / max(dot(w, 1.0), 1e-5); } struct TriplanarUV { float2 x; // 沿 X 轴投影:用 (z, y) float2 y; // 沿 Y 轴投影:用 (x, z) float2 z; // 沿 Z 轴投影:用 (x, y) }; TriplanarUV GetTriplanarUV(float3 positionWS, float3 normalWS, float scale) { TriplanarUV t; float3 p = positionWS * scale; t.x = p.zy; t.y = p.xz; t.z = p.xy; // 背面方向的投影会左右镜像(文字、箭头会反),按法线符号把 U 翻回来 float3 s = normalWS < 0 ? -1.0 : 1.0; t.x.x *= s.x; t.y.x *= s.y; t.z.x *= -s.z; return t; } half4 SampleTriplanar(TEXTURE2D_PARAM(tex, samp), TriplanarUV uv, float3 w) { half4 cx = SAMPLE_TEXTURE2D(tex, samp, uv.x); half4 cy = SAMPLE_TEXTURE2D(tex, samp, uv.y); half4 cz = SAMPLE_TEXTURE2D(tex, samp, uv.z); return cx * w.x + cy * w.y + cz * w.z; }
// 三个投影方向的混合权重:|N| 的 sharpness 次方再归一化。 // sharpness 越大,过渡带越窄;1 时过渡最宽,45° 斜面上能看到两张图叠在一起。 vec3 TriplanarWeights(vec3 normalWS, float sharpness) { vec3 w = pow(abs(normalWS), vec3(sharpness)); return w / max(dot(w, vec3(1.0)), 1e-5); } struct TriplanarUV { vec2 x; // 沿 X 轴投影:用 (z, y) vec2 y; // 沿 Y 轴投影:用 (x, z) vec2 z; // 沿 Z 轴投影:用 (x, y) }; // HLSL 的 normalWS < 0 ? -1.0 : 1.0(逐分量) vec3 Ch20AxisSign(vec3 n) { return mix(vec3(1.0), vec3(-1.0), vec3(lessThan(n, vec3(0.0)))); } TriplanarUV GetTriplanarUV(vec3 positionWS, vec3 normalWS, float scale) { TriplanarUV t; vec3 p = positionWS * scale; t.x = p.zy; t.y = p.xz; t.z = p.xy; // 背面方向的投影会左右镜像(文字、箭头会反),按法线符号把 U 翻回来 vec3 s = Ch20AxisSign(normalWS); t.x.x *= s.x; t.y.x *= s.y; t.z.x *= -s.z; return t; } // GLSL 的采样器可以直接当函数参数(对应 HLSL 的 TEXTURE2D_PARAM(tex, samp)) vec4 SampleTriplanar(sampler2D tex, TriplanarUV uv, vec3 w) { vec4 cx = texture(tex, uv.x); vec4 cy = texture(tex, uv.y); vec4 cz = texture(tex, uv.z); return cx * w.x + cy * w.y + cz * w.z; }
代码里有一处容易忽略的细节:背面投影的镜像。沿 +X 看过去和沿 −X 看过去,同一组 (z, y) 坐标在屏幕上的左右方向是相反的;草、石头这类无方向纹理看不出来,但文字、箭头、砖缝的错位就会暴露。GetTriplanarUV 按法线符号把对应投影的 U 翻转回来,Z 方向则是在正面翻转(因为相机从 −Z 看向 +Z 面时,世界 +X 在画面左侧)。


代价与取舍
- 采样数 ×3。每张贴图要采三次,带法线就是 6 次。地形上常见的优化是:权重小于某个阈值的投影直接跳过(动态分支),或者只对“岩石”这类出现在陡坡上的层做 Triplanar,草地、雪用一次顶视采样——20.4 节就是这样做的。
- 世界空间 vs 物体空间。用世界坐标投影的物体一旦移动,纹理会在表面上“游泳”。地形、建筑这类静态物体用世界空间;会动的道具应改用物体空间坐标和物体空间法线做投影。
- Mipmap 与各向异性。三次投影的 UV 导数各不相同,硬件按各自的导数选 mip,这是正确的;但过渡区两张图的模糊程度不同,远处偶尔能看到一道“清晰度接缝”,适当提高 k 就能缓解。
本案例出自《URP 渲染实战》第 20 章「地形与植被」。在章节中阅读上下文 →