Unity Shader极坐标特效:从漩涡扭曲到雷达扫描的实战指南

Unity Shader极坐标特效:从漩涡扭曲到雷达扫描的实战指南 1. 项目概述当极坐标遇上Unity Shader如果你玩过《纪念碑谷》或者《INSIDE》一定对其中那些扭曲、旋转却又充满秩序感的视觉特效印象深刻。很多看似复杂的空间扭曲效果其背后都藏着一个简洁而强大的数学工具——极坐标。今天我们不谈高深的理论就从一行代码、一个公式开始聊聊怎么在Unity Shader里用极坐标玩出花样把普通的贴图、模型变成充满艺术感和动态感的视觉奇观。这不仅仅是写一个特效更是理解如何将数学思维转化为可视化创作的过程无论是想做风格化的UI、扭曲的场景过渡还是独特的角色技能极坐标都能给你带来意想不到的灵感。简单来说极坐标特效的核心就是把我们熟悉的屏幕直角坐标系X, Y转换成用距离和角度来描述位置的极坐标系半径r 角度θ。这个转换本身并不复杂但正是这种视角的切换打开了特效创作的一扇新大门。在Unity Shader中实现它你不需要是数学天才只需要理解几个关键步骤就能让画面“动”起来。接下来我会带你从最基础的数学原理拆解开始一步步构建可复用的极坐标变换函数并实战几个从简单到进阶的案例比如实现一个动态的漩涡扭曲效果、一个放射状的扫描雷达甚至是一个风格化的极坐标万花筒。我们会避开那些华而不实的理论堆砌专注于“怎么写”和“为什么这么写”确保你看完就能动手做出东西来。2. 核心数学原理与坐标系转换拆解在动手写Shader之前我们必须先打好地基彻底搞明白直角坐标和极坐标之间到底是怎么“对话”的。这部分理解透了后面所有的特效创作都会变得顺理成章。2.1 直角坐标 vs. 极坐标视觉空间的两种语言我们最熟悉的屏幕空间或纹理空间通常使用直角坐标系。一个像素点的位置由水平方向的X和垂直方向的Y唯一确定就像地图上的经纬度网格。这种坐标系非常直观适合处理矩形区域和直线运动。而极坐标系则采用了完全不同的描述方式。它用一个点到原点的距离半径通常用r表示和该点与正X轴之间的夹角角度通常用θ或phi表示来定位一个点。你可以把它想象成雷达屏幕雷达扫描线绕中心旋转扫描线长度就是半径旋转的角度就是θ。在Shader中我们通常处理的是归一化的坐标即范围在[0, 1]或[-1, 1]的坐标。假设我们有一个中心点在(0.5, 0.5)的纹理对于纹理上的任意一点uv(其范围是[0,1])我们首先会将其偏移使中心点移动到(0, 0)。计算过程如下float2 center float2(0.5, 0.5); float2 uvOffset uv - center; // 现在坐标原点在纹理中心接下来就是核心的转换公式直角坐标转极坐标半径r length(uvOffset)。length函数计算向量的模即点到中心的距离。角度θ atan2(uvOffset.y, uvOffset.x)。atan2函数是Shader中的神器它接受y和x两个参数能返回一个范围在[-π, π]即[-3.14159, 3.14159]的角度值完美处理所有象限的情况。极坐标转回直角坐标x r * cos(θ)y r * sin(θ)转换回来后别忘了把坐标原点再加回center变回原始的纹理空间。注意atan2返回的角度范围是[-π, π]。有时为了便于处理比如用作纹理V坐标我们会将其归一化到[0, 1]区间theta atan2(y, x) / (2 * PI) 0.5。2.2 在Shader中构建极坐标变换函数理解了数学原理我们就可以在Unity Shader中将其封装成可复用的函数。一个健壮的极坐标变换函数通常需要考虑更多细节比如控制扭曲的强度、定义扭曲的中心点等。下面是一个功能更全面的极坐标扭曲函数示例它接收一个UV坐标、一个中心点、一个扭曲强度参数并返回扭曲后的UV// 将直角坐标UV转换为极坐标并进行扭曲再转换回直角坐标UV float2 PolarCoordinateTransform(float2 uv, float2 center, float strength) { // 1. 将UV原点移至中心点 float2 delta uv - center; // 2. 直角坐标转极坐标 float radius length(delta); float angle atan2(delta.y, delta.x); // 角度范围[-PI, PI] // 3. 在极坐标空间进行扭曲操作这里是一个简单的角度扭曲示例 // 扭曲强度strength控制角度附加值与半径的关系 angle strength * radius; // 4. 极坐标转回直角坐标 float2 distortedDelta; distortedDelta.x radius * cos(angle); distortedDelta.y radius * sin(angle); // 5. 将坐标原点移回 float2 distortedUV distortedDelta center; return distortedUV; }这个函数是许多极坐标特效的基石。你可以看到核心的扭曲操作发生在第3步angle strength * radius。这意味着扭曲的幅度与点到中心的距离成正比。离中心越远的点角度增加得越多从而产生了一种“漩涡”式的扭曲效果。如果strength是负值漩涡方向就会相反。如果strength是一个随时间变化的量你就能得到一个动态旋转的漩涡。实操心得在编写这类函数时务必注意数值范围。atan2返回的角度值可能很大直接用于sin/cos计算没问题但如果你需要将其作为纹理坐标比如用于采样一个环形贴图就必须进行归一化处理。另外length计算在片段着色器中虽然方便但性能上稍逊于手动计算平方dot(delta, delta)在极端性能敏感的场景下可以优化但绝大多数情况下length的简洁性更值得推荐。3. 实战案例一动态漩涡扭曲特效现在我们运用上面构建的工具来实现第一个也是最经典的极坐标特效——动态漩涡。这个特效常用于表现空间扭曲、传送门、能量汇聚等场景。3.1 Shader代码实现与逐行解析我们将创建一个Unlit Shader对一张输入的主纹理进行漩涡扭曲。关键点在于让扭曲强度_Strength随时间变化并控制漩涡的作用范围。Shader Custom/PolarVortex { Properties { _MainTex (Main Texture, 2D) white {} _Center (Vortex Center, Vector) (0.5, 0.5, 0, 0) // 中心点XY _Strength (Twist Strength, Range(-5, 5)) 1.0 _Speed (Rotation Speed, Float) 1.0 _Radius (Effect Radius, Range(0, 2)) 1.0 _Feather (Edge Feather, Range(0, 1)) 0.2 } SubShader { Tags { RenderTypeOpaque } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; float2 _Center; float _Strength; float _Speed; float _Radius; float _Feather; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } // 改进版的极坐标扭曲函数增加了半径控制和羽化 float2 PolarVortexUV(float2 uv, float2 center, float strength, float radius, float feather) { float2 delta uv - center; float dist length(delta); // 计算基于距离的衰减因子 float attenuation smoothstep(radius, radius * (1.0 - feather), dist); // smoothstep: 当distradius时结果为0当distradius*(1-feather)时结果为1中间平滑过渡 float r dist; float theta atan2(delta.y, delta.x); // 核心扭曲公式角度附加值与半径和衰减因子相关 theta strength * attenuation * r; float2 distortedDelta; distortedDelta.x r * cos(theta); distortedDelta.y r * sin(theta); return distortedDelta center; } fixed4 frag (v2f i) : SV_Target { // 让强度随时间变化产生动态旋转效果 float timeFactor _Time.y * _Speed; float dynamicStrength _Strength * sin(timeFactor * 0.5); // 使用sin让强度有正负变化效果更生动 // 计算扭曲后的UV float2 distortedUV PolarVortexUV(i.uv, _Center, dynamicStrength, _Radius, _Feather); // 采样纹理 fixed4 col tex2D(_MainTex, distortedUV); return col; } ENDCG } } }3.2 参数详解与视觉调节技巧这个Shader暴露了多个参数每个参数都控制着视觉表现的一个方面理解它们才能调出想要的效果_Center (漩涡中心)默认(0.5,0.5)是纹理中心。你可以将其绑定到屏幕的某个位置如鼠标位置实现交互式漩涡。_Strength (扭曲强度)正值产生逆时针漩涡负值产生顺时针漩涡。绝对值越大扭曲越剧烈。我们将其与sin(_Time.y)结合让强度周期性变化漩涡就会时而正向旋转时而反向旋转甚至暂停比单纯单向旋转更有“呼吸感”。_Speed (速度)控制_Time.y的乘数调整动画快慢。_Radius (作用半径)这是控制特效范围的关键。只有距离中心小于此半径的像素才会被扭曲。将其设为1.0或大于1.0意味着整个纹理都可能被影响。_Feather (边缘羽化)这是提升效果真实感的灵魂参数。它定义了从“完全扭曲”到“完全不扭曲”的过渡区域宽度。通过smoothstep函数实现平滑过渡可以避免扭曲区域和非扭曲区域之间生硬的边界让特效看起来更自然、更融合。注意事项当扭曲强度非常大时离中心较远的像素可能会被“甩”到UV坐标范围之外比如小于0或大于1。采样器会根据Wrap Mode重复或钳制来处理。通常对于背景纹理我们设置为Repeat重复模式这样扭曲出去的部分会从另一边进来形成无限循环的图案有时能产生意想不到的迷幻效果。如果希望超出部分显示为黑色或边缘色可以将Wrap Mode设置为Clamp钳制并在Shader中采样后对UV进行判断如果any(distortedUV 0 || distortedUV 1)则返回一个固定颜色。4. 实战案例二雷达扫描与环形遮罩极坐标的另一个绝佳应用是创建雷达扫描效果。雷达的本质是在一个圆形区域内一条从中心发出的“扫描线”随时间旋转照亮其路径上的信息。4.1 极坐标下的扫描线算法实现扫描线的核心思路是在极坐标下我们很容易判断一个点是否位于某个角度范围内。我们将扫描线理解为一个从中心出发、具有一定角宽度的扇形区域。片段着色器中的逻辑如下将当前像素的UV转换为极坐标得到其角度theta归一化到[0,1]和半径r。定义一个随时间匀速增长的扫描角度scanAngle范围0到1。定义一个扫描宽度scanWidth比如0.05代表18度的扇形。判断当前像素的角度theta是否落在[scanAngle - scanWidth, scanAngle]这个区间内注意处理0和1的循环边界。如果是则混合上高亮颜色如绿色并根据距离中心的远近进行亮度衰减。fixed4 frag (v2f i) : SV_Target { float2 delta i.uv - _Center; float r length(delta); float theta atan2(delta.y, delta.x) / (2.0 * 3.1415926) 0.5; // 归一化到[0,1] // 计算扫描角度随时间循环 float scanAngle frac(_Time.y * _ScanSpeed); float scanWidth _ScanWidth; // 处理角度循环比较 float angleDiff theta - scanAngle; if (angleDiff -0.5) angleDiff 1.0; // 处理跨0点的情况 if (angleDiff 0.5) angleDiff - 1.0; // 判断是否在扫描区域内 float inScan (angleDiff -scanWidth angleDiff 0) ? 1.0 : 0.0; // 基础颜色例如从一张噪声图采样模拟雷达背景信息 fixed4 baseColor tex2D(_MainTex, i.uv); // 扫描线颜色带径向衰减 float falloff 1.0 - smoothstep(0.0, _Radius, r); // 距离越远扫描线越淡 fixed4 scanColor _ScanColor * inScan * falloff; // 混合扫描线使用叠加Additive混合模式更醒目 fixed4 finalColor baseColor scanColor; return finalColor; }4.2 结合深度与法线信息增强立体感一个高级的雷达效果不会只停留在平面。我们可以结合相机的深度纹理Depth Texture和法线纹理Normals Texture让扫描线在3D场景的物体表面“滑过”产生真实的体积光照感。这需要以下步骤在Shader中声明并使用UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture)和UNITY_DECLARE_SCREENSPACE_TEXTURE(_CameraNormalsTexture)。在片段着色器中通过ComputeScreenPos和SAMPLE_DEPTH_TEXTURE获取当前像素的深度值并利用LinearEyeDepth转换为线性视角空间深度。同样获取当前像素的法线信息。在计算扫描线亮度inScan时不仅考虑UV平面的角度还可以让扫描线的“高度”或“强度”受深度或法线影响。例如当扫描线经过一个凸起的物体法线朝向与扫描方向更一致时让它更亮一些。// 假设我们已经获取了视角空间深度depth和法线normal // 计算一个基于法线方向的增强因子 float3 scanDirection float3(cos(scanAngle * 2*PI), sin(scanAngle * 2*PI), 0); float normalEffect saturate(dot(normalize(normal.xyz), normalize(scanDirection))) * 0.5 0.5; // 将法线影响融入扫描线强度 float scanIntensity inScan * falloff * normalEffect; fixed4 scanColor _ScanColor * scanIntensity;这样扫描线扫过不同朝向的表面时亮度会有微妙变化立体感瞬间提升。这是将2D屏幕后处理与3D场景信息结合的经典思路。5. 实战案例三极坐标万花筒与风格化渲染极坐标天生适合创造对称和重复的图案这正是万花筒效果的原理。我们不再满足于扭曲一张图而是要创造全新的、基于极坐标分区的风格化图像。5.1 极坐标分区与对称映射万花筒效果的核心是“折叠”图像。在极坐标下我们将整个圆周360度平均分成N个扇区然后让所有扇区都显示同一个“源扇区”的内容。算法步骤计算当前像素的极坐标角度theta归一化到[0,1]。定义扇区数量_Sectors例如6。计算每个扇区占的角度比例sectorSize 1.0 / _Sectors。计算当前像素位于第几个扇区whichSector floor(theta / sectorSize)。计算当前像素在所在扇区内的“局部角度”localAngle frac(theta / sectorSize)。关键步骤我们将所有扇区的localAngle都映射回第一个扇区或任何一个指定的源扇区。即新的角度newTheta localAngle * sectorSize如果需要映射到第一个扇区。将newTheta和原来的半径r组合转换回直角坐标UV并采样纹理。这样原本分布在360度范围内的图像内容被压缩并重复到了每一个扇区形成了完美的径向对称图案。float2 KaleidoscopeUV(float2 uv, float2 center, int numSectors) { float2 delta uv - center; float r length(delta); float theta atan2(delta.y, delta.x) / (2.0 * PI); // 范围[-0.5, 0.5] theta 0.5; // 范围[0, 1] float sectorSize 1.0 / numSectors; float whichSector floor(theta / sectorSize); float localAngle frac(theta / sectorSize); // 映射到第一个扇区 float newTheta localAngle * sectorSize; // 也可以尝试映射到其他扇区比如newTheta (localAngle * 0.5) * sectorSize; 会产生镜像效果 // 将角度范围映射回[-0.5, 0.5]以供atan2的逆运算使用这里简化处理 newTheta newTheta - 0.5; // 极坐标转回直角坐标 float2 newDelta; newDelta.x r * cos(newTheta * 2.0 * PI); newDelta.y r * sin(newTheta * 2.0 * PI); return newDelta center; }5.2 融合颜色分级与后期处理单纯的对称图案可能显得有些单调。我们可以将极坐标万花筒作为渲染管线中的一个环节与其他后处理效果结合创造出强烈的风格化画面。颜色分区可以根据扇区索引whichSector对采样到的颜色进行不同的调色。例如奇数扇区去色偶数扇区增强饱和度或者每个扇区叠加不同的色调红、黄、蓝……。fixed3 hueShift HSVtoRGB(float3((whichSector / _Sectors), 1.0, 1.0)); // 假设有HSV转RGB函数 col.rgb lerp(col.rgb, col.rgb * hueShift, _ColorBlend); // 混合原始色和色调偏移色径向模糊与发光在万花筒效果之后可以施加一个以中心为原点的径向模糊Radial Blur。这会使图案的边缘产生拖尾光效增强动态感和迷幻感。实现径向模糊通常需要在片段着色器中沿当前像素到中心的方向进行多次采样并平均。与景深、色差等效果结合在Unity的后期处理栈Post Processing Stack中可以将极坐标万花筒效果作为一个自定义的渲染器MonoBehaviour中调用Graphics.Blit插入。让它处理完图像后再交给景深、泛光、色差等标准后处理效果最终画面的层次感和专业度会大幅提升。常见问题万花筒效果的接缝处可能出现颜色不连续或锯齿。解决方法有两个一是在扇区边界处localAngle接近0或1时进行非常轻微的混合更有效的方法是在采样源纹理时使用tex2D的导数指令ddx/ddy或直接使用tex2Dgrad手动传入更小的Mipmap级别确保在尖锐的图案边缘也有足够的纹理细节避免因Mipmap选择不当导致的模糊接缝。6. 性能优化与移动端适配要点极坐标计算涉及length、atan2、sin、cos等数学函数在片段着色器中每像素执行尤其是全屏后处理时对性能有一定压力。在移动端或低端PC上需要进行优化。6.1 计算优化策略预计算与查表如果扭曲的中心、强度、范围是固定的或者变化很慢可以考虑在顶点着色器或脚本中预计算一部分信息。例如将(uv - center)这个向量在顶点着色器中计算好通过TEXCOORD插值到片段着色器虽然插值的是向量但比在片段中做减法开销小。对于简单的、周期性的角度扭曲甚至可以预计算一个极坐标扭曲的“偏移向量图”Render Texture在片段着色器中直接采样这张图来获得扭曲后的UV偏移量用一次纹理采样代替多次复杂计算。简化运算length(delta)计算的是开方。如果后续操作只关心距离的平方比如比较半径可以直接用dot(delta, delta)避免开方。atan2是相对昂贵的函数。在某些特定情况下如果不需要精确的360度角度可以用近似值。例如对于小范围的扭曲角度近似等于delta.y / delta.x需处理x为0的情况。但这会牺牲效果质量需谨慎评估。sin和cos通常由硬件高效处理但如果你需要同时计算一个角度的正弦和余弦可以利用sincos函数它通常比分别调用sin和cos效率更高。float s, c; sincos(angle, s, c); // 现在s是sin(angle), c是cos(angle)降低渲染分辨率对于全屏后处理特效一个立竿见影的优化方法是使用半分辨率或四分之一分辨率渲染。在OnRenderImage中使用RenderTexture.GetTemporary创建一个小尺寸的RT将特效渲染到小RT上最后再用一个简单的双线性采样Graphics.Blit上屏。对于漩涡、模糊这类效果视觉损失很小但性能提升显著。6.2 针对移动端的精简方案移动端GPU如Adreno, Mali, PowerVR的ALU算术逻辑单元和带宽压力都需要考虑。精度选择在移动端尽量使用half或fixed精度在HLSL中对应min16float等。对于颜色值和范围在[-1,1]或[0,1]的UV坐标half精度通常足够且能提升运算速度减少功耗。half2 delta i.uv - (half2)_Center; half r length(delta); half theta atan2(delta.y, delta.x);避免分支移动端GPU对片段着色器中的动态分支if-else处理效率较低。尽量使用step()、saturate()、lerp()等函数来替代条件判断。例如前面雷达扫描中的inScan计算可以改写为float angleDiff theta - scanAngle; angleDiff frac(angleDiff 0.5) - 0.5; // 巧用frac处理循环替代两个if判断 float inScan step(-scanWidth, angleDiff) * step(angleDiff, 0.0); // 等同于 angleDiff -scanWidth angleDiff 0特效降级在低端设备上可以动态关闭或简化特效。例如通过#ifdef SHADER_API_MOBILE或#if defined(SHADER_API_GLES)来编写简化版本的着色器代码减少扇区数量、关闭法线增强、使用更简单的混合模式等。合批与Draw Call如果特效是应用在UI元素或场景中的Sprite上确保这些物体的材质能够合批。这意味着要尽可能减少Material Property Block的频繁设置使用相同的材质球和纹理。7. 常见问题排查与调试技巧实录即使理解了原理在实际编写和调试Shader时还是会遇到各种诡异的问题。这里记录了几个我踩过的坑和解决方法。7.1 扭曲失真与接缝问题问题描述漩涡扭曲的边缘出现不自然的拉伸、断裂或明显的接缝。排查思路UV越界这是最常见的原因。当扭曲强度过大时计算出的distortedUV可能远超出[0,1]范围。首先在Shader中输出distortedUV看看例如return fixed4(distortedUV, 0, 1);。如果出现大量黑色UV0或白色UV1区域说明越界了。解决采样前对UV进行钳制distortedUV saturate(distortedUV);。或者将主纹理的Wrap Mode设置为Repeat让超出的部分重复这有时能产生循环图案但可能不符合预期。精度问题在扭曲函数中反复的三角函数运算可能导致精度损失尤其在远离中心的位置。这在高强度扭曲下更明显。解决尝试使用更高精度的数据类型如将float改为double在Shader中不现实但可以确保传入的UV和中心点坐标是足够的精度。检查是否在不需要的地方使用了低精度变量。羽化过渡生硬如果smoothstep函数的参数设置不当羽化区域可能太窄或太宽导致边界突兀。解决将_Feather和_Radius参数可视化调试。可以临时让Shader返回衰减因子attenuation作为颜色return fixed4(attenuation.xxx, 1);观察其从中心到边缘的渐变是否平滑。7.2 动态效果卡顿与闪烁问题描述随时间变化的特效如旋转的漩涡出现卡顿、跳变或闪烁。排查思路时间变量不一致确保在片段着色器中使用的_Time.y是稳定的。_Time是Unity内置的随时间累加的变量。如果动画依赖于sin(_Time.y)那么帧率波动会导致每一帧_Time.y的增量不同从而造成动画速度不均匀感觉卡顿。解决对于需要匀速旋转的效果使用frac(_Time.y * _Speed)来计算一个循环的、线性的进度值而不是用sin。sin更适合做往复运动或脉动效果。纹理滤波与Mipmap动态扭曲可能导致像素在纹理上高速移动如果纹理的滤波模式Filter Mode是Point无滤波在移动设备上可能会出现严重的锯齿和闪烁。如果使用了Mipmap且扭曲导致采样的LOD级别剧烈变化也可能引起闪烁。解决将纹理的Filter Mode设置为Bilinear或Trilinear。对于动态扭曲特效有时需要强制使用较高的Mipmap级别来避免闪烁可以使用tex2Dlod函数并指定LOD级别但这会牺牲清晰度。后处理时序问题如果特效是作为全屏后处理实现的确保它在渲染管线中的顺序正确并且每一帧都被执行。有时在编辑器里暂停游戏再运行后处理脚本可能没被正确唤醒。7.3 高级调试Frame Debugger与RenderDoc当逻辑排查无法解决问题时需要借助图形调试工具。Unity Frame Debugger这是最便捷的工具。打开Window - Analysis - Frame Debugger。它可以让你逐帧、逐个Draw Call地查看渲染状态。你可以检查传递给Shader的参数如_Center, _Strength是否正确。渲染目标RenderTexture的尺寸、格式是否正确。深度测试、混合模式等状态是否被意外更改。RenderDoc对于更底层、更复杂的问题如驱动兼容性问题、奇怪的像素输出RenderDoc是终极武器。它捕获一帧完整的GPU调用序列。你可以查看任意一个像素在片段着色器执行过程中的所有中间变量值。验证极坐标转换每一步的数学计算是否正确。检查纹理采样坐标和得到的颜色值。对比不同GPU如PC vs 移动设备上的执行结果差异。使用这些工具的关键是隔离问题。写一个最简单的测试Shader只输出中间计算步骤如半径r、角度theta在调试工具中观察其输出是否符合预期。逐步添加复杂逻辑直到问题复现就能精准定位到出错的代码行。