Lesson 53: 从零实现基础光线追踪器
练习任务
难度:中 【标杆题】
用纯 C 语言从零实现一个基础光线追踪器(raytracer)。你需要完成三组代码:
- 向量运算库(7 个函数)—
v3_add/v3_sub/v3_mul/v3_div/v3_dot/v3_len/v3_norm - 光线 - 球体求交 — 二次方程判别式法,返回最小正 t
- Phong 光照 + 主循环 — 生成相机光线、遍历球体、着色、输出 PPM 像素
v3() 构造器、ray_at() 和 to_byte() 辅助函数已提供。场景已在 main() 中预定义(2 球体 + 1 点光源 + 相机参数)。
通过 make all && ./raytracer | diff - expected_output.txt 比对验证(注意 -lm)。
提示:先读完整份骨架代码再动手。重点是理解每条光线的“从相机→经过像素→命中物体→着色”五步流程。向量运算是地基,求交是核心几何,Phong 光照是视觉呈现。
核心知识点
- 光线追踪基本循环 — 相机→像素→光线→求交→着色,是所有光线追踪器的骨架
- 三维向量运算 — 加减、标量乘除、点积、长度、归一化,7 个函数构成整个渲染管线的数学基础
- 光线 - 球体求交(判别式法) — 将“光线是否击中球体”转化为二次方程
b^2 - 4ac判别,最小正根即最近交点 - Phong 光照三分量 — Ambient(环境光,避免黑死)+ Diffuse(漫反射,Lambert 余弦定律)+ Specular(高光,反射向量)= 完整表面外观
- 反射向量推导 —
R = 2*(N·L)*N - L,入射光关于法线的镜像,镜面高光的基础 - PPM P3 格式 — 最简单的图像格式,纯文本,
printf直接输出,diff直接比对,零外部依赖 - 坐标系统映射 — 像素坐标(离散)→ 归一化坐标 [0,1] → 世界坐标(视口空间)→ 光线方向,三步映射
代码框架
/* 53_raytracer-from-scratch.c — 从零实现光线追踪,输出 PPM 图像
*
* 场景:2 个球体 + 1 个点光源 + 1 个相机
* 红球:center=(0, 0, -5), r=1
* 蓝球:center=(1, -0.5, -4), r=0.8
* 点光源:(0, 10, -3), 白色
* 相机:原点,朝向 -Z, 像平面 z=-1
*
* 任务:
* 1. 实现向量运算(加减、点积、长度、归一化)
* 2. 实现光线 - 球体求交(判别式法,返回最小正 t)
* 3. 实现 Phong 光照(ambient + diffuse + specular)
* 4. 补全主循环(生成相机光线 + 遍历球体 + 累加光照)
*
* 输出:PPM P3 文本格式,64×32 像素,通过 diff 与 expected_output.txt 比对
*/
#include <math.h>
#include <stdio.h>
/* ─── 向量 Vec3 ─── */
typedef struct {
double x, y, z;
} Vec3;
/* 构造一个向量 */
static inline Vec3 v3(double x, double y, double z) {
Vec3 v = {x, y, z};
return v;
}
/* TODO 1a: 基本向量运算 — 加减、标量乘除 (约 8 行)
*
* Vec3 v3_add(Vec3 a, Vec3 b) — 分量和:{a.x+b.x, a.y+b.y, a.z+b.z}
* Vec3 v3_sub(Vec3 a, Vec3 b) — 分量差:{a.x-b.x, a.y-b.y, a.z-b.z}
* Vec3 v3_mul(Vec3 a, double s) — 标量乘:{a.x*s, a.y*s, a.z*s}
* Vec3 v3_div(Vec3 a, double s) — 标量除:{a.x/s, a.y/s, a.z/s}
*/
/* TODO 1b: 向量点积、长度、归一化 (约 7 行)
*
* double v3_dot(Vec3 a, Vec3 b) — 点积:a.x*b.x + a.y*b.y + a.z*b.z
* double v3_len(Vec3 v) — 长度:sqrt(v3_dot(v, v))
* Vec3 v3_norm(Vec3 v) — 归一化:v / len(len>0 时除以长度,否则返回零向量)
*/
/* ─── 光线 Ray ─── */
typedef struct {
Vec3 orig, dir;
} Ray;
/* 光线上参数 t 处的点:orig + t * dir */
static inline Vec3 ray_at(Ray r, double t) { return v3_add(r.orig, v3_mul(r.dir, t)); }
/* ─── 球体 Sphere ─── */
typedef struct {
Vec3 center;
double radius;
Vec3 color;
} Sphere;
/* ─── 光源 Light ─── */
typedef struct {
Vec3 pos;
Vec3 color;
} Light;
/* ─── 场景常量 ─── */
#define WIDTH 64
#define HEIGHT 32
/* TODO 2: 光线 - 球体求交 (约 15 行)
*
* double hit_sphere(Ray r, Sphere s):
* 解二次方程 |orig + t*dir - center|^2 = radius^2
* oc = orig - center
* a = dir·dir
* b = 2 * oc·dir
* c = oc·oc - radius^2
* discriminant = b^2 - 4ac
*
* 若 disc < 0: 返回 -1.0 (无交点)
* 否则计算 t0 = (-b - sqrt(disc)) / (2a)
* t1 = (-b + sqrt(disc)) / (2a)
* 返回两个正根中较小者; 若都 ≤0 则返回 -1.0
*/
/* 将 [0,1] 浮点数转为 0-255 整数 (含 clamp) */
static int to_byte(double x) {
if (x < 0.0) x = 0.0;
if (x > 1.0) x = 1.0;
return (int)(x * 255.0 + 0.5);
}
int main(void) {
/* 场景:两个球体 */
Sphere spheres[2] = {
{v3(0, 0, -5), 1.0, v3(1, 0, 0)}, /* 红球 */
{v3(1, -0.5, -4), 0.8, v3(0, 0, 1)}, /* 蓝球 */
};
int n_spheres = 2;
/* 点光源 */
Light light = {v3(0, 10, -3), v3(1, 1, 1)};
/* 相机参数 */
Vec3 cam = v3(0, 0, 0); /* 相机在原点 */
double viewport_w = 2.0; /* 视口宽度 */
double viewport_h = 1.0; /* 视口高度 */
double focal = 1.0; /* 焦距 (像平面 z = -1) */
/* 输出 PPM P3 头 */
printf("P3\n%d %d\n255\n", WIDTH, HEIGHT);
/* TODO 3a: 主循环 — 生成光线并找最近交点 (约 12 行)
*
* for (int y = 0; y < HEIGHT; y++):
* for (int x = 0; x < WIDTH; x++):
*
* ① 计算光线方向:
* u = (x + 0.5) / WIDTH // [0, 1]
* v = (y + 0.5) / HEIGHT
* px = (u - 0.5) * viewport_w // 映射到视口 [-1, 1]
* py = (0.5 - v) * viewport_h // Y 翻转 (PPM 顶行在上)
* pz = -focal
* Ray ray = { cam, v3_norm(v3(px, py, pz)) };
*
* ② 遍历球体找最近交点:
* closest_t = -1.0; hit_idx = -1;
* for (int i = 0; i < n_spheres; i++):
* t = hit_sphere(ray, spheres[i])
* if (t > 0 && (closest_t < 0 || t < closest_t)):
* closest_t = t; hit_idx = i;
*/
/* TODO 3b: Phong 光照计算与像素输出 (约 13 行)
*
* ③ 若无交点 (hit_idx < 0):
* printf("0 0 0\n"); ← 黑色背景
*
* ④ 若有交点,Phong 光照:
* P = ray_at(ray, closest_t) // 交点位置
* N = v3_norm(v3_sub(P, spheres[hit_idx].center)) // 表面法线
* obj = spheres[hit_idx].color // 物体颜色
* L = v3_norm(v3_sub(light.pos, P)) // 指向光源
* V = v3_norm(v3_sub(cam, P)) // 指向相机
*
* // Ambient
* Vec3 ambient = v3_mul(obj, 0.1);
*
* // Diffuse (Lambertian)
* double ndotl = v3_dot(N, L);
* Vec3 diffuse = (ndotl > 0) ? v3_mul(obj, 0.7 * ndotl) : v3(0,0,0);
*
* // Specular (Phong)
* Vec3 R = v3_sub(v3_mul(N, 2.0 * ndotl), L); // 反射向量
* double rdotv = v3_dot(R, V);
* Vec3 specular = (rdotv > 0 && ndotl > 0)
* ? v3_mul(light.color, 0.5 * pow(rdotv, 32.0))
* : v3(0,0,0);
*
* Vec3 color = v3_add(v3_add(ambient, diffuse), specular);
*
* ⑤ 输出:printf("%d %d %d\n", to_byte(color.x), to_byte(color.y), to_byte(color.z));
*/
return 0;
}阅读骨架后,尝试自己填充三个 TODO 区的代码。核心模式:v3() 构造器已给出,剩下 7 个函数逐分量操作。hit_sphere 解二次方程取最小正根。主循环三步:生成光线 → 找最近交点 → Phong 着色输出。
TIP
先不往下翻看参考解答。在纸上画出“相机→像素→光线→球体→光源”的矢量关系图,标明 oc、N、L、R、V 五个向量的位置。然后对照骨架中的伪代码逐行写 C 实现。
深度讲解
1. 光线追踪原理
光线追踪是一种图像合成算法,模拟光在场景中的物理传播。核心思想是逆向追踪:从相机出发,穿过每个像素发射光线,计算光线与场景物体的交点,再根据光照模型确定该像素颜色。
┌──────────────────┐
│ Image Plane │
Camera │ (z = -focal) │
│ │ │
│ Origin (0,0,0) │ ┌────────────┐ │
└─────────────────────┤ │ Pixel │ │
│ │ ↑ │ │
│ │ Ray dir │ │
│ │ ↓ │ │
│ └────────────┘ │
└────────┬─────────┘
│
▼
┌──────────────────────┐
│ Red Sphere │
│ center=(0,0,-5) │
│ ┌─────┐ │
│ │ Hit │ ← P(t) │
│ └─────┘ │
└──────────────────────┘
│
┌────────┬────────┐
│ Point Light │
│ (0, 10, -3) │
│ L (light-P) │
└────────────────────┘为什么从相机出发? 从光源出发的大部分光线永远到不了相机,浪费计算。从相机出发只需追踪到达像素的光线,高效得多。
场景定义:红球在 (0,0,-5) 半径 1,蓝球在 (1,-0.5,-4) 半径 0.8。点光源在 (0,10,-3) 正上方白色。相机在原点朝向 -Z,像平面 z=-1,视口宽 2 高 1,分辨率 64x32。
2. 向量运算库 —— 渲染管线的数学地基
所有计算——光线方向、交点位置、表面法线、光源方向——都是对 Vec3 { double x, y, z } 的操作。
Vec3 v3_add(Vec3 a, Vec3 b) { return v3(a.x + b.x, a.y + b.y, a.z + b.z); }
Vec3 v3_sub(Vec3 a, Vec3 b) { return v3(a.x - b.x, a.y - b.y, a.z - b.z); }
Vec3 v3_mul(Vec3 a, double s) { return v3(a.x * s, a.y * s, a.z * s); }
Vec3 v3_div(Vec3 a, double s) { return v3(a.x / s, a.y / s, a.z / s); }double v3_dot(Vec3 a, Vec3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
double v3_len(Vec3 v) { return sqrt(v3_dot(v, v)); }
Vec3 v3_norm(Vec3 v) {
double len = v3_len(v);
if (len > 0.0) return v3_div(v, len);
return v3(0.0, 0.0, 0.0);
}IMPORTANT
点积 a·b = |a||b|cos(θ) 是光照计算的核心:当 a 和 b 都是单位向量时,a·b = cos(θ) 直接给出夹角余弦。Lambert 余弦定律(漫反射)和镜面高光都依赖这个性质。
CAUTION
sqrt() 来自 <math.h>,编译时必须加 -lm。忘记 -lm 会导致 undefined reference to 'sqrt'。
向量运算速查表:
| 操作 | 公式 | C 代码 |
|---|---|---|
| 加法 | a + b = (ax+bx, ay+by, az+bz) | v3_add(a,b) |
| 减法 | a-b = (ax-bx, ay-by, az-bz) | v3_sub(a,b) |
| 标量乘 | a * s = (ax*s, ay*s, az*s) | v3_mul(a,s) |
| 标量除 | a / s = (ax/s, ay/s, az/s) | v3_div(a,s) |
| 点积 | a·b = ax*bx+ay*by+az*bz | v3_dot(a,b) |
| 长度 | |v| = sqrt(v·v) | v3_len(v) |
| 归一化 | v / |v| | v3_norm(v) |
3. 光线 - 球体求交 —— 判别式法的完整推导
3.1 从几何问题到二次方程
这是光线追踪的核心几何计算。
光线方程: P(t) = orig + t * dir (t ≥ 0)
球体方程: |P - center|^2 = radius^2
代入光线方程,令 oc = orig - center:
|oc + t*dir|^2 = radius^2
oc·oc + 2t*oc·dir + t^2*dir·dir - radius^2 = 0
标准二次方程: a*t^2 + b*t + c = 0
a = dir·dir (= 1.0 若 dir 已归一化)
b = 2 * oc·dir
c = oc·oc - radius^2
判别式: disc = b^2 - 4*a*c3.2 判别式的三种几何情况
disc < 0: 无交点 — 光线从旁经过
┌───┐
│ S │ ←──── ray
└───┘
disc = 0: 相切 — 光线擦过表面(一个交点)
┌───┤
│ S │ ←──── ray
└───┘
disc > 0: 相交 — 两个交点 t0(近) 和 t1(远)
┌───┐
┌───┤ ├───┐ ←──── ray
│ └───┘ │
│ t0 t1 │
└────────────┘3.3 完整实现
double hit_sphere(Ray r, Sphere s) {
Vec3 oc = v3_sub(r.orig, s.center);
double a = v3_dot(r.dir, r.dir);
double b = 2.0 * v3_dot(oc, r.dir);
double c = v3_dot(oc, oc) - s.radius * s.radius;
double disc = b * b - 4.0 * a * c;
if (disc < 0.0) return -1.0; /* 无交点 */
double sqrt_disc = sqrt(disc);
double t0 = (-b - sqrt_disc) / (2.0 * a);
double t1 = (-b + sqrt_disc) / (2.0 * a);
if (t0 > 0.0) return t0; /* 最近的可见交点 */
if (t1 > 0.0) return t1; /* 相机在球体内部 */
return -1.0; /* 交点在相机后方 */
}每一步的物理意义:oc = 球心指向光线起点的向量;b = 2*oc·dir = oc 在光线方向上的投影(正=背离,负=朝向);c = oc·oc - r² = 起点到球心距离平方减半径平方(c<0 意味着起点在球内)。
CAUTION
返回负 t 是最常见的错误:交点必须在 t > 0 时才可见。两个正根取大的那个会渲染球体远端面(被近端遮挡),结果颜色错误。
4. 相机光线生成 —— 像素坐标到世界光线的映射
对于像素 (x, y):
Step A: 归一化 (加 0.5 偏移以穿过像素中心)
u = (x + 0.5) / WIDTH → u ∈ [0, 1]
v = (y + 0.5) / HEIGHT → v ∈ [0, 1]
Step B: 映射到视口世界坐标
px = (u - 0.5) * viewport_w → px ∈ [-1, 1]
py = (0.5 - v) * viewport_h → py ∈ [-0.5, 0.5] (Y 翻转!)
pz = -focal → pz = -1
Step C: 单位方向向量
dir = normalize(px, py, pz)
Ray = { origin=(0,0,0), direction=dir }Y 轴翻转:PPM 格式规定顶行(y=0)对应场景中 Y 最大的位置。py = (0.5 - v) * viewport_h 实现了这个翻转——当 y=0(顶行)时 v≈0,py≈+0.5,对应世界坐标上方。
像素中心偏移:(x + 0.5) 确保光线穿过像素中心而非角落。不加 0.5 会导致微小的几何偏移和锯齿。
5. Phong 光照模型 —— 三分量的完整推导
I = I_ambient + I_diffuse + I_specular 光源 ★
/|
/ | L
/ |
/ N ← 表面法线
/ |
/ R | ← 反射方向
/ |
▼───────● ← 表面 P
|
▼ V ← 指向相机分量 1: Ambient(环境光)
Vec3 ambient = v3_mul(obj_color, 0.1); // k_a = 0.1模拟间接光照(多次弹射),防止阴影区域全黑。系数 0.1 意味着表面永远有 10% 的基础亮度。
分量 2: Diffuse(漫反射,Lambertian)
double ndotl = v3_dot(N, L);
Vec3 diffuse = (ndotl > 0) ? v3_mul(obj_color, 0.7 * ndotl) : v3(0,0,0);遵循 Lambert 余弦定律——表面亮度正比于 N·L(= cosθ)。θ=0°(正对光源)时 N·L=1(最亮),θ=90° 时 N·L=0(最暗),θ>90°(背面)clamp 到 0。
分量 3: Specular(高光,Phong)
Vec3 R = v3_sub(v3_mul(N, 2.0 * ndotl), L); // 反射向量
double rdotv = v3_dot(R, V);
Vec3 specular = (rdotv > 0 && ndotl > 0)
? v3_mul(light_color, 0.5 * pow(rdotv, 32.0))
: v3(0,0,0);反射向量 R = 2*(N·L)*N - L 是入射光关于法线的镜像。推导过程:
L = (L·N)*N + (L - (L·N)*N) // 平行分量 + 垂直分量
R = (L·N)*N - (L - (L·N)*N) // 平行相同,垂直取反
= 2*(L·N)*N - LR·V 越大,反射光越接近视线方向(越亮)。pow(..., 32) 将亮度集中在反射方向附近——指数越大,亮点越锐利,表面看起来越“光滑”。
| 分量 | 公式 | 系数 | 物理意义 |
|---|---|---|---|
| Ambient | obj_color * 0.1 | k_a=0.1 | 防止阴影全黑 |
| Diffuse | obj_color * 0.7 * max(0, N·L) | k_d=0.7 | 面朝光源更亮 |
| Specular | white * 0.5 * pow(max(0, R·V), 32) | k_s=0.5 | 镜面方向亮点 |
6. 法线与数据结构
球体表面法线:N = normalize(P - center)(从球心指向表面,不是反过来)。
数据结构总览:
Vec3 { double x, y, z } — 7 个运算函数
Ray { Vec3 orig, dir } — ray_at(r, t)
Sphere { Vec3 center; double radius; Vec3 color; }
Light { Vec3 pos; Vec3 color; }7. PPM P3 格式与完整流程
P3 ← 魔数
64 32 ← 宽 高
255 ← 最大颜色值
203 0 0 ← 像素(0,0): R G B
0 0 0 ← 像素(1,0): 黑色背景
...PPM(Portable Pixmap)是最简单的图像格式:纯文本,printf 直接输出,diff 直接比对,零外部依赖。to_byte() 将 [0,1] 浮点颜色 clamp 到 0~255:
static int to_byte(double x) {
if (x < 0.0) x = 0.0;
if (x > 1.0) x = 1.0;
return (int)(x * 255.0 + 0.5);
}完整渲染流程:
for y = 0 to HEIGHT-1:
for x = 0 to WIDTH-1:
① 生成光线: pixel → world → ray direction
② 找最近交点: 遍历球体, 取最小正 t
③ 无交点 → 黑; 有交点 → Phong(ambient+diffuse+specular)
④ 输出 to_byte(color)参考解答
练习1: 向量运算 — 7 个函数
Vec3 v3_add(Vec3 a, Vec3 b) { return v3(a.x + b.x, a.y + b.y, a.z + b.z); }
Vec3 v3_sub(Vec3 a, Vec3 b) { return v3(a.x - b.x, a.y - b.y, a.z - b.z); }
Vec3 v3_mul(Vec3 a, double s) { return v3(a.x * s, a.y * s, a.z * s); }
Vec3 v3_div(Vec3 a, double s) { return v3(a.x / s, a.y / s, a.z / s); }
double v3_dot(Vec3 a, Vec3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
double v3_len(Vec3 v) { return sqrt(v3_dot(v, v)); }
Vec3 v3_norm(Vec3 v) {
double len = v3_len(v);
if (len > 0.0) return v3_div(v, len);
return v3(0.0, 0.0, 0.0);
}每个操作都是对 x、y、z 分量的算术运算。v3_norm 需处理零向量:除零返回零向量。
练习2: 光线 - 球体求交 — hit_sphere
double hit_sphere(Ray r, Sphere s) {
Vec3 oc = v3_sub(r.orig, s.center);
double a = v3_dot(r.dir, r.dir);
double b = 2.0 * v3_dot(oc, r.dir);
double c = v3_dot(oc, oc) - s.radius * s.radius;
double disc = b * b - 4.0 * a * c;
if (disc < 0.0) return -1.0;
double sqrt_disc = sqrt(disc);
double t0 = (-b - sqrt_disc) / (2.0 * a);
double t1 = (-b + sqrt_disc) / (2.0 * a);
if (t0 > 0.0) return t0;
if (t1 > 0.0) return t1;
return -1.0;
}要点:判别式 < 0 直接返回 -1。取最小正根。注意 double 浮点运算(不是 int)。
练习3: 主循环 + Phong 光照
int main(void) {
/* 场景数据 */
Sphere spheres[2] = {
{v3(0, 0, -5), 1.0, v3(1, 0, 0)},
{v3(1, -0.5, -4), 0.8, v3(0, 0, 1)},
};
int n_spheres = 2;
Light light = {v3(0, 10, -3), v3(1, 1, 1)};
Vec3 cam = v3(0, 0, 0);
double viewport_w = 2.0, viewport_h = 1.0, focal = 1.0;
printf("P3\n%d %d\n255\n", WIDTH, HEIGHT);
for (int y = 0; y < HEIGHT; y++) {
for (int x = 0; x < WIDTH; x++) {
/* ① 生成光线 */
double u = (x + 0.5) / WIDTH;
double v = (y + 0.5) / HEIGHT;
double px = (u - 0.5) * viewport_w;
double py = (0.5 - v) * viewport_h;
double pz = -focal;
Ray ray = {cam, v3_norm(v3(px, py, pz))};
/* ② 找最近交点 */
double closest_t = -1.0;
int hit_idx = -1;
for (int i = 0; i < n_spheres; i++) {
double t = hit_sphere(ray, spheres[i]);
if (t > 0.0 && (closest_t < 0.0 || t < closest_t)) {
closest_t = t;
hit_idx = i;
}
}
/* ③ 着色 */
if (hit_idx < 0) {
printf("0 0 0\n");
} else {
/* ④ Phong 光照 */
Vec3 P = ray_at(ray, closest_t);
Vec3 N = v3_norm(v3_sub(P, spheres[hit_idx].center));
Vec3 obj = spheres[hit_idx].color;
Vec3 L = v3_norm(v3_sub(light.pos, P));
Vec3 V = v3_norm(v3_sub(cam, P));
Vec3 ambient = v3_mul(obj, 0.1);
double ndotl = v3_dot(N, L);
Vec3 diffuse = (ndotl > 0.0)
? v3_mul(obj, 0.7 * ndotl) : v3(0,0,0);
Vec3 R = v3_sub(v3_mul(N, 2.0 * ndotl), L);
double rdotv = v3_dot(R, V);
Vec3 specular = (rdotv > 0.0 && ndotl > 0.0)
? v3_mul(light.color, 0.5 * pow(rdotv, 32.0))
: v3(0,0,0);
Vec3 color = v3_add(v3_add(ambient, diffuse), specular);
printf("%d %d %d\n",
to_byte(color.x), to_byte(color.y), to_byte(color.z));
}
}
}
return 0;
}核心流程:① 像素→世界→光线方向 ② 遍历球体取最小正 t ③ 无交点输出黑 ④ 有交点计算 Phong ⑤ to_byte 钳位输出。
对照检查:
v3_norm中检查了len > 0吗?hit_sphere返回的是最小正 t 吗?py做了(0.5 - v)翻转吗?R是2*(N·L)*N - L(不是L - 2*(N·L)*N)吗?编译时加了-lm吗?
常见错误与陷阱
| 错误 | 为什么错 | 正确做法 |
|---|---|---|
忘加 -lm | sqrt() 和 pow() 在 libm 中 | LDLIBS = -lm |
| Y 轴未翻转 | PPM 顶行对应世界坐标 Y 最大 | py = (0.5 - v) * viewport_h |
| 像素不加 0.5 偏移 | 光线穿过像素角而非中心 | (x + 0.5) / WIDTH |
| hit_sphere 返回负 t | 交点在相机后方,不可见 | 只返回 > 0 的根 |
| 两个正根取大的 | 远端面被近端遮挡 | 取最小正 t |
| N·L 或 R·V 未 clamp | 负值使颜色变暗甚至变负 | 用 if (>0) 或三元运算符 |
| 法线方向反了 | 写成 center-P | N = normalize(P - center) |
| 反射公式写反 | 符号错误导致高光位置偏移 | R = 2*(N·L)*N - L |
| to_byte 不 clamp | >1.0 使 printf 输出 >255 | clamp 到 [0,1] 再乘 255 |
课堂讨论
为什么用光线追踪而不是光栅化? 光线追踪天然支持反射、折射、阴影等全局光照效果,代码逻辑直观。光栅化是实时渲染的主流,但全局效果需要额外技巧。
Phong 和 Blinn-Phong 有什么区别? Blinn-Phong 用半角向量
H = normalize(L+V)代替反射向量 R,计算N·H替换R·V,更高效,高光范围稍大。如果再加一个球体会怎样? 只需在
spheres[]中添加一个元素,n_spheres改为 3。代码无需其他修改——这是数据驱动设计的优势。怎么添加阴影? 从交点向光源发一条“阴影光线”,若在到达光源之前碰到其他物体,则该点处于阴影中。阴影中的点只保留 ambient 分量。
为什么分辨率这么低? 64×32 = 2048 条光线,在纯 CPU 上几乎瞬间完成。教学目的:小图像便于肉眼检查 PPM 文本输出,也方便 diff 比对。
PPM 为什么不直接用 PNG/BMP? PPM 是纯文本格式,
printf直接输出,无需任何图像库。教学场景下零依赖是最佳选择。
课后练习
调整 Phong 参数观察效果。修改 ambient/diffuse/specular 系数和 shininess 指数(如 k_a=0.2, k_d=0.5, k_s=0.8, shininess=64),重新编译运行,对比 PPM 输出中球体表面亮度的变化。
参考解答
c/* 在 main() 的 Phong 光照部分修改系数: * Vec3 ambient = v3_mul(obj, 0.2); // k_a=0.2 * Vec3 diffuse = (ndotl > 0.0) * ? v3_mul(obj, 0.5 * ndotl) : v3(0,0,0); // k_d=0.5 * // specular 部分: * Vec3 specular = (rdotv > 0.0 && ndotl > 0.0) * ? v3_mul(light.color, 0.8 * pow(rdotv, 64.0)) // k_s=0.8, s=64 * : v3(0,0,0); * * 预期:环境光增加 → 阴影区域更亮;漫反射减弱 → 整体偏暗; * 高光增强且更集中 → 亮点更小更亮。 */添加第三个球体。在场景中加一个绿色球体:center=(0.5, 0, -3.5), radius=0.5, color=(0,1,0)。验证只需改数组而无需修改渲染循环。
参考解答
c/* 在 main() 的场景定义中: * Sphere spheres[3] = { * {v3(0, 0, -5), 1.0, v3(1, 0, 0)}, // 红球 * {v3(1, -0.5, -4), 0.8, v3(0, 0, 1)}, // 蓝球 * {v3(0.5, 0, -3.5), 0.5, v3(0, 1, 0)}, // 绿球 * }; * int n_spheres = 3; * * 其余代码无需修改。渲染循环遍历所有球体取最近交点,数据结构驱动。 */实现 Blinn-Phong 光照。将 specular 计算从经典 Phong
pow(R·V, s)改为 Blinn-Phongpow(N·H, s),其中H = normalize(L+V)。对比两种模型的视觉效果差异。参考解答
c/* 在 specular 计算部分: * // 经典 Phong(原代码): * Vec3 R = v3_sub(v3_mul(N, 2.0 * ndotl), L); * double rdotv = v3_dot(R, V); * * // 替换为 Blinn-Phong: * Vec3 H = v3_norm(v3_add(L, V)); // 半角向量 * double ndoth = v3_dot(N, H); * Vec3 specular = (ndoth > 0.0 && ndotl > 0.0) * ? v3_mul(light.color, 0.5 * pow(ndoth, 32.0)) * : v3(0, 0, 0); * * Blinn-Phong 更高效(无需计算反射向量 R), * 高光范围略大于经典 Phong,需要调高 shininess 或降低 k_s 来补偿。 */增加阴影光线。对每个命中点,从 P 向光源方向发射一条阴影光线,检查是否有其他球体遮挡。若被遮挡,该点只接收 ambient 光。
参考解答
c/* 在 Phong 光照计算前添加阴影检测: * Vec3 to_light = v3_sub(light.pos, P); * double dist_to_light = v3_len(to_light); * Vec3 shadow_dir = v3_norm(to_light); * Ray shadow_ray = {P, shadow_dir}; * * int in_shadow = 0; * for (int i = 0; i < n_spheres; i++) { * double t = hit_sphere(shadow_ray, spheres[i]); * if (t > 0.001 && t < dist_to_light) { // 0.001 避免自交 * in_shadow = 1; * break; * } * } * * // 阴影中的点跳过 diffuse 和 specular: * Vec3 color; * if (in_shadow) { * color = ambient; // 只保留环境光 * } else { * // 正常的 ambient + diffuse + specular * } * * 注意:阴影光线方向需归一化,且 t 阈值 0.001 防止命中起点自身。 */
参考资料
- Turner Whitted. "An Improved Illumination Model for Shaded Display" (1980) — 递归光线追踪的奠基论文
- Bui Tuong Phong. "Illumination for Computer Generated Pictures" (1975) — Phong 光照模型原论文
- Peter Shirley. "Ray Tracing in One Weekend" — 现代入门教程
- 《计算机图形学》(Foley et al.) 第 16 章 — 光照模型与着色
- Wikipedia: Phong reflection model — https://en.wikipedia.org/wiki/Phong_reflection_model
- Wikipedia: Ray tracing (graphics) — https://en.wikipedia.org/wiki/Ray_tracing_(graphics)
- Netpbm (PPM format) — https://en.wikipedia.org/wiki/Netpbm
"Make it work, make it right, make it fast." — Kent Beck