从Zemax到Three.js:光学系统3D可视化数据链路全拆解
2026/9/20 7:18:26 网站建设 项目流程

前阵子我参与一套机器视觉设备的方案评审,光学工程师在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是圆锥常数。球面的时候k=0,A4、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, k=0.0, coeffs=None): # 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)
S1+62.55.0BK712.5
S2-45.32.5SF212.5
S3-150.0Air12.5

签一下“厚度”的语义:S1的厚度5.0,是从S1面顶点沿光轴到S2面顶点的距离,在这个例子里就是第一片镜片的中心厚度;S2的厚度2.5,是第二片镜片的中心厚度。S2面是胶合面,第一片和第二片的玻璃在这里紧贴,追迹时它依然是一个由BK7进入SF2的折射面。

这套参数重建之后,能简单验证一下形状方向。S1的R=+62.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, dtype=float) 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

这里为了简洁,把平面当作R=0且单独处理的部分省略了。实际代码里,平面求交就是解 z = z0 的直线方程,非常简单,但必须和球面分支分开。

3.3 多波长追迹与结果验证

双胶合消色差透镜最有意思的可视化效果是色差。用F光(486nm)、d光(587nm)、C光(656nm)三种波长分别追迹,不同波长在BK7和SF2里的折射率不一样,光线路径就会有细微差别,焦点位置也会前后错开。渲染时把F光画成蓝色、d光画成绿色或黄色、C光画成红色,镜头的光学特性一眼就能看出来。

折射率可以用Sellmeier公式算,BK7的d光折射率大约1.5168,F光和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可视化并没有想象中那么依赖高级光学理论,真正的难点在于把数据约定梳理干净。你只要把面型公式、坐标符号、材质折射率这几条线理清,剩下的就是标准的三维渲染工程问题。如果你准备在自己的机器上复现,我有一个建议:先用最简单的半透明材质把整个场景跑通,确认每个镜片的方向、厚度、口径都对,再回去追求折射和光线的视觉效果。别一上来就上物理材质,否则排查起来你根本分不清是数据错还是渲染错。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询