2. 光线追迹入门:光线向量表示法、平面与球面折射、反射定律的矩阵形式

光线追迹,说白了就是给光线画出一条精确的路径。你想想看,一束光从物体出发,经过透镜、棱镜,最后落到像面上——我们得知道它每一步往哪走、偏了多少。这就是追迹的核心。

我个人习惯把光线追迹分成两类:近轴追迹和实际追迹。近轴追迹假设光线与光轴的夹角很小,可以用线性近似;实际追迹则要考虑真实的三角函数。咱们这门课先啃近轴,因为它简单、快,而且能解决大部分工程问题。

2.1 光线的向量表示法

一条光线怎么描述?两个要素就够了:位置和方向。

位置用坐标点 P = (x, y, z) 表示,方向用单位向量 d = (α, β, γ) 表示。合起来就是一条光线的完整状态:

光线 = (P, d) = (x, y, z, α, β, γ)

这里 α, β, γ 是方向余弦,满足 α² + β² + γ² = 1。为什么用方向余弦?因为后续做折射、反射计算时,点积运算特别方便。

我的小习惯:在代码里我通常把光线存成一个 6 元组或两个 3 元组。Python 里用 (pos, dir) 的元组对,清晰又好扩展。

举个例子,一条沿 z 轴正方向传播的光线,经过点 (0, 0, 0):

P = (0, 0, 0)
d = (0, 0, 1)   # 方向余弦:α=0, β=0, γ=1

如果光线与 z 轴有个小夹角 θ,那方向余弦就是 (sinθ, 0, cosθ)。近轴近似下,sinθ ≈ θ,cosθ ≈ 1,这就简化了很多计算。

2.2 平面折射与反射

平面是最简单的界面。光线遇到平面,要么折射过去,要么反射回来。

2.2.1 平面折射——斯涅尔定律的向量形式

斯涅尔定律大家都知道:n₁ sinθ₁ = n₂ sinθ₂。但用向量怎么算?

设入射光线方向为 d,界面法向量为 n(指向入射介质),折射率为 n₁ 和 n₂。折射方向 d' 的计算公式是:

d' = (n₁/n₂) * d + [ - (n₁/n₂) * (d·n) + sqrt(1 - (n₁/n₂)² * (1 - (d·n)²)) ] * n

看着有点吓人?其实拆开看就三步:

  1. 计算入射角余弦:cosθ₁ = - (d·n)
  2. 计算折射角余弦:cosθ₂ = sqrt(1 - (n₁/n₂)² * sin²θ₁)
  3. 组合得到折射方向
我曾经踩过的坑:当 (n₁/n₂)² * sin²θ₁ > 1 时,根号里是负数——这就是全反射!代码里一定要加判断,否则会算出 NaN。我早期一个版本没处理这个,调试了整整一下午。

2.2.2 平面反射

反射比折射简单多了。反射定律说:入射角等于反射角,且入射光线、法线、反射光线共面。

向量形式就一句话:

d' = d - 2 * (d·n) * n

这个公式漂亮又简洁。你想想看,它把反射光线的方向直接算出来了,不需要任何三角函数。

操作 输入 输出 关键公式
平面折射 d, n, n₁, n₂ d' d' = (n₁/n₂)d + [ - (n₁/n₂)(d·n) + cosθ₂ ] n
平面反射 d, n d' d' = d - 2(d·n)n

2.3 球面折射与反射

球面是光学系统里最常见的曲面。透镜的两个面基本都是球面(非球面是进阶内容,咱们后面再聊)。

2.3.1 球面折射

球面折射比平面多一步:先找到光线与球面的交点,再在交点处计算法线,最后用斯涅尔定律。

设球面曲率半径为 R,球心在 C = (0, 0, R)(假设球面顶点在原点)。光线从 P 点出发,沿方向 d 传播:

  1. 求交点:解参数方程 P + t·d 与球面方程 |P + t·d - C|² = R² 的二次方程
  2. 取有效解:取 t > 0 且最小的那个根
  3. 计算法线:n = (交点 - 球心) / R,归一化
  4. 应用折射公式:用 2.2.1 节的向量公式计算折射方向
def sphere_refract(P, d, R, n1, n2):
    # 球心在 (0, 0, R)
    C = np.array([0.0, 0.0, R])
    # 解二次方程求交点
    oc = P - C
    a = d @ d
    b = 2 * (oc @ d)
    c = (oc @ oc) - R**2
    disc = b**2 - 4*a*c
    if disc < 0:
        return None  # 光线没碰到球面
    t = (-b - np.sqrt(disc)) / (2*a)
    if t < 0:
        t = (-b + np.sqrt(disc)) / (2*a)
    if t < 0:
        return None
    # 交点
    Q = P + t * d
    # 法线(指向入射介质)
    n = (Q - C) / R
    # 折射
    cosI = - (d @ n)
    if cosI < 0:
        n = -n
        cosI = - (d @ n)
    ratio = n1 / n2
    sinI2 = ratio**2 * (1 - cosI**2)
    if sinI2 > 1:
        return None  # 全反射
    cosT = np.sqrt(1 - sinI2)
    d_prime = ratio * d + (ratio * cosI - cosT) * n
    return Q, d_prime
关键点:球面折射的二次方程求解,t 的符号判断很关键。我建议始终取最小的正 t,那才是光线最先碰到的那个面。

2.3.2 球面反射

球面反射和球面折射的前三步完全一样——求交点、算法线。最后一步换成反射公式就行。

def sphere_reflect(P, d, R):
    # 求交点(同折射)
    C = np.array([0.0, 0.0, R])
    oc = P - C
    a = d @ d
    b = 2 * (oc @ d)
    c = (oc @ oc) - R**2
    disc = b**2 - 4*a*c
    if disc < 0:
        return None
    t = (-b - np.sqrt(disc)) / (2*a)
    if t < 0:
        t = (-b + np.sqrt(disc)) / (2*a)
    if t < 0:
        return None
    Q = P + t * d
    n = (Q - C) / R
    # 反射
    d_prime = d - 2 * (d @ n) * n
    return Q, d_prime

2.4 反射定律的矩阵形式

近轴光学里,我们喜欢用矩阵。为什么?因为矩阵可以串联——多个光学面就是多个矩阵相乘,最后得到一个系统矩阵。

反射矩阵长什么样?对于平面反射镜,光线向量用 (y, θ) 表示(y 是光线高度,θ 是光线与光轴的夹角):

[ y' ]   =   [ 1   0 ] [ y ]
[ θ' ]       [ 0   1 ] [ θ ]

等等,这不就是单位矩阵吗?没错,平面反射镜不改变光线的高度和角度(只改变传播方向,但近轴近似下角度符号不变)。

对于球面反射镜,情况不同了:

[ y' ]   =   [ 1       0    ] [ y ]
[ θ' ]       [ -2/R    1    ] [ θ ]

这里的 -2/R 就是球面反射镜的光焦度。R 是曲率半径,符号规则是:光线入射方向为正,球心在入射侧 R 为正。

光学元件 传输矩阵 说明
平面反射镜 [[1, 0], [0, 1]] 单位矩阵,不改变光线状态
球面反射镜 [[1, 0], [-2/R, 1]] -2/R 是光焦度,R 有符号
平面折射 [[1, 0], [0, n₁/n₂]] 改变角度,不改变高度
球面折射 [[1, 0], [(n₁-n₂)/(n₂·R), n₁/n₂]] 同时改变高度和角度
我建议:刚开始学矩阵法时,先用手算几个简单例子——比如平行光入射到凹面镜。算两遍,一遍用几何法,一遍用矩阵法。你会发现矩阵法又快又准,而且不容易出错。

2.5 本章知识体系

下面这张图把本章的核心逻辑串起来了。从光线向量表示出发,分平面和球面两条线,最后汇到矩阵形式——这就是近轴光线追迹的骨架。

第2章 知识体系结构图 第2章 光线追迹入门 · 知识体系 光线向量表示法 平面界面 球面界面 平面折射 平面反射 球面折射 球面反射 反射/折射定律的矩阵形式 系统矩阵串联 → 光线追迹 核心思路:用向量表示光线 → 用解析几何求交点 → 用斯涅尔定律/反射定律算新方向 → 用矩阵形式统一描述 → 串联成系统矩阵实现快速追迹

2.6 代码实战:一个完整的平面折射示例

光说不练假把式。咱们写个完整的 Python 函数,把平面折射走一遍。

import numpy as np

def plane_refract(d, n, n1, n2):
    """
    平面折射计算
    参数:
        d: 入射方向 (3元组或数组)
        n: 法向量 (指向入射介质)
        n1, n2: 折射率
    返回:
        折射方向 d',若全反射返回 None
    """
    d = np.array(d)
    n = np.array(n)
    # 归一化
    d = d / np.linalg.norm(d)
    n = n / np.linalg.norm(n)
    
    cosI = -np.dot(d, n)
    # 如果光线从背面入射,翻转法线
    if cosI < 0:
        n = -n
        cosI = -np.dot(d, n)
    
    ratio = n1 / n2
    sinI2 = ratio**2 * (1 - cosI**2)
    
    if sinI2 > 1:
        print("全反射发生!")
        return None
    
    cosT = np.sqrt(1 - sinI2)
    d_prime = ratio * d + (ratio * cosI - cosT) * n
    return d_prime

# 测试:空气(n=1)到玻璃(n=1.5),入射角30度
d_in = np.array([np.sin(np.radians(30)), 0, -np.cos(np.radians(30))])
n_vec = np.array([0, 0, 1])  # 法线指向z正方向
d_out = plane_refract(d_in, n_vec, 1.0, 1.5)
print(f"入射方向: {d_in}")
print(f"折射方向: {d_out}")

运行这段代码,你会看到折射角大约是 19.47 度——和斯涅尔定律算出来的一样。

工程经验:实际项目中,我习惯把光线追迹的核心函数都加上 numba 的 jit 装饰器。一条光线追几十个面,速度能快 10 倍以上。但调试阶段别加,不然报错信息看不懂。

好了,这一章的内容就到这儿。向量表示法、平面和球面的折射反射、矩阵形式——这三块是近轴光线追迹的基石。后面几章我们会在这个基础上搭更复杂的系统。