游戏引擎学习第241天:将OpenGL VSync 和 sRGB 扩展
回顾并为今天的内容定下基调。
今天我们继续昨天未完成的工作,打算直接深入推进,把剩下的部分整理完。我们正在进行一个架构方面的调整,主要是对渲染系统进行隔离,这个变动本身并不复杂,昨天已经解释过了,现在只是需要完成这个过程。
这类操作的本质是“整理”,因为我们决定将某些代码从原来的位置中分离出来,所以必须做一系列琐碎但必要的工作。我们现在就要开始动手进行这部分调整。
win32_game.cpp
:让 Win32DisplayBufferInWindow
接收 SortMemory
。
接下来我们要做的事情其实很明确,除了之后不可避免需要进行的调试工作——也就是确保我们所做的更改都能正常运行,这部分可能要稍后再进行——目前主要任务就是让所有东西在当前的新架构下顺利编译通过。
现在唯一还没有处理完的部分是 Win32 平台层的代码。之前这部分代码是直接调用渲染模块的,而我们还没完成对它的更新。可以看到,这里仍然使用了旧的 SortEntry
相关调用,这些调用仍然依赖一个内存区域,而我们现在的目标是改为直接传入临时内存(Temp Memory),这样更加清晰也更灵活。
接下来的工作重点就是整理并完成这块代码的更新。完成之后,我们就可以进入调试阶段,把所有逻辑跑通,确保新结构可以正常工作。
我们在排序渲染命令时,需要使用临时内存来进行排序操作。当前的实现不是就地排序(in-place),而是使用额外的内存进行操作。虽然没有采用特别复杂的算法,但基本可以确定,无论将来使用哪种排序方式——无论是快速排序、归并排序或其他变种——使用额外的临时内存都可能带来更好的性能。
就地排序在大多数情况下反而性能不如借助临时缓冲区的排序,特别是对渲染数据而言。我们对内存的要求也不高,这里所说的临时内存只是少量(可能仅几 MB)的空间,相较游戏运行的整体资源而言几乎可以忽略不计。所以我们认为,在绝大多数目标平台上,都可以轻松提供这块内存。
因此,结论是我们应该始终向排序模块提供临时内存作为操作空间。如果将来想试验就地排序作为一种练习也是可以的,但我们最终的实现应该使用临时内存以获得更好的性能。
所以我们打算在调用 Win32DisplayBufferInWindow
的时候,就将这块排序专用的临时内存(SortMemory)传入,这样当调用 SortEntries
时就能直接使用,无需额外分配或转换。这是我们这一阶段的清理任务,完成之后将进入调试流程,最终收尾。
game_render.cpp
:让 SortEntries
使用 SortMemory
,并继续修复编译错误。
我们需要提供一块排序专用的临时内存(sort memory),供排序过程使用。这样就不需要在排序函数内部执行多余的操作。
目前来看,排序所需要的这块临时内存结构其实很简单,只需要我们按照预期的格式传进来即可。我们会把之前用来表示临时空间的变量替换成现在这个明确表示排序用途的 sort memory
。
之后我们对代码进行了一些清理,比如移除了一些不再需要的旧变量,同时重新编译,看看目前的状态。
接下来我们注意到,在调用 SortEntries
时,PushBufferElementCount
报错为 RenderGroup
的一个不存在的成员。这个错误并不意外,因为 RenderGroup
原本是游戏端用于组织渲染数据的结构,而现在我们已经把渲染部分独立出来了,所以不应该再让这些函数依赖 RenderGroup
。
我们希望所有之前依赖 RenderGroup
的代码,都改为使用新的 game_render_commands
结构。也就是说,现在的渲染逻辑应该完全基于从游戏传过来的渲染命令集合(command set),而不是原来的渲染组。
换句话说,RenderGroup
主要是游戏构建渲染指令用的,渲染系统本身现在不再依赖它。我们希望渲染系统看到的只是“该渲染什么”,而不是“游戏是怎么组织这些东西的”。
在处理过程中,我们也顺便重命名了一些函数,让它们更清晰地表达出用途。比如原来的一些函数名可能暗示它是用于输出渲染结果的,而我们现在希望它更准确地反映“从渲染命令中生成最终图像”的含义。
过程中我们也发现了一些视觉上的代码排版问题,比如对齐没对好,缩进混乱等,也一并进行了整理,使代码更整洁、易读。
总结一下,这一阶段的核心是继续将渲染架构从“依赖游戏组织结构”过渡到“只处理渲染命令”的模型,替换掉所有旧接口和数据结构,并为排序等功能明确提供合适的内存空间。完成这部分后,架构上的改动就基本完成了,可以进入下一步调试和完善阶段。
game_render.cpp
:将 RenderGroupToOutput
重命名为 TiledCommandsRender
,并让其接受 game_render_commands *Commands
。
我们已经准备好了一个机制,可以在不同的渲染路径之间切换。因此,现在继续沿着之前的架构调整进行,我们决定不再使用诸如 RenderGroupToOutput
这样的旧名称,因为它已经不能准确表达当前的渲染结构。
我们重新命名为 TiledCommandsRender
,这个函数专门负责处理渲染命令,并最终输出渲染结果。它接收的参数类型也从之前的 RenderGroup
改为新的 game_render_commands *Commands
,这是当前架构下游戏提供给渲染系统的标准指令格式。
通过这样命名和结构调整,我们明确了函数的职责:接收一组渲染命令,然后执行瓦片化的渲染工作。新的名称更贴近功能本质,语义更清晰。
在处理代码的过程中,顺带清理了一些误删的变量,比如渲染队列的字符数据(glyphs),这些需要保留,因为仍然在使用中。我们还调整了传参方式,现在各个渲染函数调用链条中,传递的都是统一格式的渲染命令对象,而不是旧的渲染组对象。
同时,我们确认渲染任务调度器(tile render work)所需要的输入也只与 game_render_commands *Commands
相关,而不会接触旧的 RenderGroup
,因此一并完成了参数替换。这使得瓦片渲染的每个任务单元只处理它对应的渲染命令,从而进一步解耦渲染系统与游戏逻辑之间的耦合。
通过这些修改,我们完成了从旧架构向新架构的进一步转型,保持了渲染系统接口的简洁和独立,为后续的并行渲染或更高级的渲染策略打下了良好基础。整个渲染流程现在围绕 game_render_commands *Commands
展开,统一、清晰、易于维护和拓展。
game_render.cpp
:从 game_render_group.h
引入 tile_render_work
。
当前的工作重点是继续完成架构调整,特别是瓦片渲染部分(tile render work)的迁移与清理。之前这些逻辑还依赖于旧的 RenderGroup
结构,但根据最新的架构,我们已经将核心渲染流程转移到以 game_render_commands
为基础的系统中。
在调整过程中,发现瓦片渲染相关的 tile_render_work
结构依旧残留在旧的路径中,预计这个结构将被提取到新的接口下。可以考虑将其移到单独的头文件中,以便更好地组织和管理。不过这个决定暂时还没有做出,取决于后续代码结构的需求。
接下来要做的是继续统一这一部分的接口:将 tile_render_work
的输入从 RenderGroup
改为 game_render_commands
。这意味着,无论是在调度渲染任务时,还是在执行具体渲染操作时,传递的都应该是标准的渲染命令集合。这种方式更符合当前架构的目标,即实现渲染逻辑的独立性与模块化。
在新的实现中,渲染任务会被分发到各个瓦片(tiles),并基于映射到的渲染目标区域(target)与字形区域(glyph rect)来绘制内容。所有这些操作都围绕 game_render_commands
展开,这些命令集已经替代了原有的 RenderGroup
成为渲染系统的主要输入来源。
通过这一系列的替换与重构,渲染系统变得更加清晰、灵活,同时也更容易并行处理,进一步提升渲染效率和可维护性。整个渲染流程现在已经不再依赖旧有的游戏渲染逻辑结构,而是转向一种以命令为核心、数据驱动的架构设计。
game_render.cpp
:再将函数名改为 RenderCommandsToBitmap
。
我们现在的工作重点在于继续清理渲染逻辑中的旧代码,将所有依赖 RenderGroup
的部分彻底转换为基于 GameRenderCommands
的实现。
当前处理的是排序内存(SortMemory)的传递问题。之前的代码需要对传入的内存进行类型转换,现在确认可以直接使用排序内存结构,无需额外处理,这样更简洁安全。
我们检查了错误输出和元素分配部分,它们现在都已经迁移到 GameRenderCommands
上,因此需要做的仅仅是将原本依赖旧结构的调用替换为新结构对应的函数,比如用 RenderCommandsGetMap
取代旧的元素映射获取逻辑。
所有相关的渲染操作现在都以 GameRenderCommands
为核心,只需要确保函数和数据访问都指向新结构。命名上保持一致,例如“GetMap”的方法命名,有助于统一接口设计和增强代码的可读性。
整体来说,所需的调整已经基本完成,接下来主要是验证替换是否完整,确认渲染路径在新架构下是否运行正确。重构的目的达成了:旧的渲染逻辑已经剥离,渲染系统更加模块化,便于后续维护和扩展。
考虑是否直接使用 AddEntry
。
我们现在关注的是 PlatformAddEntry
的使用方式。这部分逻辑的改动带来了便利,因为我们不再必须通过平台层访问这个函数了。由于我们当前的模块是在系统内部编译的,所以可以直接调用 AddEntry
,无需将其显式导出。这为调用方式提供了更多灵活性。
当然,我们依然可以选择继续通过平台接口结构来访问该函数。这是设计层面的一个权衡问题,无论走哪条路径都可以实现功能。整个系统已经通过统一的 Platform
全局变量封装了平台相关 API,因此调用方式是抽象的、可互换的。这种设计使得调用路径可以灵活选择而不会影响最终效果。
我们回顾了之前的实现方式,Platform
是一个包含平台 API 的全局变量,其接口可以在系统的任意部分访问。这个平台 API 通常是在程序启动初始化阶段设定的。通过将函数指针填入这个结构体,实现了跨模块的灵活调用。
具体到这个函数 PlatformAddEntry
,它应该是作为某个全局平台 API 结构的一部分被设置的,而这个结构体是在如 Win32Platform.cpp
或类似平台初始化代码中初始化的。我们推测它是在 StartGameInMemory
函数执行时一并创建和填充的,这个函数一般负责初始化内存管理、平台服务和入口逻辑等关键内容。
总结来看,这部分工作围绕平台抽象层的灵活性展开。我们可以选择继续使用平台 API 层,也可以在系统内部直接调用相关函数。这种模块化、可配置的架构让系统在开发、维护和跨平台适配上都具备更强的可操作性和灵活性。
win32_game.cpp
:使 platform_api
可用于渲染器。
我们打算将 PlatformAPI
暴露到更广泛的范围,使其在渲染模块等其他地方也可以访问。这样设计有一个明显的优势:模块之间的代码在迁移或重构过程中会更加灵活,不需要因为函数路径不同而修改调用方式。
具体来说,我们考虑将原本只存在于某些特定位置的 PlatformAPI
引用,向外暴露为一个全局可用的接口。这样,无论渲染逻辑是否在平台层,或者是否被移出平台层,都可以通过统一方式调用平台服务,例如 AddEntry
等函数。
这样做的目的在于避免模块之间的“耦合位置依赖”,也就是代码调用依赖于其被放在哪个层级或文件中。通过统一的 PlatformAPI
接口,我们实现了代码可以在平台层与非平台层之间自由切换,而不需要每次移动代码时重构调用逻辑或接口。
此外,统一接口也有助于简化维护。当需要更换底层实现或扩展平台能力时,只需要修改接口实现部分,而不是大规模修改调用代码。这样设计可以提升整体架构的可扩展性和灵活性。
总结来说,这个改动的核心目的是构建一个稳定统一的访问平台层能力的桥梁,使得各个模块之间更容易解耦,同时也提升了开发效率和架构弹性。
win32_game.cpp
:继续修复编译错误。
我们将渲染流程中的临时内存部分彻底移除,改为使用专门的 SortMemory
。这个内存区域会在需要排序渲染命令时使用,并根据实际需求动态分配或扩展。
在替换过程中,我们将之前依赖的 OpenGL 输出逻辑标准化,统一命名,使其更清晰并避免混乱。以往的 RenderToOpenGL
不再直接使用,而是通过命令的统一接口处理,例如 TiledCommandRenderToX
这类函数名,以保持结构一致。
接着我们设定渲染的目标输出位图为全局 GlobalBackBuffer
,它具备我们需要的结构字段,如 Width
、Height
、Memory
、Pitch
等。我们创建一个临时的 Bitmap
结构,将其字段设置为指向 GlobalBackBuffer
,以便可以将其作为输出目标传入软件渲染流程。这样,软件渲染命令可以像处理普通位图一样操作后备缓冲。
之后我们更新了 Win32DisplayBufferInWindow
函数,使其接收必要参数,包括渲染命令队列、渲染命令结构、设备上下文、窗口尺寸,以及临时内存指针。由于排序内存可能会因渲染命令数量增长而超出当前分配的大小,我们添加了一段逻辑来判断是否需要重新分配排序内存:
- 计算需要的内存大小 = 渲染命令数 × 单个排序项大小;
- 如果当前排序内存不足,就释放旧内存并重新分配新的、足够大的内存。
这个策略是动态分配的方式,允许内存按需增长,避免渲染崩溃或中断。虽然可以改为固定内存限制,但现在我们希望保留灵活性,稍后也能扩展到支持动态增长的内存分配系统。
最后,我们设定初始排序内存为 1MB,以覆盖绝大多数情况。实际使用过程中若命令量巨大,内存会自动扩展。
整体改动确保:
- 排序逻辑不再使用旧的临时内存;
- 渲染输出统一使用标准位图结构;
- 渲染命令管线更独立和模块化;
- 排序内存根据命令量动态管理,更安全灵活;
- 渲染输出逻辑和 API 清晰统一,便于后续迁移或替换平台层逻辑。
game_platform.h
:定义 typedef u64 umm
。
我们将排序内存的分配过程提前到游戏启动之前进行初始化,确保在游戏逻辑开始运行之前就有足够的内存空间可供使用。这是为了避免在运行时进行首次分配带来的不确定性或性能抖动。
另外,在平台层中添加了 mms
类型(表示内存大小的无符号整数),这是最近引入的一种类型命名方式,用于增强代码的可读性和一致性。它目前已经作为一个标准的定义加入平台层,便于在渲染流程和内存管理中统一使用。
总体上我们现在的排序内存分配流程如下:
- 游戏启动时就会尝试分配一定大小的排序内存(默认如 1MB);
- 每次需要使用排序功能前,都会判断当前内存是否足够;
- 如果不足,则释放旧的排序内存,重新分配更大的;
- 排序内存的大小由当前渲染命令数量决定,即
RenderCommands.PushBufferElementCount * sizeof(TileSortEntry)
; - 所有相关逻辑都已统一封装在新的渲染命令体系中,不再依赖旧的
RenderGroup
系统; - 渲染目标统一为
GlobalBackBuffer
,并作为Bitmap
结构体使用; - 渲染相关函数如
Win32DisplayBufferInWindow
也已调整为接受新参数,并在内部使用排序内存;
目前整体框架趋于完善:
- 内存分配策略安全灵活;
- 排序逻辑结构清晰,便于调试与扩展;
- 接口统一命名、结构合理;
- 平台抽象层也开始引入标准类型定义,代码一致性增强。
整体感觉结构清晰、逻辑严谨,是一个可以接受并继续扩展的实现方案。
https://gitee.com/mrxiao_com/2d_game_5/commit/8ec18611f8527e2875da30546449f3e105570fd6
构建并运行,PushRenderElement
处崩溃。
目前我们虽然在结构上已经基本整理完毕,但功能层面可能还无法正常运行。由于做了大量的修改,接下来很可能会遇到不少 Bug,需要投入时间进行调试。
首先我们意识到当前渲染命令部分没有进行初始化或重置的逻辑,这显然是一个潜在的崩溃源。由于渲染命令在使用之前没有被正确地初始化,因此程序可能在首次访问时就会发生崩溃或异常。
接下来我们需要:
- 明确渲染命令结构体的初始化机制;
- 在适当时机对其进行内存清空、重置缓冲区等操作;
- 确保每帧开始时渲染命令处于已知的良好状态;
- 避免残留数据导致渲染输出错误或排序逻辑混乱;
后续的调试方向就是围绕这些基础性问题展开。需要检查是否有遗漏的初始化调用、内存是否正确配置、数据结构状态是否一致等。一旦确认渲染命令能正常工作,其他部分才有意义继续测试和完善。
目前的重点是稳定基本运行流程,确保渲染命令的生命周期和使用方式在逻辑上是自洽的。后续才能进一步验证渲染输出的正确性。
正确为 PushBuffer
分配内存。
目前我们发现存在一个重要的初始化遗漏,尤其是关于 push buffer(推送缓冲区)的部分。之前虽然定义了 push buffer size
和 pushed buffer
,但并没有完成这部分的实现,甚至出现了错误地将 virtual_alloc
函数的地址赋值给 pushed buffer
的问题。这显然不是我们想要的行为,而是因为代码尚未完成填写,导致出现了无效或意外的赋值。
为了修复这一问题,采取了以下措施:
- 使用
win32_allocate_memory
函数正确地为pushed buffer
分配内存,分配大小为push buffer size
; - 这部分逻辑与前面处理的排序内存(sort memory)初始化逻辑类似,因此决定将其和排序内存一起整理,并放到同一初始化逻辑块中;
- 将分配 push buffer 的逻辑提前执行,使得在后续渲染命令推送之前,内存已经被安全地分配并准备好使用;
- 确保推送缓冲区的生命周期和排序缓冲区一致,集中管理更利于后期维护与调试;
总体来说,这一部分的整理使得内存初始化逻辑更加集中和清晰,避免了运行时因为未初始化导致的不可预期行为。同时也为后续渲染流程中使用推送缓冲区打下了更稳固的基础。
把DrawRectangleQuickly函数和RenderCommandsToBitmap函数移动到game_render.cpp 让win32 只依赖这几个文件其他的的是编译到dll中
首次成功构建并运行!
我们继续推进了一下调试和运行流程,结果有点出乎意料,原本以为第一次运行会失败,没想到它竟然正常工作了。虽然不太明白为什么一开始就能运行成功,但结果上是好的。
目前看起来运行了一年都没有问题的逻辑现在也能继续用了,这一切似乎都还可以接受,虽然过程感觉有点奇怪,也说不清哪里不对劲。
总之,我们先按这个方向继续推进下去,后续如果再发现问题,再做调整。现在的状态基本可以说是意外地顺利,但也要保持警惕。
“一切竟然都成功了”β。
这次的运行简直让人意外,我们并没有专门为了让它工作去写那些逻辑,结果它就那样自己跑起来了,甚至连调试代码也没有出问题。
我们现在将渲染命令聚合起来,并对整个渲染流程做了调整,结果竟然也都顺利运行,没有出现任何预期之外的错误,简直让人难以置信。
只能说运气不错,也许是编程之神今天心情好,放我们一马,没让我们陷入痛苦的调试深渊。从内存分配到命令排序再到 OpenGL 渲染部分,一切都在意料之外地流畅。
引入 GlobalUseSoftwareRendering
用于切换软/硬件渲染。
我们决定先测试软件渲染部分,这样在调试过程中可能更方便定位问题。
当前的渲染路径默认是通过硬件进行的,因此这一分支暂时还没有被测试。为了更方便地在硬件与软件渲染之间切换,我们引入了一个全局变量,用于控制当前使用的渲染方式。
我们新增了一个全局变量,用于标识是否使用软件渲染。变量命名为 GlobalUseSoftwareRendering
,默认值为 false
,也就是默认使用硬件渲染,这样在实际发布版本中无需修改即可直接使用高效的硬件路径。
代码逻辑中,硬件渲染的判断条件被移除,转而依赖这个新加的全局变量来决定走哪条渲染路径。如果 GlobalUseSoftwareRendering
为 true
,就使用软件渲染;否则,继续使用默认的硬件渲染流程。
此外,我们也需要确保在程序初始化阶段正确设置该变量,并在具体的渲染调用处根据它来决定调用软件或硬件渲染逻辑。
通过这种方式,我们可以方便地在调试时强制切换到软件渲染,以便更精确地分析和验证渲染指令处理过程是否正确,从而提升整体的调试效率和灵活性。
Platform 全局变量不知道为什么不能共享同一个变量导致会出现段错误
写一个函数给Platform 赋值一下
如果不知道为什么字体会出现bitmap->Memory 空指针
如果空指针不让push 否则会段错误
字体有Bug 不知道说明原因
黑板:VSync。
我们决定暂时不展开之前的话题,因为那样只会让问题更复杂。现在我们有一个更好的想法:我们要实现垂直同步(V-Sync)。之所以现在处理这些硬件相关的内容,是因为除了能获得不少额外性能,我们也希望能够尽早实现同步功能。同步本身其实也很简单,我们可以借此机会解释清楚它的原理。
首先,我们为什么目前还没有实现垂直同步?大家知道垂直同步是什么吗?其实即使不是图形编程人员,只要玩过游戏,一般都会知道垂直同步这个概念。它其实是一个比较“老派”的技术,起源可以追溯到早期的阴极射线管(CRT)显示器。
CRT显示器的工作方式是:通过一个电子枪沿着屏幕以锯齿形的路径扫描,并点亮屏幕前面涂有磷光体的玻璃板。当电子束击中这些磷光体时,会发出红绿蓝三种颜色的光,通过不同的亮度组合来显示图像。这种扫描是从屏幕的上方逐行往下进行的。
当电子枪扫到最底部之后,它需要返回到屏幕顶部重新开始新一轮的扫描,这个过程就叫“垂直同步”——Vertical Sync。我们从这个过程延伸出现在我们所说的垂直同步的概念,也就是“等待正确的时机”来更新画面帧。这样做的目的是为了让显示设备在更新帧的过程中不会出现“画面撕裂”的现象。
什么是撕裂?就是当显示器正在刷新当前帧的时候,如果我们在刷新过程中就切换到下一帧,就会造成画面上部分是上一帧的数据,下部分是下一帧的内容,从而让用户看到一个被撕裂的画面。例如游戏中一个角色在移动时,他的身体上半部分显示的是前一帧的位置,而下半部分却显示的是新一帧的位置,看起来就像被“剪开”了一样。
所以,我们实现垂直同步,不仅仅是为了防止画面撕裂,更是为了确保整个渲染和显示系统在时间上的一致性,也就是让我们的显示输出节奏与屏幕的刷新节奏保持同步,从而实现更平稳、更稳定的画面输出体验。
黑板:glSwapInterval
。
我们可以通过调用一个名为 glSwapInterval
(有时可能是 wglSwapInterval
,具体取决于平台)的方法来设置垂直同步的行为。这个函数允许我们指定在交换缓冲区(也就是显示下一帧)之前需要等待多少个垂直同步周期。
举个例子,如果我们传入的参数是 0
,这意味着我们不等待任何垂直同步信号,调用 SwapBuffers
后立即将帧显示出来。此时不管当前显示设备是否正在刷新,我们都会强行把帧贴上去,不管时机是否合适,这种方式可能会导致画面撕裂。
如果传入的是 1
,表示我们希望等待一次垂直同步,也就是等到屏幕完成当前帧的刷新并准备好接收新一帧时再进行显示,这样就可以防止撕裂现象。如果设置为 2
,则表示每两个垂直同步周期显示一帧,也就是隔一帧刷新一次。以此类推,可以设置为 3
、4
等等,实现更加可控的帧率限制。
需要注意的是,这个函数的行为只是一个“请求”。我们调用它只是告诉系统我们“希望”按照这个频率来进行垂直同步,但并不能保证一定生效。例如在某些硬件上,特别是一些不支持垂直同步的显卡上,这个请求可能会被忽略。
因此,我们还需要在游戏逻辑中加入检测机制。如果我们发现渲染帧率远高于目标帧率(比如每帧仅耗时 2 毫秒),那么很可能垂直同步并没有生效。此时我们可能需要手动加入延迟,比如通过定时器或等待的方式,来人为控制帧率,使其稳定在 30 或 60 帧每秒,以达到想要的视觉效果。
虽然这个设置不是强制性的,但在大多数用户设备上,如果用户没有在显卡控制面板中主动关闭垂直同步,glSwapInterval
的设置通常是有效的。所以这是一种值得使用的机制,可以帮助我们获得更稳定、更流畅的画面刷新体验。
SwapBuffers
是一个非常关键的函数,主要用于双缓冲(Double Buffering)渲染机制中。
基本概念:双缓冲机制
在现代图形渲染中,为了避免图像撕裂和闪烁,我们通常不直接在屏幕上绘制画面,而是采用“双缓冲”技术。具体来说,会有两个缓冲区:
- 前缓冲区(Front Buffer):当前正在显示给用户看的画面。
- 后缓冲区(Back Buffer):我们当前正在绘制下一帧的地方,用户看不到。
我们在后缓冲区上完成一整帧的绘制操作后,不是将其逐像素地拷贝到前缓冲区,而是通过 SwapBuffers
这个函数直接将两者“交换”。
SwapBuffers 的作用:
当我们调用 SwapBuffers(hdc)
时:
- 显示设备(通常是屏幕)开始显示后缓冲区的内容。
- 后缓冲区和前缓冲区的角色被互换,原来的后缓冲区变成了新的前缓冲区。
换句话说,这一帧的图像被推送到屏幕上显示,而我们接下来要绘制的下一帧内容则会在新的后缓冲区中进行。
为什么需要它?
- 避免撕裂:绘制过程中用户不会看到中间结果,确保画面完整。
- 避免闪烁:因为不会边绘制边显示,避免部分区域更新导致的闪烁现象。
- 配合垂直同步使用:如果设置了
glSwapInterval(1)
,那么SwapBuffers
会等待垂直同步信号后再进行交换,确保画面流畅并与显示器刷新频率一致。
调用时机:
通常我们在完成一帧所有绘制操作之后立刻调用 SwapBuffers
,表示“这一帧我画完了,可以显示了”。
总结一句话:
SwapBuffers
的作用是:把我们刚刚在后缓冲区里绘制好的图像,展示到屏幕上,同时开始准备绘制下一帧的内容,是双缓冲机制中负责“显示换帧”的关键函数。
上网查资料:docs.GL
。
我们曾听说过一个叫做 docs.gl 的网站,有人推荐说这是一个查阅 OpenGL 函数文档的好地方。我们去看了一下,确实感觉这个网站很不错,界面简洁、内容清晰,是一个很实用的资源库,尤其适合查找标准的 OpenGL 调用。
我们原本想在上面查找与 SwapInterval
相关的内容,比如 wglSwapInterval
或者 glSwapInterval
,不过好像没能在站内找到有关 wglSwapInterval
的具体说明。尽管如此,这个网站整体质量很好,是一个值得收藏的工具网站,用来查阅常用的 OpenGL 接口还是非常合适的。
我们推测,可能我们实际需要使用的是 wglSwapInterval
而不是 glSwapInterval
。虽然一开始不是很确定哪个才是正确的调用,但通过比对和推理,我们大致认为 wglSwapInterval
才是我们当前平台下正确的函数。这也再次说明在查阅文档、进行平台相关调用时,了解工具函数的命名和适用范围是很重要的。
总之,docs.gl 是一个我们认为非常实用的文档站点,尤其适合查询标准 OpenGL 的函数调用。对于扩展函数,比如与平台相关的内容(例如 Windows 下的 wgl*
系列),可能就需要查阅其他更专门的文档来源了。
查阅:WGL_EXT_swap_control
。
我们在这里讨论的是 wglSwapInterval
,这是一个扩展函数,属于 Windows 平台下 OpenGL 的一部分。OpenGL 在 Windows 上的实现分为两部分:一部分是标准的 OpenGL 函数(也就是 gl*
开头的),另一部分是 Windows 特定的绑定函数(wgl*
开头),这些函数用于与操作系统进行交互,比如窗口上下文的创建和管理。
wglSwapInterval
就是属于后者的,它不属于核心 OpenGL 标准,而是 Windows 平台的扩展部分。这也意味着这个函数只能在 Windows 上使用,在 macOS 或其他操作系统上是无法使用的,必须使用各自平台的扩展方式或替代方案。
由于这是一个扩展函数,我们需要先通过 wgl
的接口来获取它的函数指针,才能正常调用。也就是说,不能像调用普通 OpenGL 函数那样直接使用,而是要先动态加载它。
这里还涉及一个兼容性的问题:Windows 系统自带的 OpenGL 实现版本较低(通常只提供到 1.1),而 wglSwapInterval
是在之后的扩展中才添加的,并不包含在默认的绑定版本中。这意味着,如果系统没有安装更高版本的显卡驱动或支持库,我们是无法直接使用这个函数的。
总结一下:
wglSwapInterval
是 Windows 专属的扩展函数,用于控制垂直同步的行为。- 它必须通过
wgl
接口调用,属于操作系统层面的内容,而不是跨平台的 OpenGL 标准接口。 - 在其他操作系统(如 macOS)上不能使用该函数。
- 要调用它,通常需要通过扩展机制动态加载函数指针。
- 该函数可能不包含在默认的 OpenGL 绑定版本中,需要依赖显卡驱动提供支持。
因此,在使用时,我们不仅要注意平台兼容性,还需要处理扩展函数的加载和版本支持问题。
尝试调用 wglSwapInterval(0)
。
我们知道,如果直接在代码中调用 wglSwapInterval
,编译器可能会提示“未识别的标识符”,这是因为这个函数并不是最初就包含在 wgl
(Windows OpenGL 绑定)中的标准函数之一。
比如我们在使用 wglCreateContext
这样的函数时,它是标准的、直接可用的,因此系统能够识别。但 wglSwapInterval
不一样,它并不是最早就定义在 wgl.h
中的函数。它是通过 OpenGL 扩展机制添加进去的,因此默认头文件里没有声明这个函数。
这其实没有问题,因为 wgl
提供了一个扩展机制,我们可以通过这个机制动态获取新的函数指针并调用这些函数。具体做法通常是使用 wglGetProcAddress("wglSwapIntervalEXT")
这样的方式来获取这个扩展函数的地址,然后通过函数指针来调用它。
所以,如果我们直接尝试调用 wglSwapIntervalEXT
而没有提前通过扩展机制加载它,自然会出现无法识别的错误。但只要通过正确方式加载,我们就可以使用这个函数。
总结如下:
wglSwapIntervalEXT
不是默认定义的标准函数,而是通过扩展机制提供的。- 编译器初始状态下无法识别这个函数,需要先获取其函数指针。
- 使用
wglGetProcAddress
可以动态加载该函数。 wgl
的扩展机制允许我们访问更高版本或新特性的 OpenGL 接口。- 在使用前必须确认显卡驱动支持对应的扩展功能。
这就是为什么虽然它不是一开始就存在的函数,我们依然可以使用它来控制垂直同步,只要通过正确的方式动态加载它就行了。
用 wglGetProcAddress
获取 wglSwapIntervalEXT
。
在初始化 OpenGL 的过程中,我们希望在代码中加入对某些扩展函数的获取操作。比如在设置完 OpenGL 并调用完 SwapBuffers
后,我们需要获取像 wglSwapIntervalEXT
这样的扩展函数。
我们需要这些扩展函数的原因是,它们在默认链接的 OpenGL 库中并没有包含。因此不能直接调用,需要通过操作系统提供的机制动态获取它们的函数指针。这种做法类似于我们在 Windows 平台下获取其他系统 API(比如 Win32 API)时使用的 GetProcAddress
,我们告诉系统:“我知道有这样一个函数,你能不能给我它的地址让我调用?”
在 OpenGL 的 Windows 实现(WGL)中,也提供了类似的机制,只不过用的是 wglGetProcAddress
而不是标准的 GetProcAddress
。它是 WGL 提供的专门用于获取 OpenGL 扩展函数地址的函数。我们调用它并传入我们想要的函数名,比如 "wglSwapIntervalEXT"
,如果当前驱动或环境支持这个扩展,它就会返回一个函数指针。
拿到指针之后,我们就可以把它转换成合适的函数类型,并调用它。比如:
PFNWGLSWAPINTERVALEXTPROC wglSwapIntervalEXT = (PFNWGLSWAPINTERVALEXTPROC)wglGetProcAddress("wglSwapIntervalEXT");if (wglSwapIntervalEXT) {wglSwapIntervalEXT(1); // 设置垂直同步为每帧刷新
}
这个操作的意义在于:设置每次交换缓冲区时都等待一次垂直同步,从而防止画面撕裂,提高显示质量。
总结:
- 在初始化 OpenGL 之后,需要动态加载某些扩展函数。
- 使用
wglGetProcAddress
获取扩展函数地址。 - 加载到的函数指针可以用来控制如垂直同步等高级特性。
- 这种机制与 Windows 下获取动态函数地址的方式类似。
- 获取后一定要检查函数是否为
nullptr
,防止调用无效指针。 - 调用
wglSwapIntervalEXT(1)
表示启用垂直同步,每帧等待刷新。
这种方式为我们提供了更多的控制权和兼容性处理能力。
定义函数类型 wgl_swap_interval_ext
。
在代码中,首先需要定义 wglSwapIntervalEXT
的函数原型。这种定义通常使用 typedef
来确保函数原型能够正确加载,类似于之前多次进行过的操作。
步骤:
-
定义函数原型:
通过typedef
来声明wglSwapIntervalEXT
函数的原型。例如,定义一个函数指针,指向wglSwapIntervalEXT
函数,类型应该是BOOL (WINAPI *) (int interval)
,表示该函数需要一个int
类型的参数。 -
确保兼容性:
为了确保代码的兼容性,特别是当编译器以 32 位或 64 位模式编译时,需要标记该函数为WINAPI
,因为 Windows 上的调用约定在不同架构下可能会有所不同。32 位和 64 位架构的调用约定不同,使用WINAPI
标记可以确保在不同平台上正确调用。 -
函数指针声明:
将wglSwapIntervalEXT
的函数指针声明为一个全局变量,这样可以在程序中任何地方使用,并且保持它的值。通过这个指针可以确保在需要时调用该函数,并根据加载的结果进行相应的操作。 -
加载扩展函数:
使用wglGetProcAddress
来加载wglSwapIntervalEXT
扩展函数,如果加载成功,就可以使用它来控制垂直同步。 -
使用全局变量:
通过全局变量存储wglSwapIntervalEXT
的函数指针,这样可以确保跨越多个函数和模块时能够方便地访问它。
// 定义函数原型
typedef BOOL (WINAPI *PFNWGLSWAPINTERVALEXTPROC)(int interval);// 声明函数指针
PFNWGLSWAPINTERVALEXTPROC wglSwapIntervalEXT = NULL;// 加载函数
wglSwapIntervalEXT = (PFNWGLSWAPINTERVALEXTPROC)wglGetProcAddress("wglSwapIntervalEXT");// 使用函数
if (wglSwapIntervalEXT) {wglSwapIntervalEXT(1); // 启用垂直同步
}
总结:
- 通过
typedef
定义函数指针。 - 使用
wglGetProcAddress
动态加载扩展函数。 - 将函数指针声明为全局变量,以便在程序的其他部分进行访问。
- 在不同的系统架构下,使用
WINAPI
来确保调用约定的兼容性。
这样做的目的是确保扩展函数可以正确加载并能够在程序中有效地使用,同时保持代码的灵活性和兼容性。
https://registry.khronos.org/OpenGL/extensions/EXT/WGL_EXT_swap_control.txt
运行游戏,看到帧率稳定在60FPS。
现在,当代码运行时,可以看到游戏达到了稳定的 60 帧每秒(FPS)。这意味着每一帧的渲染时间大约是 16 毫秒,整个系统的运行速度符合预期。这是因为 OpenGL 在执行帧交换时,会等待直到帧边界到达,确保在正确的时刻进行渲染更新,从而避免了不必要的延迟。
接下来,如果不调用 wglSwapIntervalEXT
或将其设置为 0(即不启用垂直同步),可以看到会发生什么。当设置为 0 时,游戏不再等待垂直同步,导致游戏以最快的速度运行,这就好像是启用了加速一样。此时,游戏运行速度变得非常快,大约是原来的 7 到 8 倍。这样,游戏就像是加速播放一样,帧率飞速增加,表现出非常快速的运行效果。
这表明,启用垂直同步可以有效地控制帧率,避免游戏过快或过慢,保持稳定的帧率表现。而禁用垂直同步或将其设置为零时,游戏会以最快的速度运行,完全不考虑显示器的刷新率。
注意:我们还没有完成(多次重复)。
虽然现在看起来功能基本正常,例如我们可以通过设置 wglSwapIntervalEXT(1)
来达到锁定在每秒 60 帧的效果,但实际上这还远远不够完善,有几个关键点需要进一步处理和理解。
首先,我们并不知道实际的显示器刷新率是多少。比如,如果用户使用的是一台 120Hz 的显示器,那么设置 SwapInterval=1
并不会锁定在 60 帧每秒,而是会达到 120 帧每秒。因此仅凭这一设置,我们不能准确控制帧率。所以我们需要进一步的逻辑来检测或适配显示器的实际刷新率,以实现更加精确的帧率管理。
其次,关于 OpenGL 的扩展机制,这里面也有一些需要注意的地方。仅仅调用 wglGetProcAddress("wglSwapIntervalEXT")
来获取函数指针并不足够。按照规范,应该首先通过检查扩展字符串来确认当前环境是否支持该扩展。这是因为某些函数指针即使可以获取,但并不代表驱动程序实际支持或能正确使用该扩展。
具体做法是,先通过调用 wglGetExtensionsStringEXT
(或者在一些环境中是 wglGetExtensionsStringARB
)获取一个包含所有可用扩展名称的字符串,然后在这个字符串中查找是否存在 "WGL_EXT_swap_control"
。只有在确认字符串中包含这个扩展名之后,才可以安全地获取对应函数指针。
不过,这种做法也有它的问题。即使字符串中声明了支持该扩展,也不保证 wglGetProcAddress
能返回一个有效的指针(可能返回空指针)。反过来,即使函数指针可以获取,也不能百分百代表扩展可用。因为理论上驱动也可能返回一个占位符函数或别的意图不同的实现。所以这种机制本身就有些不一致。
从实用角度来看,通常我们会直接通过 wglGetProcAddress
获取函数指针,并检测其是否为空。如果不为空就使用。但在更加“标准”的实现中,会先检查扩展字符串确认功能是否支持,然后再去获取函数指针,并在运行时动态使用。
最后还要注意一点:在查找字符串时要避免部分匹配错误,比如不能只用 strstr
简单判断,否则容易误判。应该将字符串按空格或其他分隔符分割,再逐项精确比较扩展名是否存在。
综上所述,正确管理 OpenGL 扩展和 SwapInterval 需要:
- 检测显示器刷新率:以决定目标帧率;
- 检查扩展字符串:确认扩展是否支持;
- 获取函数指针并校验;
- 根据需要调用扩展函数;
- 合理处理帧同步逻辑,避免无控制的高速运行或逻辑错乱。
这是一套完整且较为严谨的图形初始化逻辑框架,尤其适用于对性能和兼容性要求较高的项目。
查阅 ARB_framebuffer_sRGB
。
我们当前使用的是带有 ARB_framebuffer_sRGB
扩展的帧缓冲。可以看到和听到很多与这个扩展相关的术语,但关键在于,我们真正需要处理的是在创建像素格式时的具体设置。
之前我们做过类似的操作,比如在使用 OpenGL 创建像素格式时,会先描述我们想要的像素格式,在那个时候(特别是在使用 glXChooseVisual
这类函数时),我们就可以传入和 sRGB
相关的参数。这些参数并不是 Linux 特有的,而是 WGL_ARB_framebuffer_sRGB
扩展所定义的。
虽然文档中可能没有很明确地展示示例,但重点是我们可以在创建帧缓冲时指定它为 sRGB
格式,只要我们启用了相关功能。这个 sRGB
帧缓冲格式的作用,是允许我们以感知上更一致的方式来处理颜色空间,特别是进行颜色混合(blending)时。
我们目前没有设置任何 OpenGL 中关于 Gamma 校正的选项,而这会导致渲染出来的画面看起来会偏亮或者颜色不自然。虽然我们在软件渲染器中尝试正确处理了 Gamma 校正,比如使用平方值等技术,但由于现在的 OpenGL 管线并不知道这些值是已经处理过的,所以在做颜色混合时,不会得到 sRGB
感知上的正确结果。
理想情况下,我们希望能够切换回软件渲染器来对比,这样可以更明显地看到颜色处理上的差异。但当前情况下无法做到这点。所以现在即使我们传入了已经平方过的颜色值,系统也不会知道它们已经被 Gamma 处理过,最终表现出来的效果就是颜色混合不正确,没有实现 sRGB
感知的混合。
sRGB(Standard Red Green Blue)是一种标准RGB颜色空间,由HP和微软在1996年共同制定,目的是为不同设备(如显示器、打印机、摄像机)提供一致的颜色表现。它现在是网络、操作系统、显示器和图形API中最广泛使用的颜色空间之一。
sRGB 的核心概念:
-
标准化的颜色表示法:
- sRGB 定义了一个固定的颜色范围(色域),以及如何将颜色值映射为实际亮度和颜色。
- 所有设备如果遵循 sRGB 规范,显示出来的颜色就会一致。
-
伽马校正(Gamma Correction):
- sRGB 中的颜色值并不是线性亮度的直接表示,而是经过了一个伽马为 2.2 左右的非线性变换。
- 这和人眼对亮度的感知更匹配,人眼对暗部更敏感,对亮部不那么敏感。
- 举个例子,颜色值 0.5 在 sRGB 下的实际亮度并不是一半,而是接近 0.22 左右的线性亮度。
-
与人眼视觉匹配的混合(Blending):
- 在做颜色混合(例如透明图层叠加)时,如果直接使用 sRGB 的值,会导致不正确的视觉结果。
- 正确做法是:先把 sRGB 值转换为线性空间(解伽马),混合后再转回 sRGB。
为什么使用 sRGB 很重要:
方面 | 原因 |
---|---|
显示一致性 | 所有支持 sRGB 的设备上,颜色看起来几乎一样 |
人眼匹配 | 更符合人类的视觉感知,视觉效果更自然 |
网络与标准化 | HTML、CSS、图片格式(如 PNG、JPEG)默认使用 sRGB |
避免色差 | 如果不使用 sRGB 或处理不当,图片在不同设备上会出现颜色偏差 |
在编程和图形API中的应用(如 OpenGL):
- 帧缓冲(Framebuffer) 可以指定为 sRGB 格式,用于确保输出的图像颜色是经过 Gamma 校正的。
- 必须开启
GL_FRAMEBUFFER_SRGB
才能让 OpenGL 在写入帧缓冲时自动进行 Gamma 校正。 - 如果不开启或设置错误,颜色混合会在 sRGB 空间里直接执行,会导致图像变亮或不自然。
简单类比(便于理解):
- sRGB 就像是一个颜色的“翻译器”,确保所有设备“说”同一种语言。
- 不开启 sRGB,就像在不同国家用不同方言交流,结果可能完全误解颜色的本意。
ARB_framebuffer_sRGB
是 OpenGL 的一个扩展,它的作用非常关键:
让帧缓冲支持 sRGB 格式,并自动进行 Gamma 校正处理。
它能做什么?
在默认情况下,OpenGL 渲染输出到帧缓冲时,不会考虑颜色是否是线性空间的,也不会自动进行 Gamma 校正。如果你给它传入了线性颜色值(比如物理上计算出来的光照结果),OpenGL 会直接把这些值写入显示器,但显示器其实是期望收到的是 sRGB 格式(非线性的颜色),这就会导致图像太暗或太亮,看起来不自然。
而启用 ARB_framebuffer_sRGB
就可以自动帮你完成这个过程。
开启后的效果:
- 当你将颜色写入帧缓冲时(如
gl_FragColor
或gl_FragData
),OpenGL 会自动把线性颜色转换为 sRGB。 - 这样最终的显示颜色就会看起来更符合人眼感知,更真实、自然。
怎么用这个扩展?
-
检查扩展支持情况:
const GLubyte* extensions = glGetString(GL_EXTENSIONS); // 看是否包含 "GL_ARB_framebuffer_sRGB"
-
开启 sRGB 帧缓冲支持:
glEnable(GL_FRAMEBUFFER_SRGB);
-
确保你的默认帧缓冲或自定义 FBO 使用了 sRGB 颜色格式:
- 比如使用
GL_SRGB8_ALPHA8
作为纹理或 renderbuffer 的内部格式。
- 比如使用
举个例子:
假设我们在着色器中计算出了一个线性空间下的颜色值:
vec3 color = vec3(0.5); // 线性亮度 50%
- 如果没有开启
GL_FRAMEBUFFER_SRGB
,这个值会原样输出,显示器会进一步套用 Gamma,导致变暗。 - 如果开启了
GL_FRAMEBUFFER_SRGB
,OpenGL 会先将这个线性值转成 sRGB(大约 0.73),再输出到显示器,结果就正确了。
注意事项:
- 必须使用支持 sRGB 的帧缓冲格式,否则启用也无效。
- 这个扩展默认是关闭的,必须手动启用。
- 如果你在自定义帧缓冲中使用了纹理或 renderbuffer,也需要使用 sRGB 格式,比如
GL_SRGB8
或GL_SRGB8_ALPHA8
。
总结一句话:
ARB_framebuffer_sRGB
让 OpenGL 在写入帧缓冲时自动进行线性到 sRGB 的转换,从而确保颜色显示正确、自然,是做颜色管理和 Gamma 校正的关键机制。
查看软件渲染器如何做伽马校正。
为了让 sRGB
正确工作,我们需要确保两个关键位置都进行了处理:源纹理的读取和帧缓冲的写入。这跟我们之前在软件渲染器中处理颜色混合的流程是类似的。
渲染时需要做的两件事:
-
从纹理中读取颜色时,要从 sRGB 转换为线性空间(Linear Space)
这是因为纹理中的颜色通常是以 sRGB 格式存储的,但颜色混合的计算必须在线性空间中进行。 -
从帧缓冲中读取目标像素颜色(用于混合时)也要转为线性空间
完成颜色混合后,还需要把结果从线性空间转换回 sRGB,再写入帧缓冲。
在我们软件渲染器中的做法回顾:
- 在加载目标帧缓冲像素颜色时,我们先对其取平方(模拟从 sRGB 到线性空间的转换)。
- 纹理中的颜色本身就是 sRGB 编码,我们使用位移操作(shift)来做近似的解码处理,把它们也转成线性空间。
- 颜色混合完成后,我们再对颜色值取平方根,模拟从线性空间回到 sRGB,保证输出正确。
在 OpenGL 中怎么做同样的事:
-
使用 sRGB 格式的纹理:
- 在创建纹理时,指定其内部格式为
GL_SRGB8_ALPHA8
,而不是普通的GL_RGB8
或GL_RGBA8
。 - 这样在采样时,OpenGL 会自动将纹理中的颜色从 sRGB 解码为线性空间。
glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB8_ALPHA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
- 在创建纹理时,指定其内部格式为
-
启用帧缓冲的 sRGB 写入支持:
- 调用
glEnable(GL_FRAMEBUFFER_SRGB)
,这会使 OpenGL 在写入帧缓冲时自动进行从线性空间到 sRGB 的转换。 - 如果未启用这项功能,即使使用了
GL_SRGB8_ALPHA8
,也不会发生自动转换。
- 调用
-
使用支持 sRGB 的帧缓冲格式:
- 默认帧缓冲通常已经是 sRGB 格式,但如果使用 FBO(帧缓冲对象),需要指定颜色附件为
GL_SRGB8_ALPHA8
或类似格式。
- 默认帧缓冲通常已经是 sRGB 格式,但如果使用 FBO(帧缓冲对象),需要指定颜色附件为
注意点总结:
步骤 | 需要做什么 |
---|---|
提交纹理 | 用 GL_SRGB8_ALPHA8 作为内部格式,让 GPU 自动解码 sRGB |
开启 sRGB 写入支持 | 调用 glEnable(GL_FRAMEBUFFER_SRGB) |
输出帧缓冲格式 | 使用 sRGB 格式的 renderbuffer 或默认帧缓冲 |
渲染中颜色混合 | GPU 会在正确的线性空间中处理混合,结果更自然 |
写入帧缓冲 | OpenGL 会把混合结果重新编码为 sRGB 格式写入 |
总结:
我们需要在两个地方确保颜色空间正确转换:
- 纹理采样时从 sRGB 到线性空间
- 颜色混合后从线性空间回到 sRGB
只要纹理使用 GL_SRGB8_ALPHA8
格式,并开启 GL_FRAMEBUFFER_SRGB
,OpenGL 会帮我们自动完成这整套流程,避免手动处理平方、平方根等复杂步骤,同时还能保证图像在视觉上更准确自然。
https://registry.khronos.org/OpenGL/extensions/EXT/EXT_texture_sRGB.txt
有条件地使用 SRGB8_ALPHA8_EXT
扩展。
在设置纹理图像格式时,我们需要指定内部格式(internal format),而默认情况下我们使用的是 GL_RGBA8
,也就是常规的线性颜色空间纹理格式。但为了支持正确的颜色处理,我们希望使用的是 sRGB
颜色空间的纹理格式。
正确做法是使用 GL_SRGB8_ALPHA8
格式:
- 这样 OpenGL 会在采样纹理时自动从 sRGB 空间转换为线性空间。
- 替代掉原本的
GL_RGBA8
,能确保所有纹理采样时颜色值是线性化的,利于正确进行颜色混合等操作。
实现思路:
-
设置默认纹理内部格式:
- 默认值是
GL_RGBA8
- 如果检测到
GL_ARB_framebuffer_sRGB
扩展存在,就将其改为GL_SRGB8_ALPHA8
- 默认值是
-
以统一的方式设置纹理格式:
- 在全局范围中设置一个默认纹理格式变量,例如
DefaultInternalTextureFormat
- 初始化 OpenGL 时,判断是否支持 sRGB 扩展,如果支持就赋值为
GL_SRGB8_ALPHA8
,否则保持为GL_RGBA8
- 在全局范围中设置一个默认纹理格式变量,例如
-
使用时统一引用默认格式:
- 在创建纹理的地方统一使用这个变量,比如:
glTexImage2D(GL_TEXTURE_2D, 0, DefaultInternalTextureFormat, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
- 在创建纹理的地方统一使用这个变量,比如:
结果:
- 所有纹理将默认使用 sRGB 格式(前提是支持该扩展)。
- 纹理采样时会自动进行 sRGB → 线性空间的转换,保证后续混合的准确性。
- 这部分处理解决的是“纹理部分”的颜色空间问题,但帧缓冲部分仍然需要单独设置(例如开启
GL_FRAMEBUFFER_SRGB
并确保帧缓冲的颜色格式为 sRGB 类型)。
总结:
我们实现了一个机制:
检测是否支持 sRGB 扩展,如果支持,就将所有纹理的内部格式从默认的 GL_RGBA8
改为 GL_SRGB8_ALPHA8
。这样可以自动完成从 sRGB 到线性空间的转换,确保纹理在渲染时的颜色采样是准确的,颜色混合逻辑也会更自然、真实。这是实现正确颜色空间管理的关键步骤之一。
启用 GL_FRAMEBUFFER_SRGB
。
我们除了要处理纹理的 sRGB 格式之外,还需要处理另一件非常重要的事情:启用帧缓冲的 sRGB 写入功能。这一步是确保最终写入屏幕的颜色正确的重要环节。
我们需要做的事情:
-
启用帧缓冲的 sRGB 支持
- OpenGL 默认是不会自动将线性空间颜色转换为 sRGB 再写入帧缓冲的
- 必须手动调用
glEnable(GL_FRAMEBUFFER_SRGB)
,才能激活这一功能
-
同样需要检测扩展是否存在
- 在使用该功能前,必须先判断是否支持
GL_ARB_framebuffer_sRGB
扩展 - 只有扩展存在,
GL_FRAMEBUFFER_SRGB
这个 enable 选项才会有效
- 在使用该功能前,必须先判断是否支持
实现逻辑:
我们在初始化 OpenGL 的时候,要执行类似下面的逻辑:
if (ExtensionExists("GL_ARB_framebuffer_sRGB")) {glEnable(GL_FRAMEBUFFER_SRGB);
}
- 检查扩展是否支持
- 如果支持就开启
GL_FRAMEBUFFER_SRGB
,这样在将颜色写入默认帧缓冲或者自定义的 sRGB 格式帧缓冲时,OpenGL 会自动将颜色从线性空间转换为 sRGB 格式,避免颜色显示过亮或不自然
这一步的重要性:
- 前面我们已经确保纹理是
GL_SRGB8_ALPHA8
格式,这样纹理采样是正确的(sRGB → 线性) - 然后我们在片段着色器里做了一系列颜色混合操作,这些都是在线性空间中完成的
- 最后一步必须再从线性空间 → sRGB 空间,然后写入帧缓冲显示出来,否则颜色将会看起来过亮或者偏差严重
总结操作步骤:
步骤 | 操作 |
---|---|
检查是否支持 GL_ARB_framebuffer_sRGB | 使用扩展检测函数进行判断 |
启用 sRGB 写入 | 如果支持,则调用 glEnable(GL_FRAMEBUFFER_SRGB) |
配合使用 sRGB 帧缓冲格式 | 默认帧缓冲通常支持,但 FBO 需要明确使用 sRGB 格式 |
总结:
我们需要在初始化阶段做一件事:检测是否支持 GL_ARB_framebuffer_sRGB
扩展,如果支持就开启 GL_FRAMEBUFFER_SRGB
,从而启用帧缓冲的 sRGB 写入功能。这是颜色管理中的关键一环,确保从纹理采样到最终输出,每一步都在正确的颜色空间中完成,避免色彩失真。
定义所需的 GL_
扩展宏。
我们在使用 OpenGL 的扩展功能时,会遇到一些常量或宏值在标准头文件中并未定义的情况。尤其是像 GL_FRAMEBUFFER_SRGB
或 GL_SRGB8_ALPHA8
这样的值,在扩展还没正式合并进主版本的 OpenGL 时,默认的头文件里是找不到的。因此,我们需要手动为这些常量赋值,以便在代码中使用。
我们的处理方式如下:
-
手动定义缺失的常量:
- 有些扩展提供的宏在旧的头文件中没有定义
- 我们需要查找对应扩展中这些常量实际的数值,然后手动在代码中
#define
出来
例如:
#ifndef GL_FRAMEBUFFER_SRGB #define GL_FRAMEBUFFER_SRGB 0x8DB9 #endif#ifndef GL_SRGB8_ALPHA8 #define GL_SRGB8_ALPHA8 0x8C43 #endif
-
从 OpenGL 扩展头文件中获取定义:
- 可以参考 OpenGL 的扩展头文件(如
glext.h
或者扩展库的GL/glew.h
等) - 在这些文件中搜索关键词如
GL_FRAMEBUFFER_SRGB
或GL_SRGB8_ALPHA8
- 直接复制这些值并粘贴到我们自己的头文件中备用
- 可以参考 OpenGL 的扩展头文件(如
-
注意可能的版本更新:
- 有些常量(例如旧的
GL_SRGB_ALPHA
)可能已经被更现代的版本(如GL_SRGB8_ALPHA8
)所取代 - 我们要确保使用的是当前推荐的格式,以便兼容现代图形硬件和驱动
- 有些常量(例如旧的
背后机制说明:
- OpenGL 的核心规范不断更新,扩展最初以
ARB_
、EXT_
等前缀存在 - 后来如果被采纳进核心规范,可能会有不同的名字或更标准的定义
- 因此,我们需要关注扩展是否已经合并进核心,并适时替换使用新版常量
总结:
由于某些 sRGB 相关的常量默认头文件中缺失,我们需要手动为它们定义正确的数值。这些数值可以从 OpenGL 扩展头文件中查找并引用。只需定义我们实际用到的那几个,比如 GL_FRAMEBUFFER_SRGB
和 GL_SRGB8_ALPHA8
。通过这种方式,我们的代码可以在不依赖完整扩展库的情况下支持这些高级功能,实现正确的颜色空间管理。
google gl glcorearb.h
https://registry.khronos.org/OpenGL/api/GL/glcorearb.h
https://registry.khronos.org/OpenGL/extensions/EXT/EXT_sRGB.txt
黑板:ARB 的意思是 “Architecture Review Board”。
ARB 是 OpenGL 中扩展系统的一部分,全称是 Architecture Review Board,是负责维护 OpenGL 规范的机构之一。
关于扩展的演进机制:
OpenGL 的扩展机制允许硬件厂商在标准 OpenGL 功能之外,提供额外功能。这些扩展的命名和规范遵循一套标准流程:
-
厂商私有扩展(Vendor-specific Extension):
- 某个厂商(比如 NVIDIA)在自己的驱动或硬件中实现了某个新功能
- 该扩展以厂商前缀命名,例如
GL_NV_sRGB
,表示这是 NVIDIA 的私有扩展 - 只能在该厂商的设备上使用
-
共享扩展(EXT 扩展):
- 如果多个厂商都愿意支持这个功能,或者都实现了它
- 扩展会升级为
EXT_
前缀的格式,如GL_EXT_framebuffer_sRGB
- 表示这个扩展由多个厂商共同实现,有较广泛的兼容性
-
正式标准扩展(ARB 扩展):
- 当 ARB 认为这个扩展足够成熟、稳定,并且未来会成为 OpenGL 核心标准的一部分时
- 扩展将升级为
ARB_
前缀,例如GL_ARB_framebuffer_sRGB
- 表示这是一个被 OpenGL 官方机构采纳和认可的标准扩展
-
合并进核心标准:
- 如果扩展最终被 OpenGL 采纳为正式标准功能
- 它会被直接合并到核心规范中,去掉所有扩展前缀(比如
GL_FRAMEBUFFER_SRGB
就是核心规范中的名称) - 此时不再需要检查扩展是否存在,所有支持该 OpenGL 版本的实现都会包含该功能
实际使用中我们关注的:
- 使用扩展时应优先判断是否存在 ARB 或 EXT 版本
- 如果常量未在当前的头文件中定义,可从 ARB 或 EXT 扩展文档中查找定义值
- 应优先使用最终被采纳为核心的名称和值(如
GL_SRGB8_ALPHA8
和GL_FRAMEBUFFER_SRGB
)
举例:
阶段 | 命名示例 | 说明 |
---|---|---|
私有扩展 | GL_NV_sRGB | NVIDIA 独有 |
多厂商扩展 | GL_EXT_framebuffer_sRGB | 多家厂商支持 |
ARB 扩展 | GL_ARB_framebuffer_sRGB | 正式标准扩展 |
核心功能 | GL_FRAMEBUFFER_SRGB | OpenGL 正式核心规范中的功能 |
总结:
OpenGL 的扩展经历了从厂商私有、到多厂商共享、再到 ARB 标准化,最终合并进核心规范的过程。我们在实现 sRGB 渲染支持时,优先使用 ARB 或核心规范中的名称和值,这样可以确保最大的兼容性和最稳定的行为。需要使用的常量,如果在系统头文件中缺失,可以根据 ARB 最终版扩展查找其数值并手动定义。
注释掉扩展检查代码,测试渲染。
总结起来,主要是在讲解如何启用 sRGB
色彩空间支持,尤其是在 OpenGL 中启用 GL_FRAMEBUFFER_SRGB
后,如何确保正确的操作和设置。
-
直接调用扩展函数:目前并没有检查扩展是否可用,而是直接调用了与
sRGB
相关的扩展函数。这样做虽然方便,但不安全。正常情况下,应该在调用这些函数之前检查相应的扩展是否存在,以确保代码的兼容性。 -
未来计划:未来会添加扩展检查的代码,即在执行之前确认扩展是否存在。如果扩展存在,才执行相关操作。这是为了保证代码在不同的系统和硬件环境中能够稳定运行。
-
设置标志位:启用
sRGB
相关功能的关键在于设置正确的标志位。通过设置适当的标志,可以启用对sRGB
的支持,使得颜色空间能够正确处理。
总结来说,现在的操作是直接调用相关函数,这种方式虽然可以暂时解决问题,但未来应该检查扩展是否可用,并确保正确设置所有相关标志位,以确保代码的健壮性和跨平台的兼容性。
OpenGL 渲染正常,软件渲染会发白
成功实现 sRGB 渲染。
总结内容如下:
-
可能的效果:
- 目前采用的方法可能有效,也可能无效,具体效果需要实际运行之后才能确定。这样做是在尝试通过一些调整来解决当前的问题,但仍需要进一步的验证。
-
未来计划:
- 未来会进一步修改代码,使用更精确的 sRGB 渲染方式。现阶段,虽然已经实现了 sRGB 渲染,但可能并不是最准确的,因为当前的实现可能不完全符合预期。
- 目标是让软件渲染尽可能精确,而硬件渲染(如显卡支持的渲染)会更为准确,软件渲染则可能因为一些优化或“取巧”的方式,导致精度稍差。
-
Alpha 渲染改进:
- 在启用了 sRGB 渲染后,Alpha 渲染效果已经有所改善,看起来比之前更自然、不那么粗糙。
-
扩展功能:
- 至此已经实现了基本的 sRGB 渲染支持,虽然这一过程只是开始,未来还会有更多关于扩展功能的探索和改进。
总结来说,当前的实现已经取得了一些进展,使得渲染效果得到了改善,特别是在 Alpha 渲染上。不过,仍然需要进一步的优化,特别是在使用 sRGB 渲染时,未来会考虑更精确的渲染方式。
Q&A
是否会基于 OpenGL 版本或扩展实现多种渲染路径?
总结内容如下:
-
多渲染路径的实现:
- 在不同的 OpenGL 上下文版本和可用扩展情况下,是否实现多种渲染路径的问题。由于在 2D 游戏中很难要求使用一些不存在的扩展,因此不太可能实现多渲染路径,尤其是当某些扩展无法保证在所有环境下可用时。
-
扩展的检查:
- 如果确实有某个功能或扩展是必需的,但不确定它是否存在,那么可以使用条件语句来进行检查。如果扩展存在,就使用相关功能;如果不存在,则采取其他替代方案。这种方法适用于处理可能缺少的扩展功能。
-
实际应用:
- 在实际开发中,可能会遇到某些特定扩展在不同硬件或驱动环境中不可用的情况。为此,可以通过运行时检查扩展是否可用,然后根据检查结果来决定采用哪个渲染路径或功能。
总结来说,虽然多渲染路径的实现并不常见,特别是在 2D 游戏中,由于硬件或驱动的差异,但如果有需要使用的扩展,可以通过运行时的检查机制来处理。
修复 cutscene 中的圣诞老人排序逻辑。
总结如下:
-
问题分析:
- 在处理某些渲染或排序操作时,出现了一个不明原因的问题。尤其是在涉及负数或特定排序顺序时,似乎有某种不稳定的情况。具体来说,这个问题可能与排序的稳定性有关,即排序是否会始终保持一致的顺序。
-
排序稳定性问题:
- 提出了一个假设,即排序操作可能不是完全稳定的。通常情况下,稳定排序指的是如果两个元素的值相同,那么它们的相对位置应该保持不变。但在这种情况下,排序可能并没有做到这一点,从而导致了问题。
-
解决方案:
- 讨论了两个可能的解决方案。一个是通过改变排序的顺序来解决问题,另一个则是通过调整“背景”对象的位置,将其稍微移到更远的距离,从而检查问题是否得到解决。
-
背景与层次关系:
- 在调整顺序或层级时,问题似乎与第一层的特殊性质有关。即第一层可能在排序过程中扮演了某种特殊的角色,这可能是导致问题的根源。
-
混乱与不确定性:
- 整个问题令人困惑,特别是关于排序的稳定性和层次顺序的问题。虽然尝试调整背景的位置并改变排序顺序,但依然不清楚为什么这些变化会产生如此大的影响。
总的来说,这个问题似乎与渲染排序的稳定性有关,而解决方法可能涉及更精细地控制层次顺序或进一步确认排序是否符合预期。
开启 VSync 后帧时间波动是否会导致跳帧?
-
最大帧时间与同步开启的表现:
- 在没有启用同步的情况下,最大帧时间大约为 2 到 3 毫秒。而当启用同步时,帧时间通常保持在 16 毫秒左右,有时会跳到 70 毫秒。这引发了一个问题:这是否意味着帧会被跳过?
-
同步的影响与解释:
- 其实,这并不意味着帧会被跳过。出现较长的帧时间(如 70 毫秒)并不代表系统跳过了帧,而是帧时间的延迟较长。这里需要澄清的是,这种延迟并不是由于帧丢失或丢帧导致的。
-
进一步的说明:
- 在处理这些性能问题时,可能会有一些混淆。事实上,启用同步可能导致某些帧时间延长,但这并不意味着会错过某些帧。因此,理解同步如何影响帧率以及为什么会出现较大的波动是非常重要的。
黑板:SwapBuffers。
-
Swap Buffers 机制:
- 在游戏循环中,调用
swap buffers
后,并不会导致程序暂停或等待。实际上,这只是告诉驱动程序“我们已经完成了这一帧,可以开始渲染下一帧”。 - 换句话说,
swap buffers
并不会让程序等待它完成,而是告诉驱动程序可以开始下一帧的渲染工作。程序会继续执行下一段代码,而渲染可能已经开始。
- 在游戏循环中,调用
-
渲染与同步:
- 当没有启用垂直同步(V-Sync)时,每帧的渲染时间非常短,可能只有2毫秒。在这种情况下,可以迅速计算和渲染多帧,但这些帧还没有显示出来。
- 当帧缓冲区已满(例如显卡已经积累了太多未显示的帧),驱动程序会阻止进一步的渲染,直到显示器准备好显示新帧。此时,程序可能会等待。
-
Swap Buffers 和帧时间:
- 事实上,
swap buffers
并不会阻塞线程,直到驱动程序决定停止接收新的渲染数据。显卡可以选择双缓冲或三缓冲等多种渲染方式,允许在显示一帧的同时计算下一帧,从而减少等待时间。 - 但如果显卡的缓冲区太满,导致新的渲染数据无法处理,驱动程序会暂停并等待,直到能够继续处理。
- 事实上,
-
帧渲染与延迟:
- 每一帧的渲染计算可能非常快(例如2毫秒),但因为需要等待显示器进行垂直同步,导致帧的显示时间可能会较长(例如16毫秒)。
- 过多的等待时间会导致延迟增加,因此虽然显卡可以处理多帧,但为了避免增加输入延迟,通常不会让系统积累太多未显示的帧。
-
帧时间与显示等待的关系:
- 渲染帧时间与等待显示帧的时间并没有直接的关系。等待显示的时间通常和驱动程序的行为有关,它决定何时需要更多的渲染数据。
- 通常情况下,驱动程序会根据帧时间来调整渲染进度,但它们之间没有严格的直接关联。
-
结论:
- 由于显卡和驱动程序的工作方式,程序并不会因为
swap buffers
的调用而丢帧。驱动程序会根据当前显示的进度来决定何时接收新数据,而不会因为等待显示而中断渲染流程。
- 由于显卡和驱动程序的工作方式,程序并不会因为
面向对象编程是否适合游戏开发?
-
ARB 扩展在游戏开发中的问题:
- ARB(Architecture Review Board)扩展被认为在通用场景和游戏开发中都不是很理想。
- 原因在于,这类扩展往往来源于厂商推动,虽然最终被纳入标准,但在兼容性、实用性和一致性方面可能存在问题。
- 对于游戏开发而言,稳定性和跨平台一致性非常重要,而某些 ARB 扩展可能在不同硬件或驱动上表现不一致,从而导致不可预期的问题。
-
检测 OpenGL 扩展的策略:
- 检查扩展是否存在的原始方式是将扩展字符串进行字符串查找,然后判断其结尾是否为空格或制表符,以确保是精确匹配某个扩展名,而不是错误匹配到其他扩展中。
- 不过这种方式比较粗糙,并不高效或清晰。
-
更好的方式是构建哈希表:
- 更推荐的做法是解析扩展字符串,然后把所有扩展名整理进一个哈希表中。
- 查询时可以直接通过哈希表判断某个扩展是否存在,查找更快,逻辑更清晰,也更不容易出错。
- 这样可以对程序中想要支持的扩展进行统一管理,维护和调试都更方便。
-
整体思路:
- 目标是清晰、快速、可靠地判断目标扩展是否存在,以决定是否启用某些特性或路径。
- 与其手动比对字符串,不如建立一个结构化的数据结构来管理所有可能的扩展,提高开发效率与代码可维护性。
是否有必要把 swap interval
设置成大于1?
我们在某些情况下会选择将渲染帧率从更高的帧率(例如60帧)切换到较低的帧率(例如30帧),这样做是有实际意义的,尤其是在帧率无法稳定维持在目标值时。
具体解释如下:
-
当机器性能不足时的处理策略:
- 假设当前设备的性能不足以维持目标帧率,比如目标是每帧16毫秒(即60FPS),但实际运行中渲染时间总是超过这个时间,导致帧率无法稳定。
- 这种情况下,如果继续尝试维持60FPS,会产生不稳定的体验,如撕裂、卡顿等问题。
-
切换到更低帧率的好处:
- 为了获得更平滑、稳定的表现,可以将目标帧率降低一半,即改为每帧33毫秒(即30FPS)。
- 同时,将 Swap Interval(交换间隔)设置为 2,意味着每两帧与显示器同步一次,从而让渲染帧与屏幕刷新节奏保持一致。
- 这样可以确保即使在较低性能的硬件上,也能获得一致、不跳帧的显示效果。
-
主动控制帧率转换:
- 在引擎中,可以加入逻辑判断当前渲染是否能够维持目标帧率,如果不能,则自动切换到较低帧率运行。
- 这不是显卡驱动自动处理的,而是渲染程序或引擎层面的优化策略,能提升游戏或图形应用在不同硬件下的兼容性和表现。
-
整体意义:
- 这是一种动态性能调节方式,在性能不足时主动“降档”,以换取更稳定的用户体验。
- 适用于游戏、实时渲染应用等对画面连贯性要求较高的场景。
总结:当我们无法维持显示器的刷新速率时,主动将帧率降低并通过设置合适的交换间隔,可以保证画面输出稳定、同步且流畅,是一种非常实用的渲染调节技巧。
是否有异步的 VSync 方法?
目前并没有直接的异步方式可以询问 OpenGL 当前帧是否已经显示,或者还要等待多长时间才会显示这一帧。至少在常规使用的 OpenGL 版本中(尤其是较早的版本中),并没有明确提供这种机制。
以下是具体说明:
-
OpenGL 并不直接暴露帧显示状态:
- OpenGL 是一个命令式的图形 API,它的设计中,客户端应用只是向驱动发出渲染指令,而这些指令的具体执行(尤其是显示到屏幕)通常由驱动内部的缓冲机制和系统层决定。
glSwapBuffers
只是告知驱动“我们这一帧渲染完了,可以开始处理了”,并不意味着此时就会立即显示。
-
没有标准方法查询显示状态:
- 通常并不能用 OpenGL 的标准 API 查询“这一帧何时会被显示”或“是否已经被显示”,也无法直接获得预计的显示时间。
- 这类时间点控制往往是由底层的窗口系统、GPU 驱动甚至操作系统层级进行管理,而不是 OpenGL 自身控制。
-
可能存在新版本或扩展支持此类功能:
- 在较新的 OpenGL(如 4.x)或某些厂商扩展中,也许存在某些同步或时间戳功能(例如 OpenGL 的
ARB_sync
、ARB_timer_query
),这些可能可以间接帮助获取 GPU 完成某些任务的时间。 - 例如可以设置一个
fence
,然后查询这个 fence 是否已经完成,用以间接判断 GPU 是否处理完某一帧的渲染,但这并不等价于“是否已经显示”。
- 在较新的 OpenGL(如 4.x)或某些厂商扩展中,也许存在某些同步或时间戳功能(例如 OpenGL 的
-
更常见的处理方式:
- 实际开发中,若需要同步帧显示时间,可能会结合平台 API(如 Windows 的 DWM、Vulkan 的呈现时间 API,或使用 GLFW、SDL 提供的相关功能)来实现更准确的帧时间控制。
- 一些平台提供对显示垂直同步或显示管线状态的查询,可能能实现近似效果。
总结:OpenGL 本身并没有提供标准的、异步的接口来查询某一帧是否已经被显示或即将显示。虽然有些扩展可能间接支持同步机制或时间查询,但一般都不是直接用于“帧显示进度查询”。如果有类似需求,通常需要结合平台层或使用更新、更现代的图形 API(如 Vulkan)来实现更精确的帧时控制。
如何让游戏以非60FPS运行但动画速率不受影响?
要让游戏以非 60 帧每秒的速率运行且不会导致动画加快或减慢,实现起来其实非常简单直接。关键在于游戏逻辑和动画更新过程依赖的是“时间步长(delta time,简称 dt)”,而不是帧数本身。
我们在每次调用游戏更新和渲染函数时,只需要传入当前这一帧实际经过的时间(dt 值),无论是 16ms、33ms 还是其他时长,只要正确传入这个时间,动画就能按照真实时间节奏播放,而不会因为帧率变化而变快或变慢。
至于矩阵中旋转和缩放的问题:
- 在 3x3 的仿射变换矩阵中,旋转信息通常混合在矩阵的上三角(前三行三列)中。
- 缩放信息则通常存储在主对角线的位置上(即 0,0;1,1;2,2 这几个元素),因为缩放是直接放大或缩小每个轴的系数。
- 当旋转和缩放同时存在时,它们确实会互相影响:比如先缩放后旋转和先旋转后缩放得到的结果不一样,因为变换矩阵是“顺序相关”的。
- 如果想让旋转和缩放在计算中互不干扰,通常需要在构建变换矩阵时进行矩阵分解或使用统一的建构顺序(例如:先做缩放,再做旋转,最后做平移),以保持行为的可预测性。
总结:
- 保持动画时间与
dt
同步,可确保不同帧率下动画一致。 - 仿射变换矩阵中旋转与缩放确实存在耦合,要注意顺序。
- 使用一致的矩阵组合策略可避免变换失真。
如何在120Hz显示器上跳过 VSync?
当检测到显示器刷新率高于 60Hz 时,可以通过调整交换间隔(Swap Interval)来处理跳帧或重复帧的问题。具体做法是根据刷新率与目标帧率之间的整数倍关系,设置一个合适的交换间隔。例如,如果显示器是 120Hz 而游戏目标是 60Hz,就可以设置交换间隔为 2,以实现在每两个刷新周期里只交换一次缓冲区,从而锁定 60 帧。
如果刷新率是 144Hz,而希望游戏以 72Hz 运行,也可以设置交换间隔为 2。总之,只要刷新率与目标帧率之间有合适的倍数关系,都可以通过这种方式处理跳帧问题,实现稳定的帧率输出。
在 Windows 上,如果 V-Sync(垂直同步)被显式禁用,那么驱动程序通常就不会对渲染进行帧同步控制,也就是不再等待垂直同步信号来交换缓冲区。这意味着渲染帧可能会在显示器刷新周期中任意时刻被推送到屏幕,可能会导致撕裂等视觉问题。需要注意的是,V-Sync 在 Windows 中只是一个“请求”,它并不是强制保证,驱动是否真正启用由具体情况决定。
此外,还提到在图形场景中出现了一些模型姿势的问题,比如某个模型的拇指方向异常等,这种细节需要进一步在美术资源中调整和修正。
为什么 VSync 会在某些机器上引起输入延迟?
启用垂直同步(V-Sync)会导致输入延迟的根本原因在于它引入了更深层次的缓冲机制。V-Sync 的作用是等待屏幕刷新周期的垂直同步信号再进行帧的交换,以避免画面撕裂。但这种等待本身会带来延迟,因为渲染出的帧不能立刻显示,而是被“排队”等待合适的时机显示。
这种延迟在帧率较低时更为明显。比如,如果渲染速度只有每秒 30 帧、20 帧甚至 15 帧,那么每一帧在被显示之前都可能经历显著的等待时间,从而导致玩家在操作时感觉到响应不及时,产生明显的输入延迟。
至于纹理存储方面,如果将纹理以线性颜色空间格式(Linear Color Space)存储在磁盘上,是不划算的做法。原因在于:人眼对不同亮度的感知能力并非线性。暗部细节需要更多的比特数来表示才能让人感知差异,而亮部则可以用更少的数据来表现。所以,如果将纹理以线性格式直接存储,会浪费大量的数据位数,效率低且质量不佳。
这正是 sRGB 非线性颜色空间存在的意义。我们会在磁盘中使用 sRGB 格式压缩存储纹理,以便用较少的数据表现更高的视觉质量。渲染时再将 sRGB 解码成线性空间用于光照等计算,最后输出时再编码回 sRGB,这一过程确保既节省了存储空间,又提升了画面质量。整套流程虽然“看起来复杂”,但本质上是对人类视觉系统和硬件性能的一种高效配合与优化。
声音效果什么时候实现?
关于声音效果的实现时间,目前还没有确定的计划,要等真正觉得是时候再考虑加入。
关于窗口淡出到桌面的处理,是因为之前切换到 OpenGL 的时候,设置成了非双缓冲模式(non-double-buffered),并且背景被设置成透明或默认状态。在这种情况下,某些桌面环境下的窗口淡出效果会消失。现在这个淡出效果又重新出现,是因为在解决 RBG 显示相关问题的过程中,所采取的修复方法也间接地让“最上层窗口”能够再次正常渲染,从而恢复了原先的淡出动画。所以,这种桌面淡出效果目前会保留,因为它是在修复其他问题时自然回归的结果。
使用 WglSwapInterval()
后还需要 Sleep
吗?glFinish
和 glFlush
有什么区别?
关于 wglSwapInterval
、glFinish
和 glFlush
的使用,可以总结如下:
我们在渲染结束时是否需要主动 sleep
,取决于 wglSwapInterval
是否生效。
- 如果
wglSwapInterval
设置成功并正常工作(即系统或驱动确实执行了垂直同步),它会自动在合适的时候阻塞(等待),从而达到限帧效果。在这种情况下,不需要我们手动sleep
来控制帧率。 - 但如果
wglSwapInterval
没有生效,比如驱动忽略了设置(有些系统或显卡驱动会无视它),我们就得自己根据帧耗时来决定是否需要主动sleep
,以避免跑满性能、导致过热或资源占用过高。
关于 glFinish
和 glFlush
:
这两个函数用于控制 OpenGL 命令流的执行时机,关键区别在于它们对“等待完成”的严格程度不同:
-
glFlush
:- 作用是告诉 OpenGL 把当前已经积累的命令(比如绘制指令)立即发送给 GPU 开始处理;
- 它不会等待这些命令执行完成;
- 就像在写文件时调用
fflush()
,只是把缓冲内容交给系统处理,但不等到数据真的写入硬盘; - 一般在命令发送完但不想阻塞 CPU 时使用。
-
glFinish
:- 是一个更严格的同步点;
- 它会阻塞当前线程,直到所有之前提交给 OpenGL 的命令都真正完成(即 GPU 渲染也完全完成);
- 就像强制等写入操作完成之后再继续;
- 非常消耗性能,一般不建议频繁使用,除非有非常特殊的同步需求(比如用于性能测试或特定场景调试)。
总结:
- 如果
wglSwapInterval
正常,我们就不需要自己再sleep
控帧; glFlush
用于“尽快开始渲染”但不等待;glFinish
用于“等待渲染全部完成”,但性能开销大,除非必须,否则避免使用。
会使用 EXT_swap_tear
吗?
我们使用 glXSwapIntervalEXT
设置了 -1
(假设这是可能的情况),这表示我们希望禁用垂直同步或进入某种特殊模式。在这种情况下,如果调用 glXSwapBuffers
返回得特别快,也就是说帧没有被阻塞渲染或者等待,那么我们仍然需要在主循环中做一些额外的工作来控制节奏。
我们不能假设 GPU 或驱动一定会做出理想的限速行为,因为 SwapBuffers
不再自动为我们等待垂直同步信号。在这种场景下,为了不让帧率过快,导致 CPU 占用过高或逻辑更新异常快,我们就需要手动做一些同步或等待逻辑:
- 比如根据前一帧的耗时,决定是否主动
sleep
一段时间; - 或者使用更高精度的计时机制来主动等待下一个理想的刷新点。
也就是说,SwapBuffers
在没有阻塞的情况下,就不再是帧率控制点,我们必须自己引入延迟控制逻辑,才能确保渲染和游戏逻辑的稳定性。我们还是需要一些处理来维持帧时间的合理分布,而不是让它无限快跑下去。
是否应该充分利用等待 VSync 的时间做更多计算?
我们在每帧渲染过程中,是希望尽可能利用等待垂直同步(V-Sync)期间的空闲时间,去执行其他可以预先计算的任务,最大化帧间的利用率。这样可以提高程序整体性能,比如提前做一些逻辑处理、准备下一帧的数据等。理论上这是理想做法,但也不能无限度地执行更多任务。
我们当前的做法就是这样实现的:在每帧的 SwapBuffers
调用中,如果 GPU 允许继续向前缓冲,我们就继续推进下一帧的工作。这意味着我们可以跑在前面,把准备好的帧放入队列,直到 GPU 拒绝接收更多帧为止(这通常是因为达到了双缓冲或三缓冲的上限)。这个时候,我们就必须停止,等待 GPU 消化已提交的内容。
这实际上已经是一种预计算和最大化处理时间的方式。我们允许程序在不阻塞的前提下尽可能多做准备工作,但一旦达到 GPU 的缓冲上限,我们就必须等待。因为再向前推进只会带来输入延迟变大的问题。
我们不能无限制地提前渲染帧数,因为如果缓存了太多帧,玩家的输入就无法及时反馈到屏幕上,导致操作延迟严重,影响游戏体验。所以我们通常控制只提前 1 到 2 帧的缓冲量。
至于如何判断是否开启了 V-Sync 或者其实际效果,我们可以通过主动监听显示器刷新率,并通过测量每帧实际耗时来判断是否匹配刷新周期。如果帧率刚好落在刷新率整除关系上,说明 V-Sync 在发挥作用;如果帧时间非常小,可能说明 V-Sync 未启用或不起作用。这种检测可以帮助我们动态调整行为,比如是否需要手动延迟、是否需要控制帧率等。
如何在双显示器上实现不撕裂的 VSync?
在双显示器设置中,防止画面撕裂的方式与单显示器设置有所不同。为了实现同步更新,我们需要使用一个叫做“交换链”或“交换组”的机制。
具体来说,当在双显示器或多显示器环境中工作时,每个显示器对应一个渲染上下文。如果希望所有显示器同时更新,就需要确保所有显示的内容同步。首先,我们需要为每个窗口(即每个显示器)分别设置渲染上下文,然后通过将这些显示器加入到一个“交换组”中,来保证它们在同一时刻进行更新。
在更新时,可以通过指定交换组,告诉系统“现在所有显示器的内容需要同时更新”,这样它们会同时刷新,避免出现画面撕裂的情况。如果有多个显示器,并且希望其中一些显示器同时更新,其他显示器则稍微延迟,这也是可能的,只需要适当设置交换组即可。
总的来说,双显示器或多显示器设置下,防止撕裂的关键在于合理管理每个显示器的渲染上下文和交换组,通过同步它们的刷新来避免撕裂现象。