尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Unity鱼眼Shader实战:从投影原理到性能优化
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. 从零开始写一个鱼眼Shader3.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和yprojX 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和yz和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 { RenderTypeOpaque QueueGeometry } 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替代texCUBElod值根据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脚本很简单几行代码的事但能省不少时间。
RELATED

相关推荐

OpenClaw 安装卡在 libsignal?两个模块的编译与排查指南

OpenClaw 安装卡在 libsignal?两个模块的编译与排查指南

简介:这份资源面向在企业内网环境下部署 OpenClaw 的开发者与运维人员,聚焦企业防火墙导致 libsignal-node 从 GitHub 下载失败这一典型痛点,提供 whiskeysockets/libsignal-node 与 libsignal 两个必需模块的离线包,可直接放入 O…

📅 2026/10/13 3:57:13
Alation MCP Server接入实战:让AI Agent读懂企业数据元数据

Alation MCP Server接入实战:让AI Agent读懂企业数据元数据

最近帮团队盘点MCP Server生态的时候,有个现象特别明显:大家抢着把数据库、代码仓库、文档库接进AI助手,但真正被低估的,反而是企业数据目录和治理平台这一类。这周我花了不少精力把Alation的MCP Server研究了一遍,也顺…

📅 2026/10/13 3:52:13
信息化机房建设方案全解析:从供配电到动环监控的落地指南

信息化机房建设方案全解析:从供配电到动环监控的落地指南

做信息化机房建设方案这件事,我前后完整落地过几个中型机房项目,也帮朋友单位救急处理过不少半途接手或者交付即返工的糟心现场。说句实在话,一套完整的信息化机房建设方案,真正考验人的不是画图多漂亮、清单多长,而是…

📅 2026/10/13 3:52:13
MORE NEWS

更多资讯

📰

Dinic最大流算法详解:分层图、多路增广与当前弧优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

ReactDOMRoot源码解析:从createRoot到render的完整链路

做React源码分析&#xff0c;很多人一上来就啃scheduler调度器或者fiber架构&#xff0c;结果被lane、workLoop这些概念劝退。我自己的经验是&#xff0c;不如先从ReactDOMRoot入手——这是每个React应用真正的起点&#xff0c;你天天写的createRoot(...).render(<App/>)…

📰

MySQL学生选课系统源码实战:表结构设计与事务并发控制

简介&#xff1a;这份资源是面向高校计算机相关专业学生与毕业设计开发者的一套MySQL学生智能选课系统完整资料&#xff0c;包含源码、数据库与配套论文&#xff0c;适合作为课程设计、毕业设计或SSM框架练手项目。系统围绕学生、教师与课程之间的信息交互展开&#xff0c;重点…

📰

MySQL学生智能选课系统:数据库设计、事务锁与高并发避坑指南

简介&#xff1a;这份资源是面向高校计算机相关专业学生与毕业设计开发者的一套MySQL学生智能选课系统完整资料&#xff0c;包含源码、数据库与配套论文&#xff0c;适合作为课程设计、毕业设计或Java Web入门练手项目。系统围绕学生选课场景设计&#xff0c;覆盖课程浏览、教师…

📰

CPU指令执行五阶段硬件原理与流水线设计

1. 这不是普通笔记&#xff0c;是理解CPU如何“思考”的底层地图你有没有想过&#xff0c;当你敲下gcc hello.c&#xff0c;再运行./a.out&#xff0c;那一行行C代码究竟是怎么变成屏幕上跳出来的“Hello World”的&#xff1f;它没长腿&#xff0c;不会自己跑&#xff1b;它没…

📰

PCA-LSTM多变量时间序列预测:从高维特征压缩到时序建模实战

既然标题已经这么直白了&#xff0c;我也就不绕弯子。PCA-LSTM预测模型&#xff0c;说白了就是先让PCA把高维特征“挤一挤”&#xff0c;去掉冗余和噪声&#xff0c;只留下最能代表原始数据变化的主成分&#xff0c;然后再把这条压缩后的时间序列喂给LSTM去学长期依赖&#xff…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬