第 55 章 / 共 58 章 卷十 代码讲解

光线追踪渲染器

光线追踪(Ray Tracing)是计算机图形学中最优雅的渲染算法:从相机向场景发射光线,追踪每条光线与物体的交点,计算光照、反射、阴影——用纯数学生成照片级图像。本章实现一个经典的光线追踪器:球体场景、Phong 光照、镜面反射、阴影投射,输出 PPM 图像文件。代码约 200 行,但涵盖了 3D 图形学的核心概念。

本章导读

光线追踪的核心思想极其简洁:对图像中的每个像素,从相机发一条射线穿过该像素,找到射线碰到的第一个物体,计算该点的光照颜色。如果物体有反射性,继续追踪反射射线——递归直到没有交点或达到深度限制。

本章学习目标
  • 实现三维向量的基本运算(加减、点积、叉积、归一化)
  • 实现射线-球体相交检测(二次方程求根)
  • 实现 Phong 光照模型(环境光 + 漫反射 + 镜面高光)
  • 实现阴影检测(遮挡射线)
  • 实现递归反射
  • 输出 PPM 格式图像

55.1 三维向量

所有 3D 计算的基础:

class Vec3
    double x, y, z

    func make(double x, double y, double z) as Vec3
        Vec3 v = Vec3()
        v.x = x; v.y = y; v.z = z
        return v
    end

    func add(Vec3 a, Vec3 b) as Vec3
        return Vec3.make(a.x + b.x, a.y + b.y, a.z + b.z)
    end

    func sub(Vec3 a, Vec3 b) as Vec3
        return Vec3.make(a.x - b.x, a.y - b.y, a.z - b.z)
    end

    func mul(Vec3 v, double s) as Vec3
        return Vec3.make(v.x * s, v.y * s, v.z * s)
    end

    func dot(Vec3 a, Vec3 b) as double
        return a.x * b.x + a.y * b.y + a.z * b.z
    end

    func length(Vec3 v) as double
        return math.sqrt(dot(v, v))
    end

    func normalize(Vec3 v) as Vec3
        double len = length(v)
        if len == 0.0 then return Vec3.make(0, 0, 0) end
        return mul(v, 1.0 / len)
    end

    func reflect(Vec3 dir, Vec3 normal) as Vec3
        // R = D - 2(D·N)N
        double d = dot(dir, normal)
        return sub(dir, mul(normal, 2.0 * d))
    end
end

55.2 射线与相机

class Ray
    Vec3 origin
    Vec3 dir

    func make(Vec3 o, Vec3 d) as Ray
        Ray r = Ray()
        r.origin = o
        r.dir = Vec3.normalize(d)
        return r
    end

    // 射线上 t 处的点:P = O + t*D
    func pointAt(Ray r, double t) as Vec3
        return Vec3.add(r.origin, Vec3.mul(r.dir, t))
    end
end

// 相机:从 eye 向 viewport 发射射线
Vec3 eye = Vec3.make(0, 0, -5)
double fov = 1.0   // 视场角

55.3 射线-球体求交

射线 P(t) = O + tD 与球体 |P - C|² = r² 的交点,展开为二次方程:

class Sphere
    Vec3 center
    double radius
    Vec3 color
    double reflectivity   // 0.0-1.0

    func intersect(Sphere s, Ray ray) as double
        // 解 |O + tD - C|² = r²
        // → t²(D·D) + 2t(D·(O-C)) + (O-C)·(O-C) - r² = 0
        Vec3 oc = Vec3.sub(ray.origin, s.center)
        double a = Vec3.dot(ray.dir, ray.dir)
        double b = 2.0 * Vec3.dot(ray.dir, oc)
        double c = Vec3.dot(oc, oc) - s.radius * s.radius
        double disc = b * b - 4.0 * a * c

        if disc < 0.0 then return -1.0 end   // 无交点

        double sqrtDisc = math.sqrt(disc)
        double t1 = (-b - sqrtDisc) / (2.0 * a)
        double t2 = (-b + sqrtDisc) / (2.0 * a)

        // 取最近的正 t
        if t1 > 0.001 then return t1 end
        if t2 > 0.001 then return t2 end
        return -1.0
    end
end

判别式 disc < 0 表示射线与球体不相交;disc ≥ 0 时取最近的正根(t > 0.001 避免自交)。

55.4 Phong 光照模型

Phong 模型将光照分为三个分量:

// 光照计算
func shade(Vec3 point, Vec3 normal, Sphere obj, Vec3 lightPos, Vec3 lightColor) as Vec3
    Vec3 result = Vec3.make(0, 0, 0)

    // 1. 环境光(Ambient):基础亮度,避免全黑
    double ambient = 0.1
    result = Vec3.add(result, Vec3.mul(obj.color, ambient))

    // 2. 漫反射(Diffuse):Lambert 余弦定律
    Vec3 lightDir = Vec3.normalize(Vec3.sub(lightPos, point))
    double diff = Vec3.dot(normal, lightDir)
    if diff > 0.0 then
        Vec3 diffuse = Vec3.mul(obj.color, diff)
        diffuse = Vec3.make(diffuse.x * lightColor.x, diffuse.y * lightColor.y, diffuse.z * lightColor.z)
        result = Vec3.add(result, diffuse)
    end

    // 3. 镜面高光(Specular):Blinn-Phong 半角向量
    Vec3 viewDir = Vec3.normalize(Vec3.sub(eye, point))
    Vec3 halfDir = Vec3.normalize(Vec3.add(lightDir, viewDir))
    double spec = math.pow(math.max(Vec3.dot(normal, halfDir), 0.0), 64.0)
    Vec3 specular = Vec3.mul(lightColor, spec * 0.5)
    result = Vec3.add(result, specular)

    return result
end

55.5 阴影投射

从交点向光源发射射线,如果被其他物体遮挡则在阴影中:

func isInShadow(Vec3 point, Vec3 lightPos, list<Sphere> spheres) as bool
    Vec3 toLight = Vec3.sub(lightPos, point)
    double lightDist = Vec3.length(toLight)
    Ray shadowRay = Ray.make(point, Vec3.normalize(toLight))

    int i = 0
    while i < spheres.len()
        double t = Sphere.intersect(spheres[i], shadowRay)
        if t > 0.001 and t < lightDist then
            return true   // 被遮挡
        end
        i += 1
    end
    return false
end

55.6 反射

递归追踪反射射线:

func trace(Ray ray, list<Sphere> spheres, int depth) as Vec3
    if depth <= 0 then return Vec3.make(0, 0, 0) end

    // 找最近交点
    double minT = 1e20
    int hitIdx = -1
    int i = 0
    while i < spheres.len()
        double t = Sphere.intersect(spheres[i], ray)
        if t > 0.0 and t < minT then
            minT = t
            hitIdx = i
        end
        i += 1
    end

    if hitIdx == -1 then
        return Vec3.make(0.1, 0.1, 0.15)   // 背景色(深蓝灰)
    end

    Sphere obj = spheres[hitIdx]
    Vec3 point = Ray.pointAt(ray, minT)
    Vec3 normal = Vec3.normalize(Vec3.sub(point, obj.center))

    // 光照
    Vec3 color = Vec3.make(0, 0, 0)
    if not isInShadow(point, lightPos, spheres) then
        color = shade(point, normal, obj, lightPos, lightColor)
    else
        color = Vec3.mul(obj.color, 0.1)   // 阴影中只有环境光
    end

    // 反射
    if obj.reflectivity > 0.0 then
        Vec3 reflDir = Vec3.reflect(ray.dir, normal)
        Ray reflRay = Ray.make(point, reflDir)
        Vec3 reflColor = trace(reflRay, spheres, depth - 1)
        // 混合:原色 * (1-r) + 反射色 * r
        double r = obj.reflectivity
        color = Vec3.add(Vec3.mul(color, 1.0 - r), Vec3.mul(reflColor, r))
    end

    return color
end

55.7 场景定义

// 光源
Vec3 lightPos = Vec3.make(5, 10, -5)
Vec3 lightColor = Vec3.make(1.0, 1.0, 1.0)

// 场景:3 个球体
list<Sphere> spheres = list<Sphere>()

// 红色球(高反射)
Sphere s1 = Sphere()
s1.center = Vec3.make(-1.2, 0, 0)
s1.radius = 1.0
s1.color = Vec3.make(0.9, 0.1, 0.1)
s1.reflectivity = 0.6
spheres.push(s1)

// 绿色球(低反射)
Sphere s2 = Sphere()
s2.center = Vec3.make(1.2, 0, 0)
s2.radius = 1.0
s2.color = Vec3.make(0.1, 0.8, 0.1)
s2.reflectivity = 0.2
spheres.push(s2)

// 蓝色小球(镜面)
Sphere s3 = Sphere()
s3.center = Vec3.make(0, -0.5, -1.5)
s3.radius = 0.5
s3.color = Vec3.make(0.1, 0.1, 0.9)
s3.reflectivity = 0.9
spheres.push(s3)

55.8 渲染主循环

int width = 400
int height = 300
array<int> pixels = array<int>(width * height * 3)

int y = 0
while y < height
    int x = 0
    while x < width
        // 像素坐标 → 归一化设备坐标 → 世界空间方向
        double u = (2.0 * (x + 0.5) / width - 1.0) * (width / height) * fov
        double v = (1.0 - 2.0 * (y + 0.5) / height) * fov

        Vec3 dir = Vec3.make(u, v, 1.0)
        Ray ray = Ray.make(eye, dir)

        Vec3 color = trace(ray, spheres, 5)   // 最多 5 次反射

        // 颜色裁剪到 [0, 255]
        int r = int(math.min(color.x * 255.0, 255.0))
        int g = int(math.min(color.y * 255.0, 255.0))
        int b = int(math.min(color.z * 255.0, 255.0))

        int idx = (y * width + x) * 3
        pixels[idx] = r
        pixels[idx + 1] = g
        pixels[idx + 2] = b
        x += 1
    end
    y += 1
end

55.9 PPM 图像输出

PPM 是最简单的图像格式(纯文本,任何编辑器可查看):

func writePPM(string filename, int w, int h, array<int> pixels)
    string content = "P3\n" + w.toString() + " " + h.toString() + "\n255\n"
    int y = 0
    while y < h
        int x = 0
        while x < w
            int idx = (y * w + x) * 3
            content += pixels[idx].toString() + " "
            content += pixels[idx + 1].toString() + " "
            content += pixels[idx + 2].toString() + "\n"
            x += 1
        end
        y += 1
    end
    file.writeText(filename, content)
    println("图像已保存: " + filename)
end

writePPM("raytrace.ppm", width, height, pixels)

用任何图片查看器(或 GIMP、IrfanView)打开 .ppm 文件即可看到渲染结果:三个带光照和反射的球体。

55.10 扩展方向

  • 平面:添加无限平面(棋盘格纹理)作为地面
  • 多光源:多个光源,每个独立计算阴影
  • 抗锯齿:每像素发射多条射线(超采样),取平均
  • 折射:透明球体,Snell 定律计算折射方向
  • 纹理:程序化纹理(棋盘格、条纹、噪声)
  • 多线程:按行分配线程并行渲染(第 28 章)
  • BVH 加速:包围盒层次结构,将求交从 O(n) 降到 O(log n)

深入细节

深入 1:为什么 t > 0.001 而不是 t > 0

浮点精度问题:交点 P 在球面上,从 P 发射阴影射线时,由于浮点误差,射线可能"看到"自身球体(t ≈ 0.0000001)。加一个 epsilon(0.001)避免自交。这是光线追踪中最常见的 bug 来源。

深入 2:递归深度限制

两个平行镜面之间反射会无限递归。depth 参数限制最大反射次数(通常 5-10 次)。超过限制返回黑色。实际场景中,每次反射能量衰减(reflectivity < 1),5 次后贡献已可忽略。

深入 3:光线追踪 vs 光栅化

光栅化(GPU 传统方式):对每个三角形投影到屏幕,逐像素填充——快但难以处理反射/折射。光线追踪:对每个像素发射射线——慢但物理正确。现代 GPU(RTX)用硬件加速光线追踪,实现实时全局光照。

⚠️ 常见错误

❌ 错误 1:法线未归一化

光照计算依赖法线方向。如果法线长度不为 1,点积结果不正确,光照会过亮或过暗。每次计算法线后必须 normalize。

❌ 错误 2:颜色未裁剪到 [0,255]

多个光照分量叠加可能超过 1.0(如镜面高光 + 漫反射)。不裁剪会导致 PPM 中出现 > 255 的值,图像显示异常。

❌ 错误 3:Y 轴方向反了

图像坐标 Y 向下,世界坐标 Y 向上。像素 (x, y) 映射到世界方向时,v 需要取反:v = 1.0 - 2*(y+0.5)/height。不取反会导致图像上下颠倒。

本章小结

主题核心要点
向量加减、点积、归一化、反射公式 R=D-2(D·N)N
射线P(t) = O + tD,t > 0 为前方
求交二次方程 at²+bt+c=0,判别式 < 0 无交点
Phong环境光 + 漫反射(Lambert) + 镜面高光(Blinn-Phong)
阴影交点→光源射线被遮挡则在阴影中
反射递归追踪反射射线,混合原色和反射色
输出PPM 格式:P3 头 + RGB 像素值
精度t > epsilon 避免自交;颜色裁剪到 [0,255]

复习题

  1. 射线-球体求交的二次方程中,判别式 < 0 意味着什么?
  2. Phong 模型的三个分量分别模拟什么物理现象?
  3. 为什么阴影射线需要检查 t < lightDist?
  4. 反射公式 R = D - 2(D·N)N 的几何含义是什么?
  5. 为什么递归深度需要限制?不限制会怎样?
  6. t > 0.001 而不是 t > 0 的原因是什么?
  7. PPM 格式的 "P3" 头表示什么?与 P6 有什么区别?
  8. 如果要添加抗锯齿,每像素应发射多少条射线?如何合并结果?

编程练习

练习 1:添加地面

实现无限平面(y = -1)作为地面,带棋盘格纹理(根据 x/z 坐标的整数部分交替黑白)。验证球体在地面上有正确的阴影。

参考思路:平面求交 t = -(O.y + 1) / D.y;纹理用 int(x) % 2 ^ int(z) % 2 判断。

练习 2:多球体场景

生成 20 个随机位置、随机颜色、随机大小的球体,渲染一个"球体堆"场景。验证遮挡关系正确(前面的球挡住后面的)。

参考思路:math.random 生成位置/颜色/半径;trace 中找最近交点自动处理遮挡。

练习 3:多线程渲染

将渲染按行分配到多个线程(第 28 章),每个线程渲染 height/N 行。对比单线程和多线程的渲染时间。

参考思路:thread.create 启动 N 个线程,每个处理自己的行范围;主线程 join 等待全部完成。