1. 鱼眼Shader到底能做什么
第一次接触鱼眼Shader是在做一个赛车模拟项目的时候,当时想给玩家一个“坐在驾驶舱里用广角镜头看世界”的沉浸感。普通透视相机在高速行驶时视野太窄,两侧的景物一闪而过,玩家根本来不及反应,体验很差。后来尝试把相机FOV拉到90度以上,画面边缘拉伸得惨不忍睹,直线全变成了弧线,看着像哈哈镜。那时候才意识到,想要真正的鱼眼效果,光靠调相机参数是不够的,得从Shader层面去控制顶点的投影方式。
鱼眼Shader本质上是一种非线性的投影变换。常规的透视投影是把三维空间中的点通过一个视锥体映射到二维屏幕上,投影后的直线依然是直线。而鱼眼投影模拟的是真实鱼眼镜头的光学特性——视角极大(通常能到180度甚至更大),但代价是画面边缘会产生强烈的桶形畸变。这种畸变在摄影里是缺陷,但在游戏和交互应用里反而成了一种风格化表达手段。比如全景视频播放器、VR预览窗口、监控画面模拟、赛车游戏的后视镜、甚至一些艺术装置,都会用到鱼眼效果。
这篇文章适合谁看?如果你是一个Unity初学者,已经写过一两个简单的Surface Shader或者顶点片段Shader,想找一个有实际视觉效果、又不至于复杂到劝退的项目来练手,那鱼眼Shader是个非常好的选择。它涉及的数学不算深,主要是极坐标变换和三角函数,但能让你真正理解顶点着色器和片段着色器各自该干什么。如果你已经有一定Shader基础,也可以从这里学到一些性能优化和边界处理的技巧。
我当时的项目需求很明确:在一个平面上渲染出类似鱼眼镜头的画面,中心区域基本正常,越往边缘压缩越厉害,最终形成一个圆形的成像区域,圆外全部裁掉。这个效果用相机后处理也能做,但后处理是对已经投影到屏幕上的图像做二次扭曲,会丢失边缘信息,而且性能开销更大。直接在Shader里做投影变换,从源头控制顶点位置,效果更干净,也更灵活。
2. 鱼眼投影的数学原理与方案选型
2.1 为什么不能用普通透视投影凑合
普通透视投影的流程是这样的:顶点从模型空间变换到世界空间,再变换到观察空间,然后通过投影矩阵变换到裁剪空间,最后经过透视除法得到NDC坐标。这个过程中,投影矩阵是一个固定的4x4矩阵,它定义了一个视锥体,所有在这个视锥体内的顶点会被映射到NDC的[-1,1]范围内。关键点在于,这个映射是线性的——观察空间中的直线在投影后依然是直线。
鱼眼效果要求的是非线性映射。想象你站在一个球心,鱼眼镜头就是把你周围半球面上的景象压缩到一个圆盘上。距离圆心越远的地方,对应的实际角度越大,但投影到平面上的距离增长得越慢。这种“角度大、距离小”的关系就是非线性的根源。
如果用普通透视投影加后期扭曲,问题在于:透视投影已经丢弃了视锥体外的顶点信息,后期再怎么扭曲,边缘也是空的。而且后期扭曲是对屏幕空间像素操作,扭曲后的画面边缘会出现严重的采样不足和锯齿。所以正确的做法是在顶点着色器阶段就改变投影方式,让顶点直接按照鱼眼模型映射到裁剪空间。
2.2 鱼眼投影的几种常见模型
鱼眼投影有好几种数学模型,常见的有等距投影、等立体角投影、正交投影和立体投影。它们之间的区别在于入射角θ和投影半径r之间的关系。
| 投影模型 | 半径与角度关系 | 特点 | 适用场景 |
|---|---|---|---|
| 等距投影 | r = f·θ | 角度与半径成正比,最直观 | 通用鱼眼效果 |
| 等立体角投影 | r = 2f·sin(θ/2) | 面积失真最小 | 科学可视化 |
| 正交投影 | r = f·sin(θ) | 180度时边缘压缩到零 | 半球全景 |
| 立体投影 | r = 2f·tan(θ/2) | 保角但边缘拉伸大 | 地图投影 |
对于游戏和实时渲染来说,等距投影是最常用的,因为它的计算最简单,效果也最符合人们对“鱼眼”的直觉预期。等距投影的公式是r = f·θ,其中θ是入射光线与光轴的夹角,f是焦距常数。在Shader里,我们通常把θ归一化到[0,1]范围,然后直接用θ作为投影半径。
我选择等距投影还有一个实际原因:它的逆变换很简单。在片段着色器里如果需要从屏幕坐标反推方向向量,等距投影的逆运算就是θ = r/f,直接一个除法就搞定了。其他模型涉及sin、tan的反函数,计算量更大,在移动端上不划算。
2.3 在顶点着色器还是片段着色器做变换
这是一个关键决策点。有两种实现路径:
第一种是在顶点着色器里修改顶点的裁剪空间坐标。具体做法是:先把顶点变换到观察空间,计算它相对于相机的方向,然后根据鱼眼公式重新计算投影后的x和y坐标。这种方式的优点是每个顶点只算一次,性能好;缺点是如果模型面数不够,边缘会出现明显的多边形棱角,因为顶点之间的插值是线性的,而鱼眼变换是非线性的。
第二种是在片段着色器里做。顶点着色器正常输出观察空间的方向向量,片段着色器根据屏幕坐标计算鱼眼映射后的采样坐标。这种方式每个像素都要算一次,性能开销大,但效果完美,不会有棱角问题。
我的选择是混合方案:在顶点着色器里做粗粒度的鱼眼变换,保证大致的形状正确;同时在片段着色器里做精细的方向修正,消除插值误差。具体来说,顶点着色器输出观察空间的方向向量(不归一化),片段着色器拿到这个方向向量后,重新计算精确的鱼眼投影坐标。这样既利用了顶点着色器的批量处理能力,又保证了像素级的精度。
注意:如果你的目标平台是移动端,片段着色器里的三角函数要尽量少用。可以用查找表或者多项式近似来替代sin、cos,能省不少算力。
3. 从零开始写一个鱼眼Shader
3.1 准备工作:场景搭建与相机设置
在开始写Shader之前,先把场景搭好。我建了一个简单的测试场景:一个大的平面作为“屏幕”,上面挂一个材质,材质用我们写的鱼眼Shader。平面正前方放一个相机,相机朝向平面。场景里再放一些立方体和球体作为参照物,方便观察鱼眼效果是否正确。
相机的设置很关键。因为鱼眼Shader会接管投影变换,相机的投影矩阵其实会被我们覆盖掉,但相机的视野方向和位置仍然影响观察空间的计算。我把相机的FOV设成60度(这个值在Shader里会被忽略,但保持一个合理的值方便调试),近裁剪面0.3,远裁剪面1000。相机放在平面正前方,距离刚好让平面填满画面。
平面的网格分辨率要注意。如果平面只有两个三角形,顶点着色器里的鱼眼变换会导致严重的棱角问题。我给平面细分了50x50的网格,这样顶点密度足够,顶点着色器出来的形状就比较平滑了。当然,如果你用纯片段着色器方案,平面可以只有两个三角形,但性能开销会大很多。
3.2 顶点着色器的核心逻辑
顶点着色器的任务是把模型空间的顶点变换到裁剪空间,但中间要插入鱼眼投影。我先把顶点变换到观察空间,得到观察空间中的位置viewPos。然后计算这个位置的方向向量,归一化后得到dir。
接下来是关键步骤:计算入射角θ。在观察空间中,光轴是z轴负方向(Unity的观察空间是右手系,相机看向-z)。所以θ就是dir与(0,0,-1)的夹角。用点积算余弦值:cosTheta = dot(dir, float3(0,0,-1)),然后theta = acos(cosTheta)。
等距投影的半径r = theta / (PI/2),这里我把90度作为最大视角,所以θ的范围是[0, PI/2],归一化后r的范围是[0,1]。然后计算投影后的x和y:projX = r * dir.x / sin(theta),projY = r * dir.y / sin(theta)。这里除以sin(theta)是为了把方向向量投影到垂直于光轴的平面上,再乘以半径r得到最终坐标。
等等,这个公式有个问题:当θ趋近于0时,sin(theta)也趋近于0,会出现除以零。实际写代码的时候要加一个极小值保护:sinTheta = max(sin(theta), 0.0001)。或者用另一种等价形式:projX = theta * dir.x / sqrt(dir.x*dir.x + dir.y*dir.y),这样就不需要除以sin(theta)了,因为sqrt那部分就是sin(theta)乘以一个符号。我实际用的是后一种,更稳定。
最后把projX和projY赋给裁剪空间的x和y,z和w保持原来的值。这样顶点就会被“拉”到鱼眼投影的位置上。
3.3 片段着色器的方向修正
顶点着色器输出的方向向量是插值后的,在三角形内部并不精确。片段着色器要做的是:根据当前像素的屏幕坐标,反推它对应的方向向量,然后重新计算鱼眼投影坐标,用这个坐标去采样纹理或者做其他计算。
具体做法:先用i.screenPos拿到屏幕坐标,归一化到[-1,1]范围。然后根据鱼眼逆变换计算θ:theta = r * (PI/2),其中r是屏幕坐标到圆心的距离。接着计算方向向量:dir = float3(sin(theta)*cos(phi), sin(theta)*sin(phi), -cos(theta)),其中phi是屏幕坐标的极角。
拿到精确的方向向量后,就可以做各种效果了。比如采样一张全景纹理,或者计算光照,或者做边缘暗角。我当时的项目是采样一张 cubemap,把鱼眼画面映射到平面上。采样cubemap用texCUBE(_CubeMap, dir),一行代码搞定。
3.4 完整Shader代码与逐行注释
下面是我实际使用的Shader代码,基于Unity的Surface Shader框架,但改成了顶点片段着色器,因为Surface Shader的自动光照流程会干扰我们的投影变换。
Shader "Custom/FisheyeShader" { Properties { _MainTex ("Texture", 2D) = "white" {} _CubeMap ("Cubemap", CUBE) = "" {} _FOV ("Fisheye FOV", Range(90, 270)) = 180 _EdgeFade ("Edge Fade", Range(0, 0.5)) = 0.1 } SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } Cull Off ZWrite On Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; samplerCUBE _CubeMap; float _FOV; float _EdgeFade; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float3 viewDir : TEXCOORD0; float2 uv : TEXCOORD1; }; v2f vert (appdata v) { v2f o; // 变换到观察空间 float4 viewPos = mul(UNITY_MATRIX_MV, v.vertex); // 计算方向向量(观察空间中相机看向-z) float3 dir = normalize(viewPos.xyz); // 计算入射角theta float cosTheta = dot(dir, float3(0, 0, -1)); float theta = acos(clamp(cosTheta, -1.0, 1.0)); // 等距投影半径,归一化到[0,1] float maxTheta = radians(_FOV * 0.5); float r = theta / maxTheta; // 计算投影后的x和y float sinTheta = sqrt(max(0.0, 1.0 - cosTheta * cosTheta)); float2 projXY = float2(0, 0); if (sinTheta > 0.0001) { projXY = dir.xy / sinTheta * r; } // 输出裁剪空间坐标 o.pos = float4(projXY, viewPos.z, -viewPos.z); // 传递方向向量给片段着色器 o.viewDir = dir; o.uv = v.uv; return o; } fixed4 frag (v2f i) : SV_Target { // 归一化方向向量 float3 dir = normalize(i.viewDir); // 采样cubemap fixed4 col = texCUBE(_CubeMap, dir); // 边缘暗角 float2 screenUV = i.pos.xy / _ScreenParams.xy; float2 center = screenUV - 0.5; float dist = length(center) * 2.0; float fade = 1.0 - smoothstep(1.0 - _EdgeFade, 1.0, dist); col.rgb *= fade; return col; } ENDCG } } }这段代码有几个细节值得展开说。第一,o.pos的z和w我用了viewPos.z和-viewPos.z,这是为了保持深度测试的正确性。因为我们的投影是非线性的,直接用原来的投影矩阵算出来的z和w会不匹配,导致深度排序错乱。手动设置z和w可以保证深度值在观察空间中是线性的,远处的物体依然在后面。
第二,_FOV参数控制鱼眼的最大视角。180度是标准鱼眼,270度是超广角鱼眼,但超过180度后边缘会出现重叠,需要额外处理。我一般用180度,效果最自然。
第三,边缘暗角是为了掩盖圆形边界外的空白区域。因为鱼眼投影后画面是一个圆,圆外没有有效像素,如果不做处理会看到黑色或者拉伸的纹理。用smoothstep做一个平滑过渡,视觉上更舒服。
4. 实操过程中踩过的坑与排查技巧
4.1 画面撕裂与深度冲突
第一次跑起来的时候,画面出现了严重的撕裂:近处的物体被远处的物体遮挡,深度测试完全失效。排查了半天,发现是o.pos的z和w设置有问题。我一开始直接用了UnityObjectToClipPos的结果,但那个结果是基于透视投影的,和我们的鱼眼投影不匹配。后来改成手动设置viewPos.z和-viewPos.z,问题解决。
这里的原则是:裁剪空间的z和w必须保持透视投影的关系,即z/w等于观察空间的深度。这样深度缓冲才能正确工作。x和y可以随意改,但z和w不能乱动。
4.2 边缘像素拉伸与采样不足
在圆的边缘,像素被极度压缩,一个像素可能对应原来几十个像素的信息。如果直接采样cubemap,会出现严重的锯齿和闪烁。我的解决方案是:在片段着色器里根据距离圆心的距离,动态调整mipmap级别。距离越远,mipmap级别越高,相当于做了模糊处理,视觉上更平滑。
具体做法是用texCUBElod替代texCUBE,lod值根据dist计算:lod = dist * 4.0。这样边缘的采样会自动降级到低分辨率mipmap,锯齿明显减少。代价是边缘会稍微模糊一点,但鱼眼效果本来边缘就是压缩的,模糊反而更自然。
4.3 移动端性能优化
在PC上跑60帧很轻松,但移植到移动端后帧率直接掉到20。用Profiler一看,片段着色器里的acos和sqrt是性能杀手。移动端GPU对三角函数和开方的吞吐量很低,每个像素都算一遍根本扛不住。
优化方案有两个。第一,把acos换成多项式近似。在[0, PI/2]范围内,acos(x)可以用一个三次多项式拟合,误差在0.01弧度以内,视觉上看不出来。第二,把方向向量的计算从片段着色器移到顶点着色器,片段着色器只做插值。虽然精度会下降,但移动端屏幕小,肉眼几乎看不出区别。
我实际用的多项式是:theta ≈ 1.5708 - 1.5708 * x - 0.2121 * x^3,其中x是cosTheta。这个近似在x接近1的时候误差稍大,但x接近1意味着theta接近0,也就是画面中心区域,那里本来就不需要太精确。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 画面全黑 | 顶点着色器输出w为负 | 检查o.pos.w是否为正 |
| 深度错乱 | z/w不匹配观察空间深度 | 手动设置z和w |
| 边缘锯齿 | 采样不足 | 使用mipmap或手动模糊 |
| 移动端卡顿 | 片段着色器三角函数过多 | 多项式近似或移到顶点着色器 |
| 圆形边界外有拉伸 | 没有做边缘裁剪 | 在片段着色器里discard或暗角 |
| 画面上下颠倒 | 观察空间y轴方向错误 | 检查dir.y的符号 |
提示:调试Shader的时候,可以把中间变量输出成颜色,比如把
theta映射到红色通道,r映射到绿色通道,这样一眼就能看出计算是否正确。这个方法比打断点还管用。
5. 鱼眼Shader的扩展玩法
5.1 动态FOV与呼吸效果
_FOV参数不一定非要固定。我后来加了一个脚本,根据玩家的速度动态调整FOV:速度越快,FOV越大,视野越广,速度感越强。具体实现是在C#脚本里每帧计算速度,然后material.SetFloat("_FOV", Mathf.Lerp(180, 220, speed / maxSpeed))。效果很惊艳,高速时画面边缘像被“吸”进去一样,沉浸感极强。
但要注意,FOV变化不能太快,否则会晕。我用了Mathf.SmoothDamp做平滑,过渡时间0.3秒左右,体感最舒服。
5.2 多鱼眼拼接实现全景
单个鱼眼只能覆盖180度,如果要覆盖360度全景,需要两个鱼眼背对背拼接。每个鱼眼渲染到一个RenderTexture上,然后在最终显示的时候根据视角方向选择采样哪个RenderTexture,并在交界处做混合。这个方案我在一个全景视频播放器项目里用过,效果很好,但拼接缝的处理比较麻烦,需要仔细调整混合区域的大小和过渡曲线。
5.3 鱼眼效果与后期处理的结合
鱼眼Shader可以和Bloom、Color Grading等后期效果叠加。但要注意渲染顺序:鱼眼变换应该在所有后期效果之前完成,否则后期效果会基于扭曲后的画面计算,导致光晕位置错乱。我的做法是把鱼眼Shader挂在相机上作为OnRenderImage的第一步,先渲染鱼眼画面到RT,然后再对RT做后期。
这个顺序问题我踩过坑:一开始把Bloom放在鱼眼前面,结果光晕在画面边缘被拉成了长条,非常难看。后来调整顺序,先鱼眼后Bloom,光晕就正常了。
5.4 性能对比与选型建议
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 顶点着色器变换 | 性能好 | 边缘有棱角 | 低面数模型、移动端 |
| 片段着色器变换 | 效果完美 | 性能开销大 | PC、主机、高画质 |
| 混合方案 | 平衡 | 实现复杂 | 通用推荐 |
| 后期处理扭曲 | 不改Shader | 丢失边缘信息 | 快速原型 |
我个人推荐混合方案,虽然写起来麻烦一点,但效果和性能都能兼顾。如果只是做原型验证,后期处理扭曲最快,但正式项目里不建议用。
6. 一些个人体会
鱼眼Shader是我带过的新人里最受欢迎的练手项目之一。它不像PBR那样涉及大量数学公式,也不像粒子系统那样状态复杂,但能让你完整地走一遍顶点变换、投影、片段采样的流程。写完这个Shader之后,再看Unity内置的投影矩阵,会有一种“原来你在这里等着我”的感觉。
我印象最深的是一个做监控模拟的开发者,他用鱼眼Shader模拟了半球摄像头的画面,然后在上面叠加了目标检测的框。因为鱼眼画面的畸变,检测框也得跟着变形,他就在片段着色器里根据像素位置反算畸变系数,把框的顶点也做了同样的变换。这个思路很聪明,本质上就是把鱼眼变换当成了一个坐标映射函数,任何需要在这个空间里定位的东西都可以用同样的公式。
如果你正在学Shader,我建议不要一上来就啃PBR或者光线追踪。找一个像鱼眼这样有明确视觉效果、数学又不复杂的项目,从头到尾写一遍,调通,优化,然后再扩展。这个过程走下来,你对渲染管线的理解会比看十篇教程都扎实。
最后分享一个小技巧:在Scene视图里用Gizmos画一个圆,半径就是鱼眼投影的边界。这样在调整参数的时候,能直观地看到哪些物体在圆内、哪些在圆外,省得反复进Play模式确认。这个Gizmo脚本很简单,几行代码的事,但能省不少时间。