ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从Zemax到Three.js:光学系统3D可视化数据链路全拆解

从Zemax到Three.js:光学系统3D可视化数据链路全拆解 前阵子我参与一套机器视觉设备的方案评审光学工程师在Zemax里把镜头设计调得差不多了光路图也截了不少。可到了整体结构演示环节问题马上出来了设计软件的3D视图只能看个大概想放进整机三维仿真里导出的STEP模型又只剩实体没有光线想给客户看镜片内部的光路截图放大以后全是锯齿。最后需求落成一句话——要一个能把“镜片怎么排、光线怎么走”讲清楚的3D可视化。于是我做了一条从光学参数到Web三维场景的数据链路把镜头数据表和光线追迹结果变成了浏览器里可以自由旋转、爆炸拆解、切换波长的交互3D模型。这篇文章把这条链路完整拆开内容包括透镜面型重建、真实光线追迹、Three.js渲染三个部分适合光学工程师、前端可视化开发者和正在学应用光学/计算机图形学的同学。1. 为什么“把光学系统的3D视图导出来”这么麻烦1.1 光学设计软件的3D视图本质上是“设计工具”而不是“展示工具”光学设计软件自带的3D视图主要是给设计师本人做验证用的镜片之间有没有干涉光线有没有按预期到达像面边缘口径是否合理。这些场景下只要空间关系看得清画面丑一点、交互少一点完全无所谓。但一旦要把这套系统拿去讲方案、做装配仿真、做教学动画设计软件那套视图就撑不住了。我踩过的痛点很典型视角被软件固定想做一个爆炸视图把透镜从光轴上炸开得手动隐藏一个又一个面操作繁琐且不精确。想切剖面看清楚胶合面、空气间隔、孔径光阑的位置软件里没有适合演示的交互入口。二维光线图只是“线”看不出镜片厚度更没法表达光束口径和像面位置的关系。导出的STEP/IGES几何模型一般只包含镜头实体不包含光线就算包含也是零散的图形对象很难在外部统一控制。麻烦的本质在于光学数据的原始形态是“参数化的数学面”——曲率半径、厚度、材料、半口径、非球面系数而三维软件需要的是网格或实体。这两者之间缺一座桥。设计软件把这座桥做成了一个封闭的黑盒只给你几个固定视图剩下的需要自己做程序化转换。1.2 四条可视化路线为什么最终选了Three.js当时我先后评估了几种方案可能也是你现在正在纠结的选项路线上手成本交互体验数据驱动程度典型场景Blender Python脚本中等高但需要烘焙导出才能嵌入网页强几何可由脚本生成出高质量渲染图、做动画视频MATLAB / Python Plotly低低只能看较强快速验证数据、论文插图Three.js / WebGL中等高网页天然可分享强纯前端消费数据产品展示、教学、业务系统集成Unity / Unreal较高高适合VR/AR中等大型数字孪生、沉浸式仿真我个人最先尝试的是Blender渲染质量确实好但很快发现问题每改一次光学参数都要重新跑脚本生成几何、烘焙材质、再导出给别的地方用整个链路太重。后来切到Three.js直接从参数文件生成JSON浏览器刷新就是新模型这个体验完全不一样。对“参数表 → 3D场景”这种需求Three.js几乎是最短路径。2. 从镜头参数表到三维镜片面型重建的核心计算2.1 矢高公式光学面型的“通用语言”任意光学系统都可以看成一组旋转对称折射面沿光轴排列。每个面的核心参数就这几样曲率半径R、中心厚度、材料折射率、半口径如果有非球面还要加圆锥系数k和非球面系数A4、A6等。描述旋转对称面最常用的公式是矢高公式z(s) (s² / R) / (1 sqrt(1 - (1 k) * s² / R²)) A4 * s⁴ A6 * s⁶ ...这里s是表面上某点到光轴的径向距离z是相对面顶点的高度R是顶点曲率半径k是圆锥常数。球面的时候k0A4、A6都为0。这个公式在Zemax、Code V这些设计软件里几乎是一等公民叫Even Asphere。它的好处是球面和非球面能统一表达代码里也方便。R是一个带符号的数这一点要特别记住。以光轴为Z轴、光线从左往右传播的约定下R 0表示球心在面顶点的右侧R 0表示球心在左侧。平面可以直接视为R无穷大或0代码里要单独判断否则会出现除零。为什么我要强调这个公式因为很多人在做3D可视化时习惯直接拿“球心坐标半径”去画球体然后裁掉一部分。那样做球面镜片还行一遇到非球面就完全没法处理。用矢高公式做离散采样才能覆盖从普通球面到手机镜头非球面镜片的全部情况。2.2 把数学面变成网格采样、法线与实体拼装有了矢高公式生成网格的思路就清晰了在镜片半口径范围内用同心环加圆周分割的方式采样把每个采样点的(x, y)坐标算出来再用矢高公式算出z坐标就得到表面顶点的三维位置。import math def sag(r, R, k0.0, coeffsNone): # r: 径向距离 # R: 顶点曲率半径带符号 # k: 圆锥系数球面为0 # coeffs: [A4, A6, A8, ...] if R 0: # 按平面处理 return 0.0 rr r * r term rr / R denom 1.0 math.sqrt(max(0.0, 1.0 - (1.0 k) * rr / (R * R))) z term / denom if coeffs: r2 r * r z coeffs[0] * r2 * r2 # A4 * r^4 if len(coeffs) 1: z coeffs[1] * r2 ** 3 # A6 * r^6 # 需要更高阶可以继续往下加 return z表面网格的采样方式我建议这样中心放一个顶点从1到n_rings环每条环半径取 semi * i / n_rings保证最外环准确落在半口径处每条环上均匀取n_arc个点中心点与第一环连成三角扇相邻两环之间连成三角带。这个做法唯一的坑是边缘闭合。如果只按固定环数采样边缘的三角形会不整齐镜片外圈会有一圈锯齿。把最外环半径严格等于半口径就可以避免。生成表面之后还要拼成实体镜片。一个透镜实体由三部分组成前表面网格、后表面网格、连接前后表面外环的侧面。侧面通常用一排quad条带就够了。真实镜片边缘有倒角可视化阶段可以忽略先保证几何体封闭。法线的问题值得单独说。球面表面的法线不需要靠网格差分去猜直接用“顶点坐标减球心”再归一化就是准确法线方向。要注意的是Three.js默认单面渲染法线必须指向镜片外侧否则从背面看镜片会消失。追迹时用的法线和渲染用的法线可以分开处理前者用于物理计算后者用于光照效果两者不需要强行共用一套。2.3 示例一套双胶合消色差透镜的参数表为了后面所有代码都有实物可挂靠我用一套经典的双胶合消色差透镜作示例。这是一组教学用途的近似参数不代表优化后的最终设计但足够把整条流程跑通。面序号曲率半径 R(mm)到下一面厚度(mm)介质半口径(mm)S162.55.0BK712.5S2-45.32.5SF212.5S3-150.0—Air12.5签一下“厚度”的语义S1的厚度5.0是从S1面顶点沿光轴到S2面顶点的距离在这个例子里就是第一片镜片的中心厚度S2的厚度2.5是第二片镜片的中心厚度。S2面是胶合面第一片和第二片的玻璃在这里紧贴追迹时它依然是一个由BK7进入SF2的折射面。这套参数重建之后能简单验证一下形状方向。S1的R62.5半口径12.5代入矢高公式边缘矢高约1.26mm也就是前表面往右退S2的R-45.3边缘矢高约-1.76相对面顶点向左回撤。所以第一片是中心厚、边缘薄的典型双凸镜片。这个过程说明坐标符号一致后数字自己就能验证边界厚度是否合理。如果你要换成手机镜头那种带非球面的塑料镜片只需要在参数表里加上k和A4、A6系数矢高公式和网格生成逻辑完全不用动。3. 光线从哪来用Python做一次真实三维追迹3.1 追迹只有两步求交和折射为什么要自己写追迹而不是直接从设计软件导光线关键在于导出的光线往往是展示用的图形对象坐标分散很难自动化地和前端模型对应起来。自己写一个轻量追迹器反而能保持在同一条数据链路里想追多少条就追多少条。真实三维追迹的核心思路就两步光线与面求交然后用三维斯涅尔定律计算折射方向。如果愿意可以拿生活里的弹球做类比光线像一个打向玻璃表面的弹球法线就是地面入射角和折射角的关系由两侧介质的折射率比值决定。求交要解的是直线与球面的方程。给定光线起点P和方向d球心C和半径R求交距离t满足 |P t*d - C|² R²。这是一个一元二次方程取最小的正根。唯一要注意的是光学表面是球面的一部分所以求到交点后还要检查径向距离是否在半口径以内超出就说明光线已经跑到镜片外面该结束这束光的追迹了。3.2 可直接改用的核心代码下面是求交和折射的两个核心函数配合逐面循环就能完成光线传播。import numpy as np def normalize(v): return v / np.linalg.norm(v) def intersect_sphere(P, d, center, radius, semi): # P: 光线起点, d: 单位方向向量 oc P - center b np.dot(oc, d) c np.dot(oc, oc) - radius * radius disc b * b - c if disc 0: return None t -b - np.sqrt(disc) if t 1e-6: t -b np.sqrt(disc) hit P t * d # 超过半口径视为不与该镜片面相交 if np.hypot(hit[0], hit[1]) semi: return None return hit def refract(v, n, n1, n2): # v: 入射方向(单位向量), n: 表面法线 v normalize(v) cosi -np.dot(v, n) if cosi 0: # 法线与入射侧不一致翻转到入射侧 n -n cosi -np.dot(v, n) sin2t (n1 / n2) ** 2 * (1 - cosi * cosi) if sin2t 1.0: return None # 全反射 return normalize((n1 / n2) * v ((n1 / n2) * cosi - np.sqrt(1 - sin2t)) * n)这个refract函数里有一个很实用的技巧不管前端传入的法线指向哪一侧进入函数后先判断它和入射方向的夹角如果法线不指向入射侧就自动翻转。这样逐面追迹时不需要每个面都手工维护一套法线符号约定省掉很多麻烦。逐面追迹的主循环def trace_ray(origin, direction, surfaces): points [origin] pos origin.copy() dir_vec np.array(direction, dtypefloat) dir_vec dir_vec / np.linalg.norm(dir_vec) for s in surfaces: # surfaces 里的每个面要准备好: # z0(面顶点Z坐标), R(带符号), semi(半口径), n_before, n_after center np.array([0.0, 0.0, s.z0 s.R]) hit intersect_sphere(pos, dir_vec, center, abs(s.R), s.semi) if hit is None: break points.append(hit) normal (hit - center) / np.linalg.norm(hit - center) new_dir refract(dir_vec, normal, s.n_before, s.n_after) if new_dir is None: break dir_vec new_dir pos hit return points这里为了简洁把平面当作R0且单独处理的部分省略了。实际代码里平面求交就是解 z z0 的直线方程非常简单但必须和球面分支分开。3.3 多波长追迹与结果验证双胶合消色差透镜最有意思的可视化效果是色差。用F光486nm、d光587nm、C光656nm三种波长分别追迹不同波长在BK7和SF2里的折射率不一样光线路径就会有细微差别焦点位置也会前后错开。渲染时把F光画成蓝色、d光画成绿色或黄色、C光画成红色镜头的光学特性一眼就能看出来。折射率可以用Sellmeier公式算BK7的d光折射率大约1.5168F光和C光稍微高一点和低一点具体数值从玻璃库查或直接调用设计软件给出的数据即可。追迹结果一定要先验证再进渲染。我的做法是让一束平行光沿光轴入射焦点应该落在接近设计值的位置如果焦点偏离很远大概率是R符号或厚度累加出了错。让不同入射高度的平行光分别追迹看它们与像面的交点是不是聚在一小片区域内如果边缘光线和近轴光线的焦点差得离谱要么是系统本身球差大要么是你的追迹算法有bug。对比设计软件的点列图和焦点位置两者应该在合理误差范围内。这一步是整条链路的底线。可视化做得再漂亮如果光线本身是错的它没有任何工程价值。4. 在Three.js里搭一个可交互的光学系统场景4.1 前后端的分工谁该做几何重建很多刚接触Three.js的人会想着把光学参数直接丢给前端然后在浏览器里实时重建曲面。这个思路听起来灵活但实践下来问题很多前端要重复实现矢高公式、网格生成、法线计算一旦后端光学计算和前端几何生成用了两套代码哪怕只是一个符号约定不一致都会出现“数据看着对但模型不对”的诡异问题。我推荐的做法是Python端把所有几何重建和光线追迹完成导出成一份纯展示用的JSON前端只负责加载、渲染和交互。这样光学逻辑只存在于一个地方前端变成纯展示层出错面小很多。导出的JSON结构大致如下{ meta: { units: mm, title: Doublet Lens }, meshes: [ { name: lens1, vertices: [[x, y, z], [x, y, z]], faces: [[i, j, k], [i, j, k]], material: { type: glass, ior: 1.5168, color: [0.9, 0.95, 1.0] } } ], rays: [ { wavelength: d, color: #ffd700, points: [[x, y, z], [x, y, z], [x, y, z]] } ] }一条光线存成折线点序列而不是存起点和方向是为了让前端显示更简单。因为光线经过多个折射面后已经变成分段折线前端不需要再理解光学面只需要把点连起来渲染。4.2 玻璃材质从“半透明”到“物理折射”Three.js里做玻璃效果有两档选择快速方案是用MeshPhongMaterial加透明度。这个方案调试成本最低对设备要求也最低适合先验证整体结构const mat new THREE.MeshPhongMaterial({ color: 0xbfd8ff, transparent: true, opacity: 0.55, side: THREE.DoubleSide, shininess: 90 });物理方案是用MeshPhysicalMaterial的transmission属性。它可以做出“透过镜片看光线”的真实折射效果但需要环境贴图渲染压力也会大不少const mat new THREE.MeshPhysicalMaterial({ color: 0xffffff, transmission: 1, roughness: 0.02, ior: 1.52, thickness: 2.0, attenuationColor: 0xffffff, attenuationDistance: 5 });我的经验是先把快速方案跑通确认所有镜片方向、厚度、口径都对再换物理材质。否则一上来就上transmission你根本分不清画面发黑是材质没配好还是几何数据本来就是错的。换材质也有一点要注意transmission效果依赖环境贴图没有贴图时玻璃看起来是黑色的可以在场景里放一个简单的环境光贴图或者用Three.js自带的环境贴图加载器处理一下。4.3 交互与辅助元素视角、爆炸视图与剖面光学系统的3D可视化不能只是把模型摆在屏幕中央。没有交互它就退化成一张高级一点的截图。我通常会加这么几个辅助功能轨道控制器OrbitControls让用户自由旋转、缩放、平移坐标轴辅助线和半透明网格地面让人一眼分清光轴方向和空间尺度自动旋转开关适合放在大屏上循环展示爆炸视图把每个镜片沿着光轴拉开一段距离方便看清镜片前后关系和空气间隔剖面模式用Three.js的裁剪平面把镜片切去一半露出内部光路。爆炸视图的实现最简单保存每个mesh原始的中心坐标交互时乘以一个爆炸系数让镜片沿Z轴离散开。Three.js的THREE.ClippingPlane可以做剖面但要注意裁剪平面也要在材质上启用clippingPlanes参数否则不会生效。光线渲染方面直接用THREE.Line会导致细线在3D场景里很不显眼我习惯用THREE.TubeGeometry把折线变成细管。每条光线变成管状几何体后配合MeshBasicMaterial可以做得像霓虹灯一样醒目const curve new THREE.CatmullRomCurve3(points.map(p new THREE.Vector3(...p))); const tube new THREE.TubeGeometry(curve, 64, 0.05, 8, false); const rayMat new THREE.MeshBasicMaterial({ color: 0xff6600 });管径大小要相对镜片尺寸来定不能拍脑袋给固定值。否则镜头直径只有25mm光线管径给到0.5mm视觉上就会把镜片整个遮住。5. 实测中躲不开的坑坐标、网格、渲染与验证5.1 坐标系与R符号错一毫米方向全反做光学可视化的人最容易在坐标系上翻车。光学工程师习惯把光轴叫Z轴光线从物方向像方沿Z传播但Three.js渲染器通常把Y轴当“上”。如果前端直接拿光学坐标去套Three.js的默认相机视角第一眼看到的模型往往是“躺”在屏幕上的。我建议不要试图把所有光学坐标统一到Y轴而是保持“光轴Z轴”然后让相机从侧上方的45度角看过去。这样光学语义没有破坏后续追迹、剖面、爆炸视图都沿着Z轴做逻辑最顺。R符号的问题也一样。设计软件里同一块镜片不同人导出时可能用不同的正负约定。解析参数表时第一件事就是把符号约定写死R 0表示球心在面顶点的Z侧。所有数值在导入时就完成归一化后面就不会再有歧义。5.2 网格密度与边缘闭合网格密度的选择直接影响渲染效果和性能。镜片口径小、曲率半径大的时候几十个环就够了口径大且曲率很急的面比如弯月透镜边缘需要更密的采样才不会看起来像多边形。我的经验是按“弦高误差”控制密度而不是固定环数。简单估算一下相邻采样点之间的弦高误差应小于0.01mm视觉上基本就是光滑的。环数太多也没必要一个光学面给出24到48环圆周分成64到128段桌面端就能跑得很流畅。边缘闭合的坑在前面提过。如果最后一环半径没有严格取半口径外圈会有锯齿或缺口。把边缘环的半径计算改成semi * i / n_rings而不是循环中浮点累加可以规避这个问题。5.3 玻璃叠加后的渲染顺序当你把多个镜片放进同一个Three.js场景并且都用透明或折射材质时很容易出现“透过第一片看第二片”时画面发黑、边缘断裂。这不是数据问题而是透明物体渲染顺序问题。WebGL渲染透明物体时要按物体到摄像机的距离排序多个重叠的折射面尤其容易出错。处理办法有三种场景简单时把所有玻璃材质改成半透明但不开启物理折射的MeshPhongMaterial渲染顺序问题会轻微很多用物理折射材质时给不同镜片微调渲染顺序权重或者拆分渲染队列如果镜片数量不多直接把后表面材质设为双侧渲染也能缓解很多边界发黑的情况。这个坑没有一劳永逸的解法更多是靠实际画面调。所以我更推荐在功能验证阶段先别上物理折射等所有镜片位置都确认无误再逐步加材质复杂度。5.4 光线的“管径”直接影响观感有朋友第一次做光线可视化直接用THREE.Line去画光线结果3D场景里细得像头发丝根本看不清楚。换成TubeGeometry之后又因为管径给得太大把镜片内部都遮住了。管径取什么值比较合适我的经验是取系统半口径的0.5%到1%。比如镜头半口径12.5mm光线管径给0.08到0.12mm就够醒目又不会遮挡镜片结构。如果你同时渲染几十条光线每根都做高分段数的TubeGeometry性能会下降。可以降低管体分段数或者直接对同一颜色的光线用InstancedMesh批量绘制。5.5 可视化之前先用数据验证最后一步非常关键也是很多人会跳过的步骤花十分钟验证可视化数据是否正确。我一般会列一个验证清单验证项方法预期结果面顶点位置输出每个面顶点的全局Z坐标看厚度累加是否正确数值与光学设计表一致R符号方向单独渲染一块镜片从侧方观察凸凹方向与设计软件的截面图一致焦点位置平行光追迹找所有光线与像面的交点聚焦点接近设计焦距光线连续性逐段打印交点坐标看前后面是否衔接相邻交点Z坐标递增且不跳变这些检查都不需要写复杂代码但能把大部分错误拦在可视化之前。我见过太多“画面好看但追迹错误”的案例最后被光学工程师一眼看穿整段演示全部返工。可视化不是生成一张漂亮图片而是把一个工程事实准确地表达出来。这条流程我后来在好几个项目里复用最大的体会是光学系统的3D可视化并没有想象中那么依赖高级光学理论真正的难点在于把数据约定梳理干净。你只要把面型公式、坐标符号、材质折射率这几条线理清剩下的就是标准的三维渲染工程问题。如果你准备在自己的机器上复现我有一个建议先用最简单的半透明材质把整个场景跑通确认每个镜片的方向、厚度、口径都对再回去追求折射和光线的视觉效果。别一上来就上物理材质否则排查起来你根本分不清是数据错还是渲染错。
返回列表