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.5+0.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语法和HLSL(CGPROGRAM)来编写一个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 { "RenderType"="Opaque" "Queue"="Geometry"} 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.5+0.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标签是"Queue"="Geometry",这是不透明的标准队列。如果你希望将这个效果放在UI层或者需要半透明叠加(比如作为屏幕特效的一部分),就需要修改队列和混合模式。
作为UI元素:可以创建一个新的Shader,继承UI/Default,并在片元着色器中使用我们的计算逻辑。或者,将当前Shader的Tags改为
"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent",并在Pass中添加混合指令:Blend SrcAlpha OneMinusSrcAlpha ZWrite Off同时,将片元着色器返回的颜色的Alpha通道从1.0改为一个可调节的值(如
_Transparency)。作为后期屏幕特效:这需要用到Unity的CommandBuffer或后处理栈(Post Processing Stack)。更简单的方法是将Shader材质应用到一个覆盖全屏的Quad上,并确保其渲染队列在几乎所有物体之后(如
"Queue"="Geometry+500")。
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函数中的代码,检查哪一步导致输出异常。 |
| 中心点有尖锐的“十字”或扭曲 | 在中心点dist=0处进行了除法或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的世界就像这个千层镜一样,看似深邃复杂,但一旦掌握了核心的数学思想和渲染流程,就能创造出无限可能。从模仿一个网红效果开始,理解每一行代码背后的意图,然后大胆地修改参数、尝试新的函数组合、接入不同的输入数据,你就能逐渐摆脱教程的束缚,创作出属于自己的视觉魔法。