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

第5章:光线如何击中曲面

透镜图让这一步看起来毫不费力。

一条光线向前传播。它到达一个玻璃表面。发生偏折。然后继续前进。

在图中,接触点显而易见,因为你的眼睛看到线与曲线相切。但程序看不到那幅图。程序只有数字:

光线位置
光线方向
表面位置
表面形状

从这些数字出发,它必须回答一个非常具体的问题:

这条光线在哪个点击中这个表面?

这就是光线-表面求交。

它是几何光线追迹中最重要的计算之一,也是解释中最容易被跳过的一步。许多书籍和手册展示光线在表面偏折,然后迅速转入斯涅尔定律。但斯涅尔定律只有在我们知道光线与表面相交的位置以及该点处的表面法线后才能发挥作用。

因此本章会放慢节奏。

我们暂时不会涉及折射。那是第6章的内容。这里只处理几何:

光线 + 平面  → 交点
光线 + 球面 → 交点
交点 → 孔径检查
交点 → 表面法线

到本章结束时,光线能够与平面或球面表面碰撞。它还未发生偏折。但它会确切地知道偏折应该在哪里发生。

这是很大的一步。

一句话描述问题

根据第4章,我们的光线为:

r(t) = r0 + t d

其中:

r0 = 光线起点位置
d  = 归一化光线方向
t  = 前进距离参数

表面是满足某些方程的点集。

对于垂直于光轴的平面:

z = z_v

对于球面:

(x - cx)² + (y - cy)² + (z - cz)² = R²

求交意味着:

找到使得光线点 r(t) 位于表面上的 t 值。

这就是全部思想。

对于平面,方程是线性的。

对于球面,方程变为二次方程。

对于更复杂的非球面或自由曲面,方程可能需要数值求解。但平面和球面足以构建基础。

表面有一个顶点

在编写求交代码之前,我们还需要一个光学词汇。

在序列透镜处方中,每个表面沿光轴有一个顶点位置。如果第一个物理表面在 z = 0,其厚度为 5 mm,那么下一个表面顶点在 z = 5 mm

顶点是表面与光轴相交的点。

对于旋转对称球面,我们可以认为表面被放置在顶点位置 z_v,并带有一个带符号的半径 R

使用前面章节的约定:

光线通常沿 +z 方向传播
表面顶点位于 z = z_v
带符号的半径 R 给出曲率中心在 z = z_v + R

所以如果:

z_v = 0
R = +50 mm

球心位于:

z_c = 50 mm

如果:

z_v = 5
R = -50 mm

球心位于:

z_c = -45 mm

这个带符号半径的约定让一个公式在序列光学系统中既能描述凸面也能描述凹面。

我们将在教学代码中使用这个模型。

求交用的小型表面类

前面章节的 Surface 类存储了半径、厚度、材料、半直径和表面类型。对于求交,我们还需要实际的顶点位置 z

在一个完整的透镜系统中,我们可以根据厚度列计算顶点位置。对于本章,使用一个单独的、直接存储顶点的小型类会更方便。

from dataclasses import dataclass
from typing import Optional
import math
import numpy as np

@dataclass
class IntersectableSurface:
    radius: float
    z_vertex: float
    semi_diameter: Optional[float] = None
    surface_type: str = "spherical"
    comment: str = ""

这并不意味着永久性地替代处方数据结构。它是一个教学对象,让我们可以专注于求交,而无需带着整个透镜系统。

例如:

front_surface = IntersectableSurface(
    radius=50.0,
    z_vertex=0.0,
    semi_diameter=10.0,
    comment="前球面"
)

image_plane = IntersectableSurface(
    radius=float("inf"),
    z_vertex=50.0,
    semi_diameter=None,
    surface_type="plane",
    comment="像平面"
)

前表面是球面。像平面是位于 z = 50 的平面。

现在我们可以让光线击中它们了。

光线-平面求交

从最简单的情况开始。

垂直于光轴的平面方程为:

z = z_v

光线为:

r(t) = r0 + t d

只写出 z 坐标:

z(t) = z0 + t dz

令其等于平面:

z_v = z0 + t dz

解出 t

t = (z_v - z0) / dz

这与第4章中传播到某个 z 平面的计算相同。现在我们将它正式视为一个表面求交。

def intersect_plane_z(ray, z_plane, eps=1e-12):
    """返回光线与平面 z = z_plane 相交的前向距离 t。

    如果光线与平面平行或交点不在前方,则返回 None。
    """
    dz = ray.direction[2]

    if abs(dz) < eps:
        return None

    t = (z_plane - ray.position[2]) / dz

    if t <= eps:
        return None

    return t

eps 值避免将微小的数值噪声视为真实的前向交点。在光线追迹中,这很重要。当光线刚刚落到一个表面上后,我们不希望下一次的求交测试在 t = 0 处重新发现同一个表面。

测试一下:

ray = Ray(position=[0, 0, 0], direction=[0.1, 0, 1.0])

t = intersect_plane_z(ray, z_plane=50.0)
p = ray.point_at(t)

print("t =", t)
print("point =", p)

输出应该显示一个 z = 50 的点。由于光线是倾斜的,x 值将为非零。

这已经是一次求交了。

不需要任何绘图。

将求交结果转化为一个事件

我们可以编写一个辅助函数,将光线移动到平面上。

def propagate_to_plane(ray, z_plane, refractive_index=1.0):
    t = intersect_plane_z(ray, z_plane)

    if t is None:
        ray.stop(f"未击中平面 z={z_plane}")
        return ray

    ray.propagate(t, refractive_index=refractive_index)
    return ray

这里使用了第4章中的 Ray 类。它同时更新了位置和光程长。

ray = Ray(position=[0, 0, 0], direction=[0.1, 0, 1.0])
propagate_to_plane(ray, z_plane=50.0)

print(ray.position)
print(ray.opl)
print(ray.alive)

光线现在位于平面上。如果这是一个像平面,我们可以记录其 x 和 y 坐标作为点列图的一个点。

对于平面表面,求交很简单。真正的乐趣从球面开始。

光线-球面求交

一个球形光学表面是一个球体的一部分。

设其球心为:

C = [0, 0, z_c]

其半径大小为:

|R|

一个点 p = [x, y, z] 位于该球面上,如果满足:

||p - C||² = R²

光线点为:

p(t) = r0 + t d

将光线代入球面方程:

||r0 + t d - C||² = R²

这成为一个关于 t 的二次方程。

设:

oc = r0 - C

则有:

||oc + t d||² = R²

展开:

(d · d)t² + 2(d · oc)t + (oc · oc - R²) = 0

所以:

a = d · d
b = 2(d · oc)
c = oc · oc - R²

求解:

t = (-b ± sqrt(b² - 4ac)) / (2a)

由于我们的光线方向已归一化,理论上 a 应为 1。但我们仍然计算它,使函数更健壮。

判别式告诉我们光线是否击中球面:

判别式 = b² - 4ac

如果判别式为负,没有实数交点。

如果判别式为零,光线与球面相切。

如果判别式为正,光线在数学意义上有两个交点。

对于光学光线追迹,我们通常需要最近的正向交点。

代码如下:

def sphere_center_from_surface(surface):
    """返回球面的曲率中心。"""
    if math.isinf(surface.radius):
        return None

    return np.array([0.0, 0.0, surface.z_vertex + surface.radius], dtype=float)

def intersect_sphere(ray, surface, eps=1e-9):
    """返回光线与球面相交的前向距离 t。

    表面由顶点位置 z_vertex 和带符号半径表示。
    球心位于 z_vertex + radius。
    """
    if math.isinf(surface.radius):
        return intersect_plane_z(ray, surface.z_vertex, eps=eps)

    center = sphere_center_from_surface(surface)
    radius_abs = abs(surface.radius)

    oc = ray.position - center

    a = float(np.dot(ray.direction, ray.direction))
    b = 2.0 * float(np.dot(ray.direction, oc))
    c = float(np.dot(oc, oc) - radius_abs * radius_abs)

    disc = b * b - 4.0 * a * c

    if disc < 0:
        return None

    sqrt_disc = math.sqrt(max(disc, 0.0))

    t1 = (-b - sqrt_disc) / (2.0 * a)
    t2 = (-b + sqrt_disc) / (2.0 * a)

    candidates = [t for t in (t1, t2) if t > eps]

    if not candidates:
        return None

    return min(candidates)

现在测试一条轴上光线击中球形透镜的前表面。

surface = IntersectableSurface(
    radius=50.0,
    z_vertex=0.0,
    semi_diameter=10.0,
    comment="前表面"
)

ray = Ray(position=[0.0, 0.0, -20.0], direction=[0.0, 0.0, 1.0])

t = intersect_sphere(ray, surface)
p = ray.point_at(t)

print("t =", t)
print("intersection =", p)

对于轴上光线,交点应在顶点处:

[0, 0, 0]

或非常接近。

现在尝试一条轴外平行光线:

ray = Ray(position=[5.0, 0.0, -20.0], direction=[0.0, 0.0, 1.0])

t = intersect_sphere(ray, surface)
p = ray.point_at(t)

print("t =", t)
print("intersection =", p)

x 坐标应保持为 5,而 z 应略为正值。为什么是正的?因为球心在 z = 50 处,球面向着入射光线凸起。在高度 x = 5 处,表面点沿 z 轴略位于顶点右侧。

这就是从求交几何中自然出现的表面垂度。

将求交结果与垂度比较

从第2章可知,球面垂度可由下式计算:

sag(r) = R - sign(R) sqrt(R² - r²)

根据符号约定的不同,存在几种等价形式。对于我们使用的顶点-球心约定,球面满足:

x² + y² + (z - (z_v + R))² = R²

解出经过顶点的分支得到:

z = z_v + R - sign(R) sqrt(R² - r²)

其中:

r² = x² + y²

我们来实现它:

def spherical_sag(x, y, surface):
    """返回球面在横向点 (x, y) 处的 z 坐标。"""
    if math.isinf(surface.radius):
        return surface.z_vertex

    R = surface.radius
    r2 = x*x + y*y
    R2 = R*R

    if r2 > R2:
        return None

    return surface.z_vertex + R - math.copysign(math.sqrt(R2 - r2), R)

现在比较轴外交点:

x, y, z = p
print("交点 z:", z)
print("垂度 z:", spherical_sag(x, y, surface))

它们应该一致。

这是一个很好的完整性检查。光线-球面求交和表面垂度从两个不同的方向描述了相同的几何:

求交:这条光线在哪里与表面相遇?
垂度:表面在给定的 x, y 处位于何处?

两者都很有用。

为什么有两个球面交点

一条直线可能穿过球体两次。

对于一个完整的球体,这是显而易见的。光线在近端进入球体,在远端穿出。

但光学表面并不是整个球体。它是顶点附近的一个局部区域。在序列光线追迹中,我们通常需要与该表面的第一个正向交点。

这就是 intersect_sphere 选择最小正 t 的原因。

这对于许多教学例子而言是可行的,但有一个微妙之处。

在实际的序列光学系统中,预期的表面是处方中的下一个表面。光线可能在数学意义上与球体的远端相交,但那不是我们指的物理表面区域。孔径检查和序列几何有助于排除无关交点。

对于光轴附近的普通球面透镜表面,选择最近的正向交点是正确的第一规则。

当系统变得更复杂时,稳健的光线追迹需要额外的保护措施:

表面孔径限制
预期的传播方向
局部坐标系
避免自交
处理坐标间断
表面分支选择

我们目前还不需要这些全部。但知道专业光线追迹器在这里保持谨慎是有原因的,这是有益的。

孔径检查

仅找到数学交点是不够的。

光线可能在无限球面上击中清晰光学区域之外。

透镜表面有一个半直径。如果交点距离光轴太远,光线被遮挡。

对于圆形清晰孔径:

x² + y² <= semi_diameter²

代码中:

def inside_surface_aperture(point, surface):
    """如果点位于圆形清晰孔径内,返回 True。"""
    if surface.semi_diameter is None:
        return True

    x, y, _ = point
    return x*x + y*y <= surface.semi_diameter * surface.semi_diameter

测试:

surface = IntersectableSurface(
    radius=50.0,
    z_vertex=0.0,
    semi_diameter=10.0
)

points = [
    np.array([0.0, 0.0, 0.0]),
    np.array([5.0, 0.0, 0.0]),
    np.array([10.0, 0.0, 0.0]),
    np.array([11.0, 0.0, 0.0]),
]

for p in points:
    print(p, inside_surface_aperture(p, surface))

x = 11 的点在外部。

这个检查可能看起来平淡无奇。但它不是。孔径裁剪会影响光束、渐晕、点列图、光瞳采样、照明以及后续的像质计算。

一条击中数学球面但位于清晰孔径之外的光线,不应该被视为通过了透镜。

完整的交点记录

返回比 t 更多的信息是有用的。

当光线击中一个表面时,我们可能想要:

距离 t
交点
是否在孔径内
表面法线
表面引用

我们来定义一个小的交点记录。

@dataclass
class SurfaceHit:
    t: float
    point: np.ndarray
    normal: np.ndarray
    inside_aperture: bool
    surface: IntersectableSurface

现在我们需要法线。

平面的表面法线

对于平面 z = z_v,法线可以是:

[0, 0, 1]

或:

[0, 0, -1]

两者在几何上都是有效的。符号对于折射很重要,因为斯涅尔定律使用法线方向。

对于本章,我们将平面的默认几何法线定义为:

[0, 0, 1]

然后在第6章中,我们将在应用矢量折射公式之前,相对于入射光线确定法线方向。

def plane_normal():
    return np.array([0.0, 0.0, 1.0])

球面的表面法线

对于球面,某点处的法线沿球心到该点的半径方向。

法线 = normalize(point - center)

代码:

def surface_normal(point, surface):
    """返回表面上一点处的几何单位法线。"""
    if math.isinf(surface.radius) or surface.surface_type == "plane":
        return plane_normal()

    center = sphere_center_from_surface(surface)
    return normalize(point - center)

测试正半径前表面顶点处的法线:

surface = IntersectableSurface(radius=50.0, z_vertex=0.0)
point = np.array([0.0, 0.0, 0.0])

print(surface_normal(point, surface))

法线为:

[0, 0, -1]

为什么 z 为负?因为球心位于 z = +50,而顶点位于 z = 0。从球心到顶点的向量指向负 z。

对于同一个双凸透镜的后表面:

back_surface = IntersectableSurface(radius=-50.0, z_vertex=5.0)
point = np.array([0.0, 0.0, 5.0])

print(surface_normal(point, back_surface))

法线为:

[0, 0, 1]

这很好。两个球面在其顶点处的几何法线方向相反。

现在不用担心法线是否指向折射的“正确方向”。在第6章中,我们会仔细处理方向问题。

目前,法线是表面几何的一个属性。

完整的求交函数

现在将各部分组合起来。

def intersect_surface(ray, surface, eps=1e-9):
    """如果光线与表面相交,返回 SurfaceHit,否则返回 None。"""
    if surface.surface_type == "plane" or math.isinf(surface.radius):
        t = intersect_plane_z(ray, surface.z_vertex, eps=eps)
    else:
        t = intersect_sphere(ray, surface, eps=eps)

    if t is None:
        return None

    point = ray.point_at(t)
    normal = surface_normal(point, surface)
    inside = inside_surface_aperture(point, surface)

    return SurfaceHit(
        t=t,
        point=point,
        normal=normal,
        inside_aperture=inside,
        surface=surface,
    )

测试:

surface = IntersectableSurface(
    radius=50.0,
    z_vertex=0.0,
    semi_diameter=10.0,
    comment="前表面"
)

ray = Ray(position=[5.0, 0.0, -20.0], direction=[0.0, 0.0, 1.0])

hit = intersect_surface(ray, surface)

print("t:", hit.t)
print("point:", hit.point)
print("normal:", hit.normal)
print("inside aperture:", hit.inside_aperture)

这是我们的代码首次完整回答了几何问题。

光线击中了表面。我们知道在哪里。我们知道法线。我们知道该点是否在清晰孔径内。

下一章将使用法线和折射率来计算出射光线方向。

将光线移动到交点

一条交点记录告诉了我们光线会击中的位置。但光线对象本身尚未移动。

我们可以编写:

def propagate_to_surface(ray, surface, refractive_index=1.0):
    """如果可能,将光线移动到表面交点。

    不折射。仅传播到交点并检查孔径。
    """
    hit = intersect_surface(ray, surface)

    if hit is None:
        ray.stop(f"未击中表面: {surface.comment}")
        return None

    if not hit.inside_aperture:
        ray.propagate(hit.t, refractive_index=refractive_index)
        ray.stop(f"被孔径阻挡: {surface.comment}")
        return hit

    ray.propagate(hit.t, refractive_index=refractive_index)

    ray.history.append({
        "event": "surface_hit",
        "surface": surface.comment,
        "point": hit.point.copy(),
        "normal": hit.normal.copy(),
        "inside_aperture": hit.inside_aperture,
    })

    return hit

试试看:

ray = Ray(position=[5.0, 0.0, -20.0], direction=[0.0, 0.0, 1.0])

hit = propagate_to_surface(ray, surface)

print("ray position:", ray.position)
print("ray alive:", ray.alive)
print("stop reason:", ray.stop_reason)

现在光线应停留在球面上。

同样,它尚未偏折。这种限制是故意为之。我们希望每一步都是可检查的。

第5章:光线击中何处?
第6章:击中后它去向何方?

不要太早把它们混在一起。

可视化光线-球面交点

虽然我们知道图片是由计算推导出来的,但一张图仍然有帮助。

让我们追踪若干条平行光线到球面,并绘制它们的交点。

import matplotlib.pyplot as plt

surface = IntersectableSurface(
    radius=50.0,
    z_vertex=0.0,
    semi_diameter=10.0,
    comment="前球面"
)

rays = [
    Ray(position=[x, 0.0, -20.0], direction=[0.0, 0.0, 1.0])
    for x in np.linspace(-9.0, 9.0, 7)
]

hit_points = []

for ray in rays:
    hit = intersect_surface(ray, surface)
    if hit is not None and hit.inside_aperture:
        hit_points.append(hit.point)

hit_points = np.array(hit_points)

# 在 x-z 平面绘制表面轮廓。
xs = np.linspace(-10.0, 10.0, 300)
zs = [spherical_sag(x, 0.0, surface) for x in xs]

plt.plot(zs, xs, label="球面")

# 绘制入射光线段和交点。
for ray, hp in zip(rays, hit_points):
    plt.plot([ray.position[2], hp[2]], [ray.position[0], hp[0]], linewidth=0.8)

plt.scatter(hit_points[:, 2], hit_points[:, 0], label="交点")
plt.xlabel("z [mm]")
plt.ylabel("x [mm]")
plt.title("平行光线击中球面")
plt.gca().set_aspect("equal", adjustable="box")
plt.legend()
plt.show()

该图应显示直的平行光线到达曲面上的不同点。轴外光线在曲面上击中不同的 z 位置,因为表面是弯曲的。

这不仅仅是一幅画。每个接触点都是计算得到的。

这一区别正是本章的重点。

如果光线偏离了怎么办?

光线可能会偏离球面。

例如,如果光线在表面外部很远的地方传播:

ray = Ray(position=[100.0, 0.0, -20.0], direction=[0.0, 0.0, 1.0])
hit = intersect_surface(ray, surface)

print(hit)

这可能返回 None,或者根据几何,它可能在数学上击中大的母球。但如果表面的半直径很小,它应该通不过孔径检查。

这就是为什么两项检查都很重要:

光线是否与数学表面相交?
交点是否在清晰孔径内?

对于光学表面,第二个问题通常和第一个同样重要。

一条光线可能击中了球体,但射失了透镜。

相切光线与数值精度

如果一条光线刚好擦过球面,二次方程的判别式接近于零。在数值上,这可能很微妙。

在精确数学中:

判别式 = 0

意味着相切。

在浮点计算中,本应为零的值可能由于舍入而出现:

-1e-14

这就是为什么一些光线追迹器使用容差。一个健壮的函数可能将非常小的负判别式视为零。

我们使用了:

sqrt_disc = math.sqrt(max(disc, 0.0))

但在此之前先检查:

if disc < 0:
    return None

对于教学代码,这是可以接受的。对于更健壮的代码,我们可能会写成:

if disc < -eps:
    return None
disc = max(disc, 0.0)

这将允许数值舍入引起的微小负值。

这里有一个更宽容的版本:

def intersect_sphere_tolerant(ray, surface, eps=1e-9):
    if math.isinf(surface.radius):
        return intersect_plane_z(ray, surface.z_vertex, eps=eps)

    center = sphere_center_from_surface(surface)
    radius_abs = abs(surface.radius)
    oc = ray.position - center

    a = float(np.dot(ray.direction, ray.direction))
    b = 2.0 * float(np.dot(ray.direction, oc))
    c = float(np.dot(oc, oc) - radius_abs * radius_abs)

    disc = b*b - 4.0*a*c

    if disc < -eps:
        return None

    disc = max(disc, 0.0)
    sqrt_disc = math.sqrt(disc)

    t1 = (-b - sqrt_disc) / (2.0*a)
    t2 = (-b + sqrt_disc) / (2.0*a)

    candidates = [t for t in (t1, t2) if t > eps]

    if not candidates:
        return None

    return min(candidates)

数值容差不是高级奢侈品。它是在有限精度机器上让几何正常工作的组成部分。

求交与局部坐标

到目前为止,所有表面都位于 z 轴上。这对最初几章来说是足够的。

一个真正的光学设计程序需要局部坐标。一个表面可能是倾斜的、偏心的,或者放置在坐标间断之后。光线可能需要被变换到表面的局部坐标系中,在那里求交,然后再变换回来。

通用模式是:

全局光线
→ 变换到局部表面坐标
→ 计算交点
→ 计算局部法线
→ 将点和法线变换回全局坐标

这听起来很复杂,也确实如此。但请注意,基本的求交问题保持不变。局部坐标系是为了让表面方程更简单。

我们不会在本书的核心教学代码中实现坐标间断。那会分散对主要计算链的注意力。但当你看到专业库处理倾斜反射镜、棱镜、离轴系统或自由曲面时,请记住它正在做这种坐标记录工作。

骨架仍然是:

光线 + 表面方程 → t → 点 → 法线

坐标变换只是准备输入,并将结果返回给全局系统。

从处方构建表面顶点

在第2章中,透镜处方给了我们厚度。对于求交,我们需要表面顶点位置。

让我们编写一个辅助函数,将一系列处方表面转换为带有 z 位置的可求交表面。

假设我们有一个像之前一样的 Surface 类:

@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 = ""

现在进行转换:

def build_intersectable_surfaces(prescription_surfaces):
    """将处方表面转换为带有顶点 z 位置的表面。

    如果存在,跳过无限远物面行。
    """
    surfaces = prescription_surfaces

    if surfaces and math.isinf(surfaces[0].thickness):
        surfaces = surfaces[1:]

    z = 0.0
    result = []

    for s in surfaces:
        surface_type = s.surface_type

        if math.isinf(s.radius):
            surface_type = "plane"

        result.append(IntersectableSurface(
            radius=s.radius,
            z_vertex=z,
            semi_diameter=s.semi_diameter,
            surface_type=surface_type,
            comment=s.comment,
        ))

        if not math.isinf(s.thickness):
            z += s.thickness

    return result

现在透镜处方可以为求交代码提供输入。

这是第2章的数据结构首次与第4章的真实光线移动结合起来。

链条变得可执行了:

处方厚度
→ 表面顶点位置
→ 光线-表面求交
→ 交点和法线

无折射追踪通过两个表面

让我们追踪一条光线到一个单透镜的两个表面,但不使其偏折。

这在光学上是错误的,但作为几何是有用的。

prescription = [
    Surface(
        radius=float("inf"),
        thickness=float("inf"),
        surface_type="object",
        comment="无限远物面"
    ),
    Surface(
        radius=50.0,
        thickness=5.0,
        material_after="N-BK7",
        semi_diameter=10.0,
        comment="前表面"
    ),
    Surface(
        radius=-50.0,
        thickness=45.0,
        material_after="air",
        semi_diameter=10.0,
        comment="后表面"
    ),
    Surface(
        radius=float("inf"),
        thickness=0.0,
        surface_type="image",
        comment="像平面"
    ),
]

surfaces = build_intersectable_surfaces(prescription)

ray = Ray(position=[3.0, 0.0, -20.0], direction=[0.0, 0.0, 1.0])

for s in surfaces:
    hit = propagate_to_surface(ray, s, refractive_index=1.0)

    if hit is None or not ray.alive:
        break

    print(s.comment, ray.position, "normal", hit.normal)

光线穿过前表面,然后后表面,最后到达像平面。它没有偏折,因此这不是真正的光学追踪。但它确认了几何正常工作。

这种中间测试非常有用。

在添加折射之前,确保求交工作。

在添加波前之前,确保折射工作。

在添加点扩散函数之前,确保光程差工作。

一个黑箱往往就是太多未经测试的步骤堆叠在一起。

像平面只是另一个平面求交

在解释上,像平面通常是特殊的,但在几何上它很简单。

它通常被表示为一个垂直于光轴的平面:

z = z_image

当光线到达它时,我们记录它们的横向坐标:

x_image, y_image

这些坐标成为点列图点。

如果像平面仅是一个评估表面,它不需要折射。它是我们测量光线的地方,而不一定是物理边界。

这就是为什么我们的 build_intersectable_surfaces 函数可以将无限远半径的像面当作平面处理。

稍后,在完整的追迹中,像平面将是最后一个表面。到达它的光线将用于几何像分析。

表面法线与折射是分开的工作

我们很容易直接从求交跳到偏折。让我们多坚持一小会儿。

在表面交点上,我们现在知道了:

点
法线
前材料
后材料
光线方向
波长

折射计算将使用:

入射方向
表面法线
n_before
n_after

但法线本身是一个几何对象。它应该在斯涅尔定律之前且独立地计算出来。

这种分离使代码保持干净:

求交代码:
    返回点和法线

材料代码:
    返回 n_before 和 n_after

折射代码:
    返回新方向

如果所有这些都混在一个函数里,调试就会变得痛苦。

当光线偏折不正确时,你将不知道是点错了,法线错了,折射率错了,还是矢量公式错了。

小函数不仅仅是风格偏好。它们使光学错误变得可见。

下一步的预览

在本章结束时,一条击中球面的光线有一个入射方向和一个法线。

例如:

ray = Ray(position=[5.0, 0.0, -20.0], direction=[0.0, 0.0, 1.0])
surface = IntersectableSurface(radius=50.0, z_vertex=0.0, semi_diameter=10.0)

hit = intersect_surface(ray, surface)

print("入射方向:", ray.direction)
print("表面法线:", hit.normal)

入射方向可能是:

[0, 0, 1]

法线将是倾斜的,因为光线击中了远离光轴的曲面:

法线 ≈ [小的 x 分量, 0, 主要是负的 z]

那个倾斜的法线正是轴外光线偏折不同于轴上光线的原因。斯涅尔定律并不看整个透镜图。它看到的是交点处的局部表面法线。

这就是折射的局部性质。

曲面透镜并非魔法。它是许多逐点变化的局部表面法线。

这是一个美妙的想法,同时也具有计算上的实用性。

光线-表面求交中的常见错误

让我们在它们造成困扰之前,把这些陷阱点出来。

错误1:忘记选择正向交点

二次方程给出数学根。有些可能位于光线之后。使用正的 t 值,并选择最近的有效值。

错误2:忽略孔径限制

一条光线可能击中了母球,但在实际透镜表面之外。一定要检查清晰孔径。

错误3:混淆半径符号与半径大小

带符号的半径定位曲率中心。球面方程使用半径的大小。

球心 z = z_vertex + R
球面半径 = |R|

错误4:使用错误的表面分支

一个完整的球有两个面。一个序列光学表面是其顶点附近的一个局部区域。在通常情况下,最近的正向交点有效,但更复杂的系统需要谨慎。

错误5:不使用容差

浮点计算并不精确。非常小的负判别式或微小的 t 值可能导致虚假的错射或自交。

错误6:混淆垂度和求交逻辑

垂度给出已知横向坐标的表面高度。求交解决光线在何处遇到表面。它们是相关的,但回答不同的问题。

错误7:在验证交点之前计算折射

不要折射一条射失表面或被孔径阻挡的光线。

错误8:丢失法线方向

表面法线必须归一化并小心存储。折射章节将需要它。

本章给我们的东西

我们现在拥有了光线追迹的几何核心。

我们可以:

表示一个平面表面
表示一个带符号半径的球面
计算光线-平面求交
计算光线-球面求交
选择最近的正向交点
检查交点是否在清晰孔径内
在交点处计算表面法线
将光线移动到表面
记录交点

这不是一个小成就。

第4章中的光线只能在空的空间中传播。

在第5章之后,光线可以找到玻璃。

它仍不能偏折。但这对于一次基本的真实光线追迹而言,是唯一缺失的动作。

下一章将添加它。

我们将获取入射方向、表面法线以及第3章中的折射率。然后使用矢量形式的斯涅尔定律计算出射方向。

在那时,光线将最终实现图中一直展示的行为:

它将击中表面
并改变方向

但这一次,我们将确切地知道那发生在何处,使用了哪个法线,哪些折射率,以及新的方向是如何计算的。

这就是一条软件指令变成一次可检查的计算的过程。

生成的验证图

从第5章求交模型生成的光线与曲面交点图