GameTech/URP 渲染实战

案例 21.4极坐标漩涡传送门进阶

目标:用一个 Quad 做出旋转、吸入的传送门漩涡,并修掉 atan2 带来的接缝。

把 Quad 的 UV 从直角坐标换成极坐标:角度 θ = atan2(y, x) 归一化到 0..1,半径 r = 中心距离 × 2(四边中点为 1)。之后所有“UV 动画”都在极坐标里做:

Assets/Cookbook/Ch21/UVAnim.hlsl(节选:polar)
// 直角坐标 → 极坐标:x = 角度(0..1,一圈),y = 半径(中心 0,四边中点 1)
float2 ToPolar(float2 uv, float2 center)
{
    float2 d = uv - center;
    float angle = atan2(d.y, d.x) * (0.5 * INV_PI) + 0.5;
    float radius = length(d) * 2.0;
    return float2(angle, radius);
}
// 直角坐标 → 极坐标:x = 角度(0..1,一圈),y = 半径(中心 0,四边中点 1)
vec2 ToPolar(vec2 uv, vec2 center)
{
    vec2 d = uv - center;
    float angle = atan(d.y, d.x) * (0.5 * INV_PI) + 0.5;
    float radius = length(d) * 2.0;
    return vec2(angle, radius);
}
Assets/Cookbook/Ch21/Portal.shader(节选:swirl)
float2 SwirlUV(float2 polar, float t, float spin)
{
    // x:角度 × 臂数(必须是整数才能首尾相接)+ 半径 × 扭转(越往里转得越多)+ 时间旋转
    // y:半径 × 平铺 − 时间(图案不断向中心流)
    return float2(polar.x * _Arms + polar.y * _Twist + t * spin,
                  polar.y * _RadialTiling - t * _Inward);
}

// 带接缝修正的采样:atan2 在 ±π 处从 1 跳回 0,硬件按 2×2 像素块求导会得到巨大的 ddx,
// 于是选中最低一级 mip,在接缝上画出一条线。修正:另算一份“接缝挪到对面”的角度,取较小的导数
half SampleSwirl(float2 polar, float t, float spin)
{
    float2 uv = SwirlUV(polar, t, spin);
    float2 dx = ddx(uv), dy = ddy(uv);
    if (_FixSeam > 0.5)
    {
        float2 uvAlt = SwirlUV(float2(frac(polar.x + 0.5), polar.y), t, spin);
        float2 dxAlt = ddx(uvAlt), dyAlt = ddy(uvAlt);
        dx.x = abs(dx.x) < abs(dxAlt.x) ? dx.x : dxAlt.x;
        dy.x = abs(dy.x) < abs(dyAlt.x) ? dy.x : dyAlt.x;
    }
    return SAMPLE_TEXTURE2D_GRAD(_NoiseTex, sampler_NoiseTex, uv, dx, dy).r;
}
vec2 SwirlUV(vec2 polar, float t, float spin)
{
    // x:角度 × 臂数(必须是整数才能首尾相接)+ 半径 × 扭转(越往里转得越多)+ 时间旋转
    // y:半径 × 平铺 − 时间(图案不断向中心流)
    return vec2(polar.x * _Arms + polar.y * _Twist + t * spin,
                polar.y * _RadialTiling - t * _Inward);
}

// 带接缝修正的采样:atan2 在 ±π 处从 1 跳回 0,硬件按 2×2 像素块求导会得到巨大的 ddx,
// 于是选中最低一级 mip,在接缝上画出一条线。修正:另算一份“接缝挪到对面”的角度,取较小的导数
float SampleSwirl(vec2 polar, float t, float spin)
{
    vec2 suv = SwirlUV(polar, t, spin);
    vec2 dx = dFdx(suv), dy = dFdy(suv);
    if (_FixSeam > 0.5)
    {
        vec2 uvAlt = SwirlUV(vec2(fract(polar.x + 0.5), polar.y), t, spin);
        vec2 dxAlt = dFdx(uvAlt), dyAlt = dFdy(uvAlt);
        dx.x = abs(dx.x) < abs(dxAlt.x) ? dx.x : dxAlt.x;
        dy.x = abs(dy.x) < abs(dyAlt.x) ? dy.x : dyAlt.x;
    }
    return textureGrad(_NoiseTex, suv, dx, dy).r;
}

角度乘以 _Arms 后作为 U:贴图是 Repeat 寻址,U 从 0 走到整数 n,正好把贴图横向平铺 n 次首尾相接,于是得到 n 条旋臂。_Arms 如果不是整数,在 θ 回到起点的地方图案对不上,会出现一条明显的断缝。

atan2 的接缝

即使 _Arms 是整数、颜色本身连续,仍可能在 θ = ±π 处(Quad 左侧那条水平半径上)看到一条细线。原因在 mipmap:GPU 以 2×2 像素块为单位并行着色,用块内相邻像素的 UV 差(ddx/ddy)估计“一个像素覆盖多少纹素”,据此选择 mip 级别。接缝两侧的 θ 一个接近 1、一个接近 0,差值巨大,硬件以为这个像素覆盖了整张贴图,于是选中了最小的一级 mip——一个接近平均灰的值。

修正方法是自己提供导数:另算一份“把接缝挪到对面”的角度 frac(θ + 0.5),它在原接缝处是连续的;两份导数里取绝对值较小的那个,用 SAMPLE_TEXTURE2D_GRAD 采样。核心代码就是上面 SampleSwirl 里的几行。

Assets/Cookbook/Ch21/Portal.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    float2 polar = ToPolar(i.uv, float2(0.5, 0.5));
    float t = _Time.y + _Phase;

    if (_Debug > 0.5 && _Debug < 1.5) return half4(polar.x, polar.y, 0, 1);
    if (_Debug > 1.5 && _Debug < 2.5) return half4(frac(SwirlUV(polar, t, _Spin)), 0, 1);
    // 只看一层漩涡噪声并二值化:接缝处取到最低级 mip(≈ 平均灰),在黑白图案里显出一道灰线
    if (_Debug > 3.5) { float2 u = SwirlUV(polar, t, _Spin); float d = max(length(ddx(u)), length(ddy(u))); return half4(saturate(log2(d * 512.0) / 10.0).xxx, 1); }
    if (_Debug > 2.5) return half4(smoothstep(0.5, 0.7, SampleSwirl(polar, t, _Spin)).xxx, 1);

    // 两层速度不同的漩涡相乘,图案更“活”
    half n1 = SampleSwirl(polar, t, _Spin);
    half n2 = SampleSwirl(polar * float2(1, 0.7), t * 1.3, -_Spin * 0.6);
    half swirl = pow(saturate(n1 * n2 * 2.0), _Contrast);

    half r = polar.y;
    half disc = smoothstep(1.0, 0.8, r);                     // 外缘淡出
    half rim  = exp(-pow((r - 0.86) * 9.0, 2.0));            // 外圈一道亮环
    half core = exp(-r * r * 18.0);                          // 中心光核
    half3 col = lerp(_ColorInner.rgb, _ColorOuter.rgb, saturate(r * 1.2));
    half3 result = col * (swirl * disc + rim * 0.8) + _ColorInner.rgb * core;
    return half4(result, 1);
}
vec4 frag()
{
    vec2 polar = ToPolar(uv, vec2(0.5, 0.5));
    float t = _Time.y + _Phase;

    if (_Debug > 0.5 && _Debug < 1.5) return vec4(polar.x, polar.y, 0.0, 1.0);
    if (_Debug > 1.5 && _Debug < 2.5) return vec4(fract(SwirlUV(polar, t, _Spin)), 0.0, 1.0);
    // 只看一层漩涡噪声并二值化:接缝处取到最低级 mip(≈ 平均灰),在黑白图案里显出一道灰线
    if (_Debug > 3.5) { vec2 u = SwirlUV(polar, t, _Spin); float d = max(length(dFdx(u)), length(dFdy(u))); return vec4(vec3(saturate(log2(d * 512.0) / 10.0)), 1.0); }
    if (_Debug > 2.5) return vec4(vec3(smoothstep(0.5, 0.7, SampleSwirl(polar, t, _Spin))), 1.0);

    // 两层速度不同的漩涡相乘,图案更“活”
    float n1 = SampleSwirl(polar, t, _Spin);
    float n2 = SampleSwirl(polar * vec2(1, 0.7), t * 1.3, -_Spin * 0.6);
    float swirl = pow(saturate(n1 * n2 * 2.0), _Contrast);

    float r = polar.y;
    float disc = smoothstep(1.0, 0.8, r);                     // 外缘淡出
    float rim  = exp(-pow((r - 0.86) * 9.0, 2.0));            // 外圈一道亮环
    float core = exp(-r * r * 18.0);                          // 中心光核
    vec3 col = mix(_ColorInner.rgb, _ColorOuter.rgb, saturate(r * 1.2));
    vec3 result = col * (swirl * disc + rim * 0.8) + _ColorInner.rgb * core;
    return vec4(result, 1.0);
}
图 21-7左一:极坐标 UV(R = 角度、G = 半径),左侧水平线就是 θ 从 1 跳回 0 的位置;左二:加上扭转与时间偏移后的漩涡 UV;右二:UV 导数的大小(越亮越大),接缝处出现一条亮线,中心处导数也趋于无穷;右一:最终的漩涡
图 21-8接缝特写(只显示一层漩涡噪声并二值化)。左:直接采样,左侧水平方向上有一道灰线;右:用 SAMPLE_TEXTURE2D_GRAD 传入修正后的导数,接缝消失

还有一个有趣的现象:这条接缝在屏幕上时隐时现。如果接缝恰好落在两个 2×2 像素块的分界线上,块内的像素都在接缝同一侧,导数正常,就看不到线。拍图 21-7 时我们特意把 Quad 向上移了 1 个像素才让它显形——这也是为什么这类 bug 经常“在我机器上没有”。中心点是另一个奇点:所有角度在那里汇聚,导数同样趋于无穷,实际制作中用一个中心光核盖住即可。

图 21-9传送门成品:极坐标漩涡(叠加混合,HDR 颜色)+ 外圈亮环 + 中心光核,开启 Bloom 与 ACES 色调映射。石环用的是普通 Lit 材质,紫色点光模拟传送门照亮周围
叠加混合的好处传送门 Pass 使用 Blend One One:结果只会让背景变亮,多个发光效果重叠时与绘制顺序无关,不需要排序,也不写深度。代价是它无法让背景变暗——要做“黑洞”式的暗色漩涡,就得改用 Alpha 混合或正片叠底,并处理排序问题(见第 16 章)。