图形学实验框架 Dandelion 始末(二):最初原型实现

当我有了初步的需求分析结果,下一步就可以开始写代码了。虽然当初动手实现之前已经有了一些思考和设计,但后来写出的代码还是有不少问题。这一篇文章回顾最初对代码结构的设计,其中有很多粗糙的地方后来被大幅度重构,但事件循环和交互处理的部分还是大致留存下来了。

上一篇回顾: 图形学实验框架 Dandelion 始末(一):需求与设计

项目配置

如果动手的第一步是建个文件夹,那么第二步就是创建“项目” (Project) 。一个项目与一些源代码文件的区别主要在于依赖管理和构建管理,而对于 C++ 来说 CMake 已经是构建系统的事实标准了,我就先不尝试其他工具,直接跟随主流用 CMake 来管理项目。

依赖库整合

提供依赖库的方法有很多,比如包管理器、git submodule ,或者干脆要求使用者去相应项目主页上手动下载都可以。但我做的是个教学框架,配置起来越简单越好。而这几种方法都要专门指定版本,还有可能遭遇某个版本后来被开发者删掉的尴尬。因此我索性将所有的依赖库都放置在项目中,并且一并进入 git 管理。

后来我们发现 Assimp 的代码有些小问题,这种做法反倒还方便了我去手动修复,算是个意外收获了。不过这时候是没有其他想法的,只想尽量让配置简单些。

Dandelion 所依赖的第三方库有这么三种:

  • 必须编译成库文件再链接的:Assimp / GLFW
  • 仅头文件 (Header Only) 或者直接随着项目源代码一起编译的:Eigen / GLAD / Dear ImGui / spdlog / stb image / portable file dialog
  • 同时支持以上两种方式的:fmt

为了构建简单,我选择以 Header Only 的方式引入 fmt ,因此后两种的配置仅限于写好 target_include_directories 。此时刚刚开始开发,所以我也并未考虑要对 Assimp 和 GLFW 作什么特殊的配置,只简单地用 add_subdirectory 作为子目录加入了项目中。

在这个过程中遇到的一些小麻烦包括:

macOS 上构建 GLFW 时需要多加一些依赖,这是因为 macOS 与 Linux 有些差异,某些被叫做 Framework 的库也要引入。

if (APPLE)
    find_package(OpenGL REQUIRED)
    target_include_directories(${PROJECT_NAME} PRIVATE ${OPENGL_INCLUDE_DIR})
    target_link_libraries(${PROJECT_NAME}
        "-framework Cocoa"
        "-framework OpenGL"
        "-framework IOKit"
        ${OPENGL_gl_LIBRARY}
    )
endif()

spdlog 是自带一份 fmt 的 (bundled fmt) ,这时 fmt 是“依赖的依赖”。而因为“格式化字符串”是个很常用的操作,我想把 fmt 作为直接依赖引入,方便在 Dandelion 的代码中调用 fmt 的函数。要让 spdlog 不使用自己的 fmt ,就需要额外定义一个宏:

target_compile_definitions(${PROJECT_NAME}
    PRIVATE SPDLOG_FMT_EXTERNAL
)

既然不再需要,我就顺手删掉了 spdlog 自带的 fmt 以减小项目体积。

构建配置

我想让项目在以 Debug 配置构建时输出更详细的日志,而以 Release 配置构建时则少输出一些,这可以通过用宏 DEBUG 控制条件编译来实现。相应地,在 CMakeLists 中要指定只有 Debug 配置下才定义这个宏,这里我用了生成表达式 (Generator Expression) 这种比较简洁的写法:

target_compile_definitions(${PROJECT_NAME}
    PRIVATE $<$<CONFIG:Debug>:DEBUG>
)

而考虑到我们人手很少,又是从头开发,把警告等级调高以便查错是个合适的选择。我是在 Linux 上开发,此时尚未仔细考虑 Windows / macOS 上的问题,于是直接开了 -Wall -Wextra -Werror 几个选项来强制要求消除所有警告。

第一步:创建运行环境

从零开始编写代码时,我比较习惯首先从入口点写起,这样写完一步之后就能得到一个可执行的程序。Dandelion 是一个使用 OpenGL 渲染场景、使用 Dear ImGui 创建图形界面的程序。根据 OpenGL 渲染管线的要求和 Dear ImGui 的 API 约定,它在显示图形之前需要做以下几件事情:

  1. 加载 OpenGL 函数地址
  2. 初始化 OpenGL 上下文,创建窗口并加载图标
  3. 设置好 OpenGL 上下文的基本属性(如开启深度测试等)
  4. 初始化 Dear ImGui 上下文
  5. 加载 GUI 使用的汉字字体
  6. 编译并链接用于渲染场景的 shader

在程序的整个生命周期中,需要与平台 API 打交道的工作基本上仅限于此。为了便于将这些部分与其他逻辑隔离,我用一个 Platform 类来完成它们。主函数中几乎不包含任何逻辑,只创建一个 Platform 实例并将控制权交给它。

在渲染与交互的过程中,整个程序始终在执行 事件循环 ,这是一个用于处理所有输入输出的死循环,直到 GUI 被销毁才退出。在事件循环的每一趟,

  • 按照 OpenGL 双缓冲渲染模式的要求,要调用 glfwSwapBuffers 交换前后两个缓冲以更新画面
  • 按照 GLFW 的约定,要调用 glfwPollEvents 来获取设备输入
  • 按照 Dear ImGui 的约定,要调用处理输入和重绘 GUI 的 API

这些事务也是与平台 API 交互的,并且和具体的功能性逻辑没有关系,所以也是由 Platform 实例执行。而真正执行功能性逻辑(比如布局、离线渲染、建模等等)的部分应该在每趟循环中被调用,并根据当前的状态来判定要做什么操作, Platform 实例的事件循环则是这些功能性逻辑的入口。这样一来,程序的执行顺序就是 主函数 → Platform 构造函数 → Platform::eventloopPlatform 析构函数 → 返回退出。将整个过程画成活动图的形式是这样的:

life cycle: overview

Platform 除了初始化的流程比较长,其余的代码都相当短小。eventloop 实际上只有五六个函数调用,而真正主要的部分才刚刚开始。

第二步:渲染 GUI

Dandelion 的交互策略是将每一类功能集中于一个模式中,根据之前的需求分析,它应该需要布局 (layout) 、建模 (Model) 、渲染 (Render) 、模拟 (Simulate) 四个模式。此时要实现的就是布局模式的基本功能。

从事件循环走向功能逻辑

Dandelion 的所有功能都是以三维场景(或者物体)的实时显示为基础的,而在当下它还是个单线程的程序,因此在事件循环中通往功能逻辑的主要入口就是 渲染全部内容的函数 。每调用一次这个函数,程序就渲染一帧画面。另外,程序也需要处理在这段时间内到来的输入,因此我也设置了处理所有输入的入口函数。

实际上处理输入的入口函数与渲染画面的入口函数可以是同一个,在这个函数里再分别调用其他的函数就行。这里我把它们拆分开并没有什么特殊的用意,只是习惯使然。

单线程意味着:

  • 在所有的输入或运算全部被处理完之后,程序才能返回到事件循环中调用 glfwSwapBuffers 渲染下一帧,因此这个程序的帧率是不固定的,任务越重帧率越低。
  • 各种逻辑处理调用呈树形向下展开,树根就是刚才提到的入口函数。

在上一篇文章中提到,Dandelion 基本上是按照 MVC 架构设计的,因此我的入口函数放在了 Controller 类当中。这个类的唯一实例负责将输入(例如鼠标光标坐标)解释为要进行的交互操作并相应地修改场景数据,但不直接负责渲染任何东西,只调用具体的渲染函数。总而言之,控制器

  • 有两个入口函数,分别是处理输入的入口 Controller:process_input 和渲染的入口 Controller::render
  • 还有一大堆处理输入的函数,把屏幕坐标、点击和按键转换成相应的函数调用或者属性修改
  • 是一个单例的类,不会有第二个实例对象(这了我用了 Meyers' Singleton 的方法将其实现为单例类)

将上面活动图中 Functions 泳道的部分(下半部分)展开来画是这样的:

life cycle: event loop

GUI 与 UI 组件的封装

通常来说 GUI 有两种实现思路,分别是保留模式 GUI (Retained Mode GUI, RMGUI) 与立即模式 GUI (Immediate Mode GUI, IMGUI)。

前者的 API 一般是面向对象风格的:用户需要创建一些 UI 组件实例(例如按钮或者文本框),每个 UI 组件实例就是一个内部维护了若干状态的对象。当用户输入触发了某些条件,程序就修改一些对象属性,从而修改 UI 组件的外观。这样的运行流程中并不体现“帧”的概念:调用者创建了一个组件后它就显示出来,只要在处理输入的函数里修改 UI 组件的状态,不需要专门调用某个函数来“绘制” UI 组件。Qt 就是一种典型的 RMGUI 框架,它的信号-槽机制也是一种非常经典的 RMGUI 实现方案。

后者的 API 则完全不同,在调用者看来,立即模式 GUI 的 API 都是无状态的。调用者需要在每一帧调用 API 来绘制 UI 组件,而不是自己维护一个 UI 对象。例如,每一帧都调用 button 函数就会在屏幕上显示一个按钮,按钮的样式由传入的参数决定,并不存在什么按钮对象。这种 GUI 上手起来非常简单,实现方案往往也更轻量化,Dandelion 使用的 Dear ImGui 就是一种 IMGUI 框架。

乍一听起来,每一帧都调用大量的函数来绘制 UI 组件意味着每一帧都要重绘整个 GUI ,从而导致严重的性能下降。实际上 IMGUI 框架内部会维护很多状态,从而尽量避免无意义的重绘,实际性能并不会很差。IMGUI 所谓的“无状态”是对调用者无状态,不是指它的内部实现也无状态。

因为直接调函数创建 GUI 在代码规模小时更方便,所以不少开发者用 IMGUI 框架搭建一些自用的开发工具,比如游戏地图编辑器或者 tile 编辑器等等。

先简单画一个 UI 界面草图,大概是这样的:

UI sketch

整个界面上的 UI 组件只有两个,分别是工具栏 (Tool Bar) 和菜单栏 (Menu Bar) 。使用 RMGUI 框架时,肯定要创建两个对象来维护这些组件的状态;但是由 UI 组件维护状态的做法不太符合 IMGUI 框架的思路,Dandelion 这样的小规模软件也不需要这样做。

所以,虽然我在源代码中创建了 ToolbarMenubar 两个类,但这两个类的主要作用是包装 API 调用而非维护状态数据,这与面向对象设计方法中的对象语义有较大的差别。当我构造了这两个类的实例、调用 Toolbar::renderMenubar::render 这两个函数时,我的目的是调用 Dear ImGui 的 API 来绘制所有的按钮、Tab 页等元素,要做的是根据全局状态进行绘制而不是接收输入修改状态。至于调用了哪些函数、绘制了哪些基本组件,这就是一些查询 Dear ImGui 文档并逐个尝试的工作了,繁琐但没有什么值得一提的东西。

可惜的是,我在刚刚开始写代码时并没有很好地认清这一点,这导致我把最重要的全局状态——当前所处的工作模式放在了 Toolbar 这个类中。这破坏了“不将它设计成对象”的原则,让 Toolbar 的封装风格既不像对象也不像函数,埋下了迫使我日后重构代码的隐患。

到此为止,Dandelion 的代码中已经有了四个类:

  • Platform 类封装了平台相关的 API 并负责启动程序前的准备工作
  • Controller 类负责处理输入并调用渲染(绘制)逻辑,也负责维护一些全局状态,持有 ToolbarMenubar 的实例
    • Toolbar 负责绘制工具栏,目前也保存着 Dandelion 的工作模式
    • Menubar 负责绘制菜单栏

Controller::render 这个函数首先调用 Controller::process_input 处理输入,然后依次调用 Toolbar::renderMenubar::render 来绘制 UI ,最后一步就是调用 Scene::render 来渲染场景了。而 Scene 这个类正是后续保存、处理场景数据的核心。

渲染一个空场景

一个三维场景中至少包括若干物体 ,而如果考虑渲染模式(离线渲染)的要求,那么场景中还要包括光源相机两种对象。Dandelion 是用一个 Scene 对象加若干 Object / Light / Camera 对象存储场景数据的。Scene 对象持有后三种数据对象,而 Controller 对象持有全局唯一的 Scene 对象。

因为以后或许会有创建多个 Scene 实例的需求(以及对滥用单例的担忧),所以我没有将 Scene 这个类也写成单例。

那么根据这样的抽象模型,一个 Scene 对象至少应该能够

  • 加载模型文件并转换为场景中的物体
  • 持有物体、光源、相机等数据对象(负责管理它们的内存)

现在场景中还没有加载任何东西,首先要渲染出背景。Scotty3D 的代码结构中,任何会被显示到屏幕上的对象都有一个用于渲染自身的 render 方法,UI 组件、场景、物体皆然。此时我基本上是摸着 Scotty3D 过河,场景对象方面也在模仿它的设计思路,用 Scene::render 方法来渲染场景。要用经典的渲染管线渲染场景,首先要准备好两个东西:

  • 顶点数据,由要渲染的对象提供(比如物体的 mesh 、光源的坐标等)
  • 变换矩阵,除了 Model 矩阵由物体记录以外,View 和 Projection 矩阵都由当前用户的视角决定

顶点数据已经记录在 Scene 持有的数据对象中了,而视角信息则不然。不过,用户观察场景的视角很适合用一个相机来表示。这个相机具有与渲染器相机类似的属性(位置、观察方向、视角大小、宽高比等等),区别在于观察场景的相机代表了用户的观察视角,所以 GUI 界面上是看不见它的;而渲染器相机是给离线渲染使用的,用户可以直接看到它的位置和朝向。

为了便于区分,以下用主相机指代用户观察场景所用的相机,用渲染相机指代软渲染器进行离线渲染时所用的相机。对于只有一个预览窗格的 Dandelion 而言,主相机必定是唯一的,所以主相机参数直接存储在 Controller::main_camera 中。

由于布局模式下并不需要显示关于渲染的信息,现在暂不考虑光源和相机如何显示,先让 OpenGL 渲染管线渲染一个空场景。这并不难——只要在构造 Scene 对象时直接创建一个主相机并赋予它一组默认参数,再根据相机参数设置 shader 中所用的变换矩阵,就可以显示画面了。虽然场景中没有物体,但最好还是有基本的参照物来标定空间的位置和方向。最常见的参照物是在地平面 (y=0y=0) 处渲染一片方形网格,这也不难实现,只要设置一组固定的顶点坐标和颜色,然后直接调 OpenGL API 绘制 GL_LINES 类型的图元即可。

于是,一个空场景的渲染流程就是:

  1. Controller::render 函数中,根据 main_camera 的参数计算出 View / Projection 矩阵,并设置 shader 中的 uniform 变量。
  2. 进入 Scene::render 函数,直接调用 Scene::render_ground 渲染地平面网格。
  3. 进入 Scene::render_ground 函数,通过设置 shader uniform 变量关闭着色计算(渲染地平面网格不需要专门着色,整个网格的颜色是一样的)
  4. 如果是第一次渲染,那么将顶点数据(每条网格线的起点和终点)传入显存(向 OpenGL 的 Buffer 复制数据)。
  5. 调用 glDrawElements 绘制图元。

这时我终于得到了一个能看到图的软件,它大概长这个样子:

initial version of Dandelion

为了突出三个坐标轴,我额外用红、绿、蓝三色画了线,不过操作方法与绘制灰色的地平面网格完全一样。

这里虽然有了些菜单和按钮,但尚未实现它们背后的逻辑,所以点击之后也都是没反应的。

然而到目前位置,这还是个只能看不能动的界面。为了能够移动视角,我至少要给 Controller 增加一些处理鼠标输入的方法,从而让用户可以使用鼠标操控主相机。

第三步:交互处理

在三维场景中移动视角的方法有很多,我想要用一种比较方便直观的方案:

  • 拖动鼠标时视角绕着固定点(当前就是原点)旋转
  • 滚动滚轮时视角沿着观察方向移动,视野相应放大或缩小

先不考虑其他的输入,目前需要给 Controller 增加处理鼠标拖动和滚轮的函数,并在这两个函数中修改主相机的参数。而 Controller::process_input 负责判断当前接收到的是哪种输入,并调用这两个函数进行处理。

Dear ImGui 提供了 ImGuiIO::WantCaptureMouse 属性,用于表示当前光标是否位于某个 UI 组件上。当光标不不在任何 UI 组件上时,它就在渲染的场景上。所以在 Controller:process_input 函数中,要在 ImGuiIO::WantCaptureMousefalse 时根据具体输入选择要调用的函数:

  • 当按下左键拖动时,就调用 Controller::on_mouse_dragged 函数旋转视角
  • 当滚动滚轮时,就调用 Controller::on_wheel_scrolled 函数调整视野

后者只需修改主相机的坐标,因而乏善可陈;但前者还有个小麻烦——为了让拖动动作更加自然,需要将光标在屏幕上的坐标变化量(二维)映射成空间中的旋转(三维),这需要做一点点数学建模工作。

通过模拟轨迹球旋转视角

屏幕上鼠标光标的移动都是二维的,比三维空间中的旋转少一个维度。有一些设备可以直接产生三维的旋转,比如轨迹球 (track ball) 就是一种很经典的三维操纵设备(甚至还有一个 emoji 🖲就是表示轨迹球的)。它的结构中包含一个可以向任意方向旋转的小球,只要抓着这个小球转动,屏幕上的视角就按照相同(或者相反)的方向转动。为了实现类似轨迹球的操控方式,我需要自己将鼠标光标的移动模拟成轨迹球的滚动。

想象一个表面比较粗糙的轨迹球,用一根手指按在上面让它来回滚动,而指尖与轨迹球的相对位置不动。此时,一次旋转过程可以用初始和结束两个时刻指尖的坐标(起点和终点)表示。

虽然理论上轨迹球的转动方向不受限制,但它毕竟没有悬浮在空中,而是必须和底座相连。所以实际上轨迹球露出的部分只有上半球,也就是球面方程 x2+y2+z2=r2x^2+y^2+z^2=r^2z>0z>0 的部分。那么我只要给出指尖位置的 (x,y)(x,y) 坐标,就能唯一确定相应 zz 坐标。把指尖换成光标,取窗口中心为原点,屏幕平面为 z=0z=0 平面,就可以将光标位置 ps=(xs,ys)\mathbf{p}_s=(x_s,y_s) 映射到虚拟的轨迹球位置 pt=(xt,yt,zt)\mathbf{p}_t=(x_t, y_t, z_t) ;而有了轨迹球上旋转的起点和终点 pt,1,pt,2\mathbf{p}_{t,1}, \mathbf{p}_{t,2} ,我就可以求出旋转变换了。

但这个方案还不能完全解决问题。窗口是矩形的,矩形的内切圆部分可以直接求出一个 zz 坐标,按照

z=r2x2y2z=\sqrt{r^2-x^2-y^2}

计算即可,而内切圆以外的部分则不能直接对应到半球面上,光标移动到这里时就无法求出轨迹球坐标了。一种比较好的解决方案是换一种曲面:只取球面的一部分,在边界处与另一个可以无限外延的曲面相接,并保证边界处的切平面相同。OpenGL Wiki 上有一个条目 Object Mouse Trackball 选择用旋转双曲面来延展球面,效果是这样的:(图片来自 OpenGL Wiki )

composition surface for trackball simulation

x2+y2=r2/2x^2+y^2=r^2/2 (上图中红色曲线)为边界,边界以内用球面、以外用旋转双曲面,得到的分段曲面方程是:

z={r2x2y2x2+y2<r22r2/2x2+y2otherwisez= \begin{cases} \sqrt{r^2-x^2-y^2} & x^2+y^2<\frac{r^2}{2} \\ \frac{r^2/2}{\sqrt{x^2+y^2}} & \text{otherwise} \end{cases}

有了统一的坐标映射,我就可以将窗口上任意一个屏幕坐标映射到轨迹球曲面坐标。用这个坐标减去原点得到一个向量,再归一化得到方向向量。为了计算拖动过程对应的旋转变换,可以记录上一次进入 on_mouse_dragged 时的屏幕坐标,再分别用上次的和当前的屏幕坐标计算方向向量,最后构造四元数。

上面说的屏幕坐标都是以窗口中心点为原点,实际上通过 Dear ImGui API 获取到的坐标是以窗口左上角为原点的,需要先作一次平移变换。

构造旋转变换的原理是:从方向向量 d1\mathbf{d}_1 旋转到方向向量 d2\mathbf{d}_2 时,两个方向向量作一次叉乘就可以得到旋转轴、而借助点积可以得到旋转角,进而用轴-角表示法构造四元数。不过 Eigen 已经提供了 Quaternionf::FromTwoVectors 这个函数,直接传入两个单位向量就能很方便地构造出四元数了。

最后一步:载入模型

有 Assimp 的支持,加载模型文件还是很方便的,在依次调用 portable file dialog API(获取要打开的文件名)、Assimp Importer API(从文件中加载数据并解析为 aiScene 对象)后,所有的顶点和面片数据就可以直接从 aiScene 对象中读取。

不过,不同的模型文件格式可能具有不同的层次结构。最简单的格式只允许存储一个 mesh ,而复杂的格式则允许存储 mesh、物体、组等层次信息,甚至允许物体和组多层嵌套。我的观点是:作为一个实验框架,Dandelion 应该将同一个文件中加载的 mesh 放在一起,但没有必要支持多层嵌套的物体和组。因此这时我只给 Dandelion 设计了两种数据类:

  • Mesh 类存储顶点坐标、法线、面片和材质,一个 Mesh 对象只存储一个 mesh
  • Object 类存储若干个 mesh 以及这个物体的中心坐标、旋转与伸缩参数,可以生成 Model 矩阵

因此,当程序载入文件时,会生成一个用文件名命名的 Object 和若干用 mesh 名称命名的 Mesh 对象,除此以外的所有层次结构都会被抹去。由于生成 Model 矩阵所需的中心、旋转和伸缩参数都存储在 Object 对象中,所有属于该 Object Mesh 都共享同一个 Model 矩阵,导致用户不能拆开由多个 mesh 组成的物体。

load an object consisting of multiple meshes

前文说到过 Dear ImGui 的 API 是无状态的,例如绘制一个输入浮点数的拖动条要调用 SliderFloat 函数:

ImGui::SliderFloat("x", &(selected_object->center.x()), -100.0f, 100.0f, "%.2f", ImGuiSliderFlags_AlwaysClamp);

它的几个参数含义分别是:

  1. 拖动条的标签,即旁边的说明文字
  2. 影响的变量,用鼠标拖动时会修改这个变量的值,因此是传地址的
  3. 拖动条的数值范围(最小值和最大值)
  4. 显示的数字格式
  5. 一些控制样式的 flag

而为了让这个拖动条能修改场景中物体的参数,我就需要将物体相应参数的地址 selected_object->center.x() 传入其中。渲染工具栏的函数是 Toolbar::render ,这个函数调用 Toolbar::layout_mode 渲染布局模式下的组件,像 SliderFloat 这样的函数就是在这里被调用的。那么为了在调用函数绘制组件时能够访问场景数据,我就要将场景本身以引用的方式传递进来,所以一次典型的交互过程是这样的:

interaction between the tool bar and the scene

这里再次强调:之所以要让 Controller 将场景的引用传递给 Toolbar::render 而非将引用作为状态保存在 Toolbar 里,是因为视图层的 Toolbar 主要代表一系列函数调用而非对象实体,通过接收参数来访问模型层的 Scene 对象更符合之前的设计思路。

下回分解

到此为止,我就获得了一个可以加载模型,并能将物体放置到场景中任意位置、能调整物体姿态朝向的小工具,也就满足了布局模式的最低要求。

这篇文章主要回顾了我从零开始写代码时的封装思路,基本上是按照我当初创建和实现类的顺序来写。然而在涉及模型加载和场景数据的部分,文中并未解释载入的数据是如何存储在 Mesh 对象中、Mesh 对象又是如何被渲染到屏幕上的。

当然了,我大可以说“这最终都是一些 OpenGL API 调用”,然后把这部分一笔带过。但 OpenGL API 既多又杂,并且在不同的任务需求下有不同的使用思路,不象 Assimp 这种工具库有一种“最佳实践风格”。所以下一篇我会对自己初步封装 OpenGL API 的思路和过程,并从事后角度重新考虑一下当时的想法是否合理。


图形学实验框架 Dandelion 始末(二):最初原型实现
https://greyishsong.ink/图形学实验框架-Dandelion-始末(二):最初原型实现/
作者
greyishsong
发布于
2023年10月30日
许可协议