URP 渲染实战 / 第 21 章 UV 动画与粒子 / 案例 21.3
案例 21.3Flowmap:沿任意方向流动进阶
目标:让贴图沿一张“流向图”规定的方向流动,绕过石头、在漩涡处打转,并且没有周期性的跳变。
UV 滚动只能整体沿一个方向流。Flowmap 用一张贴图存下每个位置的流速向量:R、G 两个通道分别是 x、y 方向的速度,映射到 0..1 存储,0.5 表示静止。Shader 里先解码回 −1..1,再用它代替常数速度:uv − flow × t。
问题马上出现:不同位置的速度不同,随着 t 增大,相邻像素的 UV 偏移差越来越大,图案被越拉越长,最终完全变形。解决办法是让 t 只在一个周期内增长——frac(t)——周期结束时 UV 跳回原位。拉伸被限制住了,但每个周期末尾图案会“啪”地复位。
双相位(Dual Phase)消除这个跳变:用两套相位相差半个周期的 UV 各采样一次,权重随相位呈三角波:某一套 UV 即将复位的时刻,它的权重恰好为 0,此时画面完全由另一套提供;复位之后它的权重再慢慢升上来。
Assets/Cookbook/Ch21/UVAnim.hlsl(节选:flowmap)
// Flowmap 双相位采样:两套相位差半个周期的 UV,各自沿流向“漂”,权重呈三角波交替。 // 某一相位重置(跳回原位)的瞬间,它的权重恰好为 0,所以看不到跳变。 struct FlowSample { float2 uv0, uv1; float w0; // 第一套的权重,第二套为 1 - w0 }; FlowSample FlowUV(float2 uv, float2 flow, float time) { FlowSample s; float p0 = frac(time); float p1 = frac(time + 0.5); s.uv0 = uv - flow * p0; s.uv1 = uv - flow * p1 + float2(0.37, 0.21); // 第二套错开一点,避免两层图案重合时出现“重影节拍” s.w0 = 1.0 - abs(1.0 - 2.0 * p0); // p0 = 0 或 1(重置时)权重为 0,p0 = 0.5 时为 1 return s; }
// Flowmap 双相位采样:两套相位差半个周期的 UV,各自沿流向“漂”,权重呈三角波交替。 // 某一相位重置(跳回原位)的瞬间,它的权重恰好为 0,所以看不到跳变。 struct FlowSample { vec2 uv0, uv1; float w0; // 第一套的权重,第二套为 1 - w0 }; FlowSample FlowUV(vec2 uv, vec2 flow, float time) { FlowSample s; float p0 = fract(time); float p1 = fract(time + 0.5); s.uv0 = uv - flow * p0; s.uv1 = uv - flow * p1 + vec2(0.37, 0.21); // 第二套错开一点,避免两层图案重合时出现“重影节拍” s.w0 = 1.0 - abs(1.0 - 2.0 * p0); // p0 = 0 或 1(重置时)权重为 0,p0 = 0.5 时为 1 return s; }
第二套 UV 额外加了一个小偏移 (0.37, 0.21):如果两套 UV 采到的图案完全相同,只是错开半周期,人眼仍能察觉到画面每半个周期“呼吸”一次;错开采样位置后,这种规律性被打散。
Assets/Cookbook/Ch21/Flowmap.shader(节选:frag)
half4 frag(Varyings i) : SV_Target { half4 flowTex = SAMPLE_TEXTURE2D(_FlowMap, sampler_FlowMap, i.uv); if (_Mode < 0.5) return half4(flowTex.rg, 0, 1); // 直接显示流向图 // RG 存的是 0..1,映射回 -1..1 的方向向量(0.5 灰 = 静止) float2 flow = (flowTex.rg * 2.0 - 1.0) * _FlowStrength; float t = (_Time.y + _Phase) * _Speed; float2 uv = TRANSFORM_TEX(i.uv, _BaseMap); half4 c; if (_Mode < 1.5) { // 单相位:UV 沿流向越漂越远,到 frac 归零时整张图“啪”地跳回原位 c = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv - flow * frac(t)); } else { FlowSample fs = FlowUV(uv, flow, t); half4 c0 = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, fs.uv0); half4 c1 = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, fs.uv1); c = c0 * fs.w0 + c1 * (1.0 - fs.w0); } if (_UseRamp > 0.5) { half k = smoothstep(0.2, 0.9, c.r); c.rgb = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(k, 0.5)).rgb * (k * k * _Intensity); } return half4(c.rgb, 1); }
vec4 frag() { vec4 flowTex = texture(_FlowMap, uv); if (_Mode < 0.5) return vec4(flowTex.rg, 0.0, 1.0); // 直接显示流向图 // RG 存的是 0..1,映射回 -1..1 的方向向量(0.5 灰 = 静止) vec2 flow = (flowTex.rg * 2.0 - 1.0) * _FlowStrength; float t = (_Time.y + _Phase) * _Speed; vec2 uvB = TransformTex(uv, _BaseMap_ST); vec4 c; if (_Mode < 1.5) { // 单相位:UV 沿流向越漂越远,到 fract 归零时整张图“啪”地跳回原位 c = texture(_BaseMap, uvB - flow * fract(t)); } else { FlowSample fs = FlowUV(uvB, flow, t); vec4 c0 = texture(_BaseMap, fs.uv0); vec4 c1 = texture(_BaseMap, fs.uv1); c = c0 * fs.w0 + c1 * (1.0 - fs.w0); } if (_UseRamp > 0.5) { float k = smoothstep(0.2, 0.9, c.r); c.rgb = texture(_RampTex, vec2(k, 0.5)).rgb * (k * k * _Intensity); } return vec4(c.rgb, 1.0); }

双相位的叠影在网格这种规则图案上很明显,在水面、熔岩这种随机纹理上则几乎看不见。所以 Flowmap 主要用于噪声类、法线类贴图:

深入:为什么是三角波权重两套采样的权重之和必须恒为 1,否则整体亮度会随相位起伏;同时每套的权重必须在它复位的瞬间为 0。三角波
1 − |1 − 2p| 与错开半周期的另一条三角波相加恰好处处等于 1,满足这两个条件且最便宜。也可以用 sin²(πp) 与 cos²(πp),过渡更圆滑,但要多算三角函数。无论哪种,混合两张不相关的噪声都会让对比度在 w₀ = 0.5 附近下降(两张图平均后方差减半),画面会有轻微的“周期性变灰”,相位错开采样位置、并在混合后略微提高对比度可以缓解。坑:Flowmap 不能压缩成普通格式流向图是数据,必须关闭 sRGB;更重要的是不要用 DXT1/ETC 这类按 4×4 块压缩的格式——平滑的方向场会被压成块状台阶,流动时能看到一格一格的“抖动”。用 BC5(双通道)、BC7,或者干脆不压缩。本章配图为此在拍摄时直接读取 PNG 生成了一张不压缩的贴图。
参数与制作
_FlowStrength 决定一个周期内最多漂多远,_Speed 决定周期多长;两者的乘积才是视觉上的流速。强度太大会让每个周期内的拉伸重新变得明显,一般控制在贴图平铺的 0.1–0.4 之间。流向图本身可以用专门的流向绘制工具画,也可以从流体模拟或河道样条线烘焙出来;在 Houdini、Substance Designer 一类工具里都能生成。水面法线的 Flowmap 用法见第 19 章。