Skip to content

PPMinput

Output an Image

为了将渲染结果展示,一种常见的方法是将图像存入文件中。RT1Week主要使用ppm格式的图像存储。

main.cpp
#include <iostream>
#include <fstream>

int main() {
    int image_width=256,image_height=256;

    //使用fstram替代cout标准输出,以避免异常的不可见字符
    std::ofstream out("image.ppm", std::ios::out | std::ios::binary);
    if (!out) {
        std::cerr << "无法创建文件!" << std::endl;
        return 1;
    }

    out<<"P3\n"<<image_width<<' '<<image_height<<'\n'<<"255\n";

    for (int j=0;j<image_height;j++) {
        //输出进度,\r表示光标回到当前行行首,不可省略
        std::clog<<"\r当前进度:"<<(image_height-j)<<' '<<std::flush;
        for (int i=0;i<image_width;i++) {
            auto r=double(i)/(image_width-1);
            auto g=double(j)/(image_height-1);
            auto b=0.0;

            int ir=int(255.999*r);
            int ig=int(255.999*g);
            int ib=int(255.999*b);

            out<<ir<<' '<<ig<<' '<<ib<<'\n';
        }
    }
    std::clog<<"\r结束                                     \n";
    out.close();
    return 0;
}

RT1Week的实例代码使用控制台标准输出重定向以将结果保存到文件中,但这一方法在实践中会因为标准输出流输出了不可见字符,打乱了PPM图像的结构,最终导致图像无法被正常解析和显示。

因此,此处使用文件输入输出流fstream替代标准输入输出流。

创建文件输出流对象的语句如下:

    std::ofstream out("image.ppm", std::ios::out | std::ios::binary);

其中,"image.ppm"用于指定需要打开或创建的文件名,如果有需要可以在文件名前补上指定的路径;

std::ios::out | std::ios::binary指定了打开模式,out表示这是一个输出流,如果原文件存在则覆盖文件;binary表示开启二进制模式,以避免Windows默认的文本模式将\n替换成\r\n
这两个打开模式都是二进制数值,此处使用按位与表示同时开启两种模式。

Note

使用std::ios::binary输入输出是多平台开发的良好习惯。

在完成全部输出后,需要及时关闭输入输出流对象。

Utils:Vec3,Color

这一节的目的是构建存储数据的数据结构和简化图像输出的步骤。

尽管多数图形库使用4D向量存储颜色和点,代表颜色的RGBA通道或以齐次坐标的形式存储的三维坐标,但这个项目中3D向量已经足够。

我们使用一个vec3.h文件定义存储该三维向量的数据结构和相关运算符重载、常用方法,其完整代码如下:

vec3.h
//
// Created by Klingsor on 2026/5/29.
//

//目的是防止同一个头文件被多次编译,效果类似#pram once
#ifndef RT1WEEK_VEC3_H
#define RT1WEEK_VEC3_H

#include <cmath>
#include <iostream>

class vec3 {
public:
    union {
        float e[3];
        struct {float x,y,z;};
        struct {float r,g,b;};
    };

    //构造方法,无参时默认初始化为0,带三个参数时初始化为对应参数
    vec3():e{0,0,0}{}
    vec3(float e0,float e1,float e2):e{e0,e1,e2}{}

    //运算符重载
    //如果是一个const对象调用v3[i],则自动调用第一个方法,只能读值不能修改
    //如果是一个普通对象,则自动调用第二个,可读可改
    float operator[](int i) const { return e[i];}
    float& operator[](int i) {return e[i];}

    vec3 operator-() const {return vec3(-e[0],-e[1],-e[2]);}
    vec3 operator+=(const vec3& v) {
        e[0]+=v.e[0];
        e[1]+=v.e[1];
        e[2]+=v.e[2];
        return *this;
    }

    //线性乘法
    vec3& operator*=(float v) {
        e[0]*=v;
        e[1]*=v;
        e[2]*=v;
        return *this;
    }
    vec3& operator/=(float v) {
        return *this *= 1/v;
    }

    //模场
    [[nodiscard]]float length_squared() const {
        return e[0]*e[0]+e[1]*e[1]+e[2]*e[2];
    }

    //模长
    [[nodiscard]]float length() const {
        return std::sqrt(length_squared());
    }
};

//使用point3作为vec3的别名
using point3=vec3;

//重载输出流
inline std::ostream& operator<<(std::ostream& out,const vec3& v) {
    return out<<v.e[0]<<" "<<v.e[1]<<" "<<v.e[2];
}

//二元运算符重载
//向量加法 为了不必要的拷贝和避免修改操作数使用const
inline vec3 operator+(const vec3& u,const vec3& v) {
    return vec3(u[0]+v[0],u[1]+v[1],u[2]+v[2]);
}

//向量减法
inline vec3 operator-(const vec3& u,const vec3& v) {
    return vec3(u[0]-v[0],u[1]-v[1],u[2]-v[2]);
}

//逐分量乘法
inline vec3 operator*(const vec3& u,const vec3& v) {
    return vec3(u[0]*v[0],u[1]*v[1],u[2]*v[2]);
}

//线性乘法
inline vec3 operator*(const vec3& u,float v) {
    return vec3(u[0]*v,u[1]*v,u[2]*v);
}
inline vec3 operator*(float t,const vec3& v) {
    return v*t;
}
inline vec3 operator/(const vec3& u,float v) {
    return u*(1/v);
}

//点乘
inline float dot(const vec3& u,const vec3& v) {
    return u.e[0]*v.e[0]+u.e[1]*v.e[1]+u.e[2]*v.e[2];
}

//叉乘
inline vec3 cross(const vec3& u,const vec3& v) {
    return vec3(
        u.e[1]*v.e[2]-v.e[2]*u.e[1],
        u.e[2]*v.e[0]-v.e[0]*u.e[2],
        u.e[0]*v.e[1]-v.e[1]*u.e[0]
        );
}

//单位向量
inline vec3 unit_vector(const vec3& v) {
    return v/v.length();
}

#endif //RT1WEEK_VEC3_H

其中,我们使用匿名联合体存储向量的三个分量,这个结构使得vec3的实例可以用v.e[0]v.xv.r访问到同一内存地址的相同数据。
尽管这一做法严格来说在C++中不完全合法,但现代的主流C++编译器普遍已经通过拓展支持该种写法,也是现行的图形API中应用比较广泛的做法。

基于vec3.h,我们在color.h中重载输出流运算符,以简化输出,其完整代码如下:

color.h
//
// Created by Klingsor on 2026/5/29.
//

#ifndef RT1WEEK_COLOR_H
#define RT1WEEK_COLOR_H

#include "vec3.h"
#include <iostream>

//使用color作为vec3的别名
using color=vec3;

//将单个像素输出到输出流
void write_color(std::ostream& out, const color& pixel_color) {
    auto r=pixel_color.r;
    auto g=pixel_color.g;
    auto b=pixel_color.b;

    //转换为ppm格式支持的颜色参数[0,255]
    int ir=int(255.999*r);
    int ig=int(255.999*g);
    int ib=int(255.999*b);

    out<<ir<<' '<<ig<<' '<<ib<<'\n';
}

#endif //RT1WEEK_COLOR_H

这样一来,就可以将输出流对象和像素颜色传给write_color()方法,快速输出单个像素到ppm文件格式。main.cpp可以按照如下方式进行简化:

简化后的main.cpp
int main() {
    int image_width=256,image_height=256;

    //使用fstram替代cout标准输出,以避免异常的不可见字符
    std::ofstream out("image.ppm", std::ios::out | std::ios::binary);
    if (!out) {
        std::cerr << "无法创建文件!" << std::endl;
        return 1;
    }

    out<<"P3\n"<<image_width<<' '<<image_height<<'\n'<<"255\n";

    for (int j=0;j<image_height;j++) {
        //输出进度,\r表示光标回到当前行行首
        std::clog<<"\r当前进度:"<<(image_height-j)<<' '<<std::flush;
        for (int i=0;i<image_width;i++) {
            //完全简化将[0,1)范围内的颜色分量映射到[0,255]的过程
            auto pixel_color=color(float(i)/(image_width-1),float(j)/(image_height-1),0.0f);
            write_color(out,pixel_color);
        }
    }
    std::clog<<"\r结束                                     \n";
    out.close();
    return 0;
}

此外,由于C++不允许使用通配符引入文件,在工具类随着项目规模增多的情况下,可以单独用一个头文件引入同一模块的汇总头文件,如此一来就只需要在需要用到这些模块的文件中引入一次汇总头文件即可。例如:

//
// Created by Klingsor on 2026/5/29.
//

#ifndef RT1WEEK_UTILS_H
#define RT1WEEK_UTILS_H

#include "vec3.h"
#include "color.h"

#endif //RT1WEEK_UTILS_H