Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第3章:材料、波长与折射率

镜头处方里有一个小列,标题是material

在第2章中,那一列看起来很简单。某个表面写着N-BK7,另一个表面写着air。我们把材料当作该表面之后的介质,这样就已经为光线追迹做好了准备。

但材料列不仅仅是一个标签。

它是一个承诺。

当光线到达一个表面时,程序最终必须回答:

表面之前的折射率是多少?
表面之后的折射率是多少?
在当前波长下?

最后一句话就是陷阱。

不是:

折射率是多少?

而是:

在当前波长下,折射率是多少?

这一个条件改变了一切。

同一种玻璃不会把所有的颜色都弯曲同样的量。蓝光和红光看到的折射率略有不同。这种差异称为色散。正是色散使得棱镜能把白光分解为彩色。它也是镜头存在色差的原因之一。一片能把绿光聚焦得很整齐的透镜,可能会把蓝光和红光聚焦到略微不同的位置。

这不是一个旁生细节,它很早就会进入计算。

从第1章开始的完整链条是:

镜头处方
→ 表面和材料
→ 实际光线追迹
→ 近轴参考基准
→ 光瞳采样
→ 光程差
→ 光瞳函数
→ 点扩散函数
→ 光学传递函数
→ 调制传递函数

本章处于链条的第二环:

表面和材料

在我们能追迹通过玻璃的光线之前,我们需要知道玻璃在数值上意味着什么。在我们能计算折射之前,我们需要n_beforen_after。在我们能比较波长之前,我们需要一个能将波长转换为折射率的函数。

所以本章只有一个主要任务:

将材料列变成一个依赖波长的折射率函数。

我们将从物理概念入手,然后写一个小的 Python 材料模型,接着再和真实光学库处理材料的方式进行对比。

同一种玻璃在不同波长下的数值并不相同

想象一片用 N-BK7 制成的简单单透镜。

如果你问“N-BK7 的折射率是多少?”,你可能会看到一个常见的答案:

n_d ≈ 1.5168

这个数很有用,但还不够完整。

下标是有意义的。n_d中的d指的是大约在 587.6 nm 附近的一条参考谱线。光学玻璃目录中,折射率常常会在几个命名波长处给出。数值1.5168并不意味着“N-BK7 总是 1.5168”。它大致上表示:

N-BK7 在 d 线波长处的折射率大约是 1.5168。

在更短的波长下,比如大约 486.1 nm 的蓝光处,折射率会略微增大。在更长的波长下,比如大约 656.3 nm 的红光处,折射率会略微减小。

这听起来变化很小。从数值上看的确很小。但镜头恰恰是由穿过各个表面后累积起来的微小角度变化构成的。折射率的一个微小变化就能移动焦点,改变像差,并影响像质。

因此,本章的第一条规则很简单:

在光学计算中,一种材料不仅仅是一个名字,它还是一个关于波长的函数。

在代码中,我们想要这种形式:

n = refractive_index(material_name, wavelength_um)

或者,更好的形式是:

n = material.n(wavelength_um)

这正是我们所需的小小的计算对象。

为什么波长单位值得被认真对待

在写公式之前,我们需要定好单位。

光学设计通常使用:

镜头距离:毫米
波长:微米
空间频率:每毫米线对数

这种混合方式是正常的,但如果忘记了,它也会很危险。

587.6 nm 的波长是:

587.6 nm = 0.5876 µm = 0.0005876 mm

同一个物理波长可以写成三种形式。一个 Python 浮点数并不知道你想的是哪一种。

对本书中的材料计算,我们将采用微米作为波长的单位:

wavelength_um = 0.5876

这种约定并非凭空而定。许多光学玻璃色散公式就是用微米来表示波长的。当前 Optiland 的材料文档也明确指出,在调用材料折射率时,波长输入要以微米为单位。(Optiland)

所以,当我们写:

wavelength_um = 0.5876

我们指的是 d 线附近,而不是半毫米,也不是 0.5876 纳米。

这听起来很较真。但并不是。光学中的单位错误常常会产生一段时期内看起来还像那么回事的数字。然后计算结果在三个章节之后失效,而 bug 就藏在一个看起来无害的0.55里面。

还是对自己好一点,把单位写在名字里吧。

故意从错误的模型开始

最简单的材料模型是常数折射率:

class ConstantMaterial:
    def __init__(self, n):
        self.n_value = float(n)

    def n(self, wavelength_um):
        return self.n_value

像这样使用它:

air = ConstantMaterial(1.0)
glass = ConstantMaterial(1.5168)

for wl in [0.4861, 0.5876, 0.6563]:
    print(wl, glass.n(wl))

输出是:

0.4861 1.5168
0.5876 1.5168
0.6563 1.5168

对于真实的玻璃,这个模型在物理上并不正确。它说这种材料对蓝光、黄绿光和红光的折射率都一样。

那为什么要写它呢?

因为它是正确的基线。

许多光学程序都支持某种形式的、具有固定折射率的理想材料。这对测试几何关系、调试光线追迹和构建简单例子都很有用。例如,Optiland 的材料教程就包含了一个IdealMaterial,具有固定的折射率和消光系数。(Optiland)

常数折射率材料之所以不错,并不是因为它简单。只有在忘记我们做了哪些简化时,它才是错的。

这种区别很重要。

在计算中,简化是被允许的。静默的简化才是危险的。

折射率曲线

现在我们想要一种折射率随波长而变的材料。

对于可见光区域的普通光学玻璃,折射率通常随波长增加而减小。蓝光看到的折射率比红光更高,这叫做正常色散。

一个粗糙的玩具模型可以是这样的:

class ToyDispersiveMaterial:
    def __init__(self, n_ref=1.5168, wavelength_ref_um=0.5876, slope=-0.02):
        self.n_ref = float(n_ref)
        self.wavelength_ref_um = float(wavelength_ref_um)
        self.slope = float(slope)

    def n(self, wavelength_um):
        return self.n_ref + self.slope * (wavelength_um - self.wavelength_ref_um)

然后:

toy_glass = ToyDispersiveMaterial()

for wl in [0.4861, 0.5876, 0.6563]:
    print(wl, toy_glass.n(wl))

这样得到的折射率在较短波长下更高,在较长波长下更低。这从趋势上看是有用的,但并不是一个真正的玻璃模型。

我们可以把它画出来:

import numpy as np
import matplotlib.pyplot as plt

toy_glass = ToyDispersiveMaterial()
wavelengths = np.linspace(0.45, 0.70, 200)
indices = [toy_glass.n(wl) for wl in wavelengths]

plt.plot(wavelengths, indices)
plt.xlabel("wavelength [µm]")
plt.ylabel("refractive index")
plt.title("Toy dispersion model")
plt.show()

这张图值得让我们暂停一下。

处方中的材料列已经不再是一个标签,它变成了一条曲线。

之后,当我们追迹光线时,一条蓝光和一条红光穿过同一个镜头表面时,会用到不同的n_after值。这意味着它们会被折射不同的量,从而可能聚焦到不同的位置。

这条曲线就是色度行为进入计算的地方。

Sellmeier 方程

真实的光学玻璃目录通常使用色散公式。其中最常用的之一就是 Sellmeier 方程。

对于一个三项的 Sellmeier 模型:

n²(λ) = 1
      + B₁ λ² / (λ² - C₁)
      + B₂ λ² / (λ² - C₂)
      + B₃ λ² / (λ² - C₃)

其中:

n(λ) = 波长 λ 处的折射率
λ    = 波长,对光学玻璃系数通常以微米为单位
Bᵢ   = Sellmeier B 系数
Cᵢ   = Sellmeier C 系数,通常单位为 µm²

这个公式看起来比它的思想更吓人。它仍然只是一个函数:

波长 → 折射率

系数是通过对实测材料行为进行拟合得到的。不同的玻璃有不同的系数。一旦系数确定下来,程序就可以在许多波长下计算折射率。

我们来把它实现出来。

import math

class SellmeierMaterial:
    def __init__(self, B, C, name="unnamed"):
        self.B = tuple(float(x) for x in B)
        self.C = tuple(float(x) for x in C)
        self.name = name

    def n(self, wavelength_um):
        lam2 = wavelength_um * wavelength_um
        n2 = 1.0

        for B_i, C_i in zip(self.B, self.C):
            n2 += B_i * lam2 / (lam2 - C_i)

        return math.sqrt(n2)

对于 N-BK7,一组常见的 SCHOTT Sellmeier 系数是:

B1 = 1.039612120
B2 = 0.231792344
B3 = 1.010469450

C1 = 0.006000699
C2 = 0.0200179144
C3 = 103.56065300

SCHOTT 的 N-BK7 数据表列出了这些色散常数,并给出了像n_d = 1.51680以及阿贝数ν_d = 64.17这样的数值。(Schott Media)

现在写出这种玻璃:

N_BK7 = SellmeierMaterial(
    B=(1.039612120, 0.231792344, 1.010469450),
    C=(0.006000699, 0.0200179144, 103.56065300),
    name="N-BK7",
)

for wl in [0.4861327, 0.5875618, 0.6562725]:
    print(f"{wl:.7f} µm  n = {N_BK7.n(wl):.7f}")

打印出的具体数值会因四舍五入而略有不同,但你应该能观察到预期的模式:

蓝光/F 线:折射率较高
d 线:     折射率居中
红光/C 线:折射率较低

这种模式就是色散。

绘制真实的色散曲线

现在绘制折射率曲线。

import numpy as np
import matplotlib.pyplot as plt

wavelengths = np.linspace(0.40, 0.75, 400)
indices = [N_BK7.n(wl) for wl in wavelengths]

plt.plot(wavelengths, indices)
plt.xlabel("wavelength [µm]")
plt.ylabel("refractive index")
plt.title("N-BK7 refractive index from Sellmeier equation")
plt.show()

曲线从蓝光向红光方向向下倾斜。

这条曲线的坡度很平缓,但光学系统在意平缓的坡度。一个镜头表面并不会去问一个数值是否显得显著,它只是在斯涅耳定律中使用这个数值。微小的折射率差异会变成微小的角度差异。经过传播之后,这个角度差异就会变成纵向或横向的色差误差。经过多个表面,它就成了设计问题的一部分。

这就是为什么存在消色差双胶合镜。单种玻璃存在色散。将具有不同色散性质的玻璃配对在一起,可以让设计师减小色差。我们现在还不会设计消色差镜,但它们存在的原因已经在这条曲线中清晰可见了。

阿贝数:一个紧凑的色散概括

玻璃目录需要一种紧凑的方式来描述色散。一种常见的概括方式就是阿贝数。

对于 d 线惯例:

V_d = (n_d - 1) / (n_F - n_C)

其中:

n_d = 氦 d 线处折射率,约 587.6 nm
n_F = 氢 F 线处折射率,约 486.1 nm
n_C = 氢 C 线处折射率,约 656.3 nm

Optiland 的材料 API 也使用 d、F、C 谱线来定义同样的V_d。(Optiland)

我们来计算它。

F_LINE_UM = 0.4861327
D_LINE_UM = 0.5875618
C_LINE_UM = 0.6562725

def abbe_number_vd(material):
    n_F = material.n(F_LINE_UM)
    n_d = material.n(D_LINE_UM)
    n_C = material.n(C_LINE_UM)
    return (n_d - 1.0) / (n_F - n_C)

print(abbe_number_vd(N_BK7))

对于 N-BK7,结果应当接近 64.17,具体数值取决于系数的精度和舍入方式。

这个数字意味着什么?

更高的阿贝数意味着更低的色散。更低的阿贝数意味着更强的色散。

冕牌玻璃常常具有相对较高的阿贝数。火石玻璃则往往具有较低的阿贝数和较高的色散。这种区分对于传统的色差校正是很关键的。

但我们应当小心。

阿贝数只是一个概括,而不是完整的折射率模型。它用三个波长来描述一条曲线。这对于分类、比较和近似建模都很有用,但它并不等同于拥有完整的色散曲线。

这是光学计算中反复出现的一个主题:

一个紧凑的参数只有在你还记得它压缩了什么的时候才是有用的。

阿贝数不能替代n(λ)。它只是提供了关于n(λ)的一个重要线索。

波长组与权重

实际的光学设计通常不会只在一个波长下被评估。

一个简单的可见光系统可能会使用三个波长:

蓝:  0.4861 µm
绿:  0.5876 µm
红:  0.6563 µm

一个更贴近实际的成像设计可能会使用多个波长,并为它们赋予权重。权重告诉软件,在某个特定的分析或优化过程中,每个波长有多重要。

一个粗略的例子:

wavelength [µm]   weight
0.4861            0.5
0.5876            1.0
0.6563            0.5

这并不意味着绿色光线“更真实”。它只是说,在设计评估中,该波长被赋予了更高的重要性。

权重可以代表一种简化的光谱分布、传感器响应、明视觉人眼响应、设计优先级,或者仅仅是一个实际的工程选择。其含义取决于具体系统。

对于本书的教学代码,我们可以这样表示波长:

from dataclasses import dataclass

@dataclass
class Wavelength:
    value_um: float
    weight: float = 1.0
    is_primary: bool = False

wavelengths = [
    Wavelength(0.4861327, weight=0.5),
    Wavelength(0.5875618, weight=1.0, is_primary=True),
    Wavelength(0.6562725, weight=0.5),
]

然后我们可以评估材料折射率:

for wl in wavelengths:
    n = N_BK7.n(wl.value_um)
    marker = " primary" if wl.is_primary else ""
    print(f"{wl.value_um:.7f} µm  weight={wl.weight:.2f}  n={n:.7f}{marker}")

这是一个很小的数据结构,但它却是多波长光学计算的起点。

之后,当我们计算点列图时,我们可能会在多个波长下追迹光线,并用不同颜色绘制它们。当我们计算 MTF 时,我们可能会计算加权的多色结果。当优化一个镜头时,评价函数可能会包含多个波长下的误差。

后文的图形依然始于此。

更新第2章的镜头系统

在第2章中,我们的LensSystem将波长存储为一个简单的浮点数列表:

wavelengths_um = [0.5876]

现在我们可以更明确一些:

from dataclasses import dataclass, field
from typing import List, Optional
import math

@dataclass
class Wavelength:
    value_um: float
    weight: float = 1.0
    is_primary: bool = False

@dataclass
class Surface:
    radius: float
    thickness: float
    material_after: str = "air"
    semi_diameter: Optional[float] = None
    conic: float = 0.0
    is_stop: bool = False
    surface_type: str = "spherical"
    comment: str = ""

    @property
    def curvature(self):
        if math.isinf(self.radius):
            return 0.0
        return 1.0 / self.radius

@dataclass
class LensSystem:
    surfaces: List[Surface] = field(default_factory=list)
    wavelengths: List[Wavelength] = field(default_factory=list)
    units: str = "mm"

    def add_surface(self, surface):
        self.surfaces.append(surface)

    def add_wavelength(self, value_um, weight=1.0, is_primary=False):
        self.wavelengths.append(Wavelength(value_um, weight, is_primary))

现在我们需要一个材料库。

class MaterialLibrary:
    def __init__(self):
        self._materials = {}

    def add(self, name, material):
        self._materials[name] = material

    def n(self, name, wavelength_um):
        if name == "air":
            return 1.0

        if name not in self._materials:
            raise ValueError(f"Unknown material: {name}")

        return self._materials[name].n(wavelength_um)

注册 N-BK7:

materials = MaterialLibrary()
materials.add("N-BK7", N_BK7)

再次构建那片单透镜:

lens = LensSystem()

lens.add_surface(Surface(
    radius=float("inf"),
    thickness=float("inf"),
    material_after="air",
    surface_type="object",
    comment="object at infinity",
))

lens.add_surface(Surface(
    radius=+50.0,
    thickness=5.0,
    material_after="N-BK7",
    semi_diameter=10.0,
    is_stop=True,
    comment="front lens surface",
))

lens.add_surface(Surface(
    radius=-50.0,
    thickness=45.0,
    material_after="air",
    semi_diameter=10.0,
    comment="back lens surface",
))

lens.add_surface(Surface(
    radius=float("inf"),
    thickness=0.0,
    material_after="air",
    surface_type="image",
    comment="image plane",
))

lens.add_wavelength(0.4861327, weight=0.5)
lens.add_wavelength(0.5875618, weight=1.0, is_primary=True)
lens.add_wavelength(0.6562725, weight=0.5)

现在对每个物理表面和每个波长,询问材料过渡。

def material_before_after(lens, surface_index, starting_material="air"):
    if surface_index == 0:
        before = starting_material
    else:
        before = lens.surfaces[surface_index - 1].material_after

    after = lens.surfaces[surface_index].material_after
    return before, after

for wl in lens.wavelengths:
    print(f"\nWavelength {wl.value_um:.7f} µm")
    for i, surface in enumerate(lens.surfaces):
        if surface.surface_type in ("object", "image"):
            continue

        before, after = material_before_after(lens, i)
        n_before = materials.n(before, wl.value_um)
        n_after = materials.n(after, wl.value_um)

        print(
            f"surface {i}: {before:>6} n={n_before:.7f} "
            f"→ {after:>6} n={n_after:.7f}"
        )

这仍然不是光线追迹,但它现在已经为光线追迹做好了准备。

对每个表面,每个波长,程序都可以获得n_beforen_after

这正是第6章的折射算法所需要的。

斯涅耳定律正在等待这些数字

我们现在并不推导矢量折射,这值得用单独一章来讲。但我们可以预览一下为什么折射率函数如此重要。

标量形式的斯涅耳定律是:

n₁ sin θ₁ = n₂ sin θ₂

其中:

n₁ = 表面之前的折射率
n₂ = 表面之后的折射率
θ₁ = 相对于表面法线的入射角
θ₂ = 相对于表面法线的折射角

如果n₂随波长变化,那么θ₂也会随波长变化。

让我们算一个很小的例子。假设一束光在空气中以 10 度的入射角进入 N-BK7。

import math

def refracted_angle_deg(theta1_deg, n1, n2):
    theta1 = math.radians(theta1_deg)
    s2 = n1 * math.sin(theta1) / n2

    if abs(s2) > 1.0:
        return None  # total internal reflection

    theta2 = math.asin(s2)
    return math.degrees(theta2)

theta1_deg = 10.0

for wl in [F_LINE_UM, D_LINE_UM, C_LINE_UM]:
    n2 = N_BK7.n(wl)
    theta2 = refracted_angle_deg(theta1_deg, n1=1.0, n2=n2)
    print(f"{wl:.7f} µm  n={n2:.7f}  theta2={theta2:.6f} degrees")

折射角的差异会很小。这没关系,小差异是真实的。

镜头包含弯曲的表面。光线以不同的高度入射,局部的表面法线也会变化,光线在折射后会继续传播。一个表面上的微小角度差异,在另一个表面或像面上就会变成位置上的差异。

这就是色差在计算上的起源。

它并不是后来才以一个神秘的缺陷形式出现的。它始于这里:

n = n(λ)

然后斯涅耳定律使用了这个n值。

一个薄透镜近似中的纵向色差

完整的色度焦距计算需要光线追迹或近轴系统计算。两者我们以后都会学到。但现在,一个薄透镜的预览可以显示出这个方向。

对于一片在空气中的薄透镜,一个粗略的、类似透镜制造者公式的关系是:

1/f ≈ (n - 1) (1/R₁ - 1/R₂)

这个简化形式忽略了厚度,并假设透镜在空气中。对严肃的设计而言它还不够,但用它来看色散的影响已经足够了。

如果n随波长变化,那么f也会随波长变化。

让我们对一个对称的双凸透镜计算这个值,其中:

R₁ = +50 mm
R₂ = -50 mm
def thin_lens_focal_length(n, R1, R2):
    power = (n - 1.0) * (1.0 / R1 - 1.0 / R2)
    return 1.0 / power

R1 = +50.0
R2 = -50.0

for wl in [F_LINE_UM, D_LINE_UM, C_LINE_UM]:
    n = N_BK7.n(wl)
    f = thin_lens_focal_length(n, R1, R2)
    print(f"{wl:.7f} µm  n={n:.7f}  f≈{f:.3f} mm")

你应该能看到,在所有三个波长下,焦距并不完全相同。

这还不是我们最终的模型。它是对一个事实的预览,这个事实光线追迹器稍后将以更直接的方式展示出来:

单透镜并不能把所有波长都聚焦到同一位置。

现在,其原因已经清晰可见:材料曲线进入了折射计算。

材料也可以吸收光

到目前为止,我们只讨论了折射率n

许多材料数据库还会描述消光系数k,它与吸收相关。在复折射率中:

N = n + i k

实部n控制相速度和折射,而k描述损耗。

对于接下来即将学习的几何光线追迹章节,我们将主要使用n。但知道一个更丰富的材料模型也可以包含吸收信息是有益的。Optiland 的材料类既包含折射率方法,也包含消光系数方法,其材料教程演示了如何同时计算随波长变化的nk。(Optiland)

我们现在还不会把吸收构建到光线追迹器里,那样会分散对主链条的注意力。但当稍后我们讨论透射、膜层或非可见光材料时,k就会成为故事的一部分。

现在:

光线方向计算 → 需要 n(λ)
吸收/透射计算 → 可能需要 k(λ)

一步一步来。

Optiland 材料模型

我们的教学代码中有两种材料类型:

ConstantMaterial
SellmeierMaterial

真实的库需要得更多。

当前 Optiland 材料文档描述了好几种材料类型,包括IdealMaterialAbbeMaterialAbbeMaterialEMaterial;它的Material类连接到来自 refractiveindex.info 的数据,而IdealMaterial提供固定折射率的行为,AbbeMaterial则提供基于折射率和阿贝数的模型。(Optiland)

一个最简的、Optiland 风格的材料检查可以像这样:

import numpy as np
import matplotlib.pyplot as plt

from optiland.materials import IdealMaterial, AbbeMaterial, Material

# 固定折射率材料。
ideal = IdealMaterial(n=1.5, k=0)

# 用 n_d 和阿贝数构建的近似玻璃模型。
abbe_glass = AbbeMaterial(n=1.5168, abbe=64.17, model="buchdahl")

# 基于数据库的材料查找。
# 根据已安装的版本和数据库内容,
# 指定 reference 或 catalog 可以帮助消除材料的歧义。
bk7 = Material("N-BK7", reference="Schott")

wavelengths = np.linspace(0.45, 0.70, 200)

plt.plot(wavelengths, ideal.n(wavelengths), label="Ideal n=1.5")
plt.plot(wavelengths, abbe_glass.n(wavelengths), label="Abbe model")
plt.plot(wavelengths, bk7.n(wavelengths), label="N-BK7 database")

plt.xlabel("wavelength [µm]")
plt.ylabel("refractive index")
plt.title("不同材料模型对比")
plt.legend()
plt.show()

这段代码并不是要取代我们的 Sellmeier 实现,它展示的是库层面的同一种思路。

模式是稳定一致的:

材料对象 → n(wavelength)

这就是我们想要理解的接口。

真实的库可能会用到数据库、拟合公式、缓存、搜索规则和后端数组。我们的教学类用到的是三个系数和一个平方根。这种差异在工程上很重要,但概念上的契约是相同的。

材料属于表面过渡

让我们回到处方上来。

在一个顺序镜头表中,在某个表面列出的材料,通常描述的是该表面之后的介质。

对于单透镜:

surface 1: material_after = N-BK7
surface 2: material_after = air

在表面1:

n_before = n_air(λ)
n_after  = n_N-BK7(λ)

在表面2:

n_before = n_N-BK7(λ)
n_after  = n_air(λ)

这是一个最容易写出错误光线追迹器的地方。

如果你不小心在两侧使用了同一种材料,光线就不会偏折。如果你把前后搞反了,光线可能会朝错误的方向偏折。如果你使用玻璃的常数折射率,系统就不会展现出色度行为。如果你把以纳米为单位的波长,传给了一个期望以微米为单位的函数,结果可能就是无意义的。

因此,在写折射函数之前,让材料的过渡显式化是值得的。

def index_transition(lens, materials, surface_index, wavelength_um):
    before_name, after_name = material_before_after(lens, surface_index)
    n_before = materials.n(before_name, wavelength_um)
    n_after = materials.n(after_name, wavelength_um)
    return n_before, n_after

然后:

for i, surface in enumerate(lens.surfaces):
    if surface.surface_type in ("object", "image"):
        continue

    n1, n2 = index_transition(lens, materials, i, D_LINE_UM)
    print(f"surface {i}: n1={n1:.7f}, n2={n2:.7f}")

这个函数虽然很小,但它保护了后续章节的逻辑。

一个光线追迹器不应该自己去猜测该用哪个折射率。处方和材料库应当来回答这个问题。

主波长并不是唯一的波长

光学设计软件通常会标记一个波长为主波长。

主波长可能被用于布局、对焦、近轴参考基准或默认分析。但这并不意味着其他波长只是装饰。它们对于评价色差校正可能是至关重要的。

一个常见的初学者错误是看到了主波长,就忘记了其余波长。

更好的说法是:

主波长是参考波长。
波长组描述的是需要评估的光谱条件。

代码中:

def primary_wavelength(lens):
    primary = [wl for wl in lens.wavelengths if wl.is_primary]

    if len(primary) != 1:
        raise ValueError("Lens should have exactly one primary wavelength.")

    return primary[0]

print(primary_wavelength(lens))

这个函数看起来几乎过于严格了,但在这里,严格是有帮助的。模糊的光学设置会导致模糊的结果。

稍后当我们计算点列图时,我们可以选择:

只追迹主波长
追迹所有波长
追迹所有波长,并代入权重

这些是不同的分析方法。程序应当知道它正在做的是哪一种。

波长权重并不神奇

假设一个 MTF 图被标注为“多色”。

这听起来很厉害,但它仍然必须具有某个具体的含义。

在某个环节,程序必须将来自多个波长的信息组合起来。它可能会用到权重,可能会用到一个光谱分布,可能会用到传感器响应,也可能会用到一组用户自定义的简化设置。

一个简单的加权平均计算如下:

def weighted_average(values, weights):
    values = np.asarray(values, dtype=float)
    weights = np.asarray(weights, dtype=float)
    return np.sum(values * weights) / np.sum(weights)

对于折射率本身来说,对于一个镜头计算,直接取平均通常并不是正确的物理操作,因为每个波长的光线都应当用其自身的折射率来追迹。但这个函数展示了加权组合的一般思路。

一个更好的思维模型是:

对于每一个波长:
    利用 n(λ) 计算光学结果

然后:
    根据分析方法,组合各波长下的特定结果

这对于后面的内容很重要。

一个多色点列图并不是通过追迹一个平均波长来生成的。它是通过追迹多个波长,并显示或组合它们的光线交点来生成的。一个多色 MTF 也不是假装玻璃有一个平均折射率,它要求在最终组合之前,先进行波长相关的计算。

这是一个软件命令可能大量隐藏细节的地方。

按钮上可能会写着:

多色 MTF

而计算过程则是:

波长组
→ 每个波长下的材料折射率
→ 每个波长下的光线或波前计算
→ 每个波长下的像质结果
→ 加权组合

我们现在还未准备好计算整个链条,但我们现在知道了它起始于何处。

关于材料与波长的常见错误

趁这些错误还容易看出来,我们现在就把它们收集起来。

错误1:将折射率当作一个普适常数

N-BK7 ≈ 1.5168并不是一个完整的材料模型,它只是在 d 线附近的一个参考值。

当你有意想要一个简化的模型时,可以使用常数折射率。不要无意地使用它。

错误2:用错误的单位传递波长

一个期望以微米为单位的函数,应当接收到:

0.5876

而不是:

587.6

除非该函数明确期望以纳米为单位。

这是悄无声息地毁掉一个计算的最容易的方式之一。

错误3:把阿贝数和完整的色散混为一谈

阿贝数是使用选定的波长对色散做的概括。它很有用,但它并不是整个折射率曲线。

错误4:忘记材料是“表面之后”

在顺序处方中,列在第i个表面的材料,通常描述的是表面i之后的介质。

一个表面的折射需要两侧:

前一种材料 → 当前的 material_after

错误5:过早对折射率取平均

对于多波长分析,除非是一种有意的近似,否则不要用一个平均折射率去替代所有波长。应当根据所要执行的分析,对每个波长分别进行追迹或计算。

错误6:忽略有效波长范围

色散公式在指定的波长范围内有效。在该范围之外进行外推,可能会产生错误的结果。真实的材料数据库通常会附带范围信息,这是有原因的。

错误7:忘记了空气同样是一种材料

对于许多可见光的教学例子,我们将空气视为n = 1.0。在高精度工作中,空气本身也有依赖于波长、温度、气压和湿度的折射率。我们现在将使用1.0,因为这能让早期的光线追迹器保持聚焦。但这仍然是一个模型上的选择。

本章为计算链条增加了什么

在第2章中,我们可以说:

表面1材料为 N-BK7

现在我们可以说:

表面1将介质从空气变为 N-BK7

在 λ = 0.4861 µm 下:
    n_before = 1.0
    n_after  = n_N-BK7(0.4861)

在 λ = 0.5876 µm 下:
    n_before = 1.0
    n_after  = n_N-BK7(0.5876)

在 λ = 0.6563 µm 下:
    n_before = 1.0
    n_after  = n_N-BK7(0.6563)

这是一项真实的进步。

处方不再仅仅是几何关系。它现在包含了依赖于波长的光学行为。

我们的计算链条已变为:

表面表
→ 材料名称
→ 材料模型
→ 折射率 n(λ)
→ 每个表面的折射率过渡
→ 为斯涅耳折射做好准备

这正是我们定义光线之前所缺失的那一块拼图。

一个小小的最终测试

让我们写一段紧凑的测试,来确认本章的工作。

def print_surface_indices(lens, materials):
    for wl in lens.wavelengths:
        print(f"\nλ = {wl.value_um:.7f} µm, weight = {wl.weight}")
        for i, s in enumerate(lens.surfaces):
            if s.surface_type in ("object", "image"):
                continue

            before, after = material_before_after(lens, i)
            n1 = materials.n(before, wl.value_um)
            n2 = materials.n(after, wl.value_um)

            print(
                f"surface {i}: {before} → {after}, "
                f"n1={n1:.7f}, n2={n2:.7f}"
            )

print_surface_indices(lens, materials)

如果这条指令对不同波长打印出了不同的 N-BK7 折射率,那么材料系统就在正常工作。

它还没开始追迹光线。它只是在准备光线追迹所需要的数值。

这就够了。

前方的路

我们现在拥有了:

作为数据的镜头处方
表面几何关系
材料名称
波长设置
一个折射率函数 n(λ)
每个表面的折射率过渡

下一个对象就是光线本身。

一条光线并不是图画里一条模糊的线。在代码中,它必须有位置、方向、波长,或许还有强度、状态标志,以及最终还要有光程。它必须能够传播,必须能够与表面相遇,必须携带那个能告诉材料库该用哪个折射率的波长。

因此第4章将会问一个朴素的问题:

在代码中,光线是什么?

一旦我们回答了这个问题,各个部分就会开始运动起来。

镜头处方提供了世界。

材料模型赋予了每种介质其依赖于波长的行为。

而光线,将是那位旅行者。

生成的验证图

根据第3章材料模型生成的BK7色散曲线

来源与验证说明

关于波长相关材料的讨论,应当对照玻璃目录和光学常数资料来阅读,而不应仅仅对照这个简化的教学代码。在配套的验证中,BK7 色散是为教学目的生成的;实际生产工作应使用目录来源,并记录准确的玻璃供应商、参考数据、波长单位和温度假设。