本章导读
光线追踪的核心思想极其简洁:对图像中的每个像素,从相机发一条射线穿过该像素,找到射线碰到的第一个物体,计算该点的光照颜色。如果物体有反射性,继续追踪反射射线——递归直到没有交点或达到深度限制。
- 实现三维向量的基本运算(加减、点积、叉积、归一化)
- 实现射线-球体相交检测(二次方程求根)
- 实现 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)
深入细节
浮点精度问题:交点 P 在球面上,从 P 发射阴影射线时,由于浮点误差,射线可能"看到"自身球体(t ≈ 0.0000001)。加一个 epsilon(0.001)避免自交。这是光线追踪中最常见的 bug 来源。
两个平行镜面之间反射会无限递归。depth 参数限制最大反射次数(通常 5-10 次)。超过限制返回黑色。实际场景中,每次反射能量衰减(reflectivity < 1),5 次后贡献已可忽略。
光栅化(GPU 传统方式):对每个三角形投影到屏幕,逐像素填充——快但难以处理反射/折射。光线追踪:对每个像素发射射线——慢但物理正确。现代 GPU(RTX)用硬件加速光线追踪,实现实时全局光照。
⚠️ 常见错误
光照计算依赖法线方向。如果法线长度不为 1,点积结果不正确,光照会过亮或过暗。每次计算法线后必须 normalize。
多个光照分量叠加可能超过 1.0(如镜面高光 + 漫反射)。不裁剪会导致 PPM 中出现 > 255 的值,图像显示异常。
图像坐标 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] |
复习题
- 射线-球体求交的二次方程中,判别式 < 0 意味着什么?
- Phong 模型的三个分量分别模拟什么物理现象?
- 为什么阴影射线需要检查 t < lightDist?
- 反射公式 R = D - 2(D·N)N 的几何含义是什么?
- 为什么递归深度需要限制?不限制会怎样?
- t > 0.001 而不是 t > 0 的原因是什么?
- PPM 格式的 "P3" 头表示什么?与 P6 有什么区别?
- 如果要添加抗锯齿,每像素应发射多少条射线?如何合并结果?
编程练习
实现无限平面(y = -1)作为地面,带棋盘格纹理(根据 x/z 坐标的整数部分交替黑白)。验证球体在地面上有正确的阴影。
参考思路:平面求交 t = -(O.y + 1) / D.y;纹理用 int(x) % 2 ^ int(z) % 2 判断。
生成 20 个随机位置、随机颜色、随机大小的球体,渲染一个"球体堆"场景。验证遮挡关系正确(前面的球挡住后面的)。
参考思路:math.random 生成位置/颜色/半径;trace 中找最近交点自动处理遮挡。
将渲染按行分配到多个线程(第 28 章),每个线程渲染 height/N 行。对比单线程和多线程的渲染时间。
参考思路:thread.create 启动 N 个线程,每个处理自己的行范围;主线程 join 等待全部完成。