Model 场景遍历、纹理缓存与层级变换

Model 场景遍历、纹理缓存与层级变换:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。

学习目标

  • 能解释 Model 在“aiScene → Mesh 列表 → draw calls”中的职责边界
  • 能实现带父级累计变换的 processNode 深度优先遍历,并说明何时可安全忽略节点变换
  • 能在 processMesh 中安全处理缺失法线、UV、材质与空网格
  • 能用规范化完整路径去重 GPU 图片,同时保留 diffuse/specular 等每次材质绑定语义
  • 能在交互查看器中隔离单个 mesh,并定位漏递归、路径错误、嵌入纹理或缓存键错误造成的加载问题

为什么一盒模型文件还需要装配说明书

上一章你亲手攒出了一个网格——一堆顶点、一份连法、几张贴图,能自己画自己。可一辆车、一个人物可不是一个网格能搞定的:车身一块、四个轮子各一块、玻璃一块、座椅一块……一件像样的物体,是几十上百块这样的小积木拼起来的。

问题来了:美术在专业软件里做好这么一件复杂物体,导出成一个模型文件,你怎么把它读进程序里?文件里不只有一堆堆顶点,还记着「哪块是车身、哪块是轮子、各块之间谁挂在谁下面、每块该贴哪张图」——就像一盒乐高附带的那张说明书。

这章要解决的就是:照着这张说明书,把文件里的整件物体拆成一块块小网格、把每块要贴的图都备齐,最后一句话就能把整件东西画出来。没有它,你的屏幕上永远只能摆几个手写的方块和三角形;有了它,你才能把美术做好的真实模型直接搬进场景。

模型,就是一堆有名字的网格

先把这章的主角说清楚。所谓 (Model),在代码里并不神秘——它就是上一章那个 Mesh容器加加载器:里面装着一个 vector<Mesh>(这件物体被拆成的所有小网格),外加一套「把文件读进来、拆成这些网格」的加载逻辑。说白了:

  • 一个 Mesh = 一块小积木(自带顶点、连法、贴图,会自己画自己)——上一章讲过;
  • 一个 Model = 一整盒积木 + 那张说明书 = vector<Mesh> + 加载代码——本章讲。

最直观的验证方式,就是把一个真实模型拆开来看。下面这个查看器加载了一辆真实的法拉利模型,你可以切换线框看清它的三角网格结构,也可以从下拉里隔离出任意一个 mesh(车身、某个轮子、玻璃……)单独高亮:

猜一猜:在下面的查看器里,把 mesh 下拉从「全部」切到某一个具体的件,画面会只剩下哪一块?这能不能印证「一个模型其实是一组各自独立、各有名字的 mesh」?先动手试,再往下读。

拖一拖、切几个 mesh 看下来,「模型 = 一堆有名字的网格」这句话就具体了。接下来几节,就是把「从一个文件读出这一堆网格」这件事,一步步拆给你看。我们用 Assimp 这个库读文件——它能把各种格式(.obj、.gltf 等)统一读成一套通用的数据结构,省得你为每种格式各写一套解析器。

模型文件读进来,长什么样

Assimp 把文件读进来后,给你的不是一个扁平的网格列表,而是一棵有父子层级的树——叫场景图(scene)。树的每个节点是一个 aiNode,节点上不直接存网格数据,只存一串索引mMeshes),指向场景里真正的网格数组 scene->mMeshes。同时一个节点可以有若干子节点mChildren),子节点又可以再有自己的子节点。

为什么要搞成一棵树而不是一个列表?因为现实物体本就有层级:「车」下面挂着「车身」「四个轮子」,「轮子」下面可能又挂着「轮毂」「轮胎」。这种父子关系对做动画、做整体变换很有用。但本章我们只想要所有网格本身,并不关心层级——所以接下来的核心工作,就是把这棵树压平成一个网格列表。怎么压平?递归。这是本章最该吃透的一点,下面专门用一张图讲。

Model 类的三个成员

动手写代码前,先认识 Model 类要存哪几样东西。就三个:

  • 网格列表 vector<Mesh> meshes:这件物体被拆成的所有网格——最终成果就装在这里;
  • 目录字符串 directory:模型文件所在的文件夹——加载纹理时要用它拼出贴图的完整路径(贴图路径在文件里是相对的,这点稍后是个常见坑);
  • 纹理清单 textures_loaded:一份已加载纹理的清单——同一张贴图被多个网格共用时,靠它做去重,只加载一次。

这里两个名词稍后会反复用到,先点破:所谓 (directory)就是上面那个文件夹路径,纹理路径全靠拼它;而 (textures_loaded)就是上面那份已加载清单,专门用来去重。

构造函数只做一件事:调 loadModel(path) 把整件事跑起来。三个成员、一个入口——Model 类的骨架就这么简单。下面从入口开始,一段段拆。

代码逐段拆解

下面把 Model 类从入口到画出来的关键代码一段段摊开。全是 C++ + Assimp,没有 WebGL 端的对照——这是 CPU 侧把文件读成网格的活,和着色器无关。先预测三个阶段失败的现象:解析失败应立即返回错误;漏掉节点或累计变换会让零件缺失、堆到原点;缓存键或材质语义错误则会让贴图重复占显存或绑到错误 sampler。

分步1 / 3

解析并验证模型文件

用明确的后处理 flags 调 ReadFile,检查 scene、完整标志与根节点;目录用 std::filesystem::path::parent_path() 提取,避免只支持 /

第一步:loadModel——把文件交给 Assimp,记下目录,进入递归

入口函数。它做三件事:用 Assimp 的 Importer 读文件并指定一组处理标志、做错误检查、把模型文件所在的目录记下来,最后从场景图的根节点进入递归。

void Model::loadModel(const std::filesystem::path& path) {
  Assimp::Importer importer;
  const aiScene* scene = importer.ReadFile(path.string(),
      aiProcess_Triangulate |
      aiProcess_FlipUVs |
      aiProcess_GenSmoothNormals);
 
  if (!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode) {
    throw std::runtime_error(importer.GetErrorString());
  }
 
  directory = path.parent_path();
  meshes.clear();
  textures_loaded.clear();
  processNode(scene->mRootNode, scene);
}

ReadFile 的第二个参数是一组后处理标志Triangulate 把面拆成三角形,FlipUVs 翻转纹理 y,GenSmoothNormals 为缺法线资产补平滑法线。即使请求生成法线,processMesh 仍应通过 HasNormals() 做边界检查。路径则交给 std::filesystemparent_path() 同时理解 Windows 与 POSIX 分隔符,比手动寻找最后一个 / 更稳。重复加载同一个 Model 前先清空旧容器,实际工程还应先释放旧 GPU 资源或用临时对象构建成功后再交换,避免失败时留下半更新状态。

第二步:processNode——递归把树压平

本章的心脏。对每个节点做两件事:处理本节点持有的所有网格(节点的 mMeshes 只是索引,真正的网格在 scene->mMeshes 里),对本节点的每个子节点递归调用自己。这张图把「树怎么被压平成列表」画清楚:

对着图看代码——它就是图里那条深度优先路径的直译:

void Model::processNode(aiNode* node, const aiScene* scene) {
  // ① 先处理本节点自己持有的网格(mMeshes 存的是索引,去 scene 里取真东西)
  for (unsigned int i = 0; i < node->mNumMeshes; i++) {
    aiMesh* mesh = scene->mMeshes[node->mMeshes[i]];
    meshes.push_back(processMesh(mesh, scene));  // 拆成我们的 Mesh,塞进列表
  }
  // ② 再对每个子节点递归(这一步漏了,模型就只剩根节点那几块)
  for (unsigned int i = 0; i < node->mNumChildren; i++) {
    processNode(node->mChildren[i], scene);
  }
}

两个循环,顺序不能乱:第一个循环把当前节点的网格收进 meshes,第二个循环钻进每个子节点重复同样的事。第二个循环是递归的关键——少了它,遍历到根节点就停了,深藏在子节点里的车身、轮子全都收不到。这是本章头号坑(§7 第一条)。

上面是 LearnOpenGL 原章的最小版本,它只收集 mesh 索引,没有使用 node->mTransformation。对节点变换已经烘焙进顶点的 OBJ 样例通常看不出问题;但 glTF、FBX 或带层级装配的资产可能依靠节点矩阵摆放零件。此时“压平列表”必须同时保留空间关系:要么把累计矩阵存成 Mesh 实例变换,要么加载时把它烘焙进位置与法线。

void Model::processNode(aiNode* node, const aiScene* scene,
                        const aiMatrix4x4& parentWorld) {
  const aiMatrix4x4 world = parentWorld * node->mTransformation;
  for (unsigned int i = 0; i < node->mNumMeshes; ++i) {
    aiMesh* source = scene->mMeshes[node->mMeshes[i]];
    meshes.push_back(processMesh(source, scene, world));
  }
  for (unsigned int i = 0; i < node->mNumChildren; ++i)
    processNode(node->mChildren[i], scene, world);
}

若选择烘焙,位置乘完整 world;法线不能直接乘同一矩阵,而要乘其左上 3×3 的逆转置后归一化,才能在非均匀缩放下保持垂直关系。若选择保存实例矩阵,则 Mesh::Draw 或外层 renderer 必须在每次 draw call 前上传对应 model matrix。两种方案选一种并保持契约,不能只把树压平却丢掉矩阵。

第三步:processMesh——把一个 aiMesh 翻译成我们的 Mesh

processNode 把每个网格交给 processMesh,让它把 Assimp 的 aiMesh 翻译成上一章那个我们自己的 Mesh。要填三样:顶点、索引、纹理。先看顶点和索引:

Mesh Model::processMesh(aiMesh* mesh, const aiScene* scene) {
  vector<Vertex> vertices;
  vector<unsigned int> indices;
  vector<Texture> textures;
  // 顶点:逐个搬位置、法线、纹理坐标
  for (unsigned int i = 0; i < mesh->mNumVertices; i++) {
    Vertex vertex;
    vertex.Position = vec3(mesh->mVertices[i].x, mesh->mVertices[i].y, mesh->mVertices[i].z);
    if (mesh->HasNormals())
      vertex.Normal = vec3(mesh->mNormals[i].x, mesh->mNormals[i].y, mesh->mNormals[i].z);
    else
      vertex.Normal = vec3(0.0f);
    // 纹理坐标可能没有!必须判空,否则越界崩溃
    if (mesh->mTextureCoords[0])
      vertex.TexCoords = vec2(mesh->mTextureCoords[0][i].x, mesh->mTextureCoords[0][i].y);
    else
      vertex.TexCoords = vec2(0.0f, 0.0f);
    vertices.push_back(vertex);
  }

逐个顶点搬三样数据。法线和 UV 都是可选字段GenSmoothNormals 通常会补法线,但稳健代码仍用 HasNormals() 验证;无 UV 的纯色网格则让 mTextureCoords[0] 为 null。两个检查都不能省,否则遇到缺字段资产就会解引用空指针。零法线只是明确兜底,生产渲染器应记录诊断并选择生成法线或使用不依赖光照的材质。接着收集索引——遍历每个面,把面里的顶点索引一个个收进来:

  // 索引:每个面(已被 Triangulate 成三角形)贡献它的几个顶点索引
  for (unsigned int i = 0; i < mesh->mNumFaces; i++) {
    aiFace face = mesh->mFaces[i];
    for (unsigned int j = 0; j < face.mNumIndices; j++)
      indices.push_back(face.mIndices[j]);
  }
  // 纹理:取这个网格对应的材质,按类型分别加载(漫反射贴图 + 镜面贴图)
  aiMaterial* material = scene->mMaterials[mesh->mMaterialIndex];
  vector<Texture> diffuseMaps = loadMaterialTextures(material,
      aiTextureType_DIFFUSE, "texture_diffuse");
  textures.insert(textures.end(), diffuseMaps.begin(), diffuseMaps.end());
  vector<Texture> specularMaps = loadMaterialTextures(material,
      aiTextureType_SPECULAR, "texture_specular");
  textures.insert(textures.end(), specularMaps.begin(), specularMaps.end());
  return Mesh(vertices, indices, textures);  // 拼成上一章那个 Mesh
}

mesh->mFaces 是这个网格的所有面;因为第一步指定了 aiProcess_Triangulate,每个面都是三角形、正好三个索引。最后取这个网格挂的材质mesh->mMaterialIndex 又是个索引,去 scene->mMaterials 里取),把它的漫反射、镜面两类贴图加载进来(加载交给下一步的 loadMaterialTextures),连同顶点和索引一起拼成一个 Mesh 返回。

第四步:loadMaterialTextures——缓存去重,同一张贴图只加载一次

加载纹理。一个模型里,车身、引擎盖、车门很可能共用同一张贴图。如果每个网格都各自从磁盘重新读一遍,又慢又费内存。所以这里用 textures_loaded 做缓存:每张贴图加载前先按路径查缓存,查到就直接复用那份、不再读磁盘。这张图把去重画清楚——多个网格的箭头指向缓存里同一张已加载的贴图:

代码就是「先查缓存、命中则复用、否则加载并存入缓存」这个套路:

vector<Texture> Model::loadMaterialTextures(aiMaterial* mat,
    aiTextureType type, string typeName) {
  vector<Texture> textures;
  for (unsigned int i = 0; i < mat->GetTextureCount(type); i++) {
    aiString relative;
    mat->GetTexture(type, i, &relative);
    const auto fullPath = (directory / relative.C_Str()).lexically_normal();
    const string key = fullPath.generic_string();
 
    auto hit = std::find_if(textures_loaded.begin(), textures_loaded.end(),
        [&](const Texture& cached) { return cached.path == key; });
 
    Texture binding;
    if (hit != textures_loaded.end()) {
      binding = *hit;                       // 复用 GPU id
    } else {
      binding.id = TextureFromFile(fullPath.string());
      binding.path = key;
      textures_loaded.push_back(binding);   // 缓存图片资源,不缓存材质语义
    }
    binding.type = typeName;                // 本次绑定仍写 diffuse/specular
    textures.push_back(binding);
  }
  return textures;
}

缓存键使用规范化后的完整路径,避免同一文件被不同相对写法重复加载。更重要的是,缓存复用的是图片 GPU id,不是材质槽位语义:同一图片可能既被某材质当 diffuse,也被另一个材质当 specular;命中后仍要把当前 typeName 写给 binding.type,否则上一章 Mesh::Draw 会拼出错误 sampler 名。只有没命中时才走 真正读磁盘并登记 GPU 资源。

第五步:Draw——一行画完整个模型

所有网格都拆好、纹理都备齐后,画整个模型就轻松到不可思议——Model::Draw 只是个转发器,把每个 mesh 各自的 Draw 调一遍:

void Model::Draw(Shader& shader) {
  for (unsigned int i = 0; i < meshes.size(); i++)
    meshes[i].Draw(shader);  // 每块小网格自己会画自己(上一章实现)
}

就这么一个循环。真正的绘制逻辑(绑定 VAO、绑纹理、glDrawElements)都在上一章的 Mesh::Draw 里——Model 自己不碰任何 OpenGL 调用,只负责「把活分给手下每个 mesh」。这正是把复杂物体拆成一组自治网格的回报:加载阶段递归拆开、绘制阶段一行合拢。完整可运行源码将在章末提供(M5)。

容易踩的坑

小结

  • Model 负责把有效 aiScene 转成可绘制 Mesh 列表;Draw 只遍历转发,不重复实现单网格绘制
  • processNode 深度优先收集当前 mesh 与 children;层级资产还要累计 parentWorld * nodeLocal,不能丢节点矩阵
  • processMesh 对法线、UV、材质等可选数据逐项验证,再构造拥有明确 GPU 生命周期的 Mesh
  • 纹理缓存以规范化完整路径或嵌入索引标识图片资源,同一 GPU id 可复用,但每次绑定的 diffuse/specular type 要重写
  • 路径用 std::filesystem 组合;嵌入纹理先查 GetEmbeddedTexture,外部图片才从模型目录读取

练习

问题 1(用 Demo / 改代码型) 打开本章 §3 的模型查看器,把 mesh 下拉逐个切过去(车身、各个轮子、玻璃…),数一数这辆车大致由几类件组成;再切到线框观察某个轮子。结合你看到的,解释一句话:「为什么 Model::Draw 只用一个 for 循环就能画完整辆车?」如果让你在加载代码里少写哪一行,这辆车就只会剩下根节点那一两块?

问题 2(问答题) 一个模型里车身、引擎盖、车门共用同一张漆面贴图。loadMaterialTextures 如果去掉 textures_loaded 那段查缓存的逻辑,会发生什么?为什么这既费时间又费内存?正确的做法是什么?

问题 3(层级与缓存契约题) 一个 glTF 节点把轮子网格平移到车身右侧;同一张灰度图在两个材质里分别作为 diffuse 和 specular。若加载器只压平 mesh 索引,并在缓存命中时直接复用旧 Texture.type,会出现哪两个独立问题?分别怎么修?

名词解释

本章出现的专业名词,用大白话再讲一遍。

模型

本章里指一个由「一组 Mesh + 加载逻辑」构成的类(Model)。它负责把一个模型文件(.obj、.gltf 等)读进来、递归拆成多个 Mesh、把纹理按缓存加载好,并提供一行 Draw 把所有 mesh 画出来。说白了,Model 就是上一章 Mesh 的「容器 + 加载器」。详见本章「模型,就是一堆有名字的网格」一节。

processNode 递归

加载模型时的核心递归函数。Assimp 把文件读成一棵有父子层级的 aiNode 树,processNode 对它做深度优先遍历——每到一个节点,处理这个节点自己持有的网格(塞进 meshes 列表),对它的每个子节点递归调用自己。这样整棵树就被「压平」成一个扁平的网格列表。漏了递归子节点,模型就会残缺。详见本章「processNode——递归把树压平」一节。

纹理缓存

一个记录「已加载过哪些纹理」的清单(textures_loaded)。每加载一张贴图就把它的路径和数据记进来;下次再遇到同一张贴图,先在这里按路径查一遍,查到就直接复用、不重复从磁盘读。作用是去重——避免一个模型里多个网格共用同一张贴图时反复加载,省时间也省内存。详见本章「loadMaterialTextures——缓存去重」一节。

目录

一个字符串(directory),存模型文件所在的文件夹路径。模型文件里记录的贴图路径通常是相对模型文件的相对路径,所以加载纹理时要把这个目录前缀拼上去,才能拼出磁盘上的完整路径。不拼它,程序会跑到当前工作目录找贴图、找不到,模型就变黑或报错。详见本章「Model 类的三个成员」与第一步代码。

TextureFromFile

一个辅助函数,负责真正从磁盘把一张图片文件读成 OpenGL 纹理对象、返回纹理 id。它接收贴图的相对路径和模型所在目录(directory),内部把两者拼成完整路径再去加载。本章只关心调用它时要把 directory 传进去;具体读图、生成纹理的细节同纹理章。详见本章「loadMaterialTextures——缓存去重」一节。

版本、来源与运行边界

本章以 Joey de Vries 的 LearnOpenGL 原章 为授权改编依据,教学运行基线是 OpenGL 3.3 Core Profile。Khronos 当前发布的规范参照是 OpenGL 4.6 Core Profile;这里用 4.6 规范核查术语和状态合同,但不把 4.6 API 偷偷倒填为原教程内容。GLFW、GLAD、Assimp 与驱动版本都属于运行环境,不能拿“编译通过”替代对 context、资源和 framebuffer 结果的验证。

正式概念与状态责任

  • model:在“Model 场景遍历、纹理缓存与层级变换”中由Model 对象、节点遍历器与 texture cache负责解释其输入、受控状态和可观察结果;运行时以节点路径/变换、mesh 索引、纹理规范化路径、cache hit 与场景包围盒定位它的第一处变化。
  • directory:在“Model 场景遍历、纹理缓存与层级变换”中由Model 对象、节点遍历器与 texture cache负责解释其输入、受控状态和可观察结果;运行时以节点路径/变换、mesh 索引、纹理规范化路径、cache hit 与场景包围盒定位它的第一处变化。
  • texture cache:在“Model 场景遍历、纹理缓存与层级变换”中由Model 对象、节点遍历器与 texture cache负责解释其输入、受控状态和可观察结果;运行时以节点路径/变换、mesh 索引、纹理规范化路径、cache hit 与场景包围盒定位它的第一处变化。
  • recursive:在“Model 场景遍历、纹理缓存与层级变换”中由Model 对象、节点遍历器与 texture cache负责解释其输入、受控状态和可观察结果;运行时以节点路径/变换、mesh 索引、纹理规范化路径、cache hit 与场景包围盒定位它的第一处变化。

章专属 OpenGL 状态实验

先预测“processNode 递归索引 mesh,processMesh 转换数据并查询材质纹理”发生后,Model 对象、节点遍历器与 texture cache应怎样改变目录、节点层级、mesh 列表、材质纹理、缓存键和层级变换;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。

实验一:Context—资源—结果合同

选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。

Context · resource · observable result

Model 场景遍历、纹理缓存与层级变换:状态合同

递归展开 aiNode,转换每个 aiMesh,并按规范化路径复用已加载纹理

验证场景

官方教程正式概念

logl-17 · 基线帧

model固定 context、资源内容与输入事件,执行“processNode 递归索引 mesh,processMesh 转换数据并查询材质纹理”

状态所有者Model 对象、节点遍历器与 texture cache
受控状态/资源目录、节点层级、mesh 列表、材质纹理、缓存键和层级变换
触发命令processNode 递归索引 mesh,processMesh 转换数据并查询材质纹理

冻结输入:model

Model 对象、节点遍历器与 texture cache记录目录、节点层级、mesh 列表、材质纹理、缓存键和层级变换

结果:得到可重复的初始 GL 状态与资源身份

观测:节点路径/变换、mesh 索引、纹理规范化路径、cache hit 与场景包围盒中的初始快照

预期:Model 对象、节点遍历器与 texture cache得到可复查结果,并持续满足“每个 scene mesh 按节点引用处理,重复纹理只创建一次 GPU 对象”

实验二:CPU 命令到 GPU 结果的五段轨迹

逐段执行“processNode 递归索引 mesh,processMesh 转换数据并查询材质纹理”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“每个 scene mesh 按节点引用处理,重复纹理只创建一次 GPU 对象”。

CPU command · GL state · GPU result

Model 场景遍历、纹理缓存与层级变换:五段轨迹

选择一段命令—资源—结果1 / 5

当前观测:节点路径/变换、mesh 索引、纹理规范化路径、cache hit 与场景包围盒中的初始快照

不变量:每个 scene mesh 按节点引用处理,重复纹理只创建一次 GPU 对象

实验三:单故障与同输入恢复

注入“忽略节点局部变换就把所有 mesh 压平,层级模型的零件重叠到原点”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有节点路径/变换、mesh 索引、纹理规范化路径、cache hit 与场景包围盒一起恢复才算修复。

Single fault · first divergence · replay

Model 场景遍历、纹理缓存与层级变换:反例与恢复

故障:忽略节点局部变换就把所有 mesh 压平,层级模型的零件重叠到原点

1. 冻结输入一致

第 1 次沿用同一 context、资源、uniform 与 draw 输入

2. 注入单故障一致

保持其余输入不变,仅注入“忽略节点局部变换就把所有 mesh 压平,层级模型的零件重叠到原点”

3. 定位首差一致

每个 scene mesh 按节点引用处理,重复纹理只创建一次 GPU 对象

4. 清理并重放一致

节点路径/变换、mesh 索引、纹理规范化路径、cache hit 与场景包围盒

最小可重放检查

unit: logl-17
owner: Model 对象、节点遍历器与 texture cache
state_or_resource: 目录、节点层级、mesh 列表、材质纹理、缓存键和层级变换
command: processNode 递归索引 mesh,processMesh 转换数据并查询材质纹理
pass_invariant: 每个 scene mesh 按节点引用处理,重复纹理只创建一次 GPU 对象
single_fault: 忽略节点局部变换就把所有 mesh 压平,层级模型的零件重叠到原点
required_evidence: 节点路径/变换、mesh 索引、纹理规范化路径、cache hit 与场景包围盒

复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。

出处声明

本文为改编重写,改编自 Joey de Vries 的 LearnOpenGL。原文:learnopengl.com

原作及译作以 CC BY-NC 4.0 协议授权,本改编版同样遵循该协议(署名—非商业性使用)。

讨论

评论区加载中…