案例 21.4极坐标漩涡传送门进阶
目标:用一个 Quad 做出旋转、吸入的传送门漩涡,并修掉 atan2 带来的接缝。
把 Quad 的 UV 从直角坐标换成极坐标:角度 θ = atan2(y, x) 归一化到 0..1,半径 r = 中心距离 × 2(四边中点为 1)。之后所有“UV 动画”都在极坐标里做:
- 沿角度方向滚动 → 旋转;
- 沿半径方向滚动 → 向中心吸入或向外扩散;
- 角度加上
r × twist→ 越靠里转得越多,直线条纹变成螺旋臂。
// 直角坐标 → 极坐标: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); }
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 里的几行。
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); }


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

Blend One One:结果只会让背景变亮,多个发光效果重叠时与绘制顺序无关,不需要排序,也不写深度。代价是它无法让背景变暗——要做“黑洞”式的暗色漩涡,就得改用 Alpha 混合或正片叠底,并处理排序问题(见第 16 章)。