Unity Shader实现千层镜效果:从原理到实战的完整指南

Unity Shader实现千层镜效果:从原理到实战的完整指南
1. 项目概述从“网红千层镜”到Shader实战最近在逛一些创意展览或者刷短视频的时候你肯定见过那种效果一个看似普通的镜面但往里看却仿佛有无限延伸的、层层叠叠的光影隧道深邃得仿佛没有尽头。这就是所谓的“千层镜”或“深渊镜”效果。作为一个Unity开发者看到这种酷炫的视觉效果第一反应就是“这玩意儿用Shader应该能搞出来”。没错这种效果的核心驱动力就是Shader它通过巧妙的数学计算和纹理采样在GPU上实时渲染出这种视觉假象远比在3D场景里真的摆放无数层模型要高效和灵活得多。今天要聊的就是如何在Unity3D里不依赖复杂的建模和灯光仅凭一个片元着色器Fragment Shader实现这种极具视觉冲击力的无限延伸镜面效果。我会从最基础的原理开始拆解一步步带你写出这个Shader并附上完整的、可直接运行的源码。无论你是刚接触Shader的新手想理解其背后的渲染思维还是有一定经验的开发者需要快速实现一个吸引眼球的展示效果这篇文章都能给你提供一条清晰的路径。我们不止步于“能用”更要弄明白“为什么这么写”以及在实际项目集成时需要注意哪些坑。2. 效果原理深度拆解视觉欺骗的艺术千层镜效果本质上是一种视觉欺骗它利用了人眼对透视和重复图案的感知特性。其核心原理可以概括为在一个二维的平面比如一个Quad上通过Shader程序模拟出一个视觉上无限纵深的三维隧道。2.1 核心视觉模型的建立想象一下你站在一条笔直的、两侧布满相同间隔图案的走廊里看向尽头。走廊的透视关系是离你越远的物体它在你的视野中显得越小并且向视野中心的“消失点”汇聚。千层镜效果就是在模拟这个场景。在Shader中我们通常不直接构建3D几何体。相反我们把要渲染的屏幕像素每个片元当作一个观察点。对于这个观察点我们需要计算出一个“视线方向”。但更常见的简化方法是我们直接利用该像素在模型局部空间或屏幕空间的UV坐标来构建一个2D的“观察平面”。关键思路将Quad的UV坐标(0,0)到(1,1)的区域重新映射为一个从中心点(0.5, 0.5)向外发散的坐标系。离中心越远的UV点我们将其解释为在虚拟隧道中“位置越深”或“层数越靠后”的点。同时为了制造“层”的感觉我们需要一个重复的、有节奏的图案。2.2 距离场与重复函数这是实现无限延伸感的关键数学工具。我们首先需要定义一个“距离”函数。最常用的是计算当前像素UV坐标到中心点的欧几里得距离float2 center float2(0.5, 0.5); float2 uvOffset i.uv - center; // i.uv 是当前像素的UV坐标 float dist length(uvOffset);这个dist值范围大致是[0, 0.707]从中心到角落。直接使用这个距离我们只能得到一个从中心向外的简单渐变。为了制造出“一层一层”的环状效果我们需要引入重复。这里就会用到frac函数或者正弦/余弦函数。一种经典方法是使用sin或cos函数对距离进行调制float layers sin(dist * _Frequency _Time.y * _Speed) * 0.5 0.5;这里_Frequency控制层数的密度频率_Time.y结合_Speed可以让层动起来产生向内或向外流动的动画。sin函数的输出范围是[-1, 1]通过*0.50.5将其映射到[0, 1]的亮度范围。这样dist值的变化就会导致亮度周期性变化形成明暗相间的圆环也就是“层”。注意单纯使用sin(dist * _Frequency)产生的层在中心区域dist很小会非常密集且扭曲在边缘则稀疏。为了让层的分布更均匀、更像一个笔直的隧道我们通常需要对dist进行非线性变换或者使用倒数1.0 / dist来作为输入这样越靠近中心dist小倒数越大层的间隔在视觉上就更均匀。这是实现“深渊”感的一个重要技巧。2.3 颜色与光效增强仅有黑白的层状条纹是不够的。为了增强科技感、梦幻感或“深渊”感我们需要引入颜色。基于层的颜色映射我们可以将上面计算出的layers值一个0到1之间的标量作为一个索引去查询一个渐变纹理Gradient或者用函数生成颜色。例如float3 color lerp(_ColorDark.rgb, _ColorBright.rgb, layers);这会在明暗层之间插值两种颜色。更复杂的做法是使用smoothstep和多个颜色进行分段插值模拟霓虹灯般的色彩变化。添加中心光晕在UV中心区域叠加一个额外的高亮光晕可以极大地增强视觉纵深感和焦点。这通常通过一个基于dist的衰减函数实现例如exp(-dist * _GlowIntensity)或saturate(1.0 - dist * _GlowFalloff)。扭曲与噪声为了让效果看起来更自然、更有机而不是完美的数学图形可以引入一些噪声。对用于计算层的dist值或者UV本身进行轻微的噪声扰动可以让层的边缘产生不规则的波动模拟光在不均匀介质中传播的效果。可以使用简单的sin波叠加或者采样一张噪声纹理。2.4 动画与交互性一个静态的深渊镜吸引力有限。动态效果才是其灵魂。时间驱动动画如上所述在层计算函数中加入_Time.y变量可以让条纹旋转、脉动或流动。例如让_Time.y乘以一个速度系数加到sin函数的相位中产生旋转动画或者乘以一个系数加到dist上产生缩放动画。基于深度的动画让不同“深度”的层以不同速度运动可以营造出更强的立体感和混沌感。这可以通过在_Time.y上乘以一个与dist相关的因子来实现。交互式控制通过Shader暴露属性Properties到Unity材质面板我们可以实时调整颜色、频率、速度、光晕强度等。更进一步可以通过脚本将外部参数如音频振幅、玩家距离传递到Shader实现声音可视化或距离感应等交互效果。3. Shader实现全流程解析理解了原理我们开始动手编写Shader。我们将使用Unity的ShaderLab语法和HLSLCGPROGRAM来编写一个Unlit Shader因为它不依赖于复杂的光照模型只处理颜色输出最适合这种全屏效果。3.1 创建Shader与基本结构首先在Unity中创建一个新的Unlit Shader文件命名为“AbyssMirror”。清空默认内容我们从最基本的框架开始搭建。Shader Custom/AbyssMirror { Properties { // 颜色属性 _MainColor (Main Color, Color) (0.1, 0.3, 0.8, 1) _SecondaryColor (Secondary Color, Color) (0.8, 0.1, 0.3, 1) // 效果控制 _Frequency (Layer Frequency, Range(10, 200)) 80 _Speed (Animation Speed, Float) 1.0 _GlowIntensity (Center Glow Intensity, Range(0, 10)) 3.0 _GlowFalloff (Glow Falloff, Range(1, 20)) 5.0 _Distortion (Layer Distortion, Range(0, 0.5)) 0.1 _NoiseScale (Noise Scale, Range(0.1, 10)) 2.0 _NoiseSpeed (Noise Speed, Range(0, 5)) 0.5 } SubShader { Tags { RenderTypeOpaque QueueGeometry} LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc // 将Properties中的变量声明到CG代码中 fixed4 _MainColor; fixed4 _SecondaryColor; float _Frequency; float _Speed; float _GlowIntensity; float _GlowFalloff; float _Distortion; float _NoiseScale; float _NoiseSpeed; // 顶点着色器输入结构 struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; // 顶点着色器输出/片元着色器输入结构 struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; // 顶点着色器最基本的模型-视图-投影变换 v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } // 片元着色器核心效果在这里实现 fixed4 frag (v2f i) : SV_Target { // 效果计算将在这里完成 return fixed4(1,0,0,1); // 临时返回红色 } ENDCG } } FallBack Diffuse }这是一个干净的模板。Properties块定义了所有我们可以在材质面板上调节的参数。顶点着色器只是做了必要的坐标变换。现在我们要在frag函数中填充核心逻辑。3.2 核心片元着色器实现接下来我们一步步构建frag函数。第一步计算基础距离和方向向量fixed4 frag (v2f i) : SV_Target { // 将UV坐标从[0,1]映射到[-0.5, 0.5]使中心点为(0,0) float2 uv i.uv - 0.5; // 计算到中心的距离 float dist length(uv); // 计算方向向量用于后续可能的极坐标变换或扭曲 float2 dir normalize(uv); // 为了防止中心点dist为0导致除法问题加一个极小值 dist max(dist, 0.001); // ... 后续代码 }第二步添加噪声扭曲为了让层的边缘不那么规则我们先对距离dist施加一个基于噪声的扭曲。这里我们使用一个简单的正弦波模拟噪声实际项目中可以替换为纹理采样。// 简单的动画噪声函数 float noise sin(dir.x * _NoiseScale _Time.y * _NoiseSpeed) * cos(dir.y * _NoiseScale _Time.y * _NoiseSpeed) * 0.1; // 将噪声应用到距离上 float distortedDist dist noise * _Distortion;第三步计算层状图案这是最关键的一步。我们使用经过扭曲的距离来计算层。为了得到更好的深渊透视效果我们使用1.0 / distortedDist来放大中心区域的细节。// 使用倒数来增强透视感频率参数控制层数密度 float depthFactor 1.0 / distortedDist; float layer sin(depthFactor * _Frequency _Time.y * _Speed); // 将sin输出从[-1,1]映射到[0,1] layer layer * 0.5 0.5;第四步颜色混合与光晕添加// 基于层值在两种颜色间插值 fixed3 layerColor lerp(_MainColor.rgb, _SecondaryColor.rgb, layer); // 计算中心光晕使用指数衰减模拟 float glow exp(-dist * _GlowFalloff) * _GlowIntensity; fixed3 glowColor _MainColor.rgb * glow; // 使用主色作为光晕色 // 合并层颜色和光晕颜色 fixed3 finalColor layerColor glowColor; // 输出最终颜色Alpha通道设为1不透明 return fixed4(finalColor, 1.0); }至此一个基本的、动态的千层镜效果Shader就完成了。将它赋给一个材质并应用到场景中的一个Quad或Plane上你就能看到无限延伸的彩色漩涡了。3.3 效果优化与增强上面的基础版本已经不错但我们可以做得更好。优化1平滑与抗锯齿sin函数产生的层边缘可能有些生硬。我们可以使用smoothstep函数来创造更平滑的过渡或者对layer值进行多次采样和模糊性能开销会增大。一个简单的方法是// 替代简单的 layer sin(...)*0.50.5; float rawLayer sin(depthFactor * _Frequency _Time.y * _Speed); // 使用smoothstep制造一个带平滑过渡的条纹 float layer smoothstep(-0.2, 0.2, rawLayer);优化2多层叠加与运动差为了增加细节和体积感我们可以用不同频率和相位的sin波进行叠加。float layer1 sin(depthFactor * _Frequency _Time.y * _Speed) * 0.5 0.5; float layer2 cos(depthFactor * _Frequency * 1.7 _Time.y * _Speed * 0.7) * 0.5 0.5; float layer3 sin(depthFactor * _Frequency * 0.6 _Time.y * _Speed * 1.3) * 0.5 0.5; float combinedLayer (layer1 * 0.5 layer2 * 0.3 layer3 * 0.2);然后使用这个combinedLayer进行颜色混合。不同频率和速度的层叠加会产生更复杂、更有机的流动效果。优化3添加纹理细节我们可以引入一张噪声纹理或流动纹理来扰动UV或者直接与层颜色相乘增加表面的细节和随机性。// 在Properties中添加 _NoiseTex (Noise Tex, 2D) white {} // 在CG变量中声明 sampler2D _NoiseTex; float4 _NoiseTex_ST; // 在frag函数中 float2 noiseUV i.uv * _NoiseScale float2(0, _Time.y * _NoiseSpeed); fixed4 noiseTex tex2D(_NoiseTex, noiseUV); // 将噪声值应用到层计算或最终颜色上 layer * (noiseTex.r * 0.5 0.75); // 轻微调制亮度4. 在Unity场景中的集成与应用技巧Shader写好了最终要放到项目里用起来。这里有几个关键的集成点和避坑指南。4.1 材质设置与参数调节创建材质将写好的Shader拖到Project窗口或者对Shader右键选择“Create - Material”会自动生成同名材质。参数调节心法_Frequency频率这是控制“层数”密度的核心参数。值太小如10层数少像几根粗管子值太大如200层数极其密集在中心区域可能因为采样精度问题出现闪烁或摩尔纹。通常80-150是比较理想的视觉范围。_Speed速度控制动画快慢。正值和负值控制旋转方向。对于背景效果建议速度慢一些0.1-1.0如果需要强视觉引导可以快一些。_GlowIntensity和_GlowFalloff光晕这两个参数需要配合调节。_GlowFalloff控制光晕从中心向外衰减的速度值越大光晕越集中。_GlowIntensity控制光晕亮度。常见问题光晕过强会“吃掉”周围的层状细节导致中心一片白需要反复调节找到平衡。_Distortion扭曲少量扭曲0.05-0.2可以打破完美圆形让效果更自然。但扭曲过大会让图形变得难以辨认失去“镜面”或“隧道”的感觉。实操心得调节参数时不要只盯着一个参数猛调。最好的方法是先确定主色调_MainColor,_SecondaryColor然后把频率调到你觉得层数合适的程度接着调速度看动画是否舒服最后再微调光晕和扭曲来增加细节和氛围。经常缩小视图看看整体效果避免陷入局部参数的纠结。4.2 渲染队列与混合模式我们的Shader标签是QueueGeometry这是不透明的标准队列。如果你希望将这个效果放在UI层或者需要半透明叠加比如作为屏幕特效的一部分就需要修改队列和混合模式。作为UI元素可以创建一个新的Shader继承UI/Default并在片元着色器中使用我们的计算逻辑。或者将当前Shader的Tags改为QueueTransparent IgnoreProjectorTrue RenderTypeTransparent并在Pass中添加混合指令Blend SrcAlpha OneMinusSrcAlpha ZWrite Off同时将片元着色器返回的颜色的Alpha通道从1.0改为一个可调节的值如_Transparency。作为后期屏幕特效这需要用到Unity的CommandBuffer或后处理栈Post Processing Stack。更简单的方法是将Shader材质应用到一个覆盖全屏的Quad上并确保其渲染队列在几乎所有物体之后如QueueGeometry500。4.3 性能考量与优化这个Shader虽然计算不复杂但在低端移动设备上大面积使用比如全屏背景或大量实例化时仍需注意性能。计算复杂度核心计算是sin,cos,length,normalize和一些乘加。sin和cos是相对昂贵的函数。我们代码中使用了2-3次。如果性能吃紧可以考虑减少叠加的层数比如只用一层sin。用纹理查找Texture Lookup替代部分函数计算。例如预计算一个环形渐变纹理用dist和角度去采样但这会牺牲一些动态变化的灵活性。简化噪声函数或者完全移除噪声。精度选择在移动平台将变量精度从float改为half或fixed可以提升性能。例如颜色计算完全可以用fixed距离计算可以用half。但要注意half精度在数值很大或很小时可能精度不足导致闪烁。建议先使用float开发确保效果稳定在最终发布前针对目标平台进行精度降级测试。half2 uv i.uv - 0.5; half dist length(uv); fixed3 finalColor ...;批处理与GPU Instancing如果场景中有很多个使用相同材质、相同Shader的千层镜物体可以尝试开启GPU Instancing来减少Draw Call。在Shader的Properties块后面和SubShader之前添加以下指令CustomEditor UnityEditor.ShaderGUI并在代码中确保使用支持Instancing的宏现代Unity版本默认支持。但要注意如果每个镜子的参数如颜色、速度需要独立控制则无法合批。5. 常见问题排查与进阶思路即使按照步骤操作你也可能会遇到一些问题。这里记录一些我踩过的坑和解决方案。5.1 效果显示异常问题排查表问题现象可能原因解决方案屏幕一片纯色如红色片元着色器默认返回了测试颜色核心逻辑未生效Shader编译错误。1. 检查Unity Console窗口是否有Shader编译错误粉色警告。2. 逐步取消注释frag函数中的代码检查哪一步导致输出异常。中心点有尖锐的“十字”或扭曲在中心点dist0处进行了除法或normalize操作产生无穷大或NaN值。在计算dist后立即使用max(dist, 0.001)或dist 1e-5来确保分母不为零。对dir的计算也要在dist大于一个阈值后进行。层状图案在中心区域闪烁或抖动中心区域1.0/dist值极大导致sin函数输入变化剧烈采样精度不足。1. 对depthFactor进行钳制depthFactor min(1.0 / distortedDist, _MaxDepth)。2. 使用smoothstep平滑层过渡替代硬边缘的sin。3. 尝试使用fwidth函数进行抗锯齿处理。动画速度不稳定或卡顿在片元着色器中直接使用_Time.y其值每帧变化但在极高速或极低速下观感不佳。1. 使用_Time.y * _Speed时确保_Speed不是极大或极小的值。2. 考虑使用frac(_Time.y)来制作循环动画或使用sin(_Time.y)来制作往复动画。在VR或AR中效果错位默认UV映射可能不适用于单通道渲染或特定渲染管线。检查渲染管线Built-in, URP, HDRP。URP/HDRP中可能需要包含不同的核心库并使用TransformObjectToHClip等特定函数。对于单通道立体渲染需要考虑双眼UV差异。5.2 效果进阶与扩展思路当你掌握了基础版本后可以尝试以下方向进行扩展创造出独一无二的效果3D化与曲面应用目前效果是基于一个平面Quad的。你可以尝试将Shader应用到球体、圆柱体或自定义的曲面网格上。关键在于在片元着色器中你需要使用模型空间或世界空间的位置和法线信息来计算“视线方向”和“深度”而不是简单的UV。这可以创造出包裹在物体表面的深渊效果非常酷炫。与场景交互让深渊镜效果对周围环境做出反应。遮挡融合利用深度纹理让深渊效果只在特定深度范围内显示或者与场景中的其他物体产生遮挡关系。反射增强结合屏幕空间反射SSR或立方体贴图Cubemap让镜面本身能反射出周围的环境而无限深渊作为反射内容的背景虚实结合。物理触发通过脚本将玩家的位置、速度等信息传递给Shader如_PlayerPos,_PlayerDist动态改变效果的频率、颜色或扭曲强度。风格化演变基础的光滑色带看腻了可以轻易改变风格。赛博朋克使用锐利的条形色带用step函数替代smoothstep颜色选用高饱和度的蓝、紫、粉。熔岩/地狱将颜色渐变改为黑、红、橙、黄并叠加一张流动的、扭曲的噪声纹理模拟热浪。水墨风格使用更柔和的颜色灰阶层状图案用噪声纹理采样替代数学函数制造出氤氲开来的感觉。性能与质量的平衡对于移动端可以开发一个简化版本Simplified Version。例如预渲染几帧不同的深渊动画到一张序列帧纹理或视频中在运行时播放。这虽然失去了部分动态性和交互性但性能开销极低适合作为静态背景或装饰元素。Shader的世界就像这个千层镜一样看似深邃复杂但一旦掌握了核心的数学思想和渲染流程就能创造出无限可能。从模仿一个网红效果开始理解每一行代码背后的意图然后大胆地修改参数、尝试新的函数组合、接入不同的输入数据你就能逐渐摆脱教程的束缚创作出属于自己的视觉魔法。