Metal-cpp: Offscreen Tiny Renderer

By Jinfu Wei and Codex

这一册实现一个纯 C++ 的离屏 renderer。你会在代码里创建 color attachment、depth attachment、render pipeline、vertex/index buffer、texture、sampler 和 camera uniform,然后把结果读回 CPU,写成 PPMPNGJPG 图片。

书里的所有结果图都来自同一个渲染程序。为了让光照差异更容易观察,本书使用两组测试资产:

跟着正文完成之后,你应该能够自己替换 OBJ 和材质图片文件,并生成对应的离屏渲染结果。

Overview

第一册已经建立了基本的 Metal 执行模型:CPU 创建资源和命令,GPU 执行 shader,然后把结果写回图片。第二册保留“输出图片”这个终点,把中间步骤换成 render pipeline:vertex shader 处理几何,fragment shader 计算材质和光照,render pass 把像素写进离屏 texture。

Compute pipeline and render pipeline comparison
第一册和第二册都把结果写成图片;区别在于第二册开始使用 vertex shader、fragment shader 和 render pass。

下面这段是当前阶段要执行的命令,不是写进源码文件的内容。先用它确认工程能配置、编译并运行。

terminal
cmake -S . -B build
cmake --build build --target MetalCppTinyRenderer

./build/MetalCppTinyRenderer/MetalCppTinyRenderer \
  assets/meshes/uv_sphere.obj \
  assets/textures/warm_metal.ppm \
  lambert \
  build/MetalCppTinyRenderer/lambert.png

Project Layout

先把工程拆成三个文件。后面的每一章都会往这三个文件里继续补内容:

下面这段不是要拷贝到某个源码文件里,而是当前 Book2 的最小目录分工说明。

project layout
main.cpp      # command line arguments and output path
Renderer.cpp  # mesh/texture loading, camera, offscreen render pass, image writeback
Shaders.metal # vertex shader and four fragment shader stages

main.cpp 负责命令行参数,Renderer.cpp 负责资源和 draw path,Shaders.metal 负责 vertex 和 fragment shader。

Device and Command Queue

先创建 MTL::DeviceMTL::CommandQueue。这一步和第一册完全一样,后面所有 render pass 都通过这个 queue 提交。

Device, Command Queue, and Command Buffer relationship
MTL::Device 创建资源,MTL::CommandQueue 创建并提交 command buffer。

打开 Renderer.cpp,在设备初始化阶段新增下面这段代码。这里是新增,不是替换之前的任何函数。

Renderer.cpp
MTL::Device* device = MTL::CreateSystemDefaultDevice();
if (!device)
{
    std::cerr << "Metal is not available on this Mac.\n";
    return false;
}

MTL::CommandQueue* queue = device->newCommandQueue();

Offscreen Render Target

这一章开始创建离屏 color texture 和 depth texture。render pass 会把颜色写进 color attachment,把深度写进 depth attachment;一帧结束后,CPU 再把 color texture 读回并保存成图片文件。

继续修改 Renderer.cpp。先新增一个创建 color texture 的辅助代码段;它属于新的 texture 创建逻辑,可以直接加到资源创建相关函数旁边。

Renderer.cpp
MTL::TextureDescriptor* descriptor = MTL::TextureDescriptor::alloc()->init();
descriptor->setTextureType(MTL::TextureType2D);
descriptor->setPixelFormat(MTL::PixelFormatBGRA8Unorm);
descriptor->setWidth(width);
descriptor->setHeight(height);
descriptor->setStorageMode(MTL::StorageModeManaged);
descriptor->setUsage(MTL::TextureUsageRenderTarget);
MTL::Texture* colorTexture = device->newTexture(descriptor);

然后在同一个文件里,继续在编码 draw call 之前新增 render pass descriptor。它依赖上面创建好的 colorTexture

Renderer.cpp
MTL::RenderPassDescriptor* pass = MTL::RenderPassDescriptor::renderPassDescriptor();
MTL::RenderPassColorAttachmentDescriptor* color = pass->colorAttachments()->object(0);
color->setTexture(colorTexture);
color->setLoadAction(MTL::LoadActionClear);
color->setStoreAction(MTL::StoreActionStore);
color->setClearColor(MTL::ClearColor(0.08, 0.22, 0.25, 1.0));

完成这一步后,程序已经具备完整的“render to texture -> read back -> write image”路径。

Mesh and Texture Input

接下来加入外部输入。这个 renderer 读取两种文件格式:

先回到 main.cpp。在命令行参数解析部分新增下面四行默认值,让程序可以从外部文件读取 mesh、texture、stage 和输出路径。

main.cpp
options.meshPath = argc > 1 ? argv[1] : "assets/meshes/uv_sphere.obj";
options.texturePath = argc > 2 ? argv[2] : "assets/textures/warm_metal.ppm";
options.stage = argc > 3 ? argv[3] : "lambert";
options.outputPath = argc > 4 ? argv[4] : "build/MetalCppTinyRenderer/lambert.png";

保持 draw path 不变,只替换 mesh 和 texture,后面就可以用同一套程序比较不同资产上的着色结果。

如果 OBJ 没有提供 vn,参考代码会根据三角形几何自动生成平滑法线;如果没有 vt,参考代码会给出一个基础 UV,方便继续观察光照结果。

Camera and Framing

有了外部 mesh 之后,需要先解决“镜头放在哪里”。这里根据 mesh bounds 估计相机位置,再计算观察矩阵和投影矩阵。

继续修改 Renderer.cpp。在准备 uniform 数据的地方新增 camera framing 相关代码;如果你已经有 meshwidthheight,就可以接上这一段。

Renderer.cpp
Vec3 eye = fitCameraPosition(mesh);
Vec3 target = meshCenter(mesh);
Mat4 view = lookAt(eye, target, {0.0f, 1.0f, 0.0f});
Mat4 proj = perspective(45.0f, float(width) / float(height), 0.1f, 100.0f);

运行程序时,如果模型能完整落进画面,就说明 camera framing 已经工作正常。

Vertex Shader

第二册的 vertex shader 负责三件事:

打开 Shaders.metal。先新增顶点输入结构体 Vertex 和顶点输出结构体 VertexOut。这是新的类型定义,可以直接放在文件开头已有的 Uniforms 附近。

Shaders.metal
struct Vertex
{
    packed_float3 position;
    packed_float3 normal;
    float2 uv;
};

struct VertexOut
{
    float4 position [[position]];
    float3 worldPosition;
    float3 normal;
    float2 uv;
};

结构体准备好以后,继续在 Shaders.metal 中新增 vertex_main。如果文件里已经有旧的顶点函数,就用这一版替换;如果还没有,就直接新增。

Shaders.metal
vertex VertexOut vertex_main(uint vertexID [[vertex_id]],
                             device const Vertex* vertices [[buffer(0)]],
                             constant Uniforms& uniforms [[buffer(1)]])
{
    const Vertex vtx = vertices[vertexID];
    const float3 position = float3(vtx.position);
    const float3 normal = float3(vtx.normal);
    VertexOut out;
    out.position = uniforms.mvp * float4(position, 1.0);
    out.worldPosition = (uniforms.model * float4(position, 1.0)).xyz;
    out.normal = normalize((uniforms.model * float4(normal, 0.0)).xyz);
    out.uv = vtx.uv;
    return out;
}

Texture Sampling

贴图路径在这一章接入。fragment shader 先从 texture 里取出 albedo,后面的所有光照算法都在这个基础上继续计算。

先在 Renderer.cpp 里新增用于保存解码结果的数据结构。这个结构体是新的,可以直接放在 mesh / uniform 相关结构体后面。

Renderer.cpp新增 TextureData 和 MaterialTextures
struct TextureData
{
    uint32_t width = 0;
    uint32_t height = 0;
    std::vector<uint8_t> rgba;
};

struct MaterialTextures
{
    TextureData baseColor;
    TextureData roughness;
    TextureData metallic;
    TextureData normal;
    TextureData ao;
};

如果你还在只支持 PPM 的阶段,下一步先把最基础的 PPM 解码函数写出来。下面这段代码新增到 Renderer.cpp,通常放在资源加载辅助函数区域。

Renderer.cpp新增 loadPPM
TextureData loadPPM(const std::filesystem::path& path)
{
    std::ifstream in(path);
    if (!in)
    {
        throw std::runtime_error("Could not open texture: " + path.string());
    }
    if (nextToken(in) != "P3")
    {
        throw std::runtime_error("Only ASCII P3 PPM textures are supported.");
    }

    TextureData texture;
    texture.width = static_cast<uint32_t>(std::stoul(nextToken(in)));
    texture.height = static_cast<uint32_t>(std::stoul(nextToken(in)));
    const int maxValue = std::stoi(nextToken(in));
    texture.rgba.resize(texture.width * texture.height * 4);

    for (uint32_t i = 0; i < texture.width * texture.height; ++i)
    {
        const int r = std::stoi(nextToken(in));
        const int g = std::stoi(nextToken(in));
        const int b = std::stoi(nextToken(in));
        texture.rgba[i * 4 + 0] = static_cast<uint8_t>(r * 255 / maxValue);
        texture.rgba[i * 4 + 1] = static_cast<uint8_t>(g * 255 / maxValue);
        texture.rgba[i * 4 + 2] = static_cast<uint8_t>(b * 255 / maxValue);
        texture.rgba[i * 4 + 3] = 255;
    }
    return texture;
}

再继续在 Renderer.cpp 中新增 PNG/JPG 的解码函数。这个函数是新增的,不替换 loadPPM();它和 loadPPM() 并列存在。

Renderer.cpp新增 loadImageWithImageIO
TextureData loadImageWithImageIO(const std::filesystem::path& path)
{
    CFStringRef pathString = CFStringCreateWithCString(kCFAllocatorDefault, path.string().c_str(), kCFStringEncodingUTF8);
    CFURLRef url = CFURLCreateWithFileSystemPath(kCFAllocatorDefault, pathString, kCFURLPOSIXPathStyle, false);
    CFRelease(pathString);

    CGImageSourceRef source = CGImageSourceCreateWithURL(url, nullptr);
    CFRelease(url);
    CGImageRef image = CGImageSourceCreateImageAtIndex(source, 0, nullptr);
    CFRelease(source);

    TextureData texture;
    texture.width = static_cast<uint32_t>(CGImageGetWidth(image));
    texture.height = static_cast<uint32_t>(CGImageGetHeight(image));
    texture.rgba.resize(static_cast<size_t>(texture.width) * texture.height * 4);

    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
    CGContextRef context = CGBitmapContextCreate(
        texture.rgba.data(),
        texture.width,
        texture.height,
        8,
        texture.width * 4,
        colorSpace,
        kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
    CGColorSpaceRelease(colorSpace);

    CGContextDrawImage(context, CGRectMake(0, 0, texture.width, texture.height), image);
    CGContextRelease(context);
    CGImageRelease(image);
    return texture;
}

先修改 Renderer.cpp 里的贴图读取函数,把原来只支持 PPM 的入口改成“按扩展名选择加载器”。这是替换旧函数调用,不是新增一个并行分支。

Renderer.cpp修改 loadTexture 入口,黄色 = 修改
std::string extension = path.extension().string();
std::transform(extension.begin(), extension.end(), extension.begin(), [](unsigned char c) {
    return static_cast<char>(std::tolower(c));
});
if (extension == ".ppm")
{
    return loadPPM(path);
}
return loadImageWithImageIO(path);

如果你要继续支持一整组 PBR 贴图,再在 Renderer.cpp 中新增一个材质贴图收集函数。它先读取命令行传入的 base color,再尝试自动查找同目录的 roughness、metallic、normal 和 AO。

Renderer.cpp新增 loadMaterialTextures
MaterialTextures loadMaterialTextures(const RenderOptions& options, const Uniforms& uniforms)
{
    MaterialTextures textures;
    textures.baseColor = loadTexture(options.texturePath);
    textures.roughness = makeSolidTexture(
        static_cast<uint8_t>(std::clamp(uniforms.roughness, 0.0f, 1.0f) * 255.0f),
        static_cast<uint8_t>(std::clamp(uniforms.roughness, 0.0f, 1.0f) * 255.0f),
        static_cast<uint8_t>(std::clamp(uniforms.roughness, 0.0f, 1.0f) * 255.0f));
    textures.metallic = makeSolidTexture(
        static_cast<uint8_t>(std::clamp(uniforms.metallic, 0.0f, 1.0f) * 255.0f),
        static_cast<uint8_t>(std::clamp(uniforms.metallic, 0.0f, 1.0f) * 255.0f),
        static_cast<uint8_t>(std::clamp(uniforms.metallic, 0.0f, 1.0f) * 255.0f));
    textures.normal = makeSolidTexture(128, 128, 255);
    textures.ao = makeSolidTexture(255, 255, 255);

    const std::array<std::pair<std::string, TextureData*>, 4> optionalMaps = {{
        {"_roughness", &textures.roughness},
        {"_metallic", &textures.metallic},
        {"_normal", &textures.normal},
        {"_ao", &textures.ao},
    }};

    for (const auto& [suffix, target] : optionalMaps)
    {
        const std::filesystem::path candidate = siblingTexturePath(options.texturePath, suffix);
        if (std::filesystem::exists(candidate))
        {
            *target = loadTexture(candidate);
        }
    }
    return textures;
}

然后继续在 Renderer.cpprenderScene() 里,把旧的单张贴图读取替换成材质贴图组读取。这里是对原有 texture/sampler 初始化代码的修改。

Renderer.cpp修改 renderScene 里的贴图绑定,绿色 = 新增,黄色 = 修改
MaterialTextures materialTextures = loadMaterialTextures(options, uniforms);
MTL::Texture* baseColorTexture = createTexture(device, materialTextures.baseColor);
MTL::Texture* roughnessTexture = createTexture(device, materialTextures.roughness);
MTL::Texture* metallicTexture = createTexture(device, materialTextures.metallic);
MTL::Texture* normalTexture = createTexture(device, materialTextures.normal);
MTL::Texture* aoTexture = createTexture(device, materialTextures.ao);
MTL::SamplerState* sampler = createSampler(device);

如果文件名以 _basecolor 结尾,参考代码还会自动查找同目录下的 _roughness_metallic_normal_ao。这样命令行只需要传 base color 路径,就能把同一套 PBR 贴图包一起接进来。

Rendering Progression

下面开始按顺序提高渲染结果。四张球体图使用同一组 mesh、texture、camera 和 light。顶点数据、相机、draw call、render pass 都保持不变,变化集中在 fragment shader 如何解释这些输入。

你可以把这四个阶段理解成“在同一套 renderer 上逐步加信息”:

algorithm summary
Albedo Only        = base color
Lambert Diffuse    = base color + normal + light
Blinn-Phong        = Lambert + view + highlight
Metallic-Roughness = base color + normal + roughness
                     metallic + AO + Fresnel/GGX
Overview of the four shading stages
四个阶段的区别不是换了一套新 renderer,而是在同一个 draw path 里逐步加入新的着色项。

可以把这四个阶段理解成四个问题:

Algorithm 1: Albedo Only

第一步先只显示 base color。这个阶段用来检查 uv、sampler 和 texture upload 是否正确。

Shaders.metal 中新增一个新的 fragment 函数 fragment_albedo。这是第一版片元着色器,不需要修改其他 fragment 函数。

Shaders.metal新增 fragment_albedo
const float3 albedo = baseColor.sample(baseSampler, in.uv).rgb;
return float4(albedo, 1.0);
Sphere albedo only result
Algorithm 1 `Albedo Only`:只有 base color,没有明暗变化。它先验证纹理路径是否正确。

Algorithm 2: Lambert Diffuse

第二步加入漫反射。Lambert 模型只做一件事:比较表面法线 N 和光照方向 L 的夹角。夹角越小,点积 N · L 越大,表面就越亮。当前实现里,这个 N 可以来自几何法线,也可以来自 normal map 经过 TBN 变换后的世界空间法线。

Lambert diffuse uses the dot product between normal and light direction
Lambert 的核心是余弦项 max(N · L, 0)。它把“表面朝向哪里”直接转换成明暗变化。

继续在 Shaders.metal 中新增第二个 fragment 函数 fragment_lambert。它是在 fragment_albedo 之外增加的新函数,不是修改 fragment_albedo

Shaders.metal新增 fragment_lambert
const float3 N = sampleWorldNormal(in, normalMap, baseSampler);
const float3 L = normalize(uniforms.lightDirection.xyz);
const float diffuse = max(dot(N, L), 0.0);
const float sky = 0.35 + 0.65 * clamp(N.y * 0.5 + 0.5, 0.0, 1.0);
const float3 color = albedo * (0.10 + 0.65 * diffuse + 0.25 * sky);
return float4(color, 1.0);
Sphere Lambert result
Algorithm 2 `Lambert Diffuse`:开始出现稳定的受光面和背光面。这一步解决的是“亮面和暗面从哪里来”。

Algorithm 3: Blinn-Phong

第三步加入 Blinn-Phong 高光。除了法线 N 和光照方向 L,现在还需要观察方向 V。把 LV 相加并归一化,可以得到 half vector H。当法线更接近 H 时,高光会更强。它不是物理正确模型,但非常适合让新手先看懂“视角变化为什么会影响亮点位置”。

Blinn-Phong uses a half vector between light and view direction
Blinn-Phong 在 Lambert 的基础上新增了镜面高光项。它回答的是“为什么有些角度会出现亮点”。

这一步仍然修改 Shaders.metal,但方式和前两步不同:现在是新增一个新的 fragment_blinn_phong 函数,并且用行级标记显示它相对 Lambert 多出来的内容。

Shaders.metal新增 fragment_blinn_phong,绿色 = 新增,黄色 = 修改
const float3 N = sampleWorldNormal(in, normalMap, baseSampler);
const float3 L = normalize(uniforms.lightDirection.xyz);
const float3 V = normalize(uniforms.cameraPosition.xyz - in.worldPosition);
const float3 H = normalize(L + V);
const float diffuse = max(dot(N, L), 0.0);
const float sky = 0.35 + 0.65 * clamp(N.y * 0.5 + 0.5, 0.0, 1.0);
const float specular = pow(max(dot(N, H), 0.0), 48.0);
const float3 color = albedo * (0.08 + 0.55 * diffuse + 0.22 * sky) + specular * 0.28;
Sphere Blinn-Phong result
Algorithm 3 `Blinn-Phong`:球面开始出现集中的高光,材质从“只有明暗”变成了“开始有视角相关反射特征”。

Algorithm 4: Metallic-Roughness PBR

第四步切换到 metallic-roughness PBR。PBR 仍然保留漫反射和镜面反射,但不再用一个简单的常数高光近似,而是把镜面反射拆成 Fresnel、visibility 和微表面分布三部分。metallic 决定能量更多留在 diffuse 还是 specular,roughness 决定高光是锐利还是宽散,ao 用来抑制环境项在缝隙和遮蔽区域里过亮。

PBR combines diffuse and Cook-Torrance specular terms
PBR 不是“更复杂的高光系数”,而是把材质控制拆成更明确的物理参数。

最后在 Shaders.metal 中新增 fragment_pbr。如果你已经完成 fragment_lambertfragment_blinn_phong,这一段可以作为第四个独立 fragment 函数继续加进去。下面保留的是当前实现里最关键的一段能量分配逻辑。

Shaders.metal新增 fragment_pbr
const float3 albedo = srgbToLinear(baseColor.sample(baseSampler, in.uv).rgb);
const float roughness = clamp(roughnessMap.sample(baseSampler, in.uv).r, 0.04, 1.0);
const float metallic = clamp(metallicMap.sample(baseSampler, in.uv).r, 0.0, 1.0);
const float ao = clamp(aoMap.sample(baseSampler, in.uv).r, 0.0, 1.0);
const float3 N = sampleWorldNormal(in, normalMap, baseSampler);
const float3 L = normalize(uniforms.lightDirection.xyz);
const float3 V = normalize(uniforms.cameraPosition.xyz - in.worldPosition);
const float3 H = normalize(L + V);
const float NdotL = max(dot(N, L), 0.0);
const float NdotV = max(dot(N, V), 0.0);
const float NdotH = max(dot(N, H), 0.0);
const float VdotH = max(dot(V, H), 0.0);
const float D = distributionGGX(NdotH, roughness);
const float G = geometrySchlickGGX(NdotV, roughness)
              * geometrySchlickGGX(NdotL, roughness);
const float3 F0 = mix(float3(0.04), albedo, metallic);
const float3 F = fresnelSchlick(VdotH, F0);
const float3 specular = (D * G * F) / max(4.0 * NdotV * NdotL, 1.0e-5);
const float3 kD = (1.0 - F) * (1.0 - metallic);
const float3 diffuse = kD * albedo / M_PI_F;
const float skyMix = clamp(N.y * 0.5 + 0.5, 0.0, 1.0);
const float3 ambientDiffuse = albedo * (0.18 + 0.22 * skyMix) * (1.0 - metallic) * ao;
float3 color = (diffuse + specular) * NdotL;
color += ambientDiffuse * uniforms.ambient;
color = tonemapACES(color);
Sphere PBR result
Algorithm 4 `Metallic-Roughness PBR`:现在不只是“加一个更复杂的高光”,而是开始把 diffuse、specular、roughness、metallic、AO 放进同一套能量分配里。

Downloaded PBR Material

前面的四个阶段主要验证算法顺序。接下来换成一组真正的 PBR 贴图包,检查当前实现能不能读入 base color、roughness、metallic、normal 和 ambient occlusion,并把这些信息组合成最终结果。

本节使用的是 Poly HavenRusty Metal 02 1K 贴图。仓库里已经保存了拆分后的五张图:

这组材质很适合检查贴图加载路径,但当前仓库里的 metallic map 是全黑的。因此 PBR 结果更接近粗糙氧化表面,而不是强反射的裸金属。想要更真实的金属反射,需要换成 metallic 非零的材质,并进一步加入环境贴图或 IBL。

这一节不需要新增新的程序入口,只需要复用前面的 renderer。下面这段是运行命令,不是写进源码文件的内容。

terminal
./build/MetalCppTinyRenderer/MetalCppTinyRenderer \
  assets/meshes/uv_sphere.obj \
  assets/textures/rusty_metal_02_basecolor.png \
  lambert \
  images/results/MetalCppTinyRenderer/08-rusty-metal-lambert.png

./build/MetalCppTinyRenderer/MetalCppTinyRenderer \
  assets/meshes/uv_sphere.obj \
  assets/textures/rusty_metal_02_basecolor.png \
  blinn \
  images/results/MetalCppTinyRenderer/10-rusty-metal-blinn.png

./build/MetalCppTinyRenderer/MetalCppTinyRenderer \
  assets/meshes/uv_sphere.obj \
  assets/textures/rusty_metal_02_basecolor.png \
  pbr \
  images/results/MetalCppTinyRenderer/09-rusty-metal-pbr.png
Rusty metal Lambert result
下载的 PBR 材质先用 `Lambert` 验证:base color 和 normal map 是否已经正确进入 shader。
Rusty metal Blinn-Phong result
`Blinn-Phong` 会开始给出镜面高光,但还没有 roughness 和 metallic 带来的能量分配。
Rusty metal PBR result
`PBR` 把 roughness、metallic 和 AO 一起接进来之后,roughness 和 AO 会让光照响应更稳定。因为这组材质的 metallic map 是黑色,结果应该读成氧化表面细节,而不是镜面感很强的裸露金属。

Test Assets

下面这三组资产会在本书后半部分反复使用:

如果你要替换自己的资产,优先选带法线、uv 完整的 OBJ,以及尺寸不太大的 PPMPNGJPG 贴图。

Complex Geometry

球体适合比较算法顺序,复杂模型更适合检查 mesh loader、camera framing 和高光在真实曲面上的表现。这里使用 Stanford Bunny 做第二组验证。

这一节不需要改源码,只需要复用前面已经写好的程序,换一组命令行输入重新运行。

terminal
./build/MetalCppTinyRenderer/MetalCppTinyRenderer \
  assets/meshes/stanford_bunny.obj \
  assets/textures/clay.ppm \
  lambert \
  images/results/MetalCppTinyRenderer/05-bunny-lambert.png

./build/MetalCppTinyRenderer/MetalCppTinyRenderer \
  assets/meshes/stanford_bunny.obj \
  assets/textures/clay.ppm \
  blinn \
  images/results/MetalCppTinyRenderer/06-bunny-blinn-phong.png
Bunny Lambert result
Stanford Bunny `Lambert`:最先暴露的是相机 framing、法线方向和曲面体积感。
Bunny Blinn-Phong result
Stanford Bunny `Blinn-Phong`:加上高光之后,耳朵、背部和腿部的表面转折会明显更容易读。

Render Pass and Readback

渲染结束后,把 GPU 结果读回 CPU。由于 color texture 使用 StorageModeManaged,先做一次 blit synchronize,再调用 getBytes() 读取像素。

继续修改 Renderer.cpp。下面这段代码接在 draw call 结束之后、写文件之前;它是在原有渲染流程后面继续新增的读回步骤。

Renderer.cpp
MTL::BlitCommandEncoder* blit = commandBuffer->blitCommandEncoder();
blit->synchronizeTexture(colorTexture, 0, 0);
blit->endEncoding();

commandBuffer->commit();
commandBuffer->waitUntilCompleted();

colorTexture->getBytes(pixels.data(),
                       width * 4,
                       MTL::Region::Make2D(0, 0, width, height),
                       0);

继续修改 Renderer.cpp 的输出部分,把原来固定写 PPM 的调用替换成按扩展名选择输出格式。下面这段是在旧的 writePPM(...) 调用位置做替换。

Renderer.cpp修改最终输出调用,黄色 = 修改
writeOutputImage(options.outputPath, pixels, options.width, options.height);

参考代码现在会根据输出路径扩展名自动选择写法:.ppm 继续走原来的二进制 PPM,.png.jpg 通过 ImageIO 编码。

CMake Changes

这一册的 target 只包含两个 C++ 源文件:

打开 CMakeLists.txt,在项目已有的 target 定义区域新增 Book2 的可执行文件:

CMakeLists.txt
add_executable(MetalCppTinyRenderer
    src/MetalCppTinyRenderer/main.cpp
    src/MetalCppTinyRenderer/Renderer.cpp)

引入 PNG/JPG 贴图读写之后,链接的 framework 不再只有 Metal 本身,还要补上 CoreGraphicsImageIO

继续修改同一个 CMakeLists.txt target,把 include path 和 framework 链接补齐:

CMakeLists.txt
target_include_directories(MetalCppTinyRenderer PRIVATE "${METAL_CPP_ROOT}")
target_link_libraries(MetalCppTinyRenderer PRIVATE ${METAL_FRAMEWORKS})

如果你是从只支持 PPM 的版本继续改,记得同时把 METAL_FRAMEWORKS 列表补成下面这样;这一步是修改已有框架列表,不是新增一个新的变量名。

CMakeLists.txt修改 framework 列表,绿色 = 新增
set(METAL_FRAMEWORKS
    "-framework Foundation"
    "-framework CoreGraphics"
    "-framework ImageIO"
    "-framework QuartzCore"
    "-framework Metal")

shader 编译步骤保持为 .metal -> .air -> .metallib

最后仍然在 CMakeLists.txt 中新增 Book2 的 shader 编译命令。它是在 target 定义之后继续追加的构建步骤,不是替换前面的 add_executable

CMakeLists.txt
set(BOOK2_DIR "${CMAKE_BINARY_DIR}/MetalCppTinyRenderer")
set(BOOK2_METALLIB "${BOOK2_DIR}/default.metallib")
add_custom_command(
    OUTPUT "${BOOK2_METALLIB}"
    COMMAND "${CMAKE_COMMAND}" -E make_directory "${BOOK2_DIR}/ModuleCache"
    COMMAND xcrun -sdk macosx metal
            "-fmodules-cache-path=${BOOK2_DIR}/ModuleCache"
            -c "${CMAKE_CURRENT_SOURCE_DIR}/src/MetalCppTinyRenderer/Shaders.metal"
            -o "${BOOK2_DIR}/Shaders.air"
    COMMAND xcrun -sdk macosx metallib
            "${BOOK2_DIR}/Shaders.air"
            -o "${BOOK2_METALLIB}"
    DEPENDS src/MetalCppTinyRenderer/Shaders.metal)

Reference Code

最终参考代码目录是 src/MetalCppTinyRenderer/。如果你想核对最终实现,可以对照下面的文件清单:

下面同样是目录清单,不是要粘贴进源码的代码块。

project layout
src/MetalCppTinyRenderer/
  main.cpp
  Renderer.cpp
  Shaders.metal
assets/
  meshes/uv_sphere.obj
  meshes/stanford_bunny.obj
  textures/warm_metal.ppm
  textures/clay.ppm
  textures/rusty_metal_02_basecolor.png
  textures/rusty_metal_02_roughness.png
  textures/rusty_metal_02_metallic.png
  textures/rusty_metal_02_normal.png
  textures/rusty_metal_02_ao.png
  textures/README.md

预期输出:

最后这段仍然是运行命令,用来验证你已经完成了整本书的最终版本。

terminal
cmake -S . -B build
cmake --build build --target MetalCppTinyRenderer

./build/MetalCppTinyRenderer/MetalCppTinyRenderer \
  assets/meshes/uv_sphere.obj \
  assets/textures/rusty_metal_02_basecolor.png \
  pbr \
  build/MetalCppTinyRenderer/09-rusty-metal-pbr.png