1. 项目概述:当光学仿真遇上交互引擎
做光学设计仿真的人,日常工作基本都绕不开 VirtualLab、Zemax 这类专业软件。而 Unity 这名字一出来,大家第一反应基本是游戏开发。老实说,把 VirtualLab 和 Unity 放在同一个项目里,乍一听确实有点"混搭",但这恰恰是折衍混合红外物镜这类光学系统在做工程落地时,一个非常实用的技术组合。
这个项目的核心,就是用 VirtualLab 完成折衍混合红外物镜的建模仿真,再通过 Unity 搭建一个可交互的三维展示和验证环境。折衍混合红外物镜,简单说就是把传统折射透镜和衍射光学元件(DOE)结合在一起的红外成像物镜。这种设计在红外光学系统里很常见,因为它能在控制体积重量的同时,很好地校正色差和热差。
但问题来了,光学设计软件里的仿真结果,往往是二维的 MTF 曲线、点列图、波前图,这些数据对于光学工程师自己看没问题,可一旦要跟机械结构工程师、嵌入式工程师、甚至客户沟通,就非常不直观。人家看不懂你这个 RMS 波前误差 0.05λ 到底意味着什么,但如果你能在一个三维场景里,让光路、镜片、像面实时显示,还能鼠标拖拽旋转、切换视场、开关某一片透镜看光线变化,那沟通效率完全是两个量级。
这个项目的目标就是解决这个问题。我用 VirtualLab 搭好光学模型,跑完仿真,把镜片模型和光线路径的数据导出来,在 Unity 里重建整个光机系统,做成一个可交互的"光学数字孪生"。这篇文章我打算从选型逻辑、建模细节、Unity 实现、问题排查四个维度聊透,适合手里有光学仿真基础、又想往三维可视化方向拓展的工程师参考。
2. 为什么选折衍混合红外物镜做这个项目
2.1 红外物镜设计避不开的两座山:色差和热差
做红外光学系统的人都知道,红外材料的选择面很窄。中波红外常用 InSb 探测器,配合硅(Si)、锗(Ge)、硒化锌(ZnSe)这些材料,长波红外则基本是锗的天下。锗的折射率高、色散也大,单用一片锗透镜,色差和热差都很夸张,所以红外物镜很少是单片结构,基本都是多片式。
但多片式也有问题。红外材料贵,尤其是大尺寸的锗镜片,价格跟直径基本是超线性关系。而且红外系统的应用场景往往是机载、弹载、手持热像仪这类对重量体积极度敏感的地方,你不可能为了校正像差无限堆镜片。
这时候折衍混合的优势就出来了。衍射光学元件(DOE)有一个非常独特的性质:它的光焦度跟材料折射率无关,只跟表面微结构有关,而且色散特性是负的,跟常规折射材料的正色散正好相反。把 DOE 放在一片折射透镜上,可以在几乎不增加重量和厚度的前提下,同时校正色差和热差。一片折衍混合透镜,往往能顶两到三片传统球面透镜的效果。
2.2 折衍混合红外物镜的典型参数
我做这个项目时用的是一套典型的中波红外折衍混合物镜参数,大家可以参考:
| 参数项 | 数值 |
|---|---|
| 工作波段 | 3.7~4.8 μm(中波红外) |
| 焦距 | 50 mm |
| F 数 | 2.0(入瞳直径 25 mm) |
| 视场 | 2ω = 10°(全视场) |
| 探测器 | 320×256,像元尺寸 30 μm 制冷型探测器 |
| 镜片结构 | 3 片折射透镜 + 1 片衍射面(叠加在第二片透镜表面) |
| 镜片材料 | 硅、锗、硒化锌组合 |
这个参数组合在实际工程里非常典型。F 数 2.0 是制冷探测器匹配的常见值,保证了足够的能量收集能力;焦距 50 mm 的视场角能覆盖常规搜索跟踪场景;折衍混合的设计让 MTF 在奈奎斯特频率(约 17 lp/mm)处能保持在 0.4 以上,衍射极限水平。
2.3 为什么选 VirtualLab 而不是 Zemax
很多人的第一反应是,折衍混合镜片设计不是 Zemax 也能做吗?为什么非要碰 VirtualLab?这里我得说清楚。
Zemax(现在叫 OpticStudio)当然能做折衍混合设计,它的二元面、全息面模型对于初步设计阶段完全够用。但 VirtualLab 在处理衍射光学元件的微观结构方面有独特优势,它内置了严格的电磁场传播算法(Fourier Modal Method, FMM),可以精确仿真 DOE 表面微结构(比如台阶深度、周期、占空比)对衍射效率的影响,还能分析加工误差带来的效率损失。
另一个重要原因是 VirtualLab 的"场追迹"(Field Tracing)概念。传统光线追迹假设光在所有介质中沿直线传播,遇到表面才发生偏折;而 VirtualLab 的场追迹是把光当成电磁场来传播,可以精确处理光的偏振、相干、干涉和衍射效应。这对折衍混合系统尤其重要,因为 DOE 的衍射效率跟波长、入射角、偏振都有关系,传统光线追迹很难把这些效应完整地放进同一个模型里。
当然,VirtualLab 的建模方式跟 Zemax 差别很大,它的逻辑更像"构建一个物理光学实验台",而不是"填表式光学设计"。上手门槛高一些,但一旦跑通了,对 DOE 这种非序列、含微结构的光学系统,理解深度是 Zemax 给不了的。
3. VirtualLab 建模与仿真核心环节
3.1 镜片结构设计与模型搭建
先用 Zemax 做初始结构设计拿到合理的镜片半径、厚度、玻璃材料,这是常规做法。我可不是说 VirtualLab 不适合做初始设计,而是 Zemax 的全局优化能力确实成熟,先用它把结构搜出来,效率高得多。
把 Zemax 里的镜片数据拿到 VirtualLab 里建模时,我用了 VirtualLab 的"界面组件"创建透镜元件。每个镜片的曲率半径、厚度、材料、口径都按设计值设置好。这里有一个关键细节:折衍混合透镜在 VirtualLab 里需要把折射面和衍射面分开定义,然后在同一个位置叠加。衍射面的参数不是直接输入相位函数系数就行,而是要先在 VirtualLab 的"光栅"工具箱里设计 DOE 的微结构参数(槽深、周期、台阶数),或者直接用界面编辑器里的 Binary 面、谐波衍射面模型。
我给项目里那片 DOE 设定的是 8 阶量化台阶、Kinoform(连续浮雕近似型)衍射结构。8 阶是加工成本和衍射效率之间一个很平衡的点,再往上到 16 阶效率提升有限,但加工成本翻倍;往下到 4 阶效率损失明显,一级衍射效率只有 81% 左右。
3.2 衍射效率分析与温度建模仿真
折衍混合系统的核心优势,一半在光学设计,另一半在 DOE 的衍射效率。DOE 的衍射效率决定有多少光能落在设计焦点上,剩下的光散到其他衍射级次,会形成杂散光背景,直接影响成像对比度。
我在 VirtualLab 里分别仿真了不同波长、不同入射角、不同温度下的一级衍射效率。用的方法是先建一个单周期的 DOE 单元,用 FMM 求解它的衍射特性,再扫描参数。这一步有个很重要的结论:DOE 的效率曲线随波长和入射角都会变化,在设计优化时不能只看中心波长的效率,要加权整个波段和整个视场。
红外系统还要额外关心温度特性。VirtualLab 里没有内置的热膨胀同步功能,但可以手动改材料折射率和镜片曲率半径。锗的折射率温度系数 dn/dT 高达 396×10⁻⁶/°C,是所有常用红外材料里面最高的,这也是长波红外系统里锗单片化设计困难的根本原因。我仿真的方式是设置 -40°C、20°C、60°C 三个温度点,分别提取折射率变化和面型变化,在 VirtualLab 里重新跑一遍点列图和 MTF。
3.3 仿真结果导出与定位
仿真完不是终点,把数据导出给 Unity 才是关键。我需要导出三类数据:
- 镜片的三维几何模型(每片镜片的前表面、后表面、口径)
- 关键视场的光线路径(不同视场角对应的光线在每片镜片表面的入射点、出射方向)
- 像面上的结果数据(点列图坐标、MTF 数据,用于 Unity 里做对比验证)
镜片几何我导出成 STL 格式。VirtualLab 里可以先生成镜片轮廓二维图,再绕光轴旋转生成三维实体。光线路径我导出了每个视场从物面出发的 9 条特征光线(边缘、0.7 口径、轴上,每个位置取主光线和上下光线),这些光线在各表面的交点坐标和方向向量,就是 Unity 里画光线的基础数据。
4. Unity 侧的场景重建与交互实现
4.1 场景搭建与材质处理
Unity 里搭建光学场景有两个坑需要提前说。
第一个坑是坐标系统的差异。VirtualLab 里光轴方向是 Z 轴,Unity 里默认相机朝向也是 Z 轴负方向,这个要统一。我在导出数据时直接把所有坐标做了一次坐标系变换,以 VirtualLab 的像面中心为 Unity 世界坐标原点,光轴方向设为 Unity 的 Z 轴正方向,这样后面摆放相机、探测器都非常直观。
第二个坑是单位问题。VirtualLab 里所有尺寸单位是 mm,Unity 的默认单位虽然文档写的是"米",但物理引擎的步长、光照的衰减距离都是按 1 单位 ≈ 1 米来算的。所以我在导入 STL 时统一做了一个 0.001 的缩放,把毫米换算成米。如果不做这步,镜片直径 25 mm 在 Unity 里会变成 25 米,整个场景没法看。
镜片的材质处理也值得说。红外镜片在成像波段是透明的,但在可见光波段看起来就是深灰色甚至黑色的。为了展示效果,我给镜片做了两套材质:一套是物理正确的折射材质,用来模拟光路;另一套是外观着色器,给镜片表面加上菲涅尔反射效果,看起来像镀膜后的镜片。后处理上加了抗锯齿和环境光遮蔽,整体观感提升很多。但我没有加高光反射和实时光追,因为场景本身以教学展示和沟通为主,不追求照片级真实感。
4.2 光线可视化实现
Unity 里画光线,我一开始想的是用 LineRenderer,但很快发现一个问题:LineRenderer 是线渲染器,只有 1 像素宽,在复杂场景里光线会显得非常单薄。后来我改成用 Unity 的 Mesh Line 方案,每条光线用一个细长的四边形 Mesh 来表示,宽度可以控制,颜色可以渐变,在场景里很清晰。
光线数据是从 VirtualLab 导出的,每段光线的起点和终点决定了 Mesh 四边形的两个顶点对。我这里写了个光线管理脚本,核心逻辑是:
public class RayVisualizer : MonoBehaviour { public GameObject raySegmentPrefab; public Material rayMaterial; public void DrawRays(List<RaySegment> segments) { foreach (var seg in segments) { // 每条线段生成一个四边形 Mesh Vector3 start = seg.startPoint; Vector3 end = seg.endPoint; // 计算光线的方向 Vector3 dir = end - start; // 垂直于光线的两个偏移方向,用于生成四边形宽度 Vector3 up = Vector3.up; Vector3 right = Vector3.Cross(dir, up).normalized * seg.width; // 四个顶点 Vector3 v0 = start + right; Vector3 v1 = start - right; Vector3 v2 = end - right; Vector3 v3 = end + right; Mesh mesh = new Mesh(); mesh.vertices = new Vector3[] { v0, v1, v2, v3 }; mesh.triangles = new int[] { 0, 2, 1, 0, 3, 2 }; mesh.RecalculateNormals(); GameObject go = new GameObject("RaySegment"); go.transform.SetParent(transform); MeshRenderer mr = go.AddComponent<MeshRenderer>(); MeshFilter mf = go.AddComponent<MeshFilter>(); mf.mesh = mesh; mr.material = rayMaterial; mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; } } }这里有个细节:每条光线都生成一个 GameObject 效率太低,几百条光线还行,几千条就会卡。我最终做了个材质合批和网格合并优化,把同一颜色的多个光线段合并成一个静态 Mesh,性能飙升。如果读者只是做展示用,不用优化也能跑;但如果要做实时交互、切换视场、动态更新光路,合并网格就非常必要了。
4.3 交互功能设计与参数联动
Unity 场景的核心交互功能,我做了以下几个:
- 鼠标拖拽旋转观察镜筒、镜片、整体装配,用 Unity 的 Cinemachine 相机控制实现,同时支持滚轮缩放。
- 分层显示开关,可以单独显示或隐藏镜片、镜筒、光线、探测器——这个对讲解调试非常实用。
- 视场切换,点击不同视场角按钮,光线会实时切换到对应视场的光路,高亮当前光线在镜片上的足迹(footprint)。
- 点击镜片高亮并显示该镜片的设计参数(曲率半径、厚度、材料),这个功能我用最简单的方式实现:给每片镜片加 BoxCollider,用
OnMouseDown事件触发 UI 面板。
需要注意的是,Unity 的 UI 动态画线这里有一个典型的坑:如果用OnGUI画线或者用CanvasRenderer动态创建顶点,坐标系的转换很容易算错。我最后采用的方式是在世界空间里生成 Mesh,然后用Camera.WorldToScreenPoint把世界坐标投影到屏幕坐标,再画 UI 上的标注线。原因是 UI 画线的需求往往伴随着标注文字、引线箭头,这些都需要屏幕空间坐标。
5. 关键问题排查与避坑心得
5.1 许可证激活与工程协作问题
做 Unity 开发的第一步,很多人会卡在许可证上。如果你打开 Unity Hub 发现提示 "No valid Unity Editor license found. Please activate your license.",别慌,分几种情况处理:
- 个人版免费许可:去 Unity Account 官网申请个人版许可证,然后在 Unity Hub 里登录并激活。个人版和商业版的区别在于年收入是否超过 20 万美元,往上的就得买 Pro 许可。
- 企业内网环境:如果你在公司内网,可能激活请求被防火墙挡住了。这种情况要么走 Unity Hub 的离线激活流程,要么让 IT 放行 Unity 的授权服务器地址。
- 团队协作:如果同一个项目多人开发,许可证是绑定个人账号的,不要在公用的服务器上登录自己的账号,容易被判 "unlicensed machine" 导致封禁。正确做法是每个开发者用自己账号激活,工程通过 Git LFS 或 Perforce 共享。
这里要顺带提一个 Unity 工程协作的隐藏坑:Unity 工程目录里的Library文件夹会缓存大量本地生成的产物,这个文件夹绝不能提交到版本控制里。我见过很多团队直接把整个工程目录拖进 Git,结果合并冲突刷屏,每次打开工程都卡半天。正确做法是用.gitignore过滤掉Library/、Temp/、Obj/、Logs/这几个文件夹。
5.2 DLL 加载失败排查
做 Unity 项目,难免会碰到DllNotFoundException: Unable to load DLL这类报错。尤其在光学仿真工程里,很多人会写 C++ 插件调用算法库,然后把生成的 DLL 放在 Assets 目录下,结果运行时依然报找不到。
这种报错 90% 的原因不是 DLL 不存在,而是 DLL 的依赖库没找到,或者在 x86/x64 体系结构上不匹配。以我项目里用到的 slua(一个 Lua 脚本插件)举例,如果你下载的是 slua 的二进制包,一定得确认你导入的 .dll 是 Unity 目标平台对应的版本。Windows 编辑器下 Unity 是 64 位的,如果误导入 32 位编译的 DLL,就会毫无悬念地报这个错。
排查步骤我总结一下:
- 打开 Unity 的 Console 窗口,看完整报错信息里的 DLL 名称。
- 用 Depends 或者
dumpbin /dependents查看这个 DLL 依赖了哪些系统 DLL 和第三方 DLL。 - 检查 DLL 是否被放在
Assets/Plugins目录下,并且Plugin Import Settings里的平台勾选是否正确。 - 如果依赖的是 Visual C++ 运行库,确认目标机器装有对应版本的 VC++ Redistributable。
- 最后再排查是否被杀毒软件误删或隔离。
这一步排查下来能解决绝大多数 DLL 加载问题。
5.3 UI 动态画线的坐标问题与性能优化
前面提过,在 Unity 的 UI 系统里动态画线,最容易出的问题是坐标系混淆。我在项目里一开始用了RectTransform.rect来取 Canvas 的宽高,结果画出来的线全偏了。
原因在于:Unity UI 的坐标原点在 Canvas 的中心(或者底部,取决于 Canvas 的锚点和 pivot 设置),而RectTransform.rect返回的是局部坐标。如果你的 Canvas 是 Overlay 模式,从鼠标屏幕坐标转 UI 坐标,必须用RectTransformUtility.ScreenPointToLocalPointInRectangle这个方法,而不是自己手动做换算。这是我踩过最久的一个坑。
另外一个问题是 UI 画线的性能。如果用Graphic类每帧重建顶点,Draw Call 会飞涨。我后来的做法是把要画的线全部合并到一个 Cached Mesh 里,然后用一个简易的自定义Raycaster做点击检测,这样画 100 条线和画 1000 条线的开销差不多。
5.4 场景光照与物体显示异常处理
很多人在 Unity 里建立光学模型后,会发现镜片渲染出来死黑一片,或者反射效果怪怪的。原因通常是 Unity 默认的材质管线跟透明物体渲染不兼容,透明物体默认不会参与深度写入,导致透明物体之间的遮挡关系混乱。
我的处理方案是给镜片使用自定义 Shader,用 Unity 的 Surface Shader 做两层渲染:深度预写和透明混合。同时把镜片的渲染队列设为Transparent(3000)。如果镜片之间互相遮挡出问题,可以把镜片上的 Mesh Collider 打开,用后处理CommandBuffer手动控制深度写入。
在 WebGL 平台发布时,帧率控制是一个绕不开的优化话题。Unity WebGL 默认启用全屏自适应分辨率,有时候会把 GPU 拉满导致帧率反而下降。我在 Build Settings 里关闭了"Auto Graphics API"里的 Vulkan(WebGL 2.0 更稳),然后把QualitySettings.vSyncCount设为 0,手动用Application.targetFrameRate = 60锁定帧率。实测下来,这种配置在集成显卡笔记本上也能稳定 50 帧以上。
5.5 视场光线缺失与焦点漂移问题
最后分享一个 VirtualLab 导出数据到 Unity 后比较隐蔽的问题:光线缺失和焦点漂移。
光线缺失的原因,往往是 VirtualLab 里设置光线的密度和口径不匹配。比如你设置了 9 条光线,但其中几个视场角超出镜片口径,那几条光线在某个表面会没有交点,导出数据里就缺了这几段。我的解决办法是在 VirtualLab 里给每个视场单独设置光线口径,用Field Trace时把Raytracing的光线采样扩展为 11×11 ,
然后在导出时做一步数据补全:如果某个视场的光线数量不足,就用已存在的边缘光线插值生成完整的路径,并在 Unity 里用虚线渲染,提醒操作者这条光线是插值结果而非仿真结果。
焦点漂移的问题,则几乎都是因为坐标变换或者单位换算没有统一。我在项目里踩过的坑是:VirtualLab 导出像面数据时,默认的像面中心可能在最后一个表面顶点之前几毫米的地方,而我在 Unity 里直接拿这个位置当探测器中心,导致点列图中看起来像是系统存在严重离焦。排查方法很简单,在 VirtualLab 里看Focus的位置,导出时把Detector放在最佳焦面位置,而不是默认像方焦面。
6. 项目扩展方向和工程落地思考
走到这一步,折衍混合红外物镜的 VirtualLab 仿真和 Unity 交互展示基本已经打通。但我在实际项目中体会到,这个方案的价值远不止做一个好看的演示,它还可以向几个方向扩展,这些方向都是我亲自尝试过或者正在推进的。
6.1 向虚拟现实(VR)方向扩展
如果你手头有 Pico 4 或 Quest 3,把项目导出到这些设备上做沉浸式光学系统评估是完全可行的。我在一个子项目中做了 Pico 4 的适配,核心工作有两块:一是手柄射线交互替代鼠标点击,二是把 UI 面板做成空间锚定面板,放置在场景中固定位置。
这里有一个值得注意的坑:Pico 4 的 Unity 开发需要开启Pico XR扩展包,并且在 Player Settings 里把Minimum API Level和Target API Level都提到 35。如果停留在默认值,部分设备测试时会随机出现渲染穿透问题。而在虚拟现实环境里观察光学系统,和平面屏幕完全不同,视差效应能让你非常直观地感受到镜片间的空气间隔,这对评审光学结构的紧凑性帮助很大。
6.2 联动物理引擎做虚拟装调和公差分析
另一个我在推进的方向,是把 Unity 的物理引擎跟光学仿真的公差分析结合起来。传统公差分析是在 VirtualLab 里用蒙特卡洛随机采样生成一组组镜片偏移、倾斜、厚度误差,然后统计 MTF 下降的分布情况。我可以把这组误差条件下的镜片位姿直接映射到 Unity 场景中,生成一系列仿真快照,再用脚本自动渲染成动画,模拟装配偏差对光路的影响。
这个思路的工程意义在于:可以把光学系统和机械结构放在同一个空间里做干涉检查。在 Unity 里加载镜筒的 CAD 模型和镜片的位姿误差,就能用碰撞检测算法提前发现某个误差组合下镜片边缘会不会碰壁。传统流程里这需要分别在光学软件和机械软件里做,这次我把它整合到一个环境里,省掉了大量的数据转手时间。
6.3 与算法仿真结合做图像退化模拟
折衍混合系统的杂散光特性,也可以用 Unity 的渲染管线和后处理做模拟。DOE 不等于 100% 衍射效率,残余级次的光会形成环形杂散光或鬼像。我计划把 VirtualLab 导出的杂散光点扩散函数(PSF)做成 Unity 的卷积后处理效果,这样在一个三维场景里就能直观看到杂散光在探测器上叠加的效果。
这个方向本质上是把光学 CAE 和游戏引擎的渲染能力做了一次桥接,不需要真正实现严格的物理光传播,只需要把光学软件计算好的 PSF 数据贴到屏幕渲染管线上就行。实施上比预想的简单,效果却非常直观。
6.4 工程落地的推进建议
如果读者有意把这个方案落地到实际项目,我的建议是按三个阶段走:
第一阶段只做静态展示,把 VirtualLab 的数据导入 Unity,做一个可以旋转观察、分层显示的演示场景,这个是基础。
第二阶段做交互验证,加入视场切换、光线动态显示、参数面板联动,让使用者在展示环境里就能查看光学系统的各个性能和结构细节。
第三阶段才接入真实的 CAD 模型和公差数据,实现光学机械联动分析,这一步是最花时间但也是价值最高的。
我见过不少团队在第一阶段就放弃了,说"这玩意不就是做个动画吗,有什么意义"。但以我的经验,问题恰恰相反,当你的光学项目需要跟非光学专业的人交流、评审、验收时,一个能"玩起来"的三维光学系统,比一百页仿真报告都有说服力。
最后分享一个小技巧:如果你打算长期维护这个方案,建议把 VirtualLab 的导出做成批处理脚本,而不是每次手动导出。VirtualLab 有 Python 脚本接口(VyPython),可以把镜头设计参数、光线采样、导出格式全部写成脚本,这样你改一个镜片半径,重新仿真的同时,Unity 工程里的数据也能自动更新,效率和准确性都会高很多。我按这个流程做完之后,整个项目维护成本降低了大半,而且同事再也不会拿着过期的仿真结果来找我讨论问题,因为 Unity 场景里的数据和 VirtualLab 永远是一致的。