Ray tracing in one weekend笔记(1~6)

2. Output an Image

为什么使用 255.999 而不是 255

这是一个非常经典的计算机图形学技巧,主要目的是为了处理浮点数精度问题并确保颜色范围的正确映射

  • 目标: 代码的目的是将一个范围在 [0.0, 1.0] 之间的浮点数(如变量 r, g, b)映射到范围在 [0, 255] 之间的整数,用于表示PPM图像格式中的颜色分量。

  • 问题: 计算机在表示浮点数时存在精度限制。一个本应是 1.0 的值,在计算过程中可能会变成一个极其接近1的数,比如 0.9999999999999999

  • 如果用 255

    • r 的值是 1.0 时,255 * 1.0 等于 255.0,强制类型转换为 int 后得到 255,结果正确。

    • 但是,如果 r 的值由于精度问题变成了 0.99999999...255 * 0.999999... 的结果会是 254.999999...。C++在将浮点数转换为整数时会截断小数部分,所以 (int)254.999999... 的结果是 254

    • 这就导致了本应最亮的颜色(255)错误地变成了次亮的颜色(254),图像的色域会不完整。

  • 使用 255.999 的解决方案:

    • 通过乘以 255.999,我们为这种精度误差提供了一个“安全边界”。

    • r1.0 时,255.999 * 1.0 结果是 255.999,转换为 int255

    • r0.99999999... 时,255.999 * 0.999999... 的结果会非常接近 255.999(例如 255.998...),转换为 int 仍然是 255

总结:使用 255.999 是一个健壮的编程技巧,可以确保 [0.0, 1.0] 的浮点数范围被完整且正确地映射到 [0, 255] 的整数范围,避免了因浮点数不精确而导致的最大值丢失问题。


为什么 r = double(i) / (image_width - 1);

  • 这行代码的目的就是在图像上创建一个平滑的颜色渐变,让颜色的强度与像素的位置相关联

  • 来分解一下这行代码:

  • for (int i = 0; i < image_width; i++): i 是像素的水平位置(列索引),从最左边的 0 变化到最右边的 image_width - 1

  • double(i): 将整数索引 i 转换为浮点数,以便进行精确的除法运算。

  • image_width - 1: 这是整个范围内的最大索引值。为什么要减1?因为像素索引是从0开始的。对于一个宽度为256的图像,它的像素索引范围是 [0, 255],总共有256个像素。分母使用 image_width - 1 (也就是255) 可以确保当 i 取到最大值 255 时,除法的结果正好是 1.0。如果分母是 image_width (256),那么最大值只能是 255/256,永远达不到 1.0

  • 归一化 (Normalization): 整个表达式 double(i) / (image_width - 1) 的作用是将像素的水平位置 i(范围 [0, 255])“归一化”到一个标准的浮点数范围 [0.0, 1.0]

同理,auto g = double(j) / (image_height - 1); 是将像素的垂直位置 j 归一化到 [0.0, 1.0]

最终的效果是:

  • 像素的红色分量 (r) 从左到右由 0.0 线性增加到 1.0

  • 像素的绿色分量 (g) 从上到下由 0.0 线性增加到 1.0

  • 像素的蓝色分量 (b) 始终为 0.0

这将生成一张从左上角的黑色 (r=0, g=0) 渐变到右下角的黄色 (r=1, g=1),右上角为红色 (r=1, g=0),左下角为绿色 (r=0, g=1) 的图像。


3. vec.h

#ifdef / #define#pragma once 的对比

预处理指令 #ifndef VEC3_H#define VEC3_H#endif 共同构成了一个“包含守卫”或“头文件守卫”。它们的作用是防止头文件的内容在单个编译单元中被多次包含。

它们的工作原理如下:

  1. #ifndef VEC3_H: 预处理器检查名为 VEC3_H 的宏是否已经被定义。

  2. 首次包含: 第一次包含 vec3.h 时,VEC3_H 尚未被定义。因此,代码会继续执行下一行。

  3. #define VEC3_H: 这行代码定义了宏 VEC3_H

  4. #endif: 文件的其余部分被包含进来,直到文件末尾的 #endif

  5. 后续包含: 如果同一个文件被再次包含,#ifndef VEC3_H 的检查将会失败,因为 VEC3_H 此时已经被定义。预处理器将跳过从此处到最终 #endif 之间的所有内容。

#pragma once 是一个更现代的指令,它虽然非标准,但得到了广泛支持,并能实现相同的目标。当预处理器看到 #pragma once 时,它会记录下该文件的路径,并确保在同一次编译中不再包含它。

它们是等效的吗?

是的,它们的_效果_基本相同:防止头文件的多次包含。但是,它们之间存在一些关键区别:

  • 标准化: #define 守卫方法是 C++ 标准的一部分,保证适用于任何合规的编译器。而 #pragma once 不是标准的一部分,但几乎所有现代编译器(如 GCC、Clang 和 MSVC)都支持它。

  • 简洁性: #pragma once 更简单,且不易出错。你只需要输入一行。而使用包含守卫,你必须小心确保宏名称是唯一的,并且不要忘记写 #endif

  • 潜在错误: 使用 #define 守卫时,如果你不小心在两个不同的文件中使用了相同的宏名称,那么只有一个文件会被包含。这可能会导致非常令人困惑的 bug。

在现代 C++ 中,使用 #pragma once 很常见,但您的示例中使用的 #define 守卫方法是经典的、完全可移植的方式。

VEC3_H 的含义和惯例

  • VEC3_H 是什么意思?

    在这里,VEC3_H 仅仅是一个唯一的标识符或宏,预处理器用它来跟踪这个特定文件是否已经被包含。它本身对 C++ 编译器没有任何内在含义。

  • 它必须是 VEC3_H 吗?必须大写吗?

    不,宏的名称并不严格要求与文件名匹配。你本可以使用 SOME_UNIQUE_NAME_HERE 这样的名字。然而,一个被广泛遵循的强大惯例是根据文件名来命名守卫宏,以确保其唯一性。

    这个惯例通常是:

    1. 取文件名 (vec3.h)。

    2. 将其转换为大写 (VEC3.H)。

    3. 将宏名称中无效的字符(如’.’)替换为下划线 (_)。这样就得到了 VEC3_H

    在 C 和 C++ 中,使用大写字母来命名宏也是一个非常普遍的惯例,目的是将它们与变量和函数名区分开来,这有助于避免命名冲突。

总结一下:

  • 包含守卫 #ifndef VEC3_H, #define VEC3_H, #endif#pragma once 的作用相同。#define 方法是标准且可移植的,而 #pragma once 是一个常见且更简洁的非标准扩展。

  • VEC3_H 是一个用作标志的唯一预处理器变量。

  • 通常遵循一个非常强的惯例:根据文件名来命名守卫宏,并全部使用大写字母,以防止命名冲突。


组织方法

  1. Vector3f (Separate Files):

    • 声明放在头文件 (.h) 中。

    • 定义(实现)放在源文件 (.cpp) 中。

    • 通过链接器(Linker)将不同编译单元(.cpp 文件)中的函数调用链接到最终的函数定义。

  2. vec3.h 的做法 (Header-Only):

    • 声明和定义都放在头文件 (.h) 中。

    • 使用 inline 关键字(或在类定义内部实现函数,这会使其隐式内联)来告诉编译器,这个函数的定义可能会在多个编译单元中出现,并且建议在调用处直接展开代码,而不是进行传统的函数调用。

vec3.h 做法的好处 (Header-Only / Inline)

1. 性能优化:消除函数调用开销

这是 vec3.h 采用这种风格最主要的原因。

  • 函数调用开销: 每次调用一个普通函数,CPU都需要保存当前执行位置、传递参数、跳转到函数地址、执行函数、然后返回。对于像向量的加法、点乘这样简单且频繁调用的操作,这些开销累加起来会影响性能。

  • 内联 (Inlining): inline 关键字建议编译器将函数体直接嵌入到调用它的地方。这样就没有了函数调用的开销。对于 vec3 u, v, w; w = u + v; 这样的代码,内联后可能就直接被编译成几条高效的汇编指令,如同你直接写 w.x = u.x + v.x; ... 一样。在图形学和光线追踪这类计算密集型应用中,这种优化至关重要。

2. 避免链接错误 (Linker Errors)

  • 如果你尝试把一个普通函数(非 inline)的完整定义放在头文件中,而这个头文件又被多个 .cpp 文件包含,链接器在链接时会发现这个函数有多个定义,从而报错(”multiple definition” error)。

  • inline 关键字的一个重要作用就是解决这个问题。它告诉链接器:“这个函数在多个地方都有定义是正常的,你只需要其中一个就行了。” 这使得将函数实现放在头文件中成为可能。

  • 不过,如果是像Vector3f那样组织,头文件只有函数的声明,哈数具体实现被单独分到一个文件。就不会出现这样的问题。

3. 易于分发和使用

vec3.h 这样的库被称为“仅头文件库 (Header-only library)”。开发者只需要 #include 这一个文件就可以使用整个库,不需要复杂的项目配置来链接额外的 .cpp 文件或库文件 (.lib, .a)。这对于小型的、通用的工具类(如数学库)来说非常方便。

Vector3f做法的好处 (Separate Files)

这是C++项目管理中的标准实践,尤其适用于大型项目。它同样有很多优点。

1. 编译速度更快

  • 当实现放在 .cpp 文件中时,如果只修改了 .cpp 文件的内容(比如函数的一个实现细节),那么只有这个 .cpp 文件需要重新编译。其他包含了对应头文件的文件则不需要。

  • 反观 Header-only 的做法,任何对头文件中函数实现的修改,都会导致所有包含了这个头文件的 .cpp 文件被重新编译,这在大型项目中会极大地拖慢编译速度。

2. 更好的代码组织和封装

  • 关注点分离: 将接口(API)的声明放在头文件中,将实现细节放在源文件中,是一种非常清晰的组织方式。当其他人想使用你的类时,他们只需要看头文件,而不需要关心具体的实现。

  • 隐藏实现: 你不希望类的使用者知道或依赖于你的内部实现。这种分离有助于实现信息隐藏(Information Hiding)。

关于运算符重载和友元函数

  • 我们来对比一下:

  • Vector3f实现: 在 Vector3f 中,像 operator+ 这样的二元运算符被实现为成员函数

  • vec3.h 的实现: operator+ 被实现为一个非成员的内联函数 inline vec3 operator+(const vec3 &u, const vec3 &v)

将二元运算符(如 +, * 等)实现为非成员函数(通常是 inline,有时是 friend是更常见且更灵活的做法。主要原因在于它支持更对称的类型转换。

例如,在 vec3.h 中,你可以写 2.0 * v,编译器会自动调用 operator*(double t, const vec3 &v)。

但在你的实现中,你重载的是 Vector3f Vector3f::operator*(float n),这意味着只有 v * 2.0f 是合法的,而 2.0f * v 会编译失败,所以在Vector3f中不得不提供一个非成员的 operator*(通常可以是友元 friend 函数,如果需要访问私有成员的话)去实现2.0f * v这样的情况。

总结:为什么要这样做?

原作者选择 Header-only 和 inline 的方式,主要是出于以下考虑:

  1. 性能 (Performance): 对于一个基础数学向量类,性能是第一位的。内联可以显著减少函数调用开销,对计算密集型程序(如光线追踪)非常关键。

  2. 便利性 (Convenience): 作为一个小而美的工具类,做成仅头文件的形式让它更容易被集成到任何项目中。

而Vector3f的做法(分离 .h.cpp)在以下方面更有优势:

  1. 可维护性 (Maintainability): 在大型项目中,清晰的接口与实现分离、更快的增量编译速度,使得项目更容易管理。

  2. 封装 (Encapsulation): 更好地隐藏实现细节。

总而言之,两种方法没有绝对的对错,而是服务于不同目标的**设计选择 (Design Trade-off)**。对于像 vec3 这样小、稳定、性能要求高的基础工具类,Header-only 的 inline 实现是业界的常见选择。对于更复杂的、业务逻辑相关的类,分离文件是更稳妥、更具扩展性的方案。


内联与友元

  • 我们来深入探讨一下内联函数 (inline function),并把它和友元函数 (friend function)进行清晰的区分。
  • 其实这两个是截然不同的概念,互相并不矛盾,也可以结合使用。放在一起纯粹是因为两种不同的运算符重载方案分别使用了内联函数方案和友元函数方案,且考虑的出发点也不同。

内联函数

  • inline 关键字的核心目标是向编译器提出一个性能优化的建议

主要目的:消除函数调用开销

  • 当你调用一个普通函数时,程序会执行一系列的“幕后”操作:
  1. 将当前指令的地址入栈(以便知道函数执行完后返回哪里)。

  2. 将函数参数复制到栈上。

  3. 跳转到函数的内存地址。

  4. 执行函数体。

  5. 将返回值(如果有)放到指定位置。

  6. 从栈中恢复指令地址,跳回原来的地方继续执行。

  • 这个过程被称为“函数调用开销 (function call overhead)”。对于大多数函数来说,这点开销微不足道。但如果一个函数非常小(比如只有一两行代码)且被极其频繁地调用(例如,在一个百万次循环中),这个开销就会累积起来,影响程序的整体性能。
  • inline 就是为了解决这个问题。将一个函数声明为 inline 时,其实是在建议编译器:“请不要生成函数调用指令,而是直接把这个函数的代码‘复制粘贴’到调用它的地方吧。”
  • 这个过程被称为**内联展开 (inlining)**。

代码示例:

C++

1
2
3
4
5
6
7
8
9
10
11
// 声明一个内联函数
inline int add(int a, int b) {
return a + b;
}

void some_function() {
int x = 5, y = 10;
// ...
int result = add(x, y); // 编译器可能会把这里替换成 int result = x + y;
// ...
}

内联展开后,some_function 在编译期间看起来就像这样,完全没有了 add 函数的调用过程:

C++

1
2
3
4
5
6
void some_function() {
int x = 5, y = 10;
// ...
int result = x + y; // 直接嵌入,没有函数调用开销
// ...
}

inline 的两个关键点

  1. 它仅仅是个建议inline 关键字不是强制命令。编译器会自行判断是否进行内联。如果一个函数太长、包含循环或递归,编译器通常会忽略 inline 建议,依然将其作为普通函数来调用。

  2. 解决头文件中的“多重定义”问题:这是 inline 一个非常重要的、实际的用途。如果你把一个函数的完整定义放在头文件(.h)中,而这个头文件被多个源文件(.cpp)包含,链接器(linker)在最后链接时会发现这个函数有多个定义,从而导致“多重定义 (multiple definition)”的链接错误。

    inline 关键字可以解决这个问题。它告诉链接器:“这个函数在多个地方都有定义是合法的,它们都是同一个函数,你只需要保留一个副本就行了。” 这也是为什么像 vec3.h 这样的仅头文件库(Header-only library)能成功的原因。


详细介绍友元函数 (friend)

friend 关键字的核心目标是打破类的封装性,提供一种受控的外部访问机制

主要目的:访问类的私有成员

  • C++ 的核心思想之一是**封装 (Encapsulation)**,即把数据(成员变量)和操作数据的方法(成员函数)捆绑在一起,并对外部隐藏数据的实现细节。通过 public, protected, private 关键字,我们可以控制谁能访问类的成员。
  • private 成员通常只能被这个类自己的成员函数访问。但是,在某些特殊情况下,一个非成员函数或者另一个类需要直接访问这个类的私有成员。这时,就可以在类定义内部使用 friend 关键字,“授权”那个函数或类成为自己的“朋友”。
  • 一个“朋友”可以访问该类的 privateprotected 成员,就像它是这个类的成员函数一样。
    代码示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>

class Vector {
private:
double x, y, z;

public:
Vector(double x, double y, double z) : x(x), y(y), z(z) {}

// 声明 operator<< 为友元函数
// 它不是成员函数,但可以访问 Vector 的 private 成员
friend std::ostream& operator<<(std::ostream& out, const Vector& v);
};

// 友元函数的定义(在类外部)
std::ostream& operator<<(std::ostream& out, const Vector& v) {
// 因为是 friend,所以这里可以直接访问 v.x, v.y, v.z
out << "(" << v.x << ", " << v.y << ", " << v.z << ")";
return out;
}

int main() {
Vector my_vec(1.0, 2.0, 3.0);
std::cout << "My vector is: " << my_vec << std::endl; // 可以正常工作
return 0;
}
  • 如果没有 friend 声明,operator<< 函数就无法访问 my_vec 的私有成员 x, y, z,代码将无法编译。

inlinefriend 的核心区别

特性 内联函数 (inline) 友元函数 (friend)
主要目的 性能优化 访问控制
解决的问题 减少函数调用开销;避免头文件中的多重定义链接错误。 允许非成员函数或另一个类访问本类的 private/protected 成员。
作用对象 函数的实现方式 函数或类的访问权限
本质 对编译器的建议,影响代码如何生成。 对编译器的规则,打破封装,授予访问权。
关键字位置 在函数定义或声明时使用。 类定义内部,用于声明谁是“朋友”。

总结一句话:inline 关心代码“如何”执行,friend 关心“谁能”访问数据 它们是两个完全正交(不相关)的概念。

结合使用 inlinefriend

  • 回到上面的 Vectoroperator<< 的例子。如果我们想把 operator<< 的实现也放在头文件中(为了方便),我们就必须把它标记为 inline 以防止多重定义错误。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 在头文件 Vector.h 中
    #include <iostream>

    class Vector {
    // ... private members ...
    public:
    // ... public members ...

    // 声明 operator<< 为友元
    friend std::ostream& operator<<(std::ostream& out, const Vector& v);
    };

    // 将友元函数的定义也放在头文件中,必须标记为 inline
    inline std::ostream& operator<<(std::ostream& out, const Vector& v) {
    out << "(" << v.x << ", " << v.y << ", " << v.z << ")";
    return out;
    }
  • 在这里:

    • friend 授予了 operator<< 访问 Vector 私有成员的权限
    • inline 解决了将这个函数定义放在头文件里可能导致的链接错误,并可能带来性能提升。

Ray tracing in one weekend笔记(1~6)
https://username.github.io/2025/08/01/Ray tracing in one weekend笔记(1~6)/
作者
AKIRA
发布于
2025年8月1日
许可协议