Skip to main content

Chapter 33: Ray Casting Fundamentals

Ray Casting 的主问题是:给定相机、像素和场景几何,如何为每个像素发出一条视线,在场景中找到最近的可见表面,并把这个命中结果稳定地转换成调试图像或后续 shading 输入。读完本章后,读者应能追踪一条 camera ray 从像素中心进入场景、经过多个几何求交函数、更新 closest hit,并解释 normal visualization、depth visualization 和求交成本之间的关系。

本章贯穿一个最小 frame:一个 pinhole camera 面向由球、平面、三角形和 AABB 包围盒组成的场景,输出两张调试图。一张图把命中法线映射为颜色,另一张图把最近命中距离映射为灰度。这个 frame 足够小,可以用 CPU 循环写出,也可以映射成 compute shader 中的 one thread per pixel;它覆盖 ray casting 的核心对象,又不会提前进入递归反射、折射、阴影光线和 BVH 构建。

Ray Casting 在这里指 primary visibility query:每个像素生成一条 primary ray,查询这条 ray 首次命中的几何表面。这个结果回答“相机从该像素方向看到什么”。Ray Tracing 后续会在命中点继续发出 shadow ray、reflection ray、refraction ray;本章只把第一条相机光线、最近交点和可视化结果讲完整。

本章的判断顺序固定为:先确认 ray 的参数域和 hit record 语义,再把每种几何体转成同一种 t 候选值,接着用 camera ray generation 产生每个像素的输入,最后用像素数、物体数、求交次数和内存访问解释复杂场景的计算负载。

33.1 Ray Casting 算法原理与几何基础

Ray 是一条从起点出发、沿方向延伸的半直线。常用表示是 r(t)=o+tdr(t)=o+t d,其中 o 是 ray origin,d 是 ray direction,t 是沿方向前进的参数。t 的有效范围通常是 [tMin, tMax]tMin 排除相机原点附近的数值噪声,tMax 限制当前查询允许的最远距离。这个表达和 PBRT 4e 的 ray 定义一致:ray 的核心数据是 origin、direction 和可取样的参数函数。

Ray Casting 把图像生成拆成大量几何查询。每个像素先由 camera 生成一个 view ray,场景循环把这条 ray 送进每个可相交对象,求交函数返回候选 hit,主循环保留正向、范围内、距离最近的候选结果。最后一个像素只需要一个 closest hit record;没有命中时输出背景色。

Hit record 是 ray casting 的统一输出。它把不同几何体的求交结果整理成同一组字段,使 shading、debug visualization 和后续 pass 可以复用相同接口。

struct Ray {
Vec3 origin;
Vec3 direction; // 通常保持归一化,便于把 t 解释为距离

Vec3 at(float t) const {
return origin + t * direction;
}
};

struct HitRecord {
float t; // ray 参数,越小表示离相机越近
Vec3 position; // origin + t * direction
Vec3 normal; // 命中点处的几何法线或插值法线
int primitiveId; // 命中的几何对象编号
int materialId; // 后续 shading 使用的材质编号
bool frontFace; // ray 是否打到表面正面
};

这个结构体里的 t 是全章最关键的比较量。两个几何体同时被同一条 ray 穿过时,较小的正向 t 对应相机先看到的表面。positionnormal 可以由 t 反算得到;保存在 hit record 中是为了让后续阶段少做重复计算。frontFace 记录 ray direction 与几何法线之间的朝向关系,透明材质、单面三角形和法线翻转都会用到这个字段。

Ray Casting 的数据路径可以写成一条稳定流程。图中每个节点都对应一个可以检查的程序位置:像素输入、相机变换、几何求交、closest hit 更新和输出写入。

这条流程的边界很清楚。Camera 负责把二维像素坐标变成三维 ray。几何体负责把自己的形状方程转成 t 候选值。主循环负责比较候选值并保留最近记录。输出 pass 负责把 record 里的字段映射成图像。任何一个像素显示异常,都可以沿这条链路反查:ray direction 是否正确、求交函数是否返回范围外 t、closest hit 是否被远处对象覆盖、normal 和 depth 的映射是否使用了同一套坐标约定。

Ray Casting 的最小实现只需要一个对象接口。每个对象的 intersect 函数接收 ray 和当前有效范围,返回是否命中,并在命中时写入 record。主循环不断缩小 closestT,使后续对象只需要报告比当前结果更近的命中。

bool castRay(const Ray& ray, const Scene& scene, HitRecord& outHit) {
bool hitAnything = false;
float closestT = scene.cameraFar;

for (const Object& object : scene.objects) {
HitRecord candidate{};
if (object.intersect(ray, scene.cameraNear, closestT, candidate)) {
hitAnything = true;
closestT = candidate.t;
outHit = candidate;
}
}

return hitAnything;
}

这里的关键约束是 object.intersect 必须只返回 [cameraNear, closestT] 内的 hit。这样主循环拿到的最后一个 record 就是最近可见表面。这个约束把“求交数学”和“可见性语义”接在一起:数学函数产生候选根,ray casting 规则筛选可见根。

33.2 Ray‑Object 求交数学与优化计算

Ray-object intersection 的目标是把每种几何体转换成同一种判断:这条 ray 是否在有效 t 范围内穿过该对象,若穿过,最近的 t 是多少,命中点的 normal 如何得到。不同几何体的方程不同,输出必须统一成 hit record。

球体求交来自把 ray 方程代入球面方程。设球心为 c,半径为 r,ray 为 p(t)=o+tdp(t)=o+t d,球面满足 pc2=r2\|p-c\|^2=r^2。令 oc = o - c,可得到二次方程:a = dot(d,d)halfB = dot(oc,d)cTerm = dot(oc,oc) - r*r,判别式 disc = halfB*halfB - a*cTermdisc 小于零时没有实数交点;大于等于零时,两个候选根分别代表进入球体和离开球体的位置。

bool hitSphere(const Ray& ray, const Sphere& sphere,
float tMin, float tMax, HitRecord& hit) {
Vec3 oc = ray.origin - sphere.center;
float a = dot(ray.direction, ray.direction);
float halfB = dot(oc, ray.direction);
float cTerm = dot(oc, oc) - sphere.radius * sphere.radius;
float disc = halfB * halfB - a * cTerm;

if (disc < 0.0f) {
return false;
}

float sqrtDisc = sqrt(disc);
float t0 = (-halfB - sqrtDisc) / a;
float t1 = (-halfB + sqrtDisc) / a;

float t = t0;
if (t < tMin || t > tMax) {
t = t1;
if (t < tMin || t > tMax) {
return false;
}
}

hit.t = t;
hit.position = ray.at(t);
hit.normal = normalize(hit.position - sphere.center);
hit.primitiveId = sphere.id;
hit.materialId = sphere.materialId;
hit.frontFace = dot(ray.direction, hit.normal) < 0.0f;
if (!hit.frontFace) {
hit.normal = -hit.normal;
}
return true;
}

这个球体函数展示了所有求交函数都要遵守的筛选顺序:先求候选根,再按 tMintMax 过滤,再写入 position 和 normal。halfB 写法减少一次乘法,同时保持推导含义清楚;Ray Tracing in One Weekend 也采用类似的最小球体求交路径来解释 primary ray 的可见性。

平面求交是一元一次方程。设平面上一点为 p0,法线为 n,平面满足 dot(p - p0, n) = 0。把 p = o + t d 代入,得到 t=dot(p0o,n)dot(d,n)t=\frac{dot(p0-o,n)}{dot(d,n)}。分母接近零表示 ray 方向与平面接近平行;此时函数应返回无命中或按特殊共面规则处理。常规渲染器中,平面常用作地面或裁剪辅助对象,t 范围筛选仍然和球体一致。

三角形求交通常使用 barycentric coordinates 或 Möller-Trumbore 算法。三角形顶点为 v0v1v2,任意三角形内部点可以写成 v0 + u*(v1-v0) + v*(v2-v0),约束是 u >= 0v >= 0u + v <= 1。求交函数同时解出 tuvt 表示 ray 上的位置,uv 判断交点是否落在三角形内部。Möller 和 Trumbore 的 Fast, Minimum Storage Ray/Triangle Intersection 给出了经典实现方式,适合网格三角形的最小存储求交。

bool hitTriangle(const Ray& ray, const Triangle& tri,
float tMin, float tMax, HitRecord& hit) {
constexpr float eps = 1e-6f;
Vec3 e1 = tri.v1 - tri.v0;
Vec3 e2 = tri.v2 - tri.v0;
Vec3 p = cross(ray.direction, e2);
float det = dot(e1, p);

if (abs(det) < eps) {
return false;
}

float invDet = 1.0f / det;
Vec3 s = ray.origin - tri.v0;
float u = invDet * dot(s, p);
if (u < 0.0f || u > 1.0f) {
return false;
}

Vec3 q = cross(s, e1);
float v = invDet * dot(ray.direction, q);
if (v < 0.0f || u + v > 1.0f) {
return false;
}

float t = invDet * dot(e2, q);
if (t < tMin || t > tMax) {
return false;
}

hit.t = t;
hit.position = ray.at(t);
hit.normal = normalize(cross(e1, e2));
hit.primitiveId = tri.id;
hit.materialId = tri.materialId;
hit.frontFace = dot(ray.direction, hit.normal) < 0.0f;
if (!hit.frontFace) {
hit.normal = -hit.normal;
}
return true;
}

这个实现默认双面命中。单面三角形可以在 det 的符号上增加 backface culling 规则:例如只接受 det > eps 的情况。这个选择属于材质和几何语义的一部分。场景中的墙面、地面和封闭网格通常希望背面也能参与 debug;树叶、布片和特殊卡片材质可能需要显式控制单面或双面。

AABB 求交的作用是快速判断 ray 是否穿过一个轴对齐包围盒。它把盒子看成三个坐标轴方向 slab 的交集,分别求出 ray 在 x、y、z 三个方向进入和离开 slab 的 t 区间,再取三个区间的交集。只要某一维交集为空,ray 就无法穿过盒子。PBRT 的 ray-bounds intersections 也采用这种 interval 收缩思路,并进一步处理浮点误差和 reciprocal direction 的优化。

bool hitAabb(const Ray& ray, const Aabb& box, float tMin, float tMax) {
for (int axis = 0; axis < 3; ++axis) {
float invD = 1.0f / ray.direction[axis];
float t0 = (box.min[axis] - ray.origin[axis]) * invD;
float t1 = (box.max[axis] - ray.origin[axis]) * invD;
if (invD < 0.0f) {
swap(t0, t1);
}
tMin = max(tMin, t0);
tMax = min(tMax, t1);
if (tMax < tMin) {
return false;
}
}
return true;
}

求交优化的第一层应放在统一 t 范围和提前收缩区间上。主循环更新 closestT 后,后续对象只需要报告更近命中。AABB 先过滤一批完全无关的对象。Backface culling 可以减少单面三角形的测试路径。Epsilon 用于处理浮点误差、ray origin 落在表面附近、平行判断和自相交风险。它的取值应和场景尺度绑定:单位为米的角色场景、单位为厘米的 CAD 模型和大世界坐标场景不应共用同一个固定阈值。

33.3 Camera Ray Generation and Closest Hit Visualization

Camera ray generation 把像素坐标转换成世界空间 ray。最小模型采用 pinhole camera:所有 ray 从同一个 eye position 出发,穿过 near plane 或虚拟 viewport 上的一个采样点。这个过程连接了 screen space、camera space 和 world space。只要这一步出错,后续求交数学即使正确,图像也会表现为左右翻转、上下翻转、FOV 异常或深度分布不稳定。

一个稳定 camera basis 可以由 eyetargetup 构造。令 forward = normalize(target - eye)right = normalize(cross(forward, up))cameraUp = cross(right, forward)。根据垂直视场角 vfov 和宽高比 aspect 得到 viewport 的半高和半宽。像素中心 (x + 0.5, y + 0.5) 先归一化到 [0, 1],再映射到 [-1, 1] 的 viewport 坐标。

Ray generateCameraRay(const Camera& camera, int x, int y, int width, int height) {
float sx = (static_cast<float>(x) + 0.5f) / static_cast<float>(width);
float sy = (static_cast<float>(y) + 0.5f) / static_cast<float>(height);

float ndcX = 2.0f * sx - 1.0f;
float ndcY = 1.0f - 2.0f * sy;

float halfHeight = tan(0.5f * camera.verticalFovRadians);
float halfWidth = camera.aspect * halfHeight;

Vec3 forward = normalize(camera.target - camera.eye);
Vec3 right = normalize(cross(forward, camera.up));
Vec3 cameraUp = cross(right, forward);

Vec3 direction = normalize(forward
+ ndcX * halfWidth * right
+ ndcY * halfHeight * cameraUp);

return Ray{camera.eye, direction};
}

ndcY 的写法把图像坐标系中向下增长的 y 转换成 camera viewport 中向上增长的 y。不同 API 的 framebuffer origin 和纹理坐标约定可能不同;ray casting 调试时,normal image 的上下翻转通常先从这里检查。rightcameraUp 的叉乘顺序决定左右手系,和后续三角形 winding、backface culling、normal orientation 有直接关系。

Closest hit visualization 是验证 ray casting 最快的方式之一。Normal visualization 把 normal[-1, 1] 映射到 [0, 1]color = 0.5 * normal + 0.5。如果球体正面朝向相机,屏幕中央通常偏蓝或偏向当前 z 轴对应的通道;边缘颜色会逐渐变化。如果同一球面上出现突然断裂,常见原因是 normal 没有归一化、frontFace 翻转规则不一致、三角形插值法线和几何法线混用。

Depth visualization 把最近命中 t 映射成灰度。一个简单写法是 depth01 = clamp((hit.t - nearDepth) / (farDepth - nearDepth), 0, 1),再输出 1 - depth01 让近处更亮。这个图回答的是“每个像素的最近表面距离是否连续”。球体深度应从中心到边缘平滑变化;平面深度应随视角形成连续梯度;背景区域应使用固定背景值。

Vec3 shadeDebug(const Ray& ray, const Scene& scene, DebugMode mode) {
HitRecord hit{};
if (!castRay(ray, scene, hit)) {
return scene.backgroundColor;
}

if (mode == DebugMode::Normal) {
return 0.5f * hit.normal + Vec3{0.5f, 0.5f, 0.5f};
}

float depth01 = clamp((hit.t - scene.depthNear) /
(scene.depthFar - scene.depthNear), 0.0f, 1.0f);
return Vec3{1.0f - depth01, 1.0f - depth01, 1.0f - depth01};
}

把本章贯穿 frame 跑通后,最小输出应能支持三类排查。第一类是 camera 排查:背景范围、图像翻转、FOV 和透视感是否符合预期。第二类是 intersection 排查:球、平面、三角形是否出现在正确位置,边界是否稳定。第三类是 hit record 排查:normal、depth、primitiveId 和 materialId 是否和几何对象一致。

同一逻辑映射到 GPU 时,通常是一张输出 texture 加一个 scene buffer。每个 pixel thread 生成自己的 camera ray,遍历对象数据,写入对应像素。此时算法语义没有改变,执行粒度发生变化:CPU 版本是外层像素循环,GPU 版本是像素并行。调试时先保证 CPU 版或小尺寸 GPU 版结果正确,再扩展到大分辨率和更多对象。

33.4 性能观察:复杂场景下的计算负载问题

Ray Casting 的朴素成本模型是 像素数 × 每像素光线数 × 被测试对象数 × 单次求交成本。本章的 primary ray 每个像素只有一条 ray,因此最直接的复杂度是 O(PN)O(PN)。一张 1920×1080 frame 有约 207 万个像素;如果每个像素遍历 10 万个三角形,单帧求交次数会达到不可接受的量级。这个模型说明了复杂场景必须使用包围体、空间划分、层级结构或 GPU 并行来控制测试数量。

不同几何体的单次求交成本并不相同。球体求交主要是 dot、乘法、平方根和两个候选根。平面求交是一次 dot 和一次除法,但有限平面还需要额外边界判断。三角形求交包含 cross、dot、若干比较和可能的 barycentric 插值。AABB 求交成本低,适合放在复杂对象前面做粗筛选。粗筛选通过“低成本盒子测试”减少“高成本精确求交”的调用次数。

Cache locality 会影响复杂场景下的实际帧时间。若对象数据以指针链表分散存放,每条 ray 都会产生大量不连续内存访问。若对象数据按 SoA 或紧凑数组存放,连续 pixel 的 primary rays 访问相近对象时更容易复用 cache line。GPU 上还要考虑 thread group 内 ray 的相干性:相邻像素的 ray 通常方向接近,它们穿过的空间区域也接近,使用包围盒和层级结构时更容易走相似路径。

Early exit 有不同语义。Closest-hit 查询要找到最近表面,普通线性遍历仍需检查所有可能比当前 closestT 更近的对象;更新 closestT 可以缩短每个对象的有效 tMax,让部分求交函数更早返回。Any-hit 查询只关心是否存在遮挡物,找到第一个合格命中即可结束;这类查询在阴影和可见性测试中常见。AABB 测试的 early reject 则发生在精确求交之前,ray 没穿过包围盒时直接跳过盒内对象。

一次性能观察应先数清楚实际测试量,再讨论优化。对于本章最小 frame,可以记录四个计数:camera rays 数量、AABB tests 数量、primitive tests 数量、hit record updates 数量。normal/depth 图像负责验证正确性,计数器负责解释成本。如果图像正确但 primitive tests 过高,问题在加速结构或对象组织;如果 primitive tests 较低但时间仍高,问题可能在内存布局、分支发散、平方根密度或输出写入。

CPU 与 GPU 的瓶颈也有不同证据。CPU 版本常见瓶颈是循环次数、函数虚调用、对象数据分散和 SIMD 利用率不足。GPU 版本常见瓶颈是 global memory 带宽、thread divergence、寄存器占用、低 ray coherence 和输出 texture 带宽。RenderDoc、Nsight 或 Xcode GPU tools 可以作为观察入口:它们能帮助定位 dispatch 时间、shader instruction、buffer 访问和写入资源,但判断仍要回到 ray 数量、对象数量和求交路径。

复杂场景的可复用检查顺序可以固定为六步:确认每像素 ray 数;确认对象数量和 primitive 数;统计粗筛选命中率;统计精确求交调用次数;观察数据布局和访问连续性;再看 GPU profiling 中的带宽、分支和 occupancy。这个顺序把“画面正确”和“计算可控”分开检查,能防止把数学错误误判为性能问题,也能防止把加速结构缺失误判为 shader 写法问题。

最小自检任务

给定相机 eye = (0, 0, 0),中央像素生成的 ray 为 origin = (0, 0, 0)direction = (0, 0, -1)。场景包含一个球体 center = (0, 0, -5)radius = 1,一个无限平面 point = (0, -1, 0)normal = (0, 1, 0)。请完成三件事:计算中央 ray 对球体的最近命中 t 和 normal;判断这条 ray 是否命中平面;说明 normal visualization 和 depth visualization 应输出什么趋势。

答案要点

球体求交中 oc = (0, 0, 5)a = 1halfB = -5cTerm = 24disc = 1。两个候选根为 t0 = 4t1 = 6,最近有效命中是 t = 4。命中点是 (0, 0, -4),球面外法线是 normalize((0, 0, -4) - (0, 0, -5)) = (0, 0, 1)。如果采用 normal color 0.5 * normal + 0.5,中央像素颜色为 (0.5, 0.5, 1.0)

平面求交分母是 dot(direction, normal) = dot((0, 0, -1), (0, 1, 0)) = 0,这条 ray 与该水平平面平行,因此没有常规单点命中。Depth visualization 中中央像素应使用球体的 t = 4 映射灰度;球体中心附近深度更近,朝轮廓方向 t 逐渐增大。背景像素使用背景深度或背景色,平面不会影响中央像素。

这个任务验证 primary ray 的几何可见性:camera ray 先进入球体,closest hit 选择较小正向 t,normal visualization 由命中点法线决定,depth visualization 由最近命中距离决定。若中央像素显示背景,优先检查 camera direction 和 t 范围;若 normal 颜色反向,优先检查 frontFace 和 normal flip;若平面错误命中,优先检查平行分母的 epsilon 判断。

本章知识点总结

  • Ray 表达:Ray 使用 origin、direction 和参数 t 表示,t 范围决定哪些几何交点参与可见性判断。
  • 主查询目标:Ray Casting 的 primary visibility query 为每个像素寻找最近可见表面,并输出统一 hit record。
  • Hit Record:Hit record 把 t、position、normal、primitiveId、materialId 和 frontFace 组织成后续 debug 与 shading 的共同输入。
  • 最近命中:Closest hit 通过不断缩小 closestT 保留当前 ray 上最靠近相机的有效交点。
  • 球体求交:Ray-sphere intersection 把 ray 方程代入球面方程,使用判别式和两个候选根筛选最近有效 t
  • 平面求交:Ray-plane intersection 由一次 dot 分母判断方向关系,再用一元一次方程得到 t
  • 三角形求交:Ray-triangle intersection 使用 barycentric 约束判断交点是否位于三角形内部,并用 t 接入 closest hit。
  • AABB 粗筛:AABB slab 测试通过收缩三个轴向 t 区间快速排除无关对象。
  • Camera Ray:Camera ray generation 把像素中心、FOV、aspect 和 camera basis 组合成世界空间方向。
  • Normal 调试:Normal visualization 把 [-1, 1] 法线映射到 [0, 1] 颜色,用于检查求交和法线朝向。
  • Depth 调试:Depth visualization 把最近 t 映射成灰度,用于检查可见表面距离是否连续。
  • 成本模型:朴素 Ray Casting 成本由像素数、每像素 ray 数、对象数和单次求交成本共同决定。
  • Early Exit:Early exit 的语义取决于查询类型,closest-hit、any-hit 和 AABB reject 对应不同结束条件。
  • 性能证据:优化前应统计 ray 数、AABB tests、primitive tests 和 hit updates,再结合 cache locality 与 GPU profiling 判断瓶颈。