当前位置: 首页 > news >正文

游戏引擎学习第243天:异步纹理下载

仓库
https://gitee.com/mrxiao_com/2d_game_6
https://gitee.com/mrxiao_com/2d_game_5

回顾并为今天设定阶段

目前的开发工作主要回到了图形渲染相关的部分。我们之前写了自己的软件渲染器,这个渲染器性能意外地好,甚至可以以相对不错的帧率运行过场动画。因此在很长一段时间里,我们根本没有考虑使用硬件加速,也没有感到迫切的需要。

不过随着游戏功能逐步完善,特别是在进入“游戏调优”阶段时,我们意识到要让游戏稳定地以 60 帧每秒运行,就不能总是依赖对软件渲染器进行微调和优化。与其不停优化每一帧渲染效率,还不如直接利用硬件加速。所以我们开始实现 OpenGL 的接口,以便启用硬件加速渲染。

OpenGL 的整合工作本身其实并不复杂,为我们的目的所需的部分非常简单,几乎不需要多少额外的工作量。到目前为止,大部分关于 OpenGL 的基础支持已经搭建完成,整个流程可以正常运行。但有一部分还没有完成,那就是**纹理下载(Texture Downloads)**的部分。

当前的纹理处理逻辑基本上还是占位符,也就是说:

  1. 纹理下载并不是一个正式的流程,也没有被系统化地集成到渲染流程中。
  2. 纹理数据的上传仍然是静态的或是临时实现,缺乏真正意义上的动态纹理加载。

为了让整个渲染流程更加健全,我们需要把纹理下载作为一个一等公民纳入整个渲染管线。这包括明确何时、如何将图像数据传输到 GPU,以及如何异步处理这些纹理加载操作,以免影响主线程的渲染效率。

接下来的开发方向会先从这个纹理部分入手。为了便于理解,会通过黑板或绘图的方式说明纹理处理的整体逻辑,并梳理出目前存在的不足。同时还计划在今天把 RGB 的读取功能整理完善,这一块工作相对简单,应该不会耗费太久的时间。剩下的大部分精力将用于纹理系统的完善。

整个目标是:让纹理加载成为正式、稳定且高效的一部分,为后续的图形表现、资源管理打好基础。完成这一部分后,渲染性能和灵活性都会有显著提升,能更好地支撑复杂场景的绘制和更高频率的画面更新。

黑板:纹理下载

在目前的图形处理架构中,存在一个非常重要但容易被忽略的核心问题:CPU 内存与 GPU 内存是完全分离的。尤其是在使用独立显卡的系统中,GPU 拥有自己的专用显存,这些显存芯片物理上就与 CPU 的内存分开,完全不共享。因此,在渲染流程中,我们必须明确处理数据在 CPU 和 GPU 之间的传输问题

举个例子,我们从磁盘中读取纹理数据后,它首先是被加载进了 CPU 的内存中。这些数据虽然此时在内存中已经存在,但对 GPU 来说是不可见的,它无法直接访问 CPU 内存。因此,我们必须有一个过程——将纹理从 CPU 内存传输到 GPU 内存中,这样 GPU 才能使用这些纹理进行渲染。

这就引出了一个重要概念:纹理的上传(Texture Upload)过程必须是明确的、可控的。不能每一帧都重新把纹理从 CPU 上传到 GPU,这会带来巨大的性能瓶颈:

  • 一张未压缩的纹理图像,比如用于某个过场动画的帧图像,可能会达到 6MB。
  • 如果每帧都重复上传这些大体积纹理,会导致显卡带宽被严重占用。
  • 带宽被耗尽的结果就是我们每帧能使用的纹理总量会受到限制,直接影响渲染质量和性能。
  • 真正瓶颈将不再是 GPU 显存容量,而是 CPU 与 GPU 之间的传输速度。

因此,在 GPU 编程中必须引入某种程度的资源保留机制(Retained Mode),也就是我们不能每次都“即时传送”纹理,而应该是:

  • 在资源首次加载时,将其完整上传至 GPU;
  • 之后每帧只需在 GPU 中引用已经存在的纹理资源;
  • 只有当纹理资源发生变化或者被替换时,才重新上传。

这种模式不仅可以大幅减少数据传输量,还可以充分利用 GPU 的高速显存,让渲染系统稳定运行在高性能状态。

这一部分的工作,就是在为后续纹理系统做铺垫,目标是:

  1. 建立起纹理从磁盘到 GPU 的完整传输链;
  2. 实现一次性上传、多次复用的机制;
  3. 避免重复上传导致的性能损耗。

只有完成了这些基础工作,我们的渲染系统才能真正进入高效运行状态,并为更多图像特效和复杂画面打下坚实的基础。

黑板:将 GPU 内存看作缓存

GPU 内存在本质上可以被看作是一种缓存机制,类似于 CPU 使用的缓存(Cache)。当 GPU 进行渲染操作时,理想的情况是所有需要使用的纹理都已经存在于 GPU 的专用显存中,这样可以在渲染过程中以极快的速度直接访问这些数据,避免从主内存中重新读取,提升性能。

但这并不意味着所有游戏纹理都可以常驻于 GPU 内存中。原因很简单:资源可能远远超过 GPU 所能容纳的容量。例如,我们可能拥有 2GB 的纹理资源,而 GPU 的显存只有 1GB。这就需要一种策略来动态管理纹理——决定哪些纹理在当前时间段是必须的,应该保留在 GPU 内存中,而不需要的可以被替换出去

这种行为就像缓存系统的管理方式一样:

  1. 我们需要有策略判断当前时间窗口(比如当前帧和接下来几十帧)内将要用到哪些纹理。
  2. 我们需要确保这些纹理提前上传到 GPU,避免在渲染关键路径上出现延迟或卡顿。
  3. 当内存不够时,我们需要主动地从 GPU 显存中移除不再需要的纹理,为新的纹理腾出空间。

虽然在某种程度上,GPU 驱动会自动进行这类纹理的“缓存管理”(即虚拟化)——比如我们分配了超过显存限制的纹理,驱动会自动根据使用情况进行换入换出。但是,如果完全依赖驱动这种自动管理,就会带来一个严重的问题:性能不确定

GPU 驱动会在我们毫不知情的情况下突然移除某个纹理,然后又尝试加载另一个,从而导致帧率下降、卡顿等问题。特别是在对性能要求极高的场景(例如固定 60 帧每秒的实时渲染),这样的行为是不可接受的

所以我们必须:

  • 主动管理 GPU 内存中的纹理资源,避免让驱动做出临时的“替换决策”;
  • 避免过度分配显存,即不要一次性将过多纹理绑定到 GPU 中;
  • 精确控制上传的时机,在纹理被真正需要之前就完成上传。

然而,问题在于,显卡厂商和驱动并不会完全公开 GPU 内部如何存储和使用纹理的格式。因此,驱动在管理这些纹理时实际上拥有更多底层细节和优化空间。这意味着:

  • 驱动自己做的换入换出,在某些情况下反而比我们手动管理要高效;
  • 我们不能精准控制纹理在 GPU 内部的实际格式(比如是否压缩、怎么排列等);
  • 这限制了我们“完全掌控”纹理生命周期的能力。

综上,我们必须在两者之间寻找平衡:

  • 在我们能控制的范围内,尽量提前判断纹理使用情况,合理调度资源;
  • 在无法规避驱动自动管理的部分,理解其机制,并尽量规避其带来的性能抖动;
  • 建立一套纹理资源管理系统,模拟缓存行为,对纹理的生命周期进行主动控制,以实现更可控的性能表现。

黑板:纹理交换

所谓的 “swizzling”(混排或重新排列),我们曾经用这个术语来描述在处理图形编程时对数据的重组操作。本质上,它指的是将某个数据结构中的元素顺序重新排列,以满足某种计算或存储需求。

举个例子,在 SIMD(单指令多数据)编程中,通常有多个“通道”或“通路”来并行处理数据,比如 4 个通道(lane),分别存放了 a、b、c、d 四个值。此时,如果想要改变这些通道的顺序,使得新的排列变为 c、b、a、d,这个操作就称为一次 swizzle。

这类操作在图形编程和 GPU 着色器中非常常见,原因包括:

  1. 格式对齐:在渲染或纹理采样中,不同的数据格式可能对元素顺序有要求,swizzling 可以帮助满足这些硬件要求;
  2. 优化计算:有些计算或指令在特定排列下执行效率更高,通过 swizzling 可以提升 SIMD 运算效率;
  3. 资源重用:某些情况下,只需要对现有通道做轻微重新组合,而不是重新加载新数据,可以节省内存带宽;
  4. 逻辑编排:有时为了简化计算逻辑(比如颜色通道顺序转换,从 RGBA 转为 BGRA 等),也会使用 swizzling。

简单来说,swizzling 就像是一种数据“调位”机制,我们可以自由地指定通道的读取顺序,比如将原本 a、b、c、d 的排列变成 c、b、a、d。这个操作虽然看似简单,但在底层图形运算中极其重要,因为它直接影响数据的访问方式和运算效率。

在计算机图形学中,swizzle(混排)是一类对向量进行分量重排的操作。Swizzle 可以将一个向量的分量重新排列,从而生成新的向量。这种操作不仅可以对分量位置进行变换,还可以实现向量维度的转换,例如从一个三维向量生成一个二维向量或五维向量,只需重复或选取原向量中的部分分量即可。

举个例子:

  • 假设有一个四维向量 A = {1, 2, 3, 4},其分量分别为 x、y、z、w;
  • 可以进行 swizzle 操作,比如 A.wwxy,结果是 {4, 4, 1, 2};
  • 还可以生成不同维度的向量,如 A.wx 得到二维向量 {4, 1},或 A.xyzwx 得到五维向量 {1, 2, 3, 4, 1}。

Swizzle 也可以结合多个向量使用,通过这种方式可以快速组合、提取或重新组织向量数据。在 GPGPU(通用图形处理器计算)应用中,这类操作非常常见,因为它们允许更高效地利用 GPU 的并行能力。

线性代数的角度来看,swizzle 实际上相当于用一个特定构造的矩阵去左乘一个列向量,这个矩阵的每一行是一个标准基向量(standard basis vector),表示了目标向量中每个分量从原始向量中“抽取”的位置。例如,如果 A = (1, 2, 3, 4)^T,那么执行 A.wwxy 操作,相当于将 A 左乘一个这样的矩阵:

[ 0 0 0 1 ]   // 取 w -> 4
[ 0 0 0 1 ]   // 再次取 w -> 4
[ 1 0 0 0 ]   // 取 x -> 1
[ 0 1 0 0 ]   // 取 y -> 2

这样就得到结果向量 B = {4, 4, 1, 2}。

Swizzle 是 GPU 编程中的一个核心机制,尤其是在着色器(shader)中,经常用于处理向量数据、优化内存访问和提高计算效率。

在图形处理中,“swizzling”(混排)不仅应用于向量,同样也会应用在纹理处理中,尤其是在纹理映射的场景里。纹理的 swizzling 主要是为了提升内存访问的效率缓存一致性,以适应图形硬件对三角形纹理采样的高性能要求。

我们原本以为纹理在内存中是线性排列的,比如一个 2D 图像,第一行的像素排在内存前面,然后是第二行,依此类推。但实际上,为了提高效率,GPU 会对纹理进行重排,让它在内存中以更适合采样访问的方式布局

以下是核心内容的详细总结:


Swizzling 的基本思想

  • 本质是对数据在内存中的布局进行重新排列,原本是一行一行存储像素,现在可能是小块为单位(如 4x4)进行分块排列
  • 这种分块方式提高了缓存命中率,因为在三角形纹理采样中,我们往往只访问纹理中的小片区域,而非整行整列。
  • 举个例子:
    • 原本顺序是:一整行16个像素
    • 重排后顺序是:多个小 4x4 区块,按块为单位排列
    • 当采样一个小块区域时(比如贴图到三角形),GPU 一次性就能获取这个块里的全部像素,提高缓存利用率。

为什么需要对纹理做 Swizzle

  • 在现代 GPU 渲染中,纹理通常不是整体用完,而是从大纹理图中抽取小块映射到三角形表面
  • 如果纹理以行优先存储,每次从图中间某小区域读取数据,会导致缓存不连续、效率低下
  • 为了解决这个问题,GPU 将纹理重新排布为块状布局,让邻近像素在内存中也尽可能相邻,提高读取效率。

实际的 swizzle 行为细节

  • 我们提交纹理到 OpenGL 时,驱动会自动执行 swizzle 操作
    • 有时这个操作是在显卡上完成的;
    • 有时如果显卡不支持,CPU 会代替完成 swizzling,但代价就是性能开销很大,需要手动访问并重新安排整个纹理内存。
  • 一旦纹理被 swizzle 后,即便还没传给 GPU,也可能会以 swizzle 后的格式保留在内存中,加快后续传输。
  • OpenGL 没有对 swizzle 的格式进行标准化
    • 我们无法手动在 CPU 侧进行 swizzle 并直接提交 GPU;
    • 因为我们并不知道具体应该怎么排布才能匹配 GPU 的要求;
    • 不同的 GPU 厂商(如 NVIDIA、AMD)甚至不同型号的 GPU,可能采用不同的 swizzle 格式

局限性和问题

  • 虽然我们希望可以避免系统自动 swizzle(因为不可控、慢),但实际却必须依赖它。
  • 原因是:没有官方公开的 swizzle 格式文档,无法预先手动完成 swizzle 并跳过这一步。
  • 这意味着我们在进行纹理上传时,仍要接受不确定的性能开销。

总结

  • GPU 为了提升三角形纹理采样性能,会对纹理做内存重排(swizzle),以优化内存访问和缓存效率;
  • 重排后的纹理不是按行线性排列,而是按小块(如 4x4)存储;
  • OpenGL 在上传纹理时自动进行 swizzle,这个过程不透明也不可控;
  • swizzle 格式并未标准化,因此我们无法在 CPU 侧安全地提前做这一步;
  • 这虽带来性能隐患,但目前是不可避免的流程。

这个机制体现了现代图形硬件在性能与灵活性之间的权衡,也是图形开发中一个比较底层但关键的细节。

黑板:2 的幂次纹理和现代矩形纹理处理

我们在图形开发中使用的是矩形纹理,这可能使我们避免了某些昂贵的处理开销,特别是在纹理提交和使用过程中。一些图形硬件对矩形纹理的处理方式与传统的“标准纹理”(主要是以2 的幂为边长的纹理)存在差异。

以下是对这段内容的详细中文总结:


纹理的形状类型和区别

  • 图形硬件中最常见的纹理是“2 的幂纹理”(Power-of-Two, PoT),即纹理的宽高都是 2 的幂次方,例如:
    • 256×256、512×256、1024×1024 等;
    • 它们可以是正方形也可以是矩形,但边长必须是 2 的幂。
  • 早期的显卡只能支持这种 PoT 纹理,不支持任意尺寸的纹理
  • 随着硬件的发展,现在许多显卡已经支持了非 2 的幂纹理,即任意尺寸的矩形纹理(例如 540×300)。

矩形纹理与 swizzling 的关系

  • 一些现代显卡对于矩形纹理可能不再进行复杂的 swizzling 操作,也就是说:
    • 它们可能直接按顺序存储纹理,不做重新排列;
    • 或者将这类纹理识别为特殊用途(比如视频帧图像、屏幕贴图等),从而跳过 swizzle 流程。
  • 有些显卡厂商甚至会悄悄将非 2 的幂纹理在内部自动扩展为 2 的幂纹理,填充空白区域并浪费一部分内存。
  • 还有的显卡则可能真正支持任意尺寸的纹理,完全按照我们提供的矩形尺寸处理

这些特性的意义与应用

  • 如果我们使用的是矩形纹理:
    • 在传输到 GPU 的过程中可能更快、更轻量,因为少了复杂的 swizzle 操作;
    • 系统可能会以**“直接贴图”的方式处理**这类纹理,更接近于视频帧、UI元素的用法,而不是用来做 3D 模型的表面贴图。
  • 对于这类纹理,硬件假设我们只是以矩形形式直接绘制到屏幕或目标区域中,而不是将其包裹在三维模型表面。
  • 因此,使用矩形纹理有时可以规避昂贵的 CPU 或 GPU 内部重排,尤其在简单的 2D 渲染流程中更加高效。

技术深度与开发者关注点

  • 这些技术细节属于非常底层的图形硬件知识,一般开发者(尤其是做通用软件开发的)并不需要掌握所有细节
  • 但了解这些大致原理是有意义的:
    • 一旦我们遇到纹理加载性能瓶颈或纹理贴图异常行为,就可以快速定位是否与 swizzle 有关
    • 即便自己不是图形专家,也能向更专业的人询问问题时表达得更准确,例如向 GPU 厂商工程师、驱动维护者等反馈。
  • 如果我们是专注性能优化的图形开发者,或者从事 GPGPU 等底层开发工作,这类知识则是必须掌握的技术储备

总结

  • 我们当前使用的矩形纹理在现代 GPU 中可能被“特殊对待”,可能不会经历昂贵的 swizzling 流程;
  • Power-of-Two 纹理仍然是大多数三维贴图场景中的主流选择;
  • 对矩形纹理的优化主要基于其用途不同,例如用于视频帧或 UI;
  • 对一般开发者来说,只需要有大致了解,当问题出现时知道从哪里入手;
  • 对性能要求极高的项目和专业图形开发者来说,则需要深入掌握这些底层机制。

了解这些,可以帮助我们更高效地处理纹理资源,特别是在纹理加载速度、显存使用和图像显示一致性方面作出更明智的选择。

黑板:我们当前纹理下载方案的问题

我们当前的纹理使用方式存在两个严重的问题,需要改进:


当前做法的问题

我们目前的做法是:当需要绘制某个位图(bitmap)时,先检查它是否已经提交给 GPU。如果没有,就立即提交(上传)纹理,然后再进行绘制。如果已经提交过,就直接使用已有的纹理句柄。这种做法存在两个主要问题:

问题一:提交时机太晚
  • 如果一个大纹理(比如 1920×1080 的图像,大约 8MB)在绘制那一帧时才被提交,GPU 就必须等到整个纹理上传完毕之后才能开始渲染这一帧。
  • 这会造成显著的性能瓶颈:渲染被阻塞,导致帧率下降甚至卡顿。
  • 实际上,这相当于创建了一个“阻塞气泡”(bubble),GPU必须等整个纹理上传完成后才能进行下一步操作。

理想的处理方式

我们希望的是:

  • 纹理上传过程可以与绘制操作并行进行,也就是说在 GPU 绘制其他帧的同时,纹理可以在后台悄悄地上传;
  • 这样当某一帧需要某个纹理时,它已经准备好了,避免 GPU 空等;
  • 本质上,这种优化方式和之前我们加载资源的分层结构是一致的:
    • 从磁盘加载到内存;
    • 从内存上传到 GPU;
    • 最后再进行绘制。

这个过程中,磁盘到内存我们已经是异步加载的;现在要做的,是让内存到 GPU 的上传也变成异步的。


上传到 GPU 也是一个独立的资源阶段

我们需要把上传到 GPU 也看作是资源加载链条中的一个阶段。流程应该是这样的:

磁盘 → 内存 → GPU → 屏幕
  • 从磁盘到内存:已经异步化;
  • 从内存到 GPU:当前是同步的,要想办法异步;
  • GPU 渲染到屏幕:实时进行。

因此,我们目标是让“内存 → GPU”的过程放到另一个线程中处理


多线程上传纹理的可行性

以前在 Windows 下,多线程上传纹理是几乎不可能做到的,因为驱动、硬件兼容性问题严重,尝试这么做基本是灾难性的后果。

但在现在的环境下,情况已经好很多:

  • 主流 GPU 和驱动都开始支持纹理流式上传
  • 通过动态将纹理上传到 GPU,可以显著减少初始加载时间;
  • 还可以支持大量高分辨率的资源,比如一个 27GB 的开放世界游戏,GPU 显存可能只有 512MB,但可以根据需要流式上传纹理内容,保持画面质量。

风险与挑战

尽管现在多线程上传纹理已经可行,但仍然存在挑战:

  • Windows 上 GPU 驱动兼容性问题依然严重;
  • 各种用户环境差异巨大:不同的显卡型号、超频设置、旧驱动程序等都可能带来不稳定性;
  • 目前仍无法完全避免这些问题,但和我们在 Windows 平台上做图形开发时原本就要面临的兼容性问题相比,并没有特别糟糕

总结

  • 现有纹理上传机制阻塞严重,需要改为异步;
  • 应该将“上传到 GPU”作为资源加载的一个完整阶段;
  • 现代图形系统(特别是在 PC 上)大多数已经支持纹理流式上传;
  • 实现方式可以通过线程,在后台持续上传纹理内容;
  • 尽管仍有兼容性风险,但相对图形开发本身的问题来说,并没有显著增加复杂度;
  • 对于高性能要求或大规模资源管理系统而言,这一步优化非常关键。

通过实现异步纹理上传机制,我们可以显著减少卡顿、优化帧率表现,并为未来更复杂的图形资源调度打下基础。

“驱动程序和 Windows,它们合谋“帮助”你”

Windows 系统中的显卡驱动程序,常常在表面上看似“为我们提供帮助”,但实际情况远没有那么简单,甚至可以说这些驱动在某些情况下反而“相互勾结”制造了更多问题。这背后的情况错综复杂:


驱动程序的“阴影操作”

  • 驱动程序通常不是中立、标准化的,它们在实际工作中会隐藏很多底层行为,开发时很难预测这些行为到底会怎么影响性能或稳定性;
  • 一些驱动在遇到未知指令、特定操作时,会进行**“自动优化”或偷偷修改执行路径**,导致程序逻辑和预期完全不符;
  • 某些驱动甚至可能在后台将操作偷偷回退为软件模拟,也就是说本以为在用 GPU 实际上是在 CPU 上跑,造成严重性能问题却不容易发现。

驱动不一致带来的兼容性灾难

  • Windows 系统中的显卡驱动由各家厂商单独维护,包括 NVIDIA、AMD、Intel 等;
  • 不同厂商的驱动实现千差万别,甚至同一厂商的不同版本驱动行为都可能不同;
  • 一些驱动程序还存在**“未公开的功能”或“专属 hack”**,只有特定游戏或软件才能触发到最优路径,其它软件就可能运行在更慢的模式;
  • 导致我们开发出的程序,在自己的机器上测试完全正常,但一旦发布到用户环境,立即暴露出各种崩溃、花屏、加载失败、性能骤降等问题。

驱动“帮助”的表象与现实

  • 表面上看,驱动为了“帮助”我们,提供了丰富的 API、自动内存管理、纹理压缩、性能统计等工具;
  • 实际上,这些“帮助”常常伴随着不可控的副作用,比如:
    • 自动合批造成延迟提交;
    • 缓存行为和同步机制不透明;
    • 显存分配策略不一致,甚至不告诉你什么时候释放了;
    • 某些优化只能在调试关掉时触发,导致开发版本和发布版本表现差异极大。

总结

  • Windows 平台下的显卡驱动,并不像文档描述的那样“可靠”和“透明”;
  • 它们会做很多表面上“优化”的事情,实际是为系统或硬件本身服务,而不是为我们的程序服务;
  • 驱动之间行为差异极大,即使写出了标准 API 的代码,也不能保证在所有用户机器上表现一致;
  • 开发图形程序时,必须非常小心对待这些驱动带来的“帮助”,很多时候它们是潜在的问题源头。

这也是为什么图形编程中调试和兼容性测试占了如此大比重,我们不仅要写对的代码,还要设法与驱动共处绕过驱动的坑理解驱动行为,才能构建一个真正稳定、可用的系统。

黑板:资源存储

我们当前所面临的情况,主要问题已经很清楚了,而我们接下来要处理的,是一个虽然次要但同样关键的问题,而且幸运的是,只要我们解决了主要问题,这个次要问题也会随之得到修复。


当前问题概述

我们现在的机制是在绘制某个纹理时才检查它是否已经上传到 GPU。如果没上传,就立刻提交到 GPU 并使用这个纹理。这个方式存在两个关键问题:

  1. 上传延迟导致帧阻塞
    大纹理(例如 1920x1080 这种)在首次上传时,GPU 必须等待整个纹理传输完成后才能开始渲染这一帧,严重影响实时性。

  2. 纹理从不清除,导致内存泄漏
    GPU 纹理内存中,一旦纹理被上传,就永远不会被删除。即使我们在 CPU 的纹理缓存中已经将其驱逐出去,对应的 GPU 资源仍然残留,最终导致 GPU 内存不断膨胀,直到耗尽为止。


CPU 和 GPU 纹理缓存不同步的问题

我们现在使用的是一个固定大小的 CPU 纹理缓存。其运作机制是:

  • 当有新的纹理需要时,先检查 CPU 缓存;
  • 如果缓存已满,就基于 LRU(最近最少使用)算法驱逐一个旧纹理;
  • 新纹理就会占据被驱逐位置;

但是:我们从未同步告诉 GPU 删除对应纹理!
结果就是 GPU 中的纹理越来越多,即使它们在 CPU 缓存中早已不复存在。


最终后果:系统资源耗尽

  • GPU 纹理内存不断填充,不断累积未使用的纹理;
  • 最终会导致 GPU 内存耗尽;
  • 显卡驱动只能开启“回退机制”,例如自动启用主内存作为纹理备份(backing store);
  • 但这也会进一步消耗主内存,直到整个系统内存用尽;
  • 游戏表现为贴图灰屏、闪退、严重卡顿或纹理加载失败。

至少必须做的事:同步清除纹理

即使我们不做更加完善的资源管理机制,也至少必须确保

  • 每当我们在 CPU 的纹理缓存中驱逐一个纹理;
  • 都必须同时通知 GPU,删除其对应的 GPU 纹理资源
  • 否则我们的程序在运行一段时间后,必然会陷入严重的内存危机。

小结

  • 我们当前的纹理上传机制存在两个问题:上传延迟纹理残留
  • 解决上传延迟问题时,也顺带可以解决纹理残留问题;
  • 纹理资源管理必须 CPU 和 GPU 同步进行,否则容易引发不可预期的问题;
  • 即使没有引入更复杂的纹理流式系统,同步清理已被驱逐的 GPU 资源 是一个最低限度必须完成的任务。

我们接下来需要做的,就是确保这两部分资源始终保持一致,并开始为更好的纹理管理系统做准备,例如异步上传和多线程预加载机制。

黑板:CPU 资源存储如何工作

目前的 GPU 资源存储(rcp you asset store) 的工作方式基本上是一个通用的分配器。具体来说,它会把资源放入存储中,当有空闲空间时,如果释放了足够的资源,系统会将新资源放入这些空闲位置。这样,我们的存储系统就能按需使用空间。

当前实现存在的问题:

  1. 分配器实现不完善
    当前的资源存储机制虽然可以工作,但它并不是最优化的。我们把资源放入存储后,没有进行更多智能的处理和优化。这种做法是为了简单快速地实现功能,但并不考虑更复杂的性能需求。

  2. 不考虑性能优化
    现在的存储方式并没有进行过度优化。我们暂时没有针对 GPU 资源存储进行深入的性能测试,因为我们目前还不清楚存储的具体需求是什么。所以,现在采取的是一种基础且简化的实现方式,等游戏运行并通过性能测试后,再根据实际情况来决定是否需要做更多优化。

避免过早优化的原因:

  1. 避免过早优化导致浪费
    在没有足够数据支撑的情况下进行优化是不可取的。这是一个常见的编程原则:过早的优化是万恶之源。我们不能在不知道系统需求的情况下,就开始做大量的性能优化,因为可能做出的优化并没有解决实际问题。反而可能会浪费时间和资源,且最后这些优化可能会被证明是无效的。

  2. 需要真实数据来指导优化
    如果没有真实的游戏运行数据,根本不知道资源存储的具体性能瓶颈在哪里。只有等游戏完全运行并进入实际的性能测试后,才能根据实际需求来进行针对性的优化。所以,应该等到有足够的数据和实际表现后,再决定是否需要优化以及优化的方向。

  3. 避免做无意义的工作
    如果在游戏开发的初期就开始过度优化系统,可能会导致在后期必须放弃这些优化,因为它们并没有解决真实的问题,甚至可能是导致其他问题的根源。

小结:

当前的 GPU 资源存储系统是一个基础的实现,我们并没有过早对其进行优化。未来是否进行优化,要等到游戏完全运行并经过性能测试后,根据真实的数据和需求来决定。这个做法遵循了编程中的经典原则:“过早优化是万恶之源”。

黑板:GPU 资源存储

现在的 GPU 资源存储(GPU asset store)其实是一个通用的分配器,可以帮助管理资源。其核心目标是让 GPU 驱动去管理资源的分配和回收,但目前的实现方式比较简化。这里有两个主要的方向,可以在将来优化和调整。

当前实现的问题:

  1. GPU 资源存储的工作方式
    现在的 GPU 资源存储相当于一个通用的内存分配器,负责管理 GPU 内存中不同的资源。每当分配一个资源时,系统会获取 GPU 返回的资源句柄并将其存储在分配器中。如果不涉及软件渲染,实际上可以将 GPU 资源存储改成一个固定大小的内存分配器,它的唯一任务就是记录 GPU 返回的句柄。

  2. 是否需要优化
    目前的资源存储分配并没有进行很大的优化。具体来说,如果未来不需要关注软件渲染的表现(因为软件渲染主要是为了教育目的,实际游戏运行中 GPU 渲染会更快),我们可以将资源存储机制简化为一个固定大小的分配器,它会从 GPU 中获取句柄,然后直接管理这些句柄的分配和释放。这样就不需要再实现复杂的优化,也不需要堆叠多个通用的内存分配器,毕竟 GPU 驱动本身已经在做这些工作。

  3. 不需要额外优化的原因
    由于 GPU 驱动本身已经在管理内存分配,并且 GPU 渲染本身要比软件渲染快得多,因此如果不涉及软件渲染,我们完全可以简化资源存储的管理机制。过度优化可能没有太大意义,反而会增加复杂性,而 GPU 驱动已经处理了大部分内存分配工作。

下一步计划:

为了进一步提高资源管理效率,需要在当前的代码中进行调整,尤其是将资源下载到 GPU 的过程与主线程分离。为了做到这一点,我们计划将资源下载过程放入独立的线程中,这样可以避免主线程被阻塞,进而提高渲染的效率。

线程化资源加载

我们目前的资源加载机制已经实现了多线程,下一步就是利用这些线程来加载纹理并将其尽早发送到 GPU 中。这是为了确保纹理尽快加载,避免在需要使用这些纹理时遇到延迟,从而影响渲染性能。

短期目标

虽然时间不多,但我们可以开始分析现有的代码,看看如何将资源下载和加载流程合理地线程化,以便在主线程中能够更高效地使用这些资源。在这里,确保我们所有的代码都在同一页面上,明确目标是非常重要的。

总的来说,当前的 GPU 资源存储机制基本上是一个简化版的内存分配器,未来的改进可能会集中在简化内存管理,避免过早优化,专注于合理利用 GPU 驱动的内存管理能力,并通过多线程加速纹理下载过程。

game_opengl.cpp:我们当前的加载方式

在当前的实现中,纹理绑定的过程在OpenGL文件中的render entry bitmap部分进行。这是一个典型的“及时加载”机制,即当需要绘制一个纹理时,系统首先会检查该纹理是否已经被加载到GPU中,并且是否有对应的句柄。如果有句柄,表示该纹理已经上传到GPU,系统只需通知GPU准备好绘制该纹理;如果没有句柄,表示该纹理还未上传,系统需要重新绑定一个新的纹理并将其传输到GPU。

这个过程其实正是我们所关心的问题,尤其是在纹理下载的过程中,如何避免由于纹理传输的延迟而导致绘制过程卡顿。具体来说,glTexImage2D 这一部分代码负责将纹理数据从内存传输到GPU。这一过程是异步的,因此我们并不知道传输需要多久完成,为了防止这一步骤阻塞渲染,我们希望能够重叠纹理下载和渲染操作。

值得注意的是,很多现代显卡(从GeForce 500系列及以上的显卡开始)都配备了专门的复制引擎,这些复制引擎的作用就是在图形渲染过程中异步地将纹理数据传输到GPU,而不会影响渲染的正常进行。这样一来,在现代硬件上,纹理的传输过程实际上是可以与渲染操作并行进行的,而不会对帧的绘制造成明显的卡顿。

总的来说,问题的核心是如何在传输纹理到GPU时避免阻塞渲染,而现代显卡的硬件设计已经为此提供了很好的支持。通过利用这些硬件特性,能够有效地实现纹理下载与渲染操作的重叠,提升整体的渲染效率。

在这里插入图片描述

game_asset.cpp:回顾 LoadBitmap 和 LoadAssetWorkDirectly 的作用

当前的问题在于,我们希望能够异步地将纹理数据传输到GPU,但OpenGL本身并没有设计来支持这种异步操作。OpenGL的设计假设每个线程只能有一个活动的上下文,这意味着无法在多个线程之间共享同一个OpenGL上下文。因此,如果我们尝试在一个线程中执行glTexImage2D(一个将纹理上传到GPU的OpenGL函数),而该函数的调用发生在主线程之外,OpenGL会无法正确处理,因为主线程和子线程无法共享同一个上下文。

具体来说,当前的实现使用了异步加载的方式,例如在game_assets.cpp文件中的LoadBitmap函数。该函数负责加载纹理文件,并创建一个包含所有必要信息的工作结构。当需要加载一个资源时,会调用这个工作结构来加载文件内容并判断该资源是否能够直接使用。如果是位图类型,系统会进一步处理它并执行glTexImage2D,这时会将纹理数据传输到GPU。

在理想情况下,我们可以在加载位图资源时直接将纹理数据传输到GPU,但问题在于glTexImage2D调用依赖于当前线程的OpenGL上下文,这就限制了我们无法简单地将其移动到另一个线程进行异步处理。每个OpenGL上下文只能在一个线程中使用,因此无法在不同的线程之间共享这个上下文。

尽管我们有了大部分必要的信息来执行纹理上传(例如纹理的宽度、高度、数据类型和内存位置等),但由于OpenGL的上下文限制,我们无法直接在后台线程中执行这些操作。如果没有这些上下文限制,我们本可以将纹理上传过程移至另一个线程,从而避免主线程被阻塞。但是,由于OpenGL的限制,我们必须找到一种方法来解决这个问题,才能实现异步上传纹理数据的目标。

目前的方案可能需要在不同的线程之间管理OpenGL上下文,或使用某种方式将纹理上传过程与渲染分离,这样才能在不阻塞主线程的情况下执行纹理的上传操作。
在这里插入图片描述

game_opengl.cpp:将 glTex* 调用移动到 game_asset.cpp,并考虑如果纹理未准备好就暂停帧

为了实现异步纹理加载和渲染优化,首先需要调整当前的代码结构。目标是将纹理的绑定操作与主渲染流程分离,使得纹理的上传过程能够在后台线程中执行,而不阻塞主渲染线程。在此过程中,首先会进行一些调整,例如:

  1. 简化代码结构:将与纹理绑定相关的代码移到渲染路径的开头,避免在渲染的过程中反复执行与纹理处理相关的逻辑。每当需要渲染一个纹理时,直接绑定该纹理。如果纹理句柄为零,则说明该纹理还未加载,系统可以选择不绘制该纹理,直接跳过这次绘制操作。

  2. 处理纹理加载的延迟:如果某个纹理尚未加载,系统有两种选择。第一种是暂停当前帧,等待纹理加载完成再继续绘制。第二种是牺牲渲染质量,允许渲染帧继续进行,尽管某些纹理可能未能加载完成。这时,主线程将继续以60帧每秒的速度进行渲染,尽管某些纹理可能会出现未加载的情况。

  3. 清理OpenGL代码:当前的代码中涉及OpenGL函数调用,这样做的问题在于我们不确定当前代码是否真的运行在OpenGL环境中,可能在非OpenGL平台上运行。因此,我们需要将OpenGL相关的代码隔离出来,确保代码路径清晰,并将OpenGL的调用封装成独立的模块。这样,渲染逻辑与OpenGL的具体实现将相互解耦,保持代码的可维护性。

  4. 处理OpenGL上下文:另一个关键问题是如何在异步操作中使用OpenGL。由于OpenGL不允许在多个线程中共享同一个上下文,我们需要为纹理加载过程创建一个新的OpenGL上下文,以便可以在后台线程中执行纹理的上传操作。这意味着我们必须在后台线程中为纹理上传操作提供一个独立的OpenGL上下文,使得纹理的下载过程能够独立于主线程进行。

由于时间限制,当前无法完全实现这一目标,但已经开始为后续的实现做准备。通过整理和封装OpenGL操作,可以为未来的异步纹理加载奠定基础,同时确保渲染主线程不会被阻塞,提升渲染性能。

在这里插入图片描述

在这里插入图片描述

win32_game.cpp:将图片的各个部分拼接起来

在讨论OpenGL上下文的创建和线程管理时,核心任务是确保每个可能需要调用 glTexImage2D 的线程都拥有一个与主上下文共享的OpenGL上下文,以便能在不同线程间进行纹理上传等操作。

首先,在创建OpenGL上下文时,涉及到的操作包括:

  1. 上下文的创建:最初,创建了一个OpenGL 1.0上下文,然后为了支持更现代的OpenGL版本,切换到OpenGL 4.0以上的版本。这个过程中,涉及到的上下文共享是非常关键的。具体来说,通过设置共享上下文,确保在一个线程中上传的纹理可以被其他线程访问。
    在这里插入图片描述

  2. 共享上下文:在OpenGL中,多个上下文可以共享同一块内存,这样当纹理上传到一个上下文时,其他上下文可以直接访问这个纹理。在多线程环境中,我们希望创建的每一个线程都有一个共享的OpenGL上下文,这样各个线程都能下载纹理而不发生冲突或失败。
    在这里插入图片描述

  3. 线程管理:在创建多个线程时,每个线程都需要与OpenGL上下文关联,否则当调用 glTexImage2D 等OpenGL函数时,操作会失败。为了处理这个问题,必须确保每个工作线程在执行时,都能访问到已共享的OpenGL上下文。
    在这里插入图片描述

在这里插入图片描述

  1. 工作队列:每个工作线程会从工作队列中获取任务,当任务是与纹理相关时,它会调用 finalizeAsBitmap 来处理纹理。此时,线程需要一个有效的OpenGL上下文来执行纹理上传操作。如果没有正确的上下文,这些操作将无法成功。

因此,为了支持多线程纹理上传操作,需要为每个工作线程创建一个OpenGL上下文,并确保这些上下文之间是共享的。这样,每个线程就能在没有阻塞主线程的情况下,异步地上传纹理到GPU。同时,必须确保驱动程序支持多个线程同时下载纹理,才能避免因资源争用导致的潜在问题。

win32_game.cpp:在 ThreadProc 中调用 Win32CreateOpenGLContextForWorkerThread

在多线程环境中,若要实现纹理数据的异步上传到GPU,必须为每个工作线程创建独立的、共享主上下文的OpenGL上下文。这是实现高效资源管理和避免阻塞主线程的关键步骤。以下是具体的分析与操作流程:


1. 在工作线程进入工作循环前创建OpenGL上下文

在每个工作线程执行任务之前,必须确保该线程拥有自己的OpenGL上下文。这个上下文应与主线程的OpenGL上下文共享资源,以便纹理数据上传后能被渲染线程正确访问。创建流程大致如下:

  • 在线程启动后、进入任务处理循环前,调用 Win32CreateOpenGLContextForWorkerThread(或类似命名的函数)。
  • 该函数需使用平台相关的OpenGL上下文创建API(例如WGL的 wglCreateContextAttribsARB)创建新的上下文。
  • 创建上下文时,需指定共享上下文参数,确保资源一致性。

2. 平台独立性和调用OpenGL代码的隔离

为了保持平台代码与OpenGL代码的独立性,避免在通用逻辑中硬编码OpenGL函数调用,需做如下设计:

  • 在线程初始化时调用平台层代码完成OpenGL上下文的创建与绑定(例如通过 Win32 平台层调用)。
  • 在资源加载逻辑中,仅进行标记或触发机制(如将需要上传的数据加入任务队列),由线程内已设置好的OpenGL上下文完成实际上传。

3. 避免纹理上传直接插入核心逻辑

不建议直接在 FinalizeBitmap 等函数中插入 glTexImage2D 调用,原因如下:

  • 这将导致平台无关逻辑中掺杂OpenGL平台相关代码,破坏模块划分。
  • 可能引入不可控的延迟和错误,特别是在没有有效上下文时调用OpenGL API。

更好的方案包括:

  • 方案一:在平台层提供一个包装接口,供资源系统在必要时触发纹理上传操作。
  • 方案二:引入一个上传队列,将纹理数据标记并加入队列,由另一个专门用于上传的线程集中处理。

4. 线程安全与上下文有效性

由于OpenGL要求每个上下文在任何时间只能绑定一个线程,因此:

  • 每个线程必须拥有独立的上下文。
  • 这些上下文需在创建时通过 wglShareListswglCreateContextAttribsARB 等方式与主上下文共享。
  • 上传线程每次处理纹理前,需将自己的上下文设置为当前(使用 wglMakeCurrent)。

5. 总结

我们当前的目标,是将纹理上传过程从主线程中剥离出来,转移到后台线程并实现异步执行。为此,必须:

  • 确保每个后台线程拥有共享的OpenGL上下文。
  • 保持平台相关逻辑与核心游戏逻辑的隔离。
  • 可能引入专门的上传线程或上传任务队列,进一步解耦资源加载流程。

这样的结构将确保即便在主线程需要高性能渲染时,也能并行处理大量纹理数据上传,提升整体渲染系统的吞吐效率和响应能力。

在这里插入图片描述

在这里插入图片描述

win32_game.cpp:引入 Win32CreateOpenGLContextForWorkerThread

我们需要为每个工作线程创建一个独立的 OpenGL 上下文,并确保它们共享主上下文的资源,这样这些线程才能执行纹理下载操作。为此,我们要做的是以下几个关键步骤,下面是对整个流程的详细梳理与总结:


1. 创建用于工作线程的 OpenGL 上下文函数

我们需要新写一个函数,例如 Win32CreateOpenGLContextForWorkerThread,这个函数的唯一职责就是创建 OpenGL 上下文。核心逻辑几乎和主上下文创建一致,但有以下区别:

  • 必须传入共享上下文:我们不能像主上下文那样传 0,而是要传入一个已有的上下文(例如主线程上下文),以实现资源共享。
  • 可能需要传入窗口的 DC(设备上下文):虽然这个上下文不会直接渲染到窗口,但根据参考文档的建议,传入有效的 DC 是合理且推荐的做法。

2. 如何获取所需参数

要创建上下文,我们需要两个关键数据:

  • 共享上下文(OpenGL RC)
  • 窗口设备上下文(Window DC)

我们可以在初始化主窗口时保存这些值,并在之后需要为工作线程创建上下文时传递过去。


3. 全局保存共享上下文和窗口 DC

为了让每个线程都能访问到这些信息,我们可以:

  • 在初始化 OpenGL 的过程中将 HGLRC(OpenGL 渲染上下文)和 HDC(设备上下文)保存到全局或结构体中。
  • 将这些信息作为参数传递给线程队列的初始化函数,使线程在启动时可以使用这些值创建自己的上下文。

4. 修改线程队列初始化流程

目前线程队列是在创建窗口之前初始化的,因此拿不到窗口的句柄 HWND 和设备上下文。我们需要调整初始化顺序:

  • 先创建窗口
  • 再初始化线程队列
  • 此时我们就可以将窗口句柄、OpenGL 上下文、设备上下文传给线程队列的初始化函数

这样每个线程就可以在启动时:

  • 拿到这些参数
  • 使用它们创建共享 OpenGL 上下文
  • 将上下文设为当前上下文(使用 wglMakeCurrent

5. 统一 OpenGL 上下文属性

我们使用一套统一的上下文属性 attribs,可以将它提取为全局常量(例如 Win32OpenGLAttribs),以供所有上下文创建共享使用。这样可以确保所有 OpenGL 上下文一致,避免兼容性问题。


6. 创建失败的处理

如果创建上下文失败(wglMakeCurrent 不成功),这是一个致命错误:

  • 因为我们依赖该线程上传纹理到 GPU
  • 如果没有上下文,该线程将无法进行任何 GPU 操作
  • 此时程序可能需要直接终止或发出严重错误提示

7. 总结操作步骤

最终的操作步骤如下:

  1. 主线程初始化窗口,保存 HWNDHDC
  2. 初始化主 OpenGL 上下文,保存 HGLRC
  3. 提取上下文属性为全局常量
  4. HDCHGLRC 传递给线程队列创建函数
  5. 每个工作线程启动时使用这些值创建自己的共享上下文
  6. 将上下文设为当前上下文,准备执行纹理上传

通过这种方式,我们可以实现多线程环境下安全、正确的 OpenGL 纹理异步上传,避免阻塞主线程渲染流程,提高系统响应效率。整个系统也更具可扩展性和清晰的模块边界。

https://developer.download.nvidia.com/GTC/PDF/GTC2012/PresentationPDF/S0356-GTC2012-Texture-Transfers.pdf
在这里插入图片描述

在这里插入图片描述

win32_game.cpp:引入结构体 win32_thread_startup

我们现在的目标是:当线程实际启动并执行任务处理函数(ThreadProc)时,确保线程拥有创建 OpenGL 上下文所需的全部数据。因此,我们计划对现有结构和初始化方式进行扩展和调整。以下是详细思路与操作步骤的总结:


1. 线程初始化参数结构扩展

我们计划定义一个新的结构体,例如 Win32ThreadStartup,用于在线程启动时传递更多的信息。这个结构体将包含:

  • PlatformWorkQueue:用于线程处理工作的任务队列(原有功能)
  • HWND WindowHandle:窗口句柄,用于获取 HDC
  • HGLRC SharedGLRC:用于与主线程共享资源的 OpenGL 上下文

这样,每个线程在创建之初就拥有创建共享上下文所需的全部数据。


2. 改造线程启动流程

原先线程通过一个比较简单的参数(如平台工作队列)进行启动。现在我们要把这个流程改造成:

  • 创建 Win32ThreadStartup 实例
  • 填入窗口句柄、主线程共享 OpenGL 上下文、平台队列等数据
  • 将这个结构体作为参数传入线程函数

这样线程函数启动后,就可以从这个结构体中提取需要的数据,并完成 OpenGL 上下文的初始化。


3. 区分主线程和工作线程数据

考虑到并不是每个线程都需要进行 OpenGL 操作(比如只有低优先级的队列需要执行纹理上传),我们会为有需要的线程传入完整的 WindowHandleSharedGLRC,而其他线程可以传入空值或默认值表示它们不需要这些功能。


4. 初始化线程时的数据构造

在主程序中初始化线程的时候,我们将:

  • 先创建窗口,获取 HWND
  • 初始化主 OpenGL 上下文,获得主 HGLRC
  • 构建包含这些信息的 Win32ThreadStartup 数据
  • 在线程创建时传入这个数据

5. 线程内创建共享 OpenGL 上下文

在线程函数 ThreadProc 内部:

  • 提取 Win32ThreadStartup 中的信息
  • 使用提供的 HWND 获取设备上下文 HDC
  • 调用封装好的函数 Win32CreateOpenGLContextForWorkerThread 来创建共享 OpenGL 上下文
  • 设置当前上下文为刚刚创建的上下文(wglMakeCurrent

这样线程就具备了执行 OpenGL 操作(例如上传纹理)的能力。


6. 回退机制(可选)

如果 wglMakeCurrent 或上下文创建失败,可以判断为严重错误。目前我们的判断是:

  • 如果无法创建上下文,就无法上传纹理
  • 整个系统将丧失异步纹理传输能力
  • 程序可能无法正常运行,需记录错误并中止初始化或给出错误提示

7. 总结核心变更点

  • 定义新的结构体 Win32ThreadStartup,统一线程初始化所需的数据
  • 修改线程创建方式,使用完整的结构体作为参数
  • 在线程中创建与主上下文共享资源的 OpenGL 上下文
  • 保证所有工作线程都能正确进行 GPU 操作(如上传纹理)

通过这一系列改造,我们建立了一个灵活且可扩展的多线程 OpenGL 初始化机制,为实现异步纹理上传等图形优化任务打下了稳定的基础。整个方案保持平台层和工作队列逻辑的清晰分离,同时最大限度地复用了已有的初始化逻辑与上下文管理方案。

在这里插入图片描述

win32_game.cpp:将 Startup 直接传递给 Win32MakeQueue

我们目前已经处于一个关键的位置,可以填充用于线程启动的结构体了。接下来的工作是确保每个线程在创建时都能正确接收到其专属的启动信息。下面是详细的中文总结:


1. 统一线程初始化结构体传递逻辑

我们设计了一个新的结构体 Win32ThreadStartup,用于封装线程启动所需的数据。该结构体中包含:

  • 线程要处理的任务队列指针(PlatformWorkQueue
  • 窗口句柄(HWND
  • 主 OpenGL 上下文句柄(HGLRC

在整个初始化过程中,我们始终会传入这个结构体,因此我们可以认为每个线程在启动时都会收到一个有效的 Win32ThreadStartup 实例。


2. 修改线程创建逻辑

在线程创建时:

  • 不再单独传入工作队列,而是将其作为结构体的一部分传入
  • 直接将 Win32ThreadStartup 实例作为线程函数的参数传递
  • 保证每个线程获得唯一的、属于自己的结构体实例,防止线程竞争访问共享数据

3. 在线程函数中提取数据

线程实际运行后,在 ThreadProc 中:

  • 通过参数 lpParameter 拿到传入的 Win32ThreadStartup 实例
  • 从中提取窗口句柄、OpenGL 上下文等信息
  • 根据这些信息创建并设置线程自己的 OpenGL 上下文

4. 避免多线程数据竞争问题

由于多个线程几乎同时启动,每个线程都必须有自己独立的 Win32ThreadStartup 实例。不能直接引用共享的结构体或数组中的某一项,因为可能还没被正确初始化或者已经被另一个线程读取。

我们必须:

  • 在主线程中为每个线程单独构造一个 Win32ThreadStartup 实例
  • 确保在传入线程之前,这些实例的数据已经全部准备好
  • 不共享指向相同内存的结构体指针

5. 优化结构体传递方式

因为结构体中已经包含了我们所需的所有信息,我们可以完全取消以往只传入队列指针的旧做法,直接用结构体作为唯一参数。这让代码结构更加统一、逻辑更加清晰。


6. 后续步骤准备

接下来我们将会:

  • 在主线程中为每个工作线程构造并传入 Win32ThreadStartup
  • 在线程中创建 OpenGL 上下文,并调用 wglMakeCurrent 设置为当前上下文
  • 使线程具备进行纹理上传等 OpenGL 操作的能力
  • 确保整套机制能够在多线程中稳定运行

通过这套机制,我们实现了一个线程安全、结构清晰的 OpenGL 多上下文系统,使每个线程都能在自己的上下文中独立执行图形操作,同时与主上下文共享资源。这为后续的多线程纹理处理打下了坚实基础。
在这里插入图片描述

win32_game.cpp:撤销并改为引入 GlobalOpenGLRC 和 GlobalDC

我们原本打算通过为每个线程传递一个包含必要信息的结构体(如 OpenGL 上下文、窗口句柄、设备上下文等)来完成初始化操作,但实际过程中发现这种做法带来了很多问题。下面是这部分内容的详细中文总结:


1. 原结构体传递方案的问题

我们起初尝试为每个工作线程分配一个专属的结构体,其中封装了:

  • 线程关联的工作队列指针
  • OpenGL 上下文句柄(HGLRC
  • 窗口句柄(HWND
  • 设备上下文句柄(HDC

但这样做导致了一些复杂问题:

  • 每个线程都必须拥有独立的结构体副本,不能共享,否则数据会被覆盖或读取冲突
  • 管理多个结构体副本变得很繁琐
  • 对线程函数的调用方式也变得臃肿复杂
  • 新增逻辑不但没有减少 bug,反而引入了更多潜在问题

因此我们决定废弃这种做法。


2. 转而使用全局变量

我们重新思考策略,决定使用全局变量来保存关键资源,这些资源在程序运行期间并不会更改:

  • 全局保存的 OpenGL 上下文句柄(globalHGLRC
  • 全局保存的设备上下文句柄(globalHDC

在 OpenGL 初始化完成之后,我们直接将这些句柄存储到全局变量中,供后续所有线程统一访问。


3. 优势与实现方式

这种方式的优势在于:

  • 所有线程都可以方便访问这些共享资源
  • 初始化逻辑更清晰
  • 避免了线程间传递结构体带来的同步和生命周期管理问题
  • 减少了代码冗余和复杂度

具体实现时:

  • Win32InitOpenGL 函数中,不再释放获取的 HDC,而是存入全局变量
  • 创建 OpenGL 上下文后,同样保存该 HGLRC 到全局变量中
  • 每当有线程需要设置上下文,只需调用封装函数 CreateOpenGLContextForWorkerThread,它从全局变量中获取数据完成配置

4. 注意事项与容错

虽然这种方式更简洁,但也有前提条件:

  • 全局变量在任何线程使用前必须已经正确初始化
  • 初始化必须在多线程创建之前完成
  • 如果创建 OpenGL 上下文失败,将导致整个图形功能无法运行,这是一个“致命错误”场景,程序应当停止或降级运行

5. 相关函数的调整

我们对相关函数做了如下调整:

  • CreateContextARB 返回的 OpenGL 上下文不再是局部变量,而是直接保存到 globalHGLRC
  • 获取的窗口 HDC 不再立即释放,而是保存到 globalHDC
  • CreateOpenGLContextForWorkerThread 不再接收参数,而是直接引用全局变量完成配置

6. 当前问题排查

我们发现最后函数签名可能存在不一致的问题,比如传入窗口句柄未被使用等小错误。这些会在后续调试中进一步完善和修复。


总结

我们将线程初始化信息从“结构体传参”方式切换为使用“全局变量”,从而显著简化了线程资源配置的流程,降低了多线程同步复杂度。这一更改虽然在架构上退回了一步,但实际效果上却更为稳健、安全,也更适合当前上下文中的 OpenGL 使用模式。
在这里插入图片描述

运行游戏,但什么也没看到

我们目前的系统已经重新编译并能够运行,从理论上讲已经具备多线程上下文的创建能力。但是,由于尚未真正实现纹理的下载和处理,屏幕上并不会显示任何内容。因此尽管底层线程机制看似运作正常,整体功能仍处于未完成状态。


当前存在的问题与缺失

  1. 纹理资源尚未加载:
    虽然我们创建了多个工作线程,理论上具备处理能力,但目前没有任何线程真正去处理或下载纹理资源。也就是说,尽管上下文存在,但未利用这些上下文去上传或处理图像数据。

  2. 渲染结果不可见:
    因为没有纹理资源,渲染阶段自然无法展示任何实际图像。这导致屏幕显示为空,视觉上看起来程序似乎“什么都没做”。


下一步工作

  1. 完善资源加载流程:
    我们需要进入资源管理部分(asset file)并开始实现纹理的加载逻辑。这一步很关键,它将确保有实际的数据送入 GPU 进行渲染。

  2. 验证 OpenGL 上下文使用是否正确:
    除了资源加载,我们还需确认以下操作是否成功:

    • 每个工作线程是否成功获取了 OpenGL 上下文
    • wglMakeCurrent 调用是否生效
    • 上下文之间是否真正可以共享资源
  3. 补全多线程渲染支持:
    确保每个工作线程在正确的上下文下执行 OpenGL 操作,并能安全地进行资源上传。所有上下文之间的共享机制必须经过测试验证,确保不会引起崩溃或数据不同步的问题。


当前阶段评估

目前系统的底层线程机制和 OpenGL 上下文支持看起来是架构合理的,但尚未完成任何实际可见的渲染工作。下一步的重点是连接纹理资源加载逻辑与线程上下文初始化逻辑,确保它们可以协同工作,最终实现纹理的异步上传和显示。

总的来说,我们还处于比较初级的阶段,虽然已经打下了一定的技术基础,但离实际的渲染输出还有一段关键的开发工作要做。接下来需要逐步推进资源加载、上下文验证以及线程安全机制,直到系统能够正确渲染纹理内容。
在这里插入图片描述

win32_game.cpp:在 platform_work_queue 中添加 bool32 NeedsOpenGL

目前我们对 OpenGL 上下文的初始化和绑定逻辑做了进一步的优化,主要是为了避免对不需要使用 OpenGL 的线程进行无谓的上下文创建操作,简化系统结构,提升效率。


当前的工作内容与优化思路

  1. 明确哪些线程需要 OpenGL:
    只有低优先级线程才会进行资源下载(例如纹理),因此只有这些线程才需要绑定 OpenGL 上下文。高优先级线程(如主线程或计算密集型线程)并不涉及资源上传工作,故无需进行 OpenGL 上下文创建。

  2. 扩展 PlatformWorkQueue 的结构:
    PlatformWorkQueue 中新增了一个字段,例如 NeedsOpenGL 或类似的布尔变量,用于标记该队列是否需要支持 OpenGL。通过这个标志,我们可以在创建线程时判断是否需要附加 OpenGL 初始化流程。

  3. 线程初始化中做条件判断:
    在每个线程启动时,检查所属队列是否需要 OpenGL。如果 NeedsOpenGL 为 true,就执行 OpenGL 上下文的创建、设置等逻辑,否则直接跳过,避免不必要的资源分配。


创建队列时设置标志位

  • 高优先级队列:
    创建前将 NeedsOpenGL 设置为 false,表示它不进行资源处理,不需要 OpenGL。

  • 低优先级队列:
    创建前将 NeedsOpenGL 设置为 true,因为这些线程会处理诸如纹理下载等图形相关任务,必须拥有上下文。

这样一来,在调用 Win32MakeQueue 函数时,我们能根据实际用途动态设置队列是否需要 OpenGL,从而合理分配资源并优化初始化过程。


总结现阶段结果

  • 成功为线程队列系统引入了“是否需要 OpenGL”的判定机制。
  • 避免为所有线程都创建 OpenGL 上下文,减少了开销。
  • 保持了系统逻辑上的清晰性,使得线程行为更明确、更可控。
  • 后续还可以进一步优化,如根据线程的具体任务动态调整队列标志,甚至在运行时切换。

这一步的优化为系统的资源管理和初始化打下了更加稳固的基础,尤其是在多线程与图形上下文交叉使用的场景中显得尤为重要。后续工作将主要集中在确保线程按需执行图形操作,同时保持系统的稳定性与性能。
在这里插入图片描述

运行游戏,依然看不到任何东西

现在系统的 OpenGL 上下文创建逻辑已经更加正确,仅为真正需要图形处理的线程创建了两个 OpenGL 上下文,避免了不必要的资源浪费,整体结构也变得更加清晰。


当前状态总结:

  1. OpenGL 上下文初始化完成:
    成功地为两个工作线程各自创建了独立的 OpenGL 上下文,并且正确地根据线程是否需要处理图形资源来决定是否需要上下文。

  2. 尚未处理纹理下载:
    当前系统中还没有真正实现纹理下载逻辑,资产系统并未调用或触发任何资源加载功能,因此即使上下文已经就绪,也还没有使用它们去处理图像数据或 GPU 上传等工作。


临时方案:引入基础测试用的回调函数

为了快速验证整套系统流程是否正确,可以暂时添加一个简单的回调函数作为测试,这样能立刻观察多线程环境中 OpenGL 上下文是否工作正常。

示例方案:
  • 定义一个简单的测试回调函数,在其中调用一个基础的 OpenGL 函数,例如生成或绑定一个纹理,确保上下文有效。
  • 将该回调加入低优先级的工作队列中,确保该线程拥有 OpenGL 上下文权限。
  • 观察是否触发错误,如上下文丢失、非法调用等。

该测试方案虽然简单,但足以验证当前结构的可用性。


后续可以计划的步骤:

  1. 在资产系统中正式集成资源加载逻辑:
    包括纹理、模型或着色器的读取、解析与上传流程。

  2. 构建资源调度与缓存系统:
    让加载过程具备状态控制,避免重复上传,增加性能控制点。

  3. 增加线程资源安全性校验与容错机制:
    尤其是在 OpenGL 上下文切换或销毁时,需确保不会误操作或越权访问。


小结

虽然当前还未真正触发任何资源加载操作,但系统结构已基本成型。借助一个简易的回调测试机制,可以快速验证多线程 OpenGL 支持是否可靠,为后续正式资源处理打下坚实基础。下一步只需将资产系统与图形资源操作对接,就能实现完整的加载流程。整体来看,工作进度已步入关键节点,后续将更聚焦于实用功能与系统稳定性构建。
在这里插入图片描述

game_platform.h:在 platform_api 中添加 platform_allocate_texture 和 platform_deallocate_texture,并添加相应调用

为了让 OpenGL 在纹理提交和释放上能够正确运作,需要确保平台代码中有机制能被触发,以实际完成纹理下载与显卡上传的过程。整个系统需要具备一种方式来触发这些平台层的行为,并能够反映图像资源在图形硬件上的状态变化。


当前实现逻辑与设计思路:

  1. 目标:
    实现平台层纹理上传和释放的接口,使其可以从资产系统被调用,从而完成对 GPU 的操作。

  2. 结构划分清晰:
    已将整个调用流程分为两大类函数:

    • 一类仅用于调试用途。
    • 一类属于平台层暴露给游戏逻辑的正式接口,数量保持精简,避免 API 泛滥。
  3. 接口简化优化:
    当前的接口中存在过多不必要的函数,因此进行了精简,只保留对游戏逻辑真正有意义的操作接口,避免冗余暴露。


新增接口的设计思路:

  • 提出一种新的接口设计方式,类似于统一的“纹理分配和释放”机制。
  • 将纹理提交过程简化为两个操作:
    1. AllocateTexture —— 向平台层请求上传纹理数据。
    2. DeallocateTexture —— 向平台层通知释放指定纹理资源。
AllocateTexture:
  • 接收纹理的 宽度高度 以及 数据指针
  • 返回值为一个纹理句柄(handle),可供后续引用和管理。
  • 不涉及额外复杂的状态数据,仅关注实际需要传递的基础纹理信息。
  • 未来如需支持压缩格式、mipmap 等特性,可在此基础上扩展。
DeallocateTexture:
  • 接收纹理句柄作为参数。
  • 仅需要知道需要释放哪个纹理,不需额外参数,接口极简化。

示例结构(伪代码):

void* PlatformAllocateTexture(int width, int height, void* data);
void PlatformDeallocateTexture(void* textureHandle);
  • 这样一来,平台层接收到调用请求后可在适当的线程和上下文中执行 OpenGL 纹理上传操作。
  • 回传的句柄可用于后续绘制操作或销毁。

总结:

通过新增这两个核心接口,平台层即可与资产系统打通,从而具备将纹理数据上传到 GPU 的能力,同时保持接口极简、清晰、易扩展。该方案目前足以满足系统初期需求,并为后续高级图形特性留出了扩展空间。下一步只需在适当线程中调用这些接口,即可开始处理实际纹理资源。整个架构向更合理的资源管理方式又前进了一步。
在这里插入图片描述

game_asset.cpp:在 FinalizeAsset_Bitmap 的情况下调用 Platform.AllocateTexture

为了让平台层实现纹理的实际上传操作,我们在加载资源文件并读取完数据之后,进一步添加了 PlatformAllocateTexture 的调用,用以创建 GPU 纹理资源。整个过程基于已有的资源加载逻辑,接入 OpenGL 相关函数,从而完成从磁盘数据到 GPU 显存的完整路径。


当前操作与结构整合流程:

  1. 平台接口调用
    PlatformReadDataFromFile 之后,插入 PlatformAllocateTexture 的调用,该函数负责接收解析后的位图数据并在 GPU 上创建相应纹理。

  2. 不再需要多余数据
    去除了一些旧代码中不再使用的无关数据,只保留纹理上传所需的核心信息:宽度、高度和原始像素数据。

  3. 平台层函数定义与对接
    在回调定义区域中新增了 PlatformAllocateTexturePlatformDeallocateTexture 的函数指针,并将这两个新函数像 PlatformAllocateMemory 一样注册进平台回调结构中,使上层逻辑可以调用。


OpenGL 实际上传逻辑:

  1. 句柄生成
    使用 glGenTextures 生成新的纹理句柄,确保句柄未被占用。

  2. 绑定纹理句柄
    调用 glBindTexture(GL_TEXTURE_2D, handle) 将新句柄绑定到当前 OpenGL 状态中,准备接受像素数据。

  3. 上传数据
    使用 glTexImage2D 上传从资源文件中加载出的图像像素,参数包括纹理尺寸、格式以及数据指针。

  4. 解绑清理
    出于线程安全和状态一致性考虑,在上传完成后立刻使用 glBindTexture(GL_TEXTURE_2D, 0) 解绑,防止线程污染当前上下文状态。


位图数据与句柄整合:

  • 通过 work->Asset.Header.Bitmap 获取所需的图像信息,该结构体内已经包含宽高以及图像指针。
  • 上传后返回生成的 OpenGL 句柄,并作为 void* 类型返回给调用者,以供后续绘制使用。
  • 添加断言以确保返回的句柄可以安全转换为 void*,提高健壮性。

释放纹理资源:

  • 提供 PlatformDeallocateTexture 接口,接收纹理句柄,调用 glDeleteTextures 将其从显存中移除。
  • 由于纹理句柄在 OpenGL 中是唯一标识,释放时无需附加任何额外信息。

总结:

通过将 PlatformAllocateTexture 接入资源加载流程,实现了资源从磁盘到 GPU 的自动流转,构建了初步的资源上传系统。纹理创建过程已封装在平台层中,与上层游戏逻辑解耦,确保接口简洁且可维护。同时,上传完成后立即解绑,有效避免了状态污染问题,为多线程纹理加载奠定了基础。接下来只需完成纹理释放的对应路径,即可形成完整生命周期管理。

在这里插入图片描述

在这里插入图片描述

win32_game.cpp:编写 PLATFORM_DEALLOCATE_TEXURE

在处理纹理资源释放的逻辑中,我们为 OpenGL 提供了对应的释放函数 glDeleteTextures,用于在不再需要某个纹理时回收显存资源。为了实现这一点,我们对传入释放函数的指针进行了转换和统一命名,确保其在整个系统中能清晰地被识别和追踪。


核心逻辑详解:

  1. 句柄转换与识别
    平台接口中,释放纹理函数接收到的纹理标识是以 void* 类型传入的。为了能够调用 OpenGL 的 glDeleteTextures,我们需要将其转换为实际的 GLuint 类型,即 OpenGL 的纹理句柄。这个转换是安全的,并与申请阶段的返回值保持一致。

  2. 追踪纹理句柄来源
    为了在资源管理中保持一致性,我们回溯了纹理句柄的生成与储存位置。发现原始的句柄在 LoadedBitmap 结构体中被记录,而此结构体本身很可能挂在某个更大的系统模块下,例如渲染队列或资源表等。

  3. 统一命名与清晰性改进
    原本该字段可能命名不清(例如仅为 Handle),导致无法直观判断其用途。于是对该成员进行了重命名为 TextureHandle,并在代码中所有引用到它的位置统一修改,确保其含义明确,避免混淆。这样一来,任何看到该字段的开发者都能立刻理解其为 GPU 上的纹理标识。

  4. 纹理句柄位置重构
    为了进一步优化结构设计,我们确认了该句柄应该属于 LoadedBitmap 类型中的一员,而非渲染分组或其他结构体中的字段。这样能够更合理地划分责任,使 LoadedBitmap 成为图像资源在 GPU 与内存间状态的桥梁。

  5. 恢复误操作与编辑效率提升
    在过程中出现了误删或误操作的情况,快速回顾并学习了撤销/重做操作的快捷键,避免造成实际数据丢失,同时提升了开发效率。


整体结果与价值:

通过以上修改与整理:

  • 明确了纹理句柄的生命周期,从生成、绑定、上传到最终释放,建立了完整闭环;
  • 提高了代码可读性与结构清晰度,通过统一命名确保代码在协作和维护过程中更易理解;
  • 为后续的资源管理提供了良好基础,避免了句柄误用或内存泄漏等潜在问题;
  • 保证了 OpenGL 接口调用的合法性与正确性,使平台层与渲染层的集成更加顺畅。

这一步完成后,纹理的分配与释放流程就完全具备了,配合资源系统就能实现自动化的显存管理。

在现阶段,我们已经实现了纹理的后台下载逻辑。通过 OpenGL 接口,利用 glTexImage2D 完成了纹理数据的上传,并使用 glGenTextures 生成句柄,再通过 glBindTexture 完成绑定,最后解除绑定确保状态干净,避免后台线程造成干扰。整体流程现在已经可以在后台自动加载纹理。


当前进展与核心内容整理:

已完成部分:
  1. 纹理后台加载机制搭建

    • 下载线程中已能根据数据动态生成 OpenGL 纹理并上传;
    • 使用默认的内部格式与采样设置;
    • 上传完成后立即解除绑定,保持 OpenGL 状态整洁。
  2. 纹理句柄统一管理与命名

    • 纹理句柄在 LoadedBitmap 中进行存储;
    • 重命名为 TextureHandle,明确表示用途,便于追踪与管理。
  3. 平台接口构建完成

    • 已完成 PlatformAllocateTexturePlatformDeallocateTexture 函数;
    • 下载过程中申请纹理,资源释放时手动告知 OpenGL 回收。

当前待解决问题:
  1. 资源释放(Eviction)时类型判断困难
    在执行资产回收(eviction)逻辑时,需要决定是否要调用 DeallocateTexture,但当前结构中没有清晰的类型标识来判断某一资产是否是纹理(Bitmap)。

  2. AssetMemoryHeader 信息不足

    • 当前仅包含索引、大小、代数信息;
    • 没有资产类型字段,因此在释放资产时无法得知它是否属于纹理资源,无法安全调用 OpenGL 接口释放显存。
  3. 需要扩展结构以记录资产类型

    • 建议在 AssetMemoryHeader 中新增字段,例如 AssetType
    • 该字段在资源加载时设置,记录资源是纹理、声音、字体等;
    • 在释放逻辑中读取此字段决定是否需要释放 OpenGL 资源。

后续修改计划:
  1. 扩展 AssetMemoryHeader
    添加字段 AssetType Type;,枚举值包含 AssetType_BitmapAssetType_Sound 等。

  2. 在资源加载过程中初始化类型字段
    在加载纹理等资产时,设置对应的 Type = AssetType_Bitmap,以便回收阶段识别。

  3. 更新释放逻辑
    在资源回收前判断 AssetType,若为 Bitmap,则调用 PlatformDeallocateTexture,传入句柄完成 OpenGL 资源回收。


总结:

整个后台纹理加载系统已具备完整功能路径,从加载、上传、句柄保存到可调用释放接口。但在回收逻辑中,当前结构尚未支持对资源类型的判断,存在潜在显存泄漏风险。通过为 AssetMemoryHeader 增加类型字段,可以完善整个生命周期管理机制,确保后台资源清理逻辑与显存状态保持同步。这是下一步工作的重点。

GLuint Handle 不能直接、安全地转成 void* 是因为这两个类型在底层表示和语义上是不等价的,直接转换存在类型不匹配与潜在平台不兼容的问题。我们来详细解释为什么这么做有风险,并给出更安全的替代方案。


为什么不能直接 (void *)Handle

1. GLuint 是整数类型,而 void* 是指针类型

  • GLuint 实际上是一个无符号整型(通常是 unsigned int);
  • void* 是一个通用指针类型,它代表某块内存地址
  • 把一个数字(如 5)强制当成内存地址使用,在很多平台上是未定义行为或者会引发崩溃。

2. 指针大小 ≠ 整数大小

  • 在某些平台(特别是 64 位系统)中,void* 是 8 字节,而 GLuint 仍然是 4 字节;
  • 把 32 位的 GLuint 转为 64 位指针,有可能造成高位垃圾数据,逆转时也可能出现不一致;
  • 在某些编译器下还可能直接报错或警告(例如使用 -Wint-to-void-pointer-cast)。

3. 违背类型语义和API设计初衷

  • OpenGL 返回的 GLuint 是用于作为句柄的整数,不应被当作内存指针传递;
  • void* 表示纹理句柄本质上是语义不清的设计(滥用 void*),会让后续阅读代码或维护变得困难。

正确做法推荐

方法一:使用平台无关的 uintptr_t 中转

这是 C/C++ 标准提供的类型,保证可以无损地转换整数和指针。

#include <stdint.h>return (void *)(uintptr_t)Handle;

反向转换时:

GLuint Handle = (GLuint)(uintptr_t)Pointer;

uintptr_t 是一个足够大的无符号整型,用来安全地装得下指针。


方法二:定义自己的纹理句柄类型

例如:

typedef struct TextureHandle {GLuint GLHandle;
} TextureHandle;

然后平台返回:

TextureHandle *Result = AllocateFromMemory(...);
Result->GLHandle = Handle;
return (void *)Result;

这样能保持类型安全,语义清晰,还便于未来扩展。


总结

直接将 GLuint 转为 void*不安全的行为,在 64 位平台可能会导致数据丢失或未定义行为。推荐使用 uintptr_t 做中转,或使用更明确的结构体来传递句柄。这不仅能确保代码跨平台稳定运行,也使得整体结构更清晰、易于维护。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_asset.h:在 asset_memory_header 中添加 AssetType

在这一部分,我们处理的是资源系统中如何跟踪和识别资源类型,特别是在释放资源(比如纹理)时能准确知道它的类型,以便执行对应的清理逻辑。以下是核心内容的详细整理总结:


问题背景

我们之前的资源系统设计中,并没有存储每个资源的类型(比如纹理、声音、字体等)。因为在加载和使用过程中,通过上下文就能判断类型,所以也就没有显式记录。这个做法虽然“巧妙”,但在释放资源时就出现了问题:我们需要知道资源的类型,才能决定是否需要通知 OpenGL 去删除一个纹理等。这时候如果没有类型信息,就没法正确处理资源释放。


解决思路

我们决定在资源头(Asset Memory Header)中添加一个新的字段来标识资源类型,这样即使系统后来不再持有资源,也可以通过资源头知道它曾经代表的是哪种类型,从而做出正确处理。步骤如下:

  1. 添加类型字段

    • 定义一个枚举(例如 asset_type_bitmapasset_type_soundasset_type_font 等)。
    • 在资源头结构中新增一个字段 AssetType 来存储这个枚举。
  2. 资源分配时设定类型

    • 在资源实际分配内存的时候(AcquireAssetMemory 函数),新增一个参数来指定该资源的类型。
    • 在这个函数中,将传入的类型保存到资源头中的 AssetType 字段。
  3. 资源释放时根据类型判断处理逻辑

    • 在释放资源(比如位图纹理)的时候,通过资源头中的 AssetType 判断是否需要调用平台层的 DeallocateTexture 函数。

实施细节

  • AcquireAssetMemory 中添加 AssetType 参数,使得每次分配内存时都要求明确标注资源类型。
  • 修改资源加载函数(如 LoadBitmapAssetLoadSoundAsset 等),在它们调用分配内存函数时传入正确的资源类型。
  • 在资源头插入链表的流程中(如 InsertAssetHeaderAtFront)也确保 AssetType 被正确设置。
  • 对于还未分类或暂不处理的类型,可以默认设为 AssetType_None

最终效果

通过上述更改,现在我们能够:

  • 明确每个资源的类型;
  • 在资源释放时执行正确的处理逻辑(例如调用 OpenGL 的 glDeleteTextures);
  • 保持资源系统的整洁和可维护性;
  • 为将来支持更多类型的资源(如动画、粒子系统等)打下基础。

备注

虽然之前系统“巧妙地”避开了存储类型这件事,节省了数据结构的复杂度,但一旦进入资源管理和生命周期控制层面,这类信息就变得不可或缺。及时补充这种结构性信息,是保持系统鲁棒性的重要一环。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

运行游戏,什么也没看到,就这样结束了

目前所有用于实现纹理下载的基础设施已经搭建完成。接下来需要做的就是调试,找出纹理未正确加载的情况,确保资源在需要时能被正确获取。整体的逻辑框架已经完备,现在只剩下调试和细节修正的阶段。

此外,还有一个额外可选的优化措施可以考虑:使用 OpenGL 的“fence”(栅栏)机制来同步纹理下载。该机制的作用是在纹理尚未下载完成时自动暂停 OpenGL 渲染流程,等待资源准备就绪,从而避免使用未就绪的纹理造成错误。这项机制的使用取决于实际需求——如果系统总是能在正确的时间完成纹理的下载,那么就无需引入 fence;否则可以通过 fence 来保障一致性与稳定性。

在代码中已预留了用于实现该机制的接口,虽然暂时还未决定是否启用,但未来如有需要,可以很容易将其集成进来。简而言之,目前的架构已支持完整的纹理下载流程,同时也为进一步的同步机制扩展做好了准备。现在的重点转向调试和测试阶段,确保整个系统在运行时的稳定性和正确性。

Q&A

如果机器中有多个显卡,能告诉 OpenGL 使用哪个显卡吗?

在一台设备中,如果存在多个显卡,是否可以通过 OpenGL 指定使用某一个显卡,这个问题的答案是:简短地说,不可以;详细来说,取决于具体情况

首先,如果这台机器中有两块显卡,而且来自不同厂商(例如一块是 AMD,另一块是 NVIDIA),那么系统通常只会加载其中一个厂商的驱动程序,OpenGL 只能与当前被加载的驱动进行通信。也就是说,没有办法在 OpenGL 中手动选择与哪一块显卡通信,因为上下文只会映射到当前活跃的那个驱动上。

如果两块显卡来自同一家厂商,例如都是 NVIDIA 的显卡,那么就稍微复杂一点。此时它们共享一个驱动,OpenGL 会根据用户的系统配置自动决定使用哪块显卡,比如是否启用了 SLI(多卡互联),或者是独立运行。开发者通常不能干预这个过程。

然而,对于某些特定显卡类型,例如 NVIDIA 的 Quadro 系列,存在专门的图形 API 接口,可以列举系统中的所有显卡,并为某一个特定显卡创建 OpenGL 上下文,从而实现手动选择显卡的功能。但这项能力只限于 Quadro 这样的专业卡。如果不是 Quadro,NVIDIA 的消费级显卡就不支持这种方式。

而在 AMD 显卡中,有类似的机制存在,可以通过 AMD 提供的接口选择使用的显卡(例如在多卡情况下分别使用),不过细节可能有所不同,但从理论上来说是可以的。

至于 Intel 的集成显卡,通常系统中不会出现两块 Intel GPU 的情况。但在极端的配置中,比如双插槽的 Xeon Skylake 系统,也可能出现两个集成 GPU 的场景。不过这种情况极其少见,关于 OpenGL 如何处理这种配置,目前没有确切的结论,可能系统本身会做隐藏处理。

总结:

  • 不同厂商显卡不能手动指定使用哪一块,OpenGL 上下文只能绑定到当前加载的驱动。
  • 相同厂商显卡一般共享驱动,由系统决定使用哪一块,开发者无法干预。
  • NVIDIA Quadro 等专业卡可以手动选择显卡,消费级显卡不能。
  • AMD 也可能支持手动指定显卡,视驱动支持情况而定。
  • Intel GPU 的特殊情况较少见,理论上可能出现,但处理方式不明确。

因此,除非使用专业图形工作站级别的设备并配套使用特定的 API,否则在常规使用中是无法通过 OpenGL 自行选择显卡的

NVIDIA Quadro 和 AMD 显卡(主要指 Radeon 系列)在定位、设计目标、驱动优化和价格等方面存在显著区别。以下是它们之间的详细对比,帮助我们理解两者各自的优势和适用场景。


一、定位与用途

项目NVIDIA QuadroAMD Radeon(或 Radeon Pro)
定位专业图形工作站显卡消费级游戏显卡(Radeon) / 专业图形显卡(Radeon Pro)
适用领域CAD、3D 建模、工业仿真、科学可视化、影视后期等专业工作游戏娱乐、日常使用(Radeon),部分专业应用(Radeon Pro)

二、驱动与软件支持

项目NVIDIA QuadroAMD Radeon / Pro
驱动优化专门为 AutoCAD、SolidWorks、Maya、3ds Max、Adobe 等软件优化,经过认证测试,稳定性极高Radeon 驱动更偏向游戏性能;Radeon Pro 驱动也提供部分专业软件优化,但不如 Quadro 完善
OpenGL/DirectX 支持OpenGL 支持强、兼容性好,适合专业图形处理Radeon 对 DirectX 优化更好,OpenGL 相对较弱(Radeon Pro 改善了一些)
专业认证(ISV)拥有大量 ISV(独立软件开发商)认证Radeon Pro 拥有部分认证;Radeon 无认证

三、硬件设计与特性

项目NVIDIA QuadroAMD Radeon / Pro
显存类型多为 ECC 错误校验显存(如 ECC GDDR6 或 HBM2)Radeon 多为 GDDR6,Radeon Pro 有时使用 ECC 显存
精度支持更高的双精度浮点性能(FP64),适合科学计算Radeon FP64 性能一般,Radeon Pro 相对提升
稳定性设计上偏向长时间高负载下稳定运行Radeon 偏向游戏场景,长期稳定性不如专业卡
散热与结构针对工作站优化设计,双槽结构,多为涡轮风扇便于机架散热Radeon 多为开放式散热,更适合游戏机箱散热结构

四、价格与性价比

项目NVIDIA QuadroAMD Radeon / Pro
价格通常非常昂贵,数千至数万元人民币Radeon 性价比高,适合预算有限;Radeon Pro 价格介于两者之间
性价比对于专业需求来说非常值,但价格昂贵游戏或轻度专业应用,Radeon 更划算

五、适用人群推荐

类型推荐显卡
建筑设计、工业设计、工程模拟NVIDIA Quadro / AMD Radeon Pro
图形渲染、影视后期、视觉特效NVIDIA Quadro
科学计算、深度学习(双精度要求高)NVIDIA Quadro(如 GV100)
游戏玩家、普通3D建模者AMD Radeon / NVIDIA GeForce
预算有限但偶尔做专业任务AMD Radeon Pro(性价比高)

总结一句话:

  • NVIDIA Quadro 是为专业图形工作场景打造的重型武器,稳定、精准但昂贵;
  • AMD Radeon 更适合游戏和一般使用,Radeon Pro 是其面向专业市场的折中选择,适合预算有限的开发或设计人员。

NVIDIA 是公司名,Quadro 是它旗下的一类专业显卡系列


更详细解释:

名称说明
NVIDIA(英伟达)一家总部位于美国的知名半导体公司,专门研发图形处理器(GPU)、AI 芯片等技术。旗下有多个显卡产品线。
Quadro是 NVIDIA 旗下的专业显卡产品系列,主要用于工作站和专业图形任务,例如 CAD、建模、渲染、影视制作等。已经在 2020 年后被逐步整合为 NVIDIA RTX A 系列(如 RTX A4000、A6000)。

NVIDIA 显卡产品线划分:

系列名称用途方向举例
GeForce面向游戏和消费级用户GeForce RTX 4090、RTX 3080
Quadro(旧名)
RTX A 系列(新名)
面向专业图形工作站用户Quadro P5000、Quadro RTX 6000、RTX A4000
Tesla / A100 / H100面向深度学习、科学计算、AI 训练Tesla V100、NVIDIA A100、H100
Jetson面向嵌入式设备和边缘 AIJetson Nano、Jetson Xavier NX
NVS面向商业办公和多屏幕输出NVS 510、NVS 810

总结一句话:

  • NVIDIA 是公司,Quadro 是它专门给专业人士用的显卡系列(主要用于图形工作站)。
  • 就像苹果是公司,MacBook Pro 是它的产品系列一样。

永远保持 DC 是一个好主意吗?如果用户在运行时更改了显示器配置怎么办?

我们讨论了关于是否应该一直保留设备上下文(DC)的问题,尤其是在用户更换显示器时的情形。最终得出以下几点结论:


核心总结:

  • 永久保留设备上下文(DC)并不是最糟糕的问题,即便用户在程序运行时更换了显示器。
  • 真正关键的是 OpenGL 上下文(OpenGL Context)。如果设备上下文需要重建,OpenGL 上下文会失效,这才是最需要担心的部分。
  • 用户在运行过程中更换显示器理论上不会破坏原始的 OpenGL 上下文——它仍然可以工作。

进一步推理:

  • 即使显示器发生更换,窗口的设备上下文并不会自动失效,所以只要窗口没有被破坏,通常OpenGL 上下文还能正常使用
  • 如果真的发生了变化(比如显示分辨率改变或显卡热插拔等),可能需要我们重新设置窗口的位置或其他窗口参数,以确保渲染和显示能继续正常进行。

结论:

  • 更换显示器可能导致的后果在我们的架构中不是致命问题;
  • 只需关注 OpenGL 上下文的稳定性;
  • 必要时只需重新定位窗口,不必完全重建上下文。

分配和释放纹理的功能是不是应该放在 opengl.cpp 层,而不是平台层?

我们在讨论代码结构时认为,将 DeallocateTexture 这个函数放在 OpenGL 的实现部分会比放在平台层(Platform Layer)更加合理。


主要结论如下:

  • DeallocateTexture 放在 OpenGL 层更加合适。
  • 原因是:在该函数的实际实现中,并不涉及任何平台相关的调用
  • 我们只是操作 OpenGL 的资源(如纹理释放等),这些操作本身就是跨平台的,不依赖于底层平台 API(比如 Windows 的 GDI 或其他平台接口)。
  • 因此将其留在 OpenGL 层逻辑上更清晰,也能避免让平台层承担本不该属于它的职责。

设计哲学延伸:

  • 平台层应当只负责与操作系统或硬件平台打交道的部分
  • 而像 OpenGL 这样的图形 API,已经在某种程度上屏蔽了平台差异,其内部的资源管理逻辑理应归属于 OpenGL 实现模块。
  • 这种划分可以增强模块化和可维护性,也能让代码结构更易于理解和扩展。

实际后续建议:

我们可以将 DeallocateTexture 移到 OpenGL 实现文件中,比如 opengl.cpp,并保持平台层只提供如窗口创建、上下文初始化等职责。这样我们的系统在结构上会更加清晰,职责分离也更明确。

game_opengl.cpp:将 PLATFORM_ALLOCATE_TEXTURE 从 win32_game.cpp 移入

我们认为将某些资源管理逻辑从平台层移到 OpenGL 层是完全合理的,这样做可以增强代码的可移植性与共享性。


主要观点总结如下:

  • 现有的资源管理方式完全可以适用于其他平台,比如 Linux。
  • 这部分代码逻辑并不依赖特定平台的 API,也没有调用操作系统相关的函数,因此具有良好的通用性。
  • 因此,我们可以将这部分逻辑从平台层中提取出来,转移到 OpenGL 模块中。
  • 这样一来,任何平台如果想使用相同的纹理资源加载与卸载逻辑,只需使用共享的 OpenGL 实现即可,无需再重写对应平台的代码。
  • 这一设计选择使我们能够在未来更轻松地支持多平台(例如 Windows、Linux 等),并提高整个架构的整洁性。

后续计划:

我们决定立刻执行这项调整,将对应函数迁移到合适的位置,并确保它具备良好的平台兼容性和模块独立性。这样一来,无论哪个平台接入 OpenGL 都能共享这套逻辑,提升了代码的复用率与维护效率。

微软为什么不允许 Universal Windows Platform 游戏使用独占全屏模式?这似乎是在自掘坟墓

我们对微软不允许通用 Windows 平台(UWP)游戏使用成功的全屏模式这一决策表示不解。这种做法看起来就像是在自毁前程,令人困惑。


主要内容总结如下:

  • 无法理解微软为什么不支持 UWP 游戏使用真正的全屏模式,这看起来完全不符合开发者和用户的利益。
  • 微软作为一家庞大的公司,内部有很多不同类型的人:
    • 一些人可能是有经验、想做正确决定的工程师。
    • 一些人可能缺乏经验,无法做出合适的技术决策。
    • 还有一些人可能为了自己的目标或“胜利”,会故意推动一些对整体生态不利的决策。
  • 所有这些不同层次、不同动机的人,加上项目时间压力、资源分配等多种因素混合在一起,最终就产出了像 UWP 这样的问题平台。
  • 无论这类问题是怎么产生的,最终的结果是我们不得不去适应这样的平台限制,即使它们并不合理。
  • 对于是否支持 UWP 游戏,态度非常明确:除非这是在 Windows 上发布游戏的唯一方式,否则绝不会选择 UWP。

补充观点:

  • 现在的现状是,即便知道全屏支持的限制影响体验,也无能为力。
  • 开发者只能被动接受平台的设计缺陷。
  • 对这种平台策略持消极态度,认为它并不适合游戏开发的实际需求。

我们只能继续观察平台的演变,同时尽可能避开这些不必要的限制,选择对用户和开发者最友好的技术路径。

是否有办法像纹理一样预加载顶点/法线/颜色到显卡(例如,在调用 glDrawElements 之前),还是通常这不是问题?

我们在这里讨论的是是否可以像纹理一样,将顶点、法线和颜色等数据预先上传到显卡中,以避免每帧都进行数据传输。


详细总结如下:

  • 实际上,顶点、法线和颜色数据的处理方式与纹理完全类似,它们同样可以通过缓冲区(Buffer)提前上传到显卡,就像纹理那样。
  • 当前之所以没有这么做,是因为我们使用的顶点数据都是动态的,每一帧都会发生变化,例如场景中的元素移动或变化频繁。
  • 因此,在这种动态性很强的场景中,每一帧都重新发送顶点数据是可以接受的,没有必要做预传输优化。
  • 但是,如果未来我们遇到一些静态对象(比如背景中的树木等):
    • 它们的位置和形状几乎不变。
    • 并且这些对象的顶点数量较多,导致数据传输开销变大。
    • 那么完全可以通过创建**顶点缓冲区对象(Vertex Buffer Objects, VBO)**的方式,将这些顶点一次性上传到显卡,只传一次,后续直接使用即可。
  • 总体来说,只要是“传输成本较高的静态数据”,无论是纹理还是顶点,都适合提前上传缓存
  • 在我们当前使用的渲染流程中,大部分资源的负担主要集中在纹理上,而顶点数据较少且经常变化,所以暂时无需做进一步优化。

这种处理方式提供了灵活性与性能之间的权衡:对动态数据实时更新、对静态数据提前缓存,从而在实际项目中获得更好的渲染效率和控制力。

对于 3D 模型,是否通常是将网格上传到 GPU 一次,然后编写一个着色器,通过变换矩阵绘制每个模型?

在这个讨论中,主要关注的是如何处理顶点数据,尤其是在渲染时,是否可以通过优化来减少顶点数据的传输开销。


详细总结如下:

  1. 关于上传网格数据:

    • 在一般情况下,如果涉及到静态网格或复杂的3D模型,确实可以提前上传顶点数据,并使用矩阵进行转换,从而在渲染时减少数据传输的开销。这种做法对于大型模型尤其有效,尤其是当模型包含成千上万的顶点时。
  2. 对于当前的精灵渲染:

    • 在当前的精灵渲染场景中,每个精灵仅由四个顶点表示,且每个精灵都是一个简单的矩形。因此,顶点数据传输的开销非常小。
    • 如果顶点传输的开销过高,还可以通过**几何着色器(Geometry Shader)细分着色器(Tessellation Shader)**等技术来减少顶点数量。比如,程序只需传递一个顶点,系统可以通过着色器程序生成其它所需的顶点,这样就能有效地降低顶点数据传输的需求。
  3. 顶点传输与变换的开销:

    • 即使是静态网格的传输,通常发送的变换矩阵的大小也会比发送每个顶点的开销大。因此,对于精灵这种简单的图形,顶点数据的传输开销是非常小的,几乎不值得担心。
    • 对于更复杂的情况,例如包含数十万顶点的模型,可能需要像纹理一样对顶点数据进行优化,使用**缓冲区对象(VBO)**等技术预先上传数据,从而减少每帧传输的开销。
  4. 动态顶点数据:

    • 当前的精灵数据是动态的,即每一帧都会改变位置,因此每一帧都需要更新顶点信息。如果顶点数据只是静态的,可以通过提前上传顶点数据并使用变换矩阵来减少每帧的数据传输,但对于动态数据,这种方法不太适用。
    • 在当前的精灵渲染场景中,每一帧都需要传递的位置数据是非常简单的,因此没有必要做进一步的优化。
  5. 压缩顶点数据:

    • 如果有需要,可以将顶点数据压缩,只传递中心点和缩放信息,通过几何着色器再生成完整的顶点。这种方法可以减少顶点数据的传输量,尤其是在顶点变化较少的情况下。

总体而言,对于当前的精灵渲染,顶点数据传输的开销非常小,不需要过多优化。而对于复杂的3D模型,使用缓冲区和几何着色器等技术来优化顶点数据的传输是非常有效的。

如何才能读懂那些一字母的变量代码?

在这个讨论中,主要涉及了关于代码中的单字母变量以及如何阅读和理解这类代码的内容。


详细总结如下:

  1. 单字母变量的使用:

    • 讨论中提到,使用单字母变量名的代码可以通过上下文推断来理解其含义。这种写法使得代码变得更简洁,但可能会导致可读性降低,尤其是对于不熟悉代码的人。
    • 尽管使用单字母变量(如 w, i, j)是为了提高代码的简洁性,但有时在需要解释时,还是会使用较长的变量名,这样就能在保持简洁的同时,确保重要部分能够明确理解。
  2. 代码的可读性和简洁性:

    • 代码的简洁性通常取决于上下文的清晰度,如果变量的意义可以从上下文中推断出来,使用简短的变量名是可以接受的。
    • 例如,在处理循环变量时,使用 i, j, k 等短变量名是常见做法,因为这些通常用于计数,且其含义可以从上下文推知。
  3. 简化与可读性平衡:

    • 对于一些较复杂的操作,虽然使用了简化的变量名,但仍然会在需要说明的地方使用长变量名来确保代码可理解。
    • 讨论中提到,即使使用简短的变量名,如果某些内容需要额外解释,还是会用较长的名字来确保不混淆,如 widthheight 会更明确。
  4. 代码风格的差异:

    • 不同的开发者有不同的编码风格。一些开发者可能偏向于使用更多的简写和短变量名,而另一些则可能使用更长、更具描述性的变量名。这主要取决于开发者的个人偏好以及代码所处的上下文。
    • 讨论还提到,简化的代码风格可以让代码更加紧凑和快速,但可能不适合所有场合。某些情况下,代码的清晰性和可维护性比简洁性更重要。
  5. 总结:

    • 总体来说,单字母变量名的使用是可以的,只要上下文明确且有合理的假设,可以帮助代码更简洁高效。但对于更复杂的部分,仍然需要适当使用长变量名来确保代码的可读性和可维护性。

这段讨论表明了在编码过程中,如何在简洁性可读性之间找到平衡,并且强调了根据上下文来判断是否使用简短变量名是合理的。

相关文章:

  • C++区别于C语言的提升用法(万字总结)
  • 推荐几个可以免费下载视频的软件(Neat Download Manager、蜗牛下载助手、bilidown)
  • 【安全扫描器原理】网络扫描算法
  • 【题解-Acwing】851. spfa求最短路
  • 动态自适应分区算法(DAPS)设计流程详解
  • 【Qt6 QML Book 基础】07:布局项 —— 锚定布局与动态交互(附完整可运行代码)
  • MySQL 报错解析:SQLSyntaxErrorException caused by extra comma before FROM
  • 网络原理 - 7(TCP - 4)
  • 技术视界 | 数据的金字塔:从仿真到现实,机器人学习的破局之道
  • EFISH-SBC-RK3588无人机地面基准站项目
  • 【Hive入门】Hive查询语言(DQL)完全指南:从基础查询到高级分析
  • 基于 EFISH-SBC-RK3588 的无人机通信云端数据处理模块方案‌
  • Redis-缓存应用 本地缓存与分布式缓存的深度解析
  • 解决 Dart Sass 的旧 JS API 弃用警告 的详细步骤和解决方案
  • 【激光雷达3D(6)】​3D点云目标检测方法;CenterPoint、PV-RCNN和M3DETR的骨干网络选择存在差异
  • 涂料油墨制造数字化转型的关键技术与挑战
  • 中间系统-SPF计算
  • 如何初入学习编程包含学习流程图
  • 《Python3网络爬虫开发实战(第二版)》配套案例 spa6
  • 出现delete CR eslint错误
  • 福耀科技大学发布招生章程:专业培养语种为英语,综合改革省份选考需含物化
  • 去年立案侦办侵权假冒案件3.7万起,公安部公布13起案例
  • 中国天主教组织发唁电对教皇去世表示哀悼
  • 2025全球智慧城市指数排名揭晓,阿布扎比跃升至第五位
  • 低轨卫星“千帆星座”已完成五批次组网卫星发射,未来还有这些计划
  • 大卫·第艾维瑞谈历史学与社会理论③丨尼古拉斯·卢曼与历史研究