news 2026/10/13 3:57:28

Unity鱼眼Shader实战:从FOV翻车到UV重映射的完整实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity鱼眼Shader实战:从FOV翻车到UV重映射的完整实现

1. 从一次“翻车”的广角镜头说起

做Unity有些年头了,但真正让我对Shader产生“敬畏”的,还是第一次尝试给项目里的监控摄像头做广角效果。当时的需求很明确:一个固定位置的摄像头,要能拍到比普通透视相机更宽的视野,最好带点“球面”的畸变感,看起来像真实的鱼眼镜头。我一开始想得很简单,Unity自带的Camera组件把FOV拉到最大不就完了?结果拉到接近180度的时候,画面边缘的拉伸惨不忍睹,直线全变成了夸张的弧线,而且靠近边缘的物体被拉得又扁又长,完全不是那种“鱼眼”该有的均匀畸变感。

后来我才明白,Unity默认的透视投影矩阵本质上是一个“针孔相机”模型,它假设光线通过一个无限小的孔投射到平面上。当FOV接近180度时,这个数学模型本身就崩溃了——平面投影面无法承载接近半球面的视野。真正的鱼眼镜头是通过物理镜片的折射来把半球面的光线“压缩”到一个圆形或矩形画幅里,它的投影方式和针孔模型完全不同。所以想在Unity里模拟鱼眼,靠调FOV是走不通的,必须自己写Shader,在片元着色器里手动对UV做极坐标变换,把平面图像“卷”成球面。

这篇纪要就是记录我从零开始折腾鱼眼Shader的完整过程。里面会讲到为什么不能直接用FOV、鱼眼投影的数学原理是什么、Shader里具体怎么写、踩了哪些坑、以及怎么让效果看起来更自然。如果你也是刚接触Shader的Unity开发者,或者想给项目加一点特殊的镜头效果,这篇内容应该能帮你省下不少查资料和试错的时间。

2. 鱼眼效果的本质:不是拉伸,是重新映射

2.1 针孔投影和鱼眼投影的根本差异

很多人第一次做鱼眼效果,直觉上会觉得“就是把画面往外拉嘛”。我一开始也这么想,于是尝试用顶点偏移或者简单的UV缩放来做,结果做出来的东西要么是画面被裁掉一圈,要么是边缘出现严重的像素拉伸。问题的根源在于,针孔投影和鱼眼投影是两种完全不同的映射方式。

针孔投影的公式很简单:假设三维空间里有一个点P(x, y, z),它投影到成像平面上的坐标是(x/z, y/z)。这个映射是线性的,所以直线在画面上还是直线。但鱼眼投影不是线性的,它通常用等距投影(equidistant)或者等立体角投影(equisolid angle)来描述。以最常见的等距投影为例,三维点P到光轴的角度是θ,那么它在成像平面上的半径r和θ成正比:r = f × θ。这意味着角度越大,半径增长越慢,画面边缘被“压缩”了,而不是被拉伸。

这个差异带来的直接后果是:如果你在Shader里只是简单地把UV从[0,1]映射到[-1,1]然后做极坐标变换,得到的画面边缘会非常稀疏,因为原本在平面边缘的像素被映射到了球面的更大角度上。正确的做法是反过来——对于屏幕上的每一个像素,计算出它对应的球面角度,然后去采样原始纹理。这就是所谓的“逆向映射”,也是所有镜头畸变Shader的标准做法。

2.2 为什么必须在片元着色器里做UV重映射

在Unity里实现鱼眼效果,最直接的方式是写一个屏幕后处理Shader,挂在相机上。这个Shader的输入是相机渲染出来的普通画面,输出是经过畸变处理的画面。为什么不在顶点着色器里做?因为屏幕后处理的输入是一个全屏四边形,只有四个顶点,你在顶点阶段做任何UV变换都只能影响四个角,中间的像素插值出来的UV还是线性的,根本达不到鱼眼效果。

所以必须在片元着色器里,对每一个像素单独计算它的原始UV。具体来说,对于屏幕上的像素坐标(uv),我们先把它从[0,1]映射到[-1,1],得到归一化坐标p。然后计算p到中心的距离r,以及角度φ = atan2(p.y, p.x)。接下来根据鱼眼投影公式,把r转换成对应的入射角θ,再用θ和φ反算出原始画面上的采样坐标。这个过程每个像素都要算一次,计算量不小,但现代GPU完全扛得住。

这里有一个关键点:鱼眼投影的逆映射公式。对于等距投影,r = f × θ,所以θ = r / f。但f是焦距,我们不知道具体值,不过可以通过边界条件来确定。假设我们希望画面边缘(r = 1)对应最大入射角θ_max(比如90度或120度),那么f = 1 / θ_max。这样θ = r × θ_max。然后原始画面上的采样半径r_orig = tan(θ)(因为针孔投影下,平面上的半径和tan(θ)成正比)。最后采样坐标就是(r_orig × cos(φ), r_orig × sin(φ)),再映射回[0,1]范围。

这个公式看起来简单,但实际写的时候很容易搞错符号和范围。我一开始就把θ_max设成了π/2,结果边缘的r_orig变成了无穷大,画面直接爆掉。后来改成π/3左右,效果才正常。

2.3 鱼眼Shader在项目中的实际用途

可能有人会问:现在游戏里用鱼眼效果的地方多吗?其实不少。除了监控摄像头、门镜、猫眼这类模拟场景,鱼眼效果还常用在:

  • VR/AR的预览模式:在非VR设备上预览VR内容时,用鱼眼投影可以把双眼画面压缩到一个屏幕上,方便调试。
  • 艺术化镜头:一些独立游戏用鱼眼效果来表现角色醉酒、中毒或者精神恍惚的状态。
  • 全景图查看器:把360度全景图通过鱼眼投影显示在平面上,比直接展开成立方体更直观。
  • 安防监控模拟:很多模拟类项目需要还原真实监控摄像头的画面畸变,鱼眼Shader是标配。

所以这个技术点虽然看起来偏门,但实际需求并不少。而且一旦你理解了UV重映射的思路,后面做桶形畸变、枕形畸变、甚至自定义的镜头效果都会容易很多。

3. 手写一个可用的鱼眼Shader:从骨架到细节

3.1 Shader的基本结构和属性定义

我用的Unity版本是2021 LTS,渲染管线是Built-in。如果你用的是URP,思路完全一样,只是Shader的写法要改成HLSL的格式,后处理挂载方式也不同。这里先以Built-in为例,把核心逻辑讲清楚。

首先创建一个标准的屏幕后处理Shader,结构大概是这样的:

Shader "Custom/FisheyeEffect" { Properties { _MainTex ("Base (RGB)", 2D) = "white" {} _Strength ("Distortion Strength", Range(0, 2)) = 1.0 _MaxAngle ("Max Incident Angle", Range(30, 120)) = 90.0 _Center ("Center", Vector) = (0.5, 0.5, 0, 0) } SubShader { Pass { ZTest Always Cull Off ZWrite Off CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; float _Strength; float _MaxAngle; float4 _Center; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert(appdata_img v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.texcoord; return o; } fixed4 frag(v2f i) : SV_Target { // 核心逻辑在这里 } ENDCG } } }

属性里我加了几个可调参数:_Strength控制畸变强度,_MaxAngle控制最大入射角,_Center控制鱼眼中心的位置。这些参数在实际调试时非常有用,尤其是_Center,因为有时候相机的光轴不一定在画面正中心,需要微调。

3.2 片元着色器里的极坐标变换

接下来是核心部分。在frag函数里,我们需要做以下几步:

  1. 把UV从[0,1]映射到以_Center为中心的[-1,1]范围。
  2. 计算极坐标的半径r和角度φ。
  3. 根据鱼眼投影公式计算原始采样半径。
  4. 把采样半径和角度转回笛卡尔坐标。
  5. 映射回[0,1]范围并采样。

具体代码:

fixed4 frag(v2f i) : SV_Target { // 1. 归一化坐标,以_Center为中心 float2 p = i.uv - _Center.xy; p.x *= _ScreenParams.x / _ScreenParams.y; // 修正宽高比 // 2. 极坐标 float r = length(p); float phi = atan2(p.y, p.x); // 3. 鱼眼投影逆映射 float maxAngleRad = radians(_MaxAngle); float theta = r * maxAngleRad * _Strength; // 防止theta超过90度导致tan爆炸 theta = min(theta, radians(89.0)); float r_orig = tan(theta); // 4. 转回笛卡尔坐标 float2 uv_orig; uv_orig.x = r_orig * cos(phi); uv_orig.y = r_orig * sin(phi); // 5. 映射回[0,1]并采样 uv_orig.x /= _ScreenParams.x / _ScreenParams.y; uv_orig += _Center.xy; // 边界处理:超出范围的像素返回黑色 if (uv_orig.x < 0 || uv_orig.x > 1 || uv_orig.y < 0 || uv_orig.y > 1) return fixed4(0, 0, 0, 1); return tex2D(_MainTex, uv_orig); }

这段代码看起来不长,但里面有几个坑我踩过。第一个是宽高比修正。如果不修正,鱼眼效果在非正方形屏幕上会变成椭圆形。修正的方法是把x坐标乘以屏幕的宽高比,这样在极坐标计算时,r才是真正的圆形半径。第二个是theta的上限。当theta接近90度时,tan(theta)会趋向无穷大,导致采样坐标飞出画面。所以必须加一个min限制,我一般限制在89度左右。第三个是边界处理。由于鱼眼效果会把画面边缘的像素映射到原始画面的外部,如果不处理,这些像素会采样到纹理的边界之外,出现拉伸的条纹。直接返回黑色是最简单的处理方式,也可以返回边缘像素的颜色,看具体需求。

3.3 后处理脚本的挂载和参数传递

Shader写好了,还需要一个C#脚本来把它挂到相机上,并且传递参数。这个脚本继承自MonoBehaviour,在OnRenderImage里调用Graphics.Blit:

using UnityEngine; [ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class FisheyeEffect : MonoBehaviour { public Shader fisheyeShader; [Range(0, 2)] public float strength = 1.0f; [Range(30, 120)] public float maxAngle = 90.0f; public Vector2 center = new Vector2(0.5f, 0.5f); private Material material; void OnEnable() { if (fisheyeShader == null) { fisheyeShader = Shader.Find("Custom/FisheyeEffect"); } material = new Material(fisheyeShader); material.hideFlags = HideFlags.HideAndDontSave; } void OnDisable() { if (material != null) DestroyImmediate(material); } void OnRenderImage(RenderTexture src, RenderTexture dest) { if (material != null) { material.SetFloat("_Strength", strength); material.SetFloat("_MaxAngle", maxAngle); material.SetVector("_Center", new Vector4(center.x, center.y, 0, 0)); Graphics.Blit(src, dest, material); } else { Graphics.Blit(src, dest); } } }

这个脚本里我加了[ExecuteInEditMode],这样在编辑器里调整参数就能实时看到效果,不用每次都运行。OnRenderImage是Built-in管线的后处理入口,URP的话需要用ScriptableRendererFeature,但核心的Shader逻辑是一样的。

4. 调试过程中遇到的几个“坑”和解决思路

4.1 画面边缘出现黑边或拉伸条纹

这是最常见的问题。原因有两个:一是采样坐标超出了[0,1]范围,二是纹理的Wrap Mode设置成了Repeat。如果Wrap Mode是Repeat,超出的部分会采样到画面另一侧的内容,形成奇怪的镜像条纹。解决办法很简单:把纹理的Wrap Mode改成Clamp,或者在Shader里手动判断边界。我一开始忘了改Wrap Mode,结果画面边缘出现了一圈重复的图案,看起来像万花筒,排查了半天才发现是这个问题。

另外,黑边的宽度和_MaxAngle有关。角度越大,黑边越宽。如果不想看到黑边,可以把_MaxAngle调小,或者把原始画面的FOV调大,让相机渲染出更多的内容,这样鱼眼效果裁剪后仍然能填满屏幕。

4.2 鱼眼中心偏移导致画面不对称

有时候相机的光轴并不在画面正中心,比如监控摄像头可能稍微偏下一点。如果直接用(0.5, 0.5)作为中心,鱼眼效果就会偏向一侧。这时候就需要调整_Center参数。但手动调很麻烦,我的做法是在场景里放一个空物体,把它的屏幕坐标实时传给Shader。具体来说,在C#脚本里用Camera.WorldToViewportPoint把空物体的世界坐标转成视口坐标,然后赋给_Center。这样在编辑器里拖动空物体就能直观地调整鱼眼中心,比手动输数值方便多了。

4.3 性能开销和优化建议

屏幕后处理本身就有一定的性能开销,因为每个像素都要做一次额外的采样和计算。鱼眼Shader的计算量比普通的颜色校正要大,因为涉及三角函数(atan2, tan, cos, sin)。在移动端或者低端GPU上,可能会成为瓶颈。

我实测下来,在1080p分辨率下,这个Shader在桌面端几乎无感,但在一些老款手机上会掉几帧。优化思路有几个:

  • 降低分辨率:用RenderTexture把后处理的分辨率减半,然后再上采样。鱼眼效果本身就有模糊感,降分辨率不太明显。
  • 预计算查找表:把极坐标变换的结果预计算到一张纹理里,运行时直接采样。但这样会失去参数动态调整的灵活性。
  • 简化公式:如果不需要精确的等距投影,可以用多项式近似代替三角函数。比如用r_orig = r * (1 + k1 * r^2 + k2 * r^4)这种桶形畸变公式,计算量小很多,效果也很接近。

我最后采用的是第一种方案,在移动端把后处理分辨率降到0.75倍,画质损失几乎看不出来,帧率稳定了很多。

4.4 和UI、粒子系统的兼容问题

屏幕后处理会影响整个相机的渲染结果,包括UI和粒子。如果UI是用Screen Space - Overlay模式渲染的,它不受后处理影响,因为它在相机渲染之后才绘制。但如果UI是Screen Space - Camera模式,就会被鱼眼效果扭曲,文字会变得很难看。解决办法是把UI的渲染层级放在后处理之后,或者单独用一个不挂后处理的相机来渲染UI。

粒子系统也是类似的问题。如果粒子是在世界空间里,它们会被正常扭曲,看起来还挺自然的。但如果粒子是用Billboard模式并且依赖屏幕对齐,扭曲后可能会显得很奇怪。这个需要根据具体效果来调整,没有一刀切的方案。

5. 让鱼眼效果更自然的几个进阶技巧

5.1 加入色差模拟真实镜片

真实的鱼眼镜头由于镜片折射率的不同,不同波长的光会有轻微的分离,形成色差(chromatic aberration)。在Shader里模拟这个效果很简单:对R、G、B三个通道分别用略微不同的_Strength值采样,然后合并。比如红色通道的强度乘以1.02,蓝色通道乘以0.98。这样画面边缘会出现淡淡的红蓝边,看起来更真实。

代码上只需要把原来的单次采样改成三次:

float2 uv_r = RemapUV(i.uv, _Strength * 1.02); float2 uv_g = RemapUV(i.uv, _Strength); float2 uv_b = RemapUV(i.uv, _Strength * 0.98); fixed4 col; col.r = tex2D(_MainTex, uv_r).r; col.g = tex2D(_MainTex, uv_g).g; col.b = tex2D(_MainTex, uv_b).b; col.a = 1;

其中RemapUV是把前面片元着色器里的逻辑封装成一个函数。这样写虽然多了两次采样,但效果提升很明显,尤其是画面边缘的质感。

5.2 边缘暗角增强镜头感

鱼眼镜头的边缘通常会有暗角(vignetting),因为镜筒会遮挡一部分光线。在Shader里加暗角很简单:根据像素到中心的距离r,乘以一个衰减系数。比如vignette = 1 - smoothstep(0.5, 1.0, r) * 0.5,这样边缘的亮度会降低一半。暗角不仅能增强镜头感,还能掩盖边缘的黑边和拉伸,一举两得。

5.3 动态调整畸变强度做转场效果

鱼眼畸变强度_Strength是一个很好的动画参数。我试过在场景切换时,把_Strength从0快速拉到1.5再回落到1.0,配合一点模糊,做出一种“镜头被撞击”或者“角色晕倒”的视觉效果。因为_Strength是实时计算的,不需要重新渲染,所以性能开销很小。这个技巧在动作游戏或者恐怖游戏里很实用。

5.4 用立方体贴图做真正的360度鱼眼

前面讲的都是基于屏幕后处理的“伪鱼眼”,它只能扭曲相机已经渲染出来的画面,视野范围受限于相机的FOV。如果你想要真正的360度鱼眼效果,比如把全景图显示成一个圆形,那就需要把相机渲染成立方体贴图,然后在Shader里根据方向向量去采样立方体贴图的对应面。这个做法更复杂,但视野没有限制,适合做全景查看器或者VR预览。

具体思路是:在C#里用Camera.RenderToCubemap把场景渲染到一张立方体贴图,然后在Shader里根据屏幕像素反算出方向向量,用texCUBE采样。方向向量的计算和鱼眼投影的逆映射类似,只是最后不是采样2D纹理,而是采样立方体贴图。这个方案我还在折腾中,等跑通了再单独写一篇纪要。

6. 一些零散但有用的经验记录

关于Shader的调试,我强烈建议在片元着色器里用return fixed4(r, r, r, 1)这样的方式把中间变量可视化出来。比如你想看theta的分布,就直接返回theta的值,画面会显示成灰度图,一眼就能看出哪里算错了。这个方法比用断点调试快得多,因为Shader没法打断点。

另外,Unity的Scene视图和Game视图在后处理上的表现可能不一致。有时候在Scene视图里看着正常,切到Game视图就变了。这是因为Scene视图的相机参数和Game视图不同,尤其是宽高比。所以调试后处理效果时,一定要以Game视图为准,或者把Scene视图的宽高比调成和Game视图一样。

还有一点:如果你在Shader里用了_ScreenParams,要注意它在不同渲染路径下的值可能不同。在Built-in管线的后处理里,_ScreenParams是当前渲染目标的分辨率,一般没问题。但在URP里,可能需要用_ScaledScreenParams或者手动传递分辨率。这个坑我在迁移到URP的时候踩过,画面比例完全乱了,后来查了文档才发现是_ScreenParams的值不对。

最后,关于鱼眼Shader的适用场景,我的建议是不要滥用。鱼眼效果虽然酷,但看久了容易晕。在需要长时间注视的界面里,比如背包、地图、对话框,最好不要挂鱼眼后处理。它更适合用在短暂的过场、特殊的镜头视角或者模拟类场景里。我自己的项目里,鱼眼效果只在监控摄像头和角色眩晕两个地方用了,其他时候都是关掉的。

如果你也在做类似的效果,或者有更好的实现思路,欢迎一起交流。Shader这个东西,一个人闷头写容易钻牛角尖,多看看别人的代码和思路,往往能豁然开朗。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 3:57:06

React Native开发OpenHarmony应用:文件上传与断点续传方案详解

最近有个做跨端应用的朋友问我&#xff1a;团队现在主力技术栈是React Native&#xff0c;想把应用跑到OpenHarmony设备上&#xff0c;别的功能都好说&#xff0c;唯独文件上传这块有点棘手&#xff0c;尤其是大文件、弱网环境下的断点续传。他说网上资料太散&#xff0c;要么是…

作者头像 李华
网站建设 2026/10/13 3:55:18

手写排序算法全解析:从基础到工程实战的选型指南

1. 这年头为什么还要手写排序算法先把话说在前头&#xff1a;日常开发里&#xff0c;我几乎不会自己写排序&#xff0c;直接用语言内置的sort()就完事了。但"不会写排序"和"不懂排序"是两码事&#xff0c;很多面试题、性能问题排查、甚至数据结构设计&…

作者头像 李华
网站建设 2026/10/13 3:54:55

OpenClaw与Dify融合:政企智能体云端管控与端侧执行双向奔赴

1. 为什么“云端管控”与“端侧执行”是政企智能体的刚需我做智能体落地这几年&#xff0c;一个很深的感受是&#xff1a;政企项目真正卡脖子的地方&#xff0c;从来不是某一个模型有多聪明&#xff0c;而是“模型跟业务现场之间隔着一堵墙”。墙的一边是集中化的算力、数据和管…

作者头像 李华
网站建设 2026/10/13 3:54:55

Linux磁盘管理全攻略:分区、挂载、LVM、配额与排障实践

做运维这些年&#xff0c;我越来越发现一个事实&#xff1a;很多看似诡异的问题&#xff0c;最后都能在磁盘管理上找到病灶。要么是分区规划不合理导致空间浪费&#xff0c;要么是挂载配置写错开机直接进不了系统&#xff0c;要么是日志把根分区撑爆才发现当初没做配额。磁盘管…

作者头像 李华
网站建设 2026/10/13 3:54:49

openNLP实战:用Java实现本地NLP模型训练、保存与推断

简介&#xff1a;面向Java开发者的openNLP快速入门指南&#xff0c;聚焦自然语言处理中的实体提取场景&#xff0c;帮助初学者理解如何用带注释的训练集训练自定义模型&#xff0c;并完成模型的保存、加载与在线推断。资源共12个文件&#xff0c;压缩包约869KB&#xff0c;以Ja…

作者头像 李华
网站建设 2026/10/13 3:53:48

文件打包与解压缩核心原理:inode、tar命令与权限保留指南

文件打包和解压缩几乎是操作系统课上最容易被小看的模块&#xff0c;很多人觉得这就是敲几条命令的事&#xff0c;但真到了项目里&#xff0c;权限丢失、符号链接断裂、中文文件名乱码、磁盘空间被临时文件撑爆这类问题会一个接一个找上门。这篇文章我会从操作系统视角把“打包…

作者头像 李华