第4章:如何表示一条光线
光学图纸上的一条光线看起来毫不费力。
它只是一条线。
它离开物体,穿过透镜,在表面弯曲,落到像面附近。如果几条光线一起画出,它们组成整齐的扇形。如果它们通过一个好的透镜进行追迹,光线会聚拢。如果透镜质量差,光线会发散。这个图像足够直观,以至于我们可能忘记一个简单的事实:
画出的光线还不是可计算的光线。
程序无法追迹一幅草图。它需要数字。
它需要知道光线从哪里开始、向哪个方向传播、携带什么波长、是否仍然有效,以及最终积累了多少光程。如果我们想从基本原理出发构建点列图、波前图、PSF 和 MTF,就必须首先将这个对象明确地表示出来。
本章正是关于这个对象。
不是表面。不是折射。还不到像质。
就只是光线。
这看起来可能是个小话题,但它是接下来一切内容的根基。光线追迹器并不是什么神奇的光学引擎。它是一个循环,重复地对一条光线执行几个操作:
从一条光线开始
→ 找到它撞击下一个表面的位置
→ 计算表面法线
→ 折射或反射光线
→ 传播到下一个表面
→ 重复
如果光线本身模糊不清,整个循环就会变得模糊。
因此,让我们把光线具体化。
光线是一条带有光学附加信息的参数化直线
光线的基本几何形式是:
r(t) = r0 + t d
这里:
r0 = 光线的起始点
d = 方向向量
t = 沿光线类似于距离的参数
如果 d 被归一化,使其长度为 1,那么 t 的单位与坐标相同。如果位置以毫米为单位,那么 t 的单位也是毫米。
这是一个非常有用的约定。
例如,如果:
r0 = [0, 0, 0]
d = [0, 0, 1]
那么:
r(10) = [0, 0, 10]
光线沿正 z 方向前进了 10 mm。
如果光线倾斜:
d = [0.1, 0, 0.995]
那么向前移动会同时改变 x 和 z。
这就是几何。但一条光学光线需要的不仅仅是几何。它还应该至少携带:
波长
强度
光程
有效/无效状态
之后,更高级的光线还可能携带偏振态、相位、表面历史,或用于优化的微分信息。我们现在还不需要所有这些。
目前,我们的光线将携带五项信息:
位置
方向
波长
强度
光程
以及一个状态标记:
存活或停止
这足以开始。
方向始终归一化
方向向量应当是归一化的。
这意味着:
||d|| = 1
或者:
sqrt(dx² + dy² + dz²) = 1
为什么坚持这一点?
因为这会使传播过程变得清晰。如果 d 具有单位长度,那么 t 就是物理距离。让光线移动 t = 5.0 意味着在空间中移动 5.0 mm。
如果 d 未归一化,那 t 就难以解释。方向为 [0, 0, 10] 的光线和方向为 [0, 0, 1] 的光线指向同一方向,但相同的 t 值会让它们移动完全不同的距离。
这不是我们想要的。
因此,光线构造函数应在创建时立刻将方向归一化。
import numpy as np
def normalize(v):
"""返回向量 v 的单位长度副本。"""
v = np.asarray(v, dtype=float)
norm = np.linalg.norm(v)
if norm == 0:
raise ValueError("不能归一化零向量。")
return v / norm
测试一下:
d = normalize([0, 0, 10])
print(d)
print(np.linalg.norm(d))
结果是:
[0. 0. 1.]
1.0
这个小小的辅助函数会随处可见。它是那些默默维持光线追迹稳定性的工具之一。
第一个 Ray 类
我们来写一个最小化的 Ray 类。
from dataclasses import dataclass, field
import numpy as np
@dataclass
class Ray:
position: np.ndarray
direction: np.ndarray
wavelength_um: float = 0.5876
intensity: float = 1.0
opl: float = 0.0
alive: bool = True
stop_reason: str = ""
history: list = field(default_factory=list)
def __post_init__(self):
self.position = np.asarray(self.position, dtype=float)
self.direction = normalize(self.direction)
if self.position.shape != (3,):
raise ValueError("光线位置必须是 3D 向量。")
if self.direction.shape != (3,):
raise ValueError("光线方向必须是 3D 向量。")
if self.wavelength_um <= 0:
raise ValueError("波长必须为正。")
if self.intensity < 0:
raise ValueError("强度不能为负。")
def point_at(self, t):
"""返回点 r(t) = r0 + t d。"""
return self.position + t * self.direction
def copy(self):
"""返回光线的一个副本。"""
return Ray(
position=self.position.copy(),
direction=self.direction.copy(),
wavelength_um=self.wavelength_um,
intensity=self.intensity,
opl=self.opl,
alive=self.alive,
stop_reason=self.stop_reason,
history=list(self.history),
)
这还算不上一个光线追迹器,但它是一个定义清晰的对象,可供光线追迹器使用。
一条光线有位置和方向。它知道自己的波长、强度。它有一个累积的光程 opl。它可以是存活或停止的。可以存储历史,以备我们想检查它曾经经过哪里。
现在创建一条光线:
ray = Ray(
position=[0.0, 0.0, 0.0],
direction=[0.1, 0.0, 1.0],
wavelength_um=0.5876,
)
print("位置:", ray.position)
print("方向:", ray.direction)
print("方向长度:", np.linalg.norm(ray.direction))
print("t=10 处的点:", ray.point_at(10.0))
方向自动进行了归一化。t = 10 处的点沿着归一化方向走了 10 mm。
这是第一条真正的可计算光线。
方向向量不同于角度
在简单的二维图中,我们常用角度来描述光线。
例如:
一条从光轴倾斜 5 度的光线
这对人类讨论来说没问题。但在三维计算中,方向向量往往更方便。
方向向量可以表示 x 和 y 两个方向的倾斜。它也很自然地与点积、表面法线、向量折射和求交点配合。
不过,从角度创建方向仍然是很有用的。
设光轴为正 z 轴。一条在 x-z 平面内倾斜 θx 角的光线,其方向是:
d = [sin θx, 0, cos θx]
对于小视场角,这就足够了。
import math
def direction_from_x_angle(theta_x_deg):
"""创建在 x-z 平面内倾斜的单位方向向量。"""
theta = math.radians(theta_x_deg)
return np.array([math.sin(theta), 0.0, math.cos(theta)])
for angle in [0.0, 5.0, 10.0]:
print(angle, direction_from_x_angle(angle))
对于同时有 x 和 y 方向倾斜的情况,我们可以写一个简单的辅助函数。一种简洁的构造方法是让光线指向平面 z = 1:
def direction_from_field_angles(theta_x_deg=0.0, theta_y_deg=0.0):
"""从近似的视场角创建方向向量。
光线指向 (tan θx, tan θy, 1)。
这对基于视场角的示例很方便。
"""
tx = math.tan(math.radians(theta_x_deg))
ty = math.tan(math.radians(theta_y_deg))
return normalize([tx, ty, 1.0])
测试:
print(direction_from_field_angles(0.0, 0.0))
print(direction_from_field_angles(5.0, 0.0))
print(direction_from_field_angles(0.0, 5.0))
print(direction_from_field_angles(5.0, 5.0))
这个辅助函数并非一个完整的视场模型。它只是一种实用的方式,用来生成大致沿光轴方向、并具有指定角度倾斜的光线。
之后,当我们从视场点和光瞳坐标生成光线束时,这类函数会成为更大型采样程序的一部分。
按距离传播光线
对光线最简单的操作是在均匀介质中传播。
如果光线移动物理距离 s,它的新位置是:
r_new = r_old + s d
如果介质的折射率为 n,光程增加量为:
ΔOPL = n s
这一点值得关注。
几何距离和光程不是一回事。一条光线在空气中通过 10 mm,若 n ≈ 1,光程大约 10 mm。同样的几何距离在折射率 n = 1.5 的玻璃中,其光程大约 15 mm。
这在之后计算 OPD 时会很重要。波前误差并非仅由几何距离构建。它依赖于光程。
让我们实现传播。
@dataclass
class Ray:
position: np.ndarray
direction: np.ndarray
wavelength_um: float = 0.5876
intensity: float = 1.0
opl: float = 0.0
alive: bool = True
stop_reason: str = ""
history: list = field(default_factory=list)
def __post_init__(self):
self.position = np.asarray(self.position, dtype=float)
self.direction = normalize(self.direction)
if self.position.shape != (3,):
raise ValueError("光线位置必须是 3D 向量。")
if self.direction.shape != (3,):
raise ValueError("光线方向必须是 3D 向量。")
if self.wavelength_um <= 0:
raise ValueError("波长必须为正。")
if self.intensity < 0:
raise ValueError("强度不能为负。")
def point_at(self, t):
return self.position + t * self.direction
def propagate(self, distance, refractive_index=1.0):
"""按几何距离向前移动光线。
distance 与位置单位一致,通常为 mm。
refractive_index 无量纲。
"""
if not self.alive:
return self
if distance < 0:
raise ValueError("传播距离应为非负值。")
old_position = self.position.copy()
self.position = self.point_at(distance)
self.opl += refractive_index * distance
self.history.append({
"event": "propagate",
"from": old_position,
"to": self.position.copy(),
"distance": distance,
"n": refractive_index,
"opl": self.opl,
})
return self
def stop(self, reason):
self.alive = False
self.stop_reason = reason
self.history.append({
"event": "stop",
"reason": reason,
"position": self.position.copy(),
})
return self
def copy(self):
return Ray(
position=self.position.copy(),
direction=self.direction.copy(),
wavelength_um=self.wavelength_um,
intensity=self.intensity,
opl=self.opl,
alive=self.alive,
stop_reason=self.stop_reason,
history=list(self.history),
)
现在试试:
ray = Ray([0, 0, 0], [0, 0, 1], wavelength_um=0.5876)
ray.propagate(10.0, refractive_index=1.0)
print(ray.position)
print(ray.opl)
输出是:
[ 0. 0. 10.]
10.0
通过玻璃:
ray = Ray([0, 0, 0], [0, 0, 1], wavelength_um=0.5876)
ray.propagate(10.0, refractive_index=1.5)
print(ray.position)
print(ray.opl)
位置依然是 [0, 0, 10],但光程是 15.0。
这个小小的区别是从几何光线追迹通向波前计算的一座桥梁。
传播到 z 平面
在序列光学系统中,许多有用的表面是沿 z 轴排列的。甚至在我们处理曲面之前,就有一个有用的问题:
这条光线在哪里与平面 z = z_target 相遇?
对光线:
r(t) = r0 + t d
其 z 坐标为:
z(t) = z0 + t dz
令其等于 z_target:
z_target = z0 + t dz
求解 t:
t = (z_target - z0) / dz
这个公式很简单,但它已经教会我们一个重要的习惯:求交点本质上就是解参数。我们找到使光线落在目标表面上的参数 t。
代码如下:
def distance_to_z_plane(ray, z_target, eps=1e-12):
"""返回光线到达 z = z_target 时的距离参数 t。"""
dz = ray.direction[2]
if abs(dz) < eps:
return None
t = (z_target - ray.position[2]) / dz
if t < 0:
return None
return t
现在使用它:
ray = Ray([0, 0, 0], [0.1, 0.0, 1.0])
t = distance_to_z_plane(ray, 50.0)
print("到 z=50 的距离:", t)
print("点:", ray.point_at(t))
光线到达 z 平面时,其 x 坐标不为零。这个 x 坐标就是由光线倾斜引起的横向位移。
我们可以添加一个方法:
def propagate_to_z(ray, z_target, refractive_index=1.0):
t = distance_to_z_plane(ray, z_target)
if t is None:
ray.stop(f"无法到达 z 平面 {z_target}")
return ray
return ray.propagate(t, refractive_index=refractive_index)
测试:
ray = Ray([0, 0, 0], direction_from_field_angles(theta_x_deg=5.0))
propagate_to_z(ray, 100.0, refractive_index=1.0)
print(ray.position)
print(ray.opl)
这是本书中第一个可用的类似求交点的操作。它仅是一个垂直于光轴的平面,但它引入了核心模式:
求解 t
求值 r(t)
更新光线
在第 5 章中,我们将对真正的表面使用同样的模式。
光线可能错过、向后走或变为无效
t 的公式本身还不够。一条光线可能无法沿前向到达平面。
如果 dz = 0,光线平行于 z 平面,永远不会到达不同的 z 值。
如果 t < 0,该平面在光线后方,而非前方。
这就是 distance_to_z_plane 在这些情形下返回 None 的原因。
这不仅仅是防御性编程。光线追迹充满有效性检查。
光线可能由于以下原因无效:
它错过一个表面
它到达的位置超出通光孔径
预计折射时发生全反射
它向后传播
它遇到不支持的面形
它损失了过多强度
与其假装每条光线都能存活,不如让有效性成为光线对象的一部分。
这就是我们的 Ray 类包含:
alive
stop_reason
的原因。当我们追迹一个光线束时,这将变得很有用。一些光线能穿过整个系统,另一些可能被截断。点列图通常只应包含能到达像面的光线。
从不停止光线的光线追迹器不是乐观,而是粗心。
波长属于光线
在第 3 章中,我们构建了折射率依赖于波长的材料模型:
n = n(λ)
光线追迹时,λ 应该放在哪里?
它应该放在光线对象上。
一束蓝光和红光可能从同一点出发,沿相同初始方向前进,但当它们遇到玻璃时,材料库会给出不同的折射率。这意味着折射可能会将它们偏转到稍微不同的方向上。
因此,光线必须携带其波长:
blue_ray = Ray([0, 0, 0], [0, 0, 1], wavelength_um=0.4861327)
green_ray = Ray([0, 0, 0], [0, 0, 1], wavelength_um=0.5875618)
red_ray = Ray([0, 0, 0], [0, 0, 1], wavelength_um=0.6562725)
之后,在表面处,追迹代码可以询问:
n_before = materials.n(before_material, ray.wavelength_um)
n_after = materials.n(after_material, ray.wavelength_um)
这就是材料章节与光线章节的连接方式。
透镜处方会说明哪些表面之后是什么材料。材料库说明每种材料在各波长下的表现。光线携带的波长用于选择正确的折射率。
各个部分开始组合到一起。
强度也属于光线
对早期的几何光线追迹而言,强度似乎没有必要。许多简单的光线追迹只关心光线去了哪里。
但从一开始将强度包含进来是有用的,因为实际的光学计算经常需要知道光线是否被均等加权。
一条光线的强度可能较低,因为:
它来自加权波长
它来自加权光瞳采样点
它经过一个部分透射的光学元件
它在表面发生反射或吸收
我们目前还不去模拟镀膜或吸收。但一个 intensity 字段几乎不占什么开销,却让光线对象更加诚实。
目前有一条简单规则:
intensity = 1.0 表示全权重
intensity = 0.0 表示无贡献
之后生成光线束时,我们可能会根据光瞳采样或波长权重给光线分配权重。这在汇总结果时会有影响。
再次强调,并非所有字段都会被立即使用。一个好的数据结构是为未来步骤做准备的,同时又不会变得臃肿。
光程不可能永远被忽略
初学者写的光线追迹器如果只是想得到点列图,可以忽略光程。
但本书不会止于点列图。我们要通往 OPD、波前、PSF 和 MTF。而为了走这条路,光程至关重要。
当一条光线以几何距离 s 通过折射率为 n 的介质时,它累积:
OPL += n s
对于一系列介质:
OPL = n1 s1 + n2 s2 + n3 s3 + ...
这看起来无伤大雅。但之后 OPD 会比较不同光线的光程。这种比较是几何光线追迹通往波动光学的桥梁之一。
因此,我们的光线从一开始就存储 opl。
即使我们直到第 10 章才完全使用它,这个习惯依然重要:
一条光线不只是一条几何线。它也是穿过光学介质的一条路径。
几何告诉我们它去哪里。
OPL 告诉我们它走了多远的光学距离。
光线历史用于学习,不总是用于生产
我们的 Ray 类存储一个 history 列表。每次传播事件可以附加一条小记录。
这对学习和调试很有帮助:
ray = Ray([0, 0, 0], [0.05, 0, 1])
ray.propagate(10.0, refractive_index=1.0)
ray.propagate(5.0, refractive_index=1.5)
for event in ray.history:
print(event)
生产级的光线追迹器可能不会为每条光线存储完整历史,因为这很昂贵。如果你要追迹数百万条光线,内存就会变得重要。
但对一个教学实现来说,历史很可爱。它让我们能检查路径,而不是只相信最终答案。这符合本书的精神:我们不想隐藏计算过程,而是想看到它。
之后,如果性能变得重要,我们可以关闭历史。
目前,清晰性胜出。
一个小型的光线表格
让我们生成几条光线,并将它们传播到同一个 z 平面。
rays = [
Ray([0, 0, 0], direction_from_field_angles(theta_x_deg=0.0)),
Ray([0, 0, 0], direction_from_field_angles(theta_x_deg=2.0)),
Ray([0, 0, 0], direction_from_field_angles(theta_x_deg=-2.0)),
]
z_image = 100.0
for ray in rays:
propagate_to_z(ray, z_image)
print(
f"近似角度方向={ray.direction}, "
f"击中位置={ray.position}, "
f"OPL={ray.opl:.3f}"
)
轴上光线落在 x = 0 处。倾斜光线落在相反两侧。
这还不是透镜,只是自由空间传播。但即使在这里,我们也能看到点列图的雏形:光线的着点集合。
让我们把它们画出来。
import matplotlib.pyplot as plt
xs = [ray.position[0] for ray in rays]
ys = [ray.position[1] for ray in rays]
plt.scatter(xs, ys)
plt.axhline(0, linewidth=0.5)
plt.axvline(0, linewidth=0.5)
plt.xlabel("z 平面处的 x [mm]")
plt.ylabel("z 平面处的 y [mm]")
plt.title("光线在 z 平面上的着点")
plt.gca().set_aspect("equal", adjustable="box")
plt.show()
这是一个非常无聊的点列图。很好。无聊的图有时是最好的起点。
还没有发生折射。还没有击中任何表面。也没有像差。我们仅仅是在确认光线能够移动、携带其属性,并落在某个可测量的位置。
从一条光线到许多光线
多数光学分析不会只追迹一条光线。
它追迹光线束。
一个光线束可以代表:
光瞳中不同的高度
不同的视场点
不同的波长
不同的物点
不同的采样权重
现在,让我们在 x-z 平面内创建一个简单的光线扇形,全部从原点出发,但具有不同的角度。
def make_angle_fan(theta_min_deg, theta_max_deg, count, wavelength_um=0.5876):
angles = np.linspace(theta_min_deg, theta_max_deg, count)
return [
Ray(
position=[0.0, 0.0, 0.0],
direction=direction_from_field_angles(theta_x_deg=a),
wavelength_um=wavelength_um,
)
for a in angles
]
fan = make_angle_fan(-5.0, 5.0, 11)
for ray in fan:
propagate_to_z(ray, 100.0)
xs = [ray.position[0] for ray in fan]
ys = [ray.position[1] for ray in fan]
plt.scatter(xs, ys)
plt.xlabel("z = 100 mm 处的 x")
plt.ylabel("z = 100 mm 处的 y")
plt.title("自由空间扇形光线着点")
plt.gca().set_aspect("equal", adjustable="box")
plt.show()
光线线性散开,因为没有透镜。
之后,当我们加入表面和折射时,同样的光线束会发生弯曲。经过一个透镜追迹后,像面上的着点将成为一个真正的点列图。
这个点列图不是"画"出来的。它是由光线对象在系统中传播后得到的结果。
这正是我们想实现的思维转变。
实际光线和近轴光线
现在我们需要做一个重要的区分。
实际光线就是我们一直在构建的东西:一条具有完整位置向量和方向向量的几何光线。它可以击中精确的表面,可以使用精确的法线进行折射。除非我们特意创建小角度的光线,否则它并不假定小角度。
近轴光线则不同。它存在于光轴附近的小角度近似之中。近轴光学通常追踪像光线高度和斜率这样的量,而不是完整的 3D 几何:
y = 光线高度
u = 光线角度或斜率
近轴光线并非无用的"虚假"模型。它是一个捕捉光学系统一阶骨架的简化模型。
实际光线追迹问的是:
实际光线通过实际表面几何的路径是怎样的?
近轴光线追迹问的是:
近轴的、小角度的光线在一阶近似下表现如何?
两者都很重要。
近轴光线有助于计算:
有效焦距
后焦距
主平面
F 数
放大率
一阶布局
实际光线有助于计算:
像差
点列图
光线扇形图
实际焦面行为
视场相关误差
在本书中,我们会同时用到两者。但我们绝不能混淆它们。
一个小型近轴光线类可能如下:
@dataclass
class ParaxialRay:
y: float # 光线高度
u: float # 小角度或斜率
wavelength_um: float = 0.5876
def propagate(self, distance):
"""小角度传播:y_new = y + distance * u。"""
self.y = self.y + distance * self.u
return self
这比实际的 Ray 类简单得多。它没有 3D 位置,没有精确的方向向量,没有表面求交逻辑。当我们在第 8 章学习一阶光学时,这种简单性很有用。
但第 5–7 章的光线追迹器将使用实际光线。
这就是清晰的边界:
第 4–7 章:实际光线的表示与追迹
第 8 章:近轴光线传递与一阶光学
第 9 章起:将实际光线与近轴参考进行比较
如果你心中保持这个分界清晰,许多后续主题会变得更容易。
光线不是光束
另一个有用的区分:
光线不是光束。
光线是一个几何样本。光束是光的物理分布。
当我们通过光瞳追迹许多光线时,我们并不是在声称每条光线都是微小的物理光线束。我们是在对光学系统的行为进行采样。一个光线束是一种计算上的表示。
这一点很重要,因为不同的分析会用到不同的采样思路。
对点列图,我们可能追迹有限数量的光线,并画出它们的交点。
对波前分析,我们可能追迹与光瞳坐标相关联的光线,并计算光程差。
对衍射 PSF,我们最终需要的是一个光瞳函数,而不仅仅是光线着点列表。
光线是一个工具。它很强大,但它不是光的全部物理。
这就是本书不会止步于几何光线追迹的原因。它将用光线来构建通向波前和衍射计算的桥梁。
但首先,光线本身必须是正确的。
坐标约定
现在说明我们在教学代码中要使用的坐标约定。
z 轴:名义光轴
x, y:横向坐标
光大致沿 +z 方向传播
位置以毫米为单位
波长以微米为单位
方向向量无量纲且归一化
这是一个约定,而非自然法则。其他软件可能使用不同的坐标选择或符号约定。专业的光学设计程序对这些定义非常谨慎。
就我们的目的而言,这个约定让早期示例清晰易读。
一条从原点发出、沿光轴传播的光线是:
Ray(position=[0, 0, 0], direction=[0, 0, 1])
一条从轴上 5 mm 高度发出、向前传播的光线是:
Ray(position=[0, 5, 0], direction=[0, 0, 1])
一条在 x 方向倾斜的光线是:
Ray(position=[0, 0, 0], direction=direction_from_field_angles(theta_x_deg=3.0))
这些选择简单到可以手工检查。
这正是在增加曲面之前我们所期望的。
保持单位清晰
光线对象同时携带位置和波长,但它们使用不同的单位:
位置:mm
波长:µm
这看起来可能有点奇怪,但它遵循常见的光学设计实践。
为什么不把所有东西都存成毫米?
我们当然可以。但材料色散公式和玻璃库常用微米为单位表示波长。如果我们让波长以微米为单位,第 3 章的材料代码就会保持自然。
危险在于混用它们。
在计算光线传播和表面求交点时,使用毫米。
向材料查询折射率时,传入微米。
之后,将 OPD 转为相位时,要小心:OPD 可能以毫米为单位,而波长可能以微米为单位。这种转换很重要。我们将在处理波前相位时明确处理它。
目前,只需保持标签清晰。
一个好的光线对象不会解决所有单位问题,但它可以让你看清单位的假设。
一个更细致的传播示例
让我们将波长、材料折射率和 OPL 结合起来。
假设一条绿光光线经历:
20 mm 空气中
5 mm N-BK7 中
20 mm 空气中
在 d 线,N-BK7 的折射率约为 1.5168。几何距离是:
20 + 5 + 20 = 45 mm
光程约为:
1.0 × 20 + 1.5168 × 5 + 1.0 × 20 = 47.584 mm
我们来计算它:
ray = Ray([0, 0, 0], [0, 0, 1], wavelength_um=0.5876)
ray.propagate(20.0, refractive_index=1.0)
ray.propagate(5.0, refractive_index=1.5168)
ray.propagate(20.0, refractive_index=1.0)
print("位置:", ray.position)
print("OPL:", ray.opl)
这个例子故意只考虑一维。它忽略了折射,并假设光线只是简单通过各段。但它教我们如何记账。
之后,当光线在表面发生偏折时,各段的几何长度将从求交计算中得到。折射率将从材料模型中获取。光程将逐段累积。
最终的 OPD 计算会比较这些累积的路径。
所以,即便是现在,光线对象也在承载着未来的波前信息。
绘制光线路径
光线的历史让我们可以画出简单的路径。
def extract_path_from_history(ray):
points = []
for event in ray.history:
if event["event"] == "propagate":
if not points:
points.append(event["from"])
points.append(event["to"])
return np.array(points)
ray = Ray([0, 0, 0], direction_from_field_angles(theta_x_deg=5.0))
ray.propagate(20.0)
ray.propagate(30.0)
path = extract_path_from_history(ray)
plt.plot(path[:, 2], path[:, 0], marker="o")
plt.xlabel("z [mm]")
plt.ylabel("x [mm]")
plt.title("光线在 x-z 平面内的路径")
plt.gca().set_aspect("equal", adjustable="box")
plt.show()
我们将 z 画在水平轴,x 画在垂直轴,因为这类似于通常的光学布局视图。
这依然是一条直线。这很好。在光线能正确弯曲之前,它首先应该能正确走直线。
在光线追迹中,大量 bug 往往更容易在你首先测试无聊的直线行为时被发现。
一个真实光学库做了什么
我们的 Ray 类是用于学习的。一个真实的商业光学库则必须处理更多。
例如,一个实用的光线追迹系统可能需要:
大量光线的数组
多个视场
多个波长
逐面数据
孔径剪裁
光线瞄准
主光线和边缘光线搜索
偏振态
渐晕因子
坐标断开
表面局部坐标
对 NumPy、PyTorch 或 JAX 的后端支持
作为本书的参考库, Optiland 在一个完整的光学系统模型中提供了更高级的光线追迹工作流。我们不会逐行模仿它的内部光线对象,那样会把本书变成一份 API 评注,这不是本书的目的。
取而代之,我们采用一种两层的阅读方式。
在教学代码层面,一条光线是透明的:
ray = Ray(position=[0, 0, 0], direction=[0, 0, 1], wavelength_um=0.5876)
在库层面,光线通常通过一个光学系统生成和追迹:
# 示意性的 Optiland 风格工作流。
# 具体方法名可能因版本而异。
from optiland.samples.objectives import CookeTriplet
lens = CookeTriplet()
# 该库可以绘制系统并追迹光线用于可视化。
lens.draw(num_rays=5)
这类调用有意隐藏了许多细节。它选择光线样本,将其追迹通过各表面,并绘制结果。这很有用。
但现在我们知道底层必须存在什么:
光线位置
光线方向
光线波长
表面交点
材料折射率
折射更新
有效性检查
确切的组织方式可能与我们教学用的类有所不同。但计算上的责任不会消失。
一个库可以让光线追迹变得方便,但它无法让光线变得多余。
一个最简的光线追迹骨架
现在我们可以写出一个光线追迹器的骨架,尽管尚未实现表面。
def trace_ray_through_system(ray, surfaces, materials):
"""仅是一个骨架。
第 5 章将填充光线-表面求交。
第 6 章将填充折射。
"""
for surface in surfaces:
if not ray.alive:
break
# 1. 找到光线与此表面的交点。
# intersection = intersect(ray, surface)
# 2. 如果未命中,停止光线。
# if intersection is None:
# ray.stop("missed surface")
# break
# 3. 将光线传播到交点。
# distance = intersection.distance
# ray.propagate(distance, refractive_index=current_n)
# 4. 检查通光孔径。
# if outside aperture:
# ray.stop("blocked by aperture")
# break
# 5. 计算法线。
# normal = surface_normal(surface, ray.position)
# 6. 使用 ray.wavelength_um 计算 n_before 和 n_after。
# n1, n2 = index_transition(...)
# 7. 折射光线方向。
# ray.direction = refract(ray.direction, normal, n1, n2)
pass
return ray
这个函数现在还运行不了。它是一个蓝图。
重点在于,光线对象已经包含了未来函数所需的信息。
intersect 将使用光线的位置和方向。
index_transition 将使用光线的波长。
propagate 将更新光线的位置和光程。
refract 将更新光线的方向。
光线追迹器不是一个巨大的咒语。它是一系列有明确输入和输出的小型操作的序列。
测试 Ray 类
在光线之上构建更多机制之前,我们先写几个小测试。
def test_direction_is_normalized():
ray = Ray([0, 0, 0], [0, 0, 10])
assert np.isclose(np.linalg.norm(ray.direction), 1.0)
def test_point_at():
ray = Ray([1, 2, 3], [0, 0, 1])
p = ray.point_at(5)
assert np.allclose(p, [1, 2, 8])
def test_propagate_updates_position_and_opl():
ray = Ray([0, 0, 0], [0, 0, 1])
ray.propagate(10, refractive_index=1.5)
assert np.allclose(ray.position, [0, 0, 10])
assert np.isclose(ray.opl, 15.0)
def test_propagate_to_z():
ray = Ray([0, 0, 0], [0, 0, 1])
propagate_to_z(ray, 25.0)
assert np.allclose(ray.position, [0, 0, 25.0])
test_direction_is_normalized()
test_point_at()
test_propagate_updates_position_and_opl()
test_propagate_to_z()
print("所有光线测试通过。")
这可能感觉对一个光学书籍来说有点过于像软件工程了。其实不然。
光学计算就是软件。如果基础的光线对象是错的,那么后续的光学图可能看起来仍然漂亮,但它们是错的。
小测试不是装饰,而是我们保持计算可信的方式。
表示光线时的常见错误
现在就来收集这些陷阱。
错误 1:未归一化方向
如果方向向量不是单位长度,传播距离的含义就会模糊。在构造时就归一化它。
错误 2:将方向与终点混淆
方向向量不是光线试图要到达的点,而是一个指向。如果你想定义一条从点 A 指向点 B 的光线,请计算:
d = normalize(B - A)
而不是 d = B。
错误 3:忘记波长
没有波长的光线无法在色散材料中选择正确的折射率。
你可以使用一个默认波长,但它应当是明确的。
错误 4:忽略无效光线
有些光线应该被停止。如果你继续追迹被阻挡或丢失的光线,后续图表可能会包含无意义的信息。
错误 5:将光程当作几何距离
几何距离是 s。光程是 n s。它们仅在 n = 1 时才相等。
错误 6:默默混用单位
位置可能是毫米而波长是微米。这只有当代码明确说明时才是可以接受的。
错误 7:混淆实际光线和近轴光线
近轴光线是一阶近似。实际光线是通过实际表面追迹的几何对象。两者都有用,但不可互换。
本章给了我们什么
我们现在有了一条可计算的光线。
它可以:
存储位置和方向
归一化其方向
携带波长和强度
累积光程
按距离向前移动
移动到 z 平面
无效时停止
记录历史以供检查
这看起来可能还不够戏剧性。没有透镜对焦。没有光线发生偏折。也没有点列图出现。
但基础现在是坚实的。
第 2 章的处方给了我们光学系统的数据。
第 3 章的材料模型给了我们作为波长函数的折射率。
本章的光线对象给了我们一个可以在该系统中穿行的东西。
下一个缺失的拼图是表面相遇。
一条光线可以沿直线移动。一个透镜表面位于空间某处。光线追迹器必须回答:
这条光线在哪个点会击中该表面?
对平面来说,这很容易。
对球面来说,它会变成一个二次方程。
对非球面及其他更复杂的表面,可能需要数值方法。
真正的工作就将从这里开始。
第 5 章将用我们在这里构建的光线,让它与几何形状发生碰撞。我们将手工计算光线-平面和光线-球面的交点。这是本书最重要的章节之一,因为它展示了许多讲解会跳过的那一部分。
光线图显示一条线接触到玻璃。
光线追迹器必须计算出这一次接触。
生成的验证图
