跳转到内容

Lesson 53: 从零实现基础光线追踪器

练习任务

难度:中 【标杆题】

用纯 C 语言从零实现一个基础光线追踪器(raytracer)。你需要完成三组代码:

  1. 向量运算库(7 个函数)— v3_add / v3_sub / v3_mul / v3_div / v3_dot / v3_len / v3_norm
  2. 光线 - 球体求交 — 二次方程判别式法,返回最小正 t
  3. 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
c
/* 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_arithmetic.c
c
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); }
vec3_dot_len_norm.c
c
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*bzv3_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*c

3.2 判别式的三种几何情况

disc < 0:  无交点 光线从旁经过
              ┌───┐
 S  ←──── ray
              └───┘

disc = 0:  相切 光线擦过表面(一个交点)
              ┌───┤
 S  ←──── ray
              └───┘

disc > 0:  相交 两个交点 t0()  t1()
              ┌───┐
          ┌───┤   ├───┐  ←──── ray
   └───┘
  t0   t1
          └────────────┘

3.3 完整实现

hit_sphere.c
c
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(环境光)

c
Vec3 ambient = v3_mul(obj_color, 0.1);  // k_a = 0.1

模拟间接光照(多次弹射),防止阴影区域全黑。系数 0.1 意味着表面永远有 10% 的基础亮度。

分量 2: Diffuse(漫反射,Lambertian)

c
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)

c
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 - L

R·V 越大,反射光越接近视线方向(越亮)。pow(..., 32) 将亮度集中在反射方向附近——指数越大,亮点越锐利,表面看起来越“光滑”。

分量公式系数物理意义
Ambientobj_color * 0.1k_a=0.1防止阴影全黑
Diffuseobj_color * 0.7 * max(0, N·L)k_d=0.7面朝光源更亮
Specularwhite * 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:

to_byte.c
c
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_ops.c
c
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
hit_sphere.c
c
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 光照
main_loop_phong.c
c
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) 翻转吗?R2*(N·L)*N - L(不是 L - 2*(N·L)*N)吗?编译时加了 -lm 吗?


常见错误与陷阱

错误为什么错正确做法
忘加 -lmsqrt()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-PN = normalize(P - center)
反射公式写反符号错误导致高光位置偏移R = 2*(N·L)*N - L
to_byte 不 clamp>1.0 使 printf 输出 >255clamp 到 [0,1] 再乘 255

课堂讨论

  1. 为什么用光线追踪而不是光栅化? 光线追踪天然支持反射、折射、阴影等全局光照效果,代码逻辑直观。光栅化是实时渲染的主流,但全局效果需要额外技巧。

  2. Phong 和 Blinn-Phong 有什么区别? Blinn-Phong 用半角向量 H = normalize(L+V) 代替反射向量 R,计算 N·H 替换 R·V,更高效,高光范围稍大。

  3. 如果再加一个球体会怎样? 只需在 spheres[] 中添加一个元素,n_spheres 改为 3。代码无需其他修改——这是数据驱动设计的优势。

  4. 怎么添加阴影? 从交点向光源发一条“阴影光线”,若在到达光源之前碰到其他物体,则该点处于阴影中。阴影中的点只保留 ambient 分量。

  5. 为什么分辨率这么低? 64×32 = 2048 条光线,在纯 CPU 上几乎瞬间完成。教学目的:小图像便于肉眼检查 PPM 文本输出,也方便 diff 比对。

  6. PPM 为什么不直接用 PNG/BMP? PPM 是纯文本格式,printf 直接输出,无需任何图像库。教学场景下零依赖是最佳选择。


课后练习

  1. 调整 Phong 参数观察效果。修改 ambient/diffuse/specular 系数和 shininess 指数(如 k_a=0.2, k_d=0.5, k_s=0.8, shininess=64),重新编译运行,对比 PPM 输出中球体表面亮度的变化。

    参考解答
    ex1_phong_params.c
    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);
     *
     * 预期:环境光增加 → 阴影区域更亮;漫反射减弱 → 整体偏暗;
     *      高光增强且更集中 → 亮点更小更亮。
     */
  2. 添加第三个球体。在场景中加一个绿色球体:center=(0.5, 0, -3.5), radius=0.5, color=(0,1,0)。验证只需改数组而无需修改渲染循环。

    参考解答
    ex2_third_sphere.c
    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;
     *
     * 其余代码无需修改。渲染循环遍历所有球体取最近交点,数据结构驱动。
     */
  3. 实现 Blinn-Phong 光照。将 specular 计算从经典 Phong pow(R·V, s) 改为 Blinn-Phong pow(N·H, s),其中 H = normalize(L+V)。对比两种模型的视觉效果差异。

    参考解答
    ex3_blinn_phong.c
    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 来补偿。
     */
  4. 增加阴影光线。对每个命中点,从 P 向光源方向发射一条阴影光线,检查是否有其他球体遮挡。若被遮挡,该点只接收 ambient 光。

    参考解答
    ex4_shadow_ray.c
    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 防止命中起点自身。
     */

参考资料

"Make it work, make it right, make it fast." — Kent Beck

Released under the MIT License.