C++ 游戏动画编程实用指南(全)
原文:
annas-archive.org/md5/1ec3311f50b2e1eb4c8d2a6c29a60a6b
译者:飞龙
前言
现代游戏动画有点像黑魔法。没有太多资源详细介绍如何构建基于轨道驱动的动画系统,或者高级主题,比如双四元数蒙皮。这本书的目标就是填补这个空白。本书的目标是为动画编程的黑魔法投下一些光,使这个主题对每个人都变得可接近。
本书采用“理论到实现”的方法,您将首先学习每个讨论主题的理论。一旦您理解了理论,就可以实施它以获得实际经验。
本书着重于动画编程的概念和实现细节,而不是所使用的语言或图形 API。通过专注于这些基本概念,您将能够实现一个动画系统,而不受语言或图形 API 的限制。
本书适合的读者
本书适用于想要学习如何构建现代动画系统的程序员。跟随本书的唯一要求是对 C++有一定的了解。除此之外,本书涵盖了从如何打开一个新窗口,到创建一个 OpenGL 上下文,渲染一个动画模型,以及高级动画技术的所有内容。
本书涵盖的内容
[第一章](B16191_01_Final_JC_ePub.xhtml#_idTextAnchor013),创建游戏窗口,解释了如何创建一个新的 Visual Studio 项目,创建一个 Win32 窗口,设置一个 OpenGL 3.3 渲染上下文,并启用垂直同步。本书的代码示例是针对 OpenGL 3.3 编译的。所有 OpenGL 代码都与最新版本的 OpenGL 和 OpenGL 4.6 兼容。
[第二章](B16191_02_Final_JC_ePub.xhtml#_idTextAnchor026),实现向量,涵盖了游戏动画编程中的向量数学。
[第三章](B16191_03_Final_JC_ePub.xhtml#_idTextAnchor048),实现矩阵,讨论了游戏动画编程中的矩阵数学。
[第四章](B16191_04_Final_JC_ePub.xhtml#_idTextAnchor069),实现四元数,解释了如何在游戏动画编程中使用四元数数学。
[第五章](B16191_05_Final_JC_ePub.xhtml#_idTextAnchor094),实现变换,解释了如何将位置、旋转和缩放组合成一个变换对象。这些变换对象可以按层次排列。
[第六章](B16191_06_Final_JC_ePub.xhtml#_idTextAnchor104),构建抽象渲染器,向您展示如何在 OpenGL 3.3 之上创建一个抽象层。本书的其余部分将使用这个抽象层进行渲染。通过使用抽象层,我们可以专注于动画编程的核心概念,而不是用于实现它的 API。抽象层针对 OpenGL 3.3,但代码也适用于 OpenGL 4.6。
[第七章](B16191_07_Final_JC_ePub.xhtml#_idTextAnchor128),了解 glTF 文件格式,介绍了 glTF 文件格式。glTF 是一种标准的开放文件格式,受大多数 3D 内容创建工具支持。能够加载一个通用格式将让您加载几乎任何创建工具中制作的动画。
[第八章](B16191_08_Final_JC_ePub.xhtml#_idTextAnchor142)创建曲线、帧和轨道,介绍了如何插值曲线以及曲线如何用于动画存储在层次结构中的变换。
[第九章](B16191_09_Final_JC_ePub.xhtml#_idTextAnchor155),实现动画片段,解释了如何实现动画片段。动画片段会随时间修改变换层次结构。
[第十章](B16191_10_Final_JC_ePub.xhtml#_idTextAnchor167),网格蒙皮,介绍了如何变形网格,使其与采样动画片段生成的姿势相匹配。
[第十一章](B16191_11_Final_JC_ePub.xhtml#_idTextAnchor185),优化动画管道,向您展示如何优化动画管道的部分,使其更快速和更适合生产。
第十二章**,动画之间的混合,解释了如何混合两个动画姿势。这种技术可以用来平滑地切换两个动画,而不会出现任何视觉跳动。
第十三章**,实现逆运动学,介绍了如何使用逆运动学使动画与环境互动。例如,您将学习如何使动画角色的脚在不平坦的地形上不穿透地面。
第十四章**,使用双四元数进行蒙皮,介绍了游戏动画中的双四元数数学。双四元数可用于避免在动画关节处出现捏合。
第十五章**,渲染实例化人群,展示了如何将动画数据编码到纹理中,并将姿势生成移入顶点着色器。您将使用这种技术来使用实例化渲染大型人群。
为了充分利用本书
为了充分利用本书,需要一些 C++的经验。您不必是一个经验丰富的 C++大师,但您应该能够调试简单的 C++问题。有一些 OpenGL 经验是一个加分项,但不是必需的。没有使用高级 C++特性。提供的代码针对 C++ 11 或最新版本进行编译。
本书中的代码是针对 OpenGL 3.3 Core 编写的。本书中呈现的 OpenGL 代码是向前兼容的;在出版时,OpenGL 的最高兼容版本是 4.6。在第六章,构建抽象渲染器,您将在 OpenGL 之上实现一个薄的抽象层。在本书的其余部分,您将针对这个抽象层进行编码,而不是直接针对 OpenGL。
本书中呈现的代码应该可以在运行 Windows 10 或更高版本的任何笔记本电脑上编译和运行。跟随本书的唯一硬件要求是能够运行 Visual Studio 2019 或更高版本的计算机。
Visual Studio 2019 的最低硬件要求是:
-
Windows 10,版本 1703 或更高版本
-
1.8 GHz 或更快的处理器
-
2GB 的 RAM
这些要求可以在以下网址找到:docs.microsoft.com/en-us/visualstudio/releases/2019/system-requirements
下载示例代码文件
您可以从www.packt.com
的帐户中下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packt.com/support
并注册,文件将直接通过电子邮件发送给您。
您可以按照以下步骤下载代码文件:
-
在
www.packt.com
上登录或注册。 -
选择“支持”选项卡。
-
点击“代码下载和勘误”。
-
在搜索框中输入书名,按照屏幕上的指示操作。
下载文件后,请确保使用最新版本的以下软件解压或提取文件夹:
-
Windows 上的 WinRAR/7-Zip
-
Mac 上的 Zipeg/iZip / UnRarX
-
Linux 上的 7-Zip/PeaZip
本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Game-Animation-Programming
。如果代码有更新,将在现有的 GitHub 存储库上进行更新。
我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/
上找到。快去看看吧!
使用的约定
本书中使用了许多文本约定。
CodeInText
:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:“将下载的WebStorm-10*.dmg
磁盘映像文件挂载为系统中的另一个磁盘。”
代码块设置如下:
public:
Pose();
Pose(const Pose& p);
Pose& operator=(const Pose& p);
Pose(unsigned int numJoints);
任何命令行输入或输出都会以以下方式书写:
# cp /usr/src/asterisk-addons/configs/cdr_mysql.conf.sample
/etc/asterisk/cdr_mysql.conf
粗体:表示一个新术语、一个重要词或者屏幕上看到的词,例如在菜单或对话框中,也会在文本中显示为这样。例如:“从管理面板中选择系统信息。”
注意
警告或重要说明会显示在这样。
提示和技巧会显示在这样。
第一章:创建游戏窗口
在本章中,你将设置一个简单的 Win32 窗口,并将一个 OpenGL 上下文绑定到它上。本书中将始终使用 OpenGL 3.3 核心。实际的 OpenGL 代码将非常少。
大部分特定于 OpenGL 的代码将被抽象成辅助对象和函数,这将使你能够专注于动画而不是任何特定的图形 API。你将在第六章**,构建一个抽象渲染器中编写抽象层,但现在,重要的是创建一个可以绘制的窗口。
在本章结束时,你应该能够做到以下几点:
-
打开一个 Win32 窗口
-
创建并绑定一个 OpenGL 3.3 核心上下文
-
使用 glad 加载 OpenGL 3.3 核心函数
-
为创建的窗口启用垂直同步
-
了解本书的可下载示例
技术要求
要跟随本书中的代码,你需要一台安装了最新版本的 Windows 10 的计算机,并安装了 Visual Studio。所有可下载的代码示例都是使用 Visual Studio 2019 构建的。你可以从visualstudio.microsoft.com/
下载 Visual Studio。
你可以在 GitHub 上找到本书的所有示例代码github.com/PacktPublishing/Game-Animation-Programming
。
创建一个空项目
在本书中,你将尽可能地从头开始创建代码。因此,外部依赖将会很少。要开始,请按照以下步骤在 Visual Studio 中创建一个新的空白 C++项目:
- 打开 Visual Studio,通过文件|新建|项目创建一个新项目:
图 1.1:创建一个新的 Visual Studio 项目
- 你将在弹出窗口的左侧看到项目模板。导航到已安装|Visual C++|其他。然后,选择空项目:
图 1.2:创建一个空的 C++项目
- 输入项目名称并选择项目位置。最后,点击创建。
图 1.3:指定新项目名称
如果你按照前面的步骤操作,你应该有一个新的空白项目。在本章的其余部分,你将添加一个应用程序框架和一个启用了 OpenGL 的窗口。
创建应用程序类
维护杂乱的窗口入口函数将会很困难。相反,你需要创建一个抽象的Application
类。这个类将包含一些基本函数,比如Initialize
、Update
、Render
和Shutdown
。本书提供的所有代码示例都将构建在Application
基类之上。
创建一个新文件,Application.h
。Application
类的声明在以下代码示例中提供。将这个声明添加到新创建的Application.h
文件中:
#ifndef _H_APPLICATION_
#define _H_APPLICATION_
class Application {
private:
Application(const Application&);
Application& operator=(const Application&);
public:
inline Application() { }
inline virtual ~Application() { }
inline virtual void Initialize() { }
inline virtual void Update(float inDeltaTime) { }
inline virtual void Render(float inAspectRatio) { }
inline virtual void Shutdown() { }
};
#endif
Initialize
、Update
、Render
和Shutdown
函数是应用程序的生命周期。所有这些函数将直接从 Win32 窗口代码中调用。Update
和Render
需要参数。要更新一个帧,需要知道当前帧和上一帧之间的时间差。要渲染一个帧,需要知道窗口的宽高比。
生命周期函数是虚拟的。本书可下载材料中的每一章都有一个示例,它是Application
类的子类,演示了该章节的概念。
接下来,你将向项目添加一个 OpenGL 加载器。
添加一个 OpenGL 加载器
本章依赖于一些外部代码,称为glad
。在 Windows 上创建一个新的 OpenGL 上下文时,它将使用一个传统的 OpenGL 上下文。OpenGL 的扩展机制将允许你使用这个传统上下文来创建一个新的现代上下文。
一旦现代上下文被创建,您将需要获取所有 OpenGL 函数的函数指针。这些函数需要使用 wglGetProcAdress
加载,它返回一个函数指针。
以这种方式加载每个 OpenGL 函数将非常耗时。这就是使用 OpenGL 加载器的地方;glad
将为您完成所有这些工作。OpenGL 加载器是一个库或一些代码,调用 wglGetProcAdress
来定义 OpenGL API 的函数。
在 Windows 上有几个 OpenGL 加载器可用;本书将使用 glad
。glad
是一个只包含几个文件的小型库。它有一个简单的 API;您调用一个函数就可以访问所有的 OpenGL 函数。glad
有一个基于 web 的界面;您可以在 glad.dav1d.de/
找到它。
重要提示
在使用 X 窗口系统(例如许多流行的 Linux 发行版)时,加载 OpenGL 函数的函数是 glXGetProcAddress
。与 Windows 一样,Linux 也有可用的 OpenGL 加载器。并非所有操作系统都需要 OpenGL 加载器;例如,macOS、iOS 和 Android 不需要加载器。iOS 和 Android 都运行在 OpenGL ES 上。
获取 glad
您可以从 glad.dav1d.de/
获取 glad
,这是一个基于 web 的生成器:
- 转到该网站,从 gl 下拉菜单中选择 Version 3.3,从 Profile 下拉菜单中选择 Core:
图 1.4:配置 glad
- 滚动到底部,点击 Generate 按钮。这应该开始下载一个包含所有所需代码的 ZIP 文件。
本书中提供的代码与 OpenGL 版本 3.3 或更高版本向前兼容。如果要使用更新的 OpenGL 版本,例如 4.6,将 API 下拉菜单下的 gl 更改为所需的版本。在下一节中,您将向主项目添加此 ZIP 文件的内容。
将 glad 添加到项目
一旦下载了 glad.zip
,解压其内容。将 ZIP 文件中的以下文件添加到您的项目中。不需要维护目录结构;所有这些文件都可以放在一起:
-
src/glad.c
-
include/glad/glad.h
-
include/KHR/khrplatform.h
这些文件将被包含为普通项目文件——您不需要设置 include
路径——但这意味着文件的内容需要被编辑:
- 打开
glad.c
,并找到以下 #include:
#include <glad/glad.h>
- 用
glad.h
的相对路径替换include
路径:
#include "glad.h"
- 同样,打开
glad.h
,并找到以下 #include:
#include <KHR/khrplatform.h>
- 用
khrplatform.h
的相对路径替换include
路径:
#include "khrplatform.h"
glad
现在应该已经添加到项目中,不应该有编译错误。在下一节中,您将开始实现 Win32 窗口。
创建窗口
在本节中,您将创建一个窗口。这意味着您将直接使用 Win32 API 调用来打开窗口并从代码中控制其生命周期。您还将设置一个调试控制台,可以与窗口一起运行,这对于查看日志非常有用。
重要提示
深入讨论 Win32 API 超出了本书的范围。有关任何 Win32 API 的其他信息,请参阅微软开发者网络(MSDN)docs.microsoft.com/en-us/windows/win32/api/
。
为了使日志记录变得更容易,在调试模式下将同时打开两个窗口。一个是标准的 Win32 窗口,另一个是用于查看日志的控制台窗口。这可以通过条件设置链接器来实现。在调试模式下,应用程序应链接到控制台子系统。在发布模式下,应链接到窗口子系统。
可以通过项目的属性或使用#pragma
注释在代码中设置链接器子系统。一旦子系统设置为控制台,WinMain
函数就可以从main
中调用,这将启动一个附加到控制台的窗口。
还可以通过代码执行其他链接器操作,例如链接到外部库。您将使用#pragma
命令与 OpenGL 进行链接。
通过创建一个新文件WinMain.cpp
来开始窗口实现。该文件将包含所有窗口逻辑。然后,执行以下操作:
- 将以下代码添加到文件开头。它创建了
#define
常量,减少了通过包含<windows.h>
引入的代码量:
#define _CRT_SECURE_NO_WARNINGS
#define WIN32_LEAN_AND_MEAN
#define WIN32_EXTRA_LEAN
#include "glad.h"
#include <windows.h>
#include <iostream>
#include "Application.h"
- 需要提前声明窗口入口函数和窗口事件处理函数。这是我们需要打开一个新窗口的两个 Win32 函数:
int WINAPI WinMain(HINSTANCE, HINSTANCE, PSTR, int);
LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM);
- 使用
#pragma
注释在代码中链接到OpenGL32.lib
,而不是通过项目的属性窗口。将以下代码添加到WinMain.cpp
中:
#if _DEBUG
#pragma comment( linker, "/subsystem:console" )
int main(int argc, const char** argv) {
return WinMain(GetModuleHandle(NULL), NULL,
GetCommandLineA(), SW_SHOWDEFAULT);
}
#else
#pragma comment( linker, "/subsystem:windows" )
#endif
#pragma comment(lib, "opengl32.lib")
现在需要声明一些 OpenGL 函数。通过wglCreateContextAttribsARB
创建现代 OpenGL 上下文,但是没有引用此函数。这是需要通过wglGetProcAddress
加载的函数之一,因为它是一个扩展函数。
wglCreateContextAttribsARB
的函数签名可以在wglext.h
中找到。wglext.h
头文件由 Khronos 托管,并且可以在 OpenGL 注册表的www.khronos.org/registry/OpenGL/index_gl.php
上找到。
无需包含整个wglext.h
头文件;您只需要与创建现代上下文相关的函数。以下代码直接从文件中复制。它包含了相关#define
常量和函数指针类型的声明:
#define WGL_CONTEXT_MAJOR_VERSION_ARB 0x2091
#define WGL_CONTEXT_MINOR_VERSION_ARB 0x2092
#define WGL_CONTEXT_FLAGS_ARB 0x2094
#define WGL_CONTEXT_CORE_PROFILE_BIT_ARB 0x00000001
#define WGL_CONTEXT_PROFILE_MASK_ARB 0x9126
typedef HGLRC(WINAPI* PFNWGLCREATECONTEXTATTRIBSARBPROC)
(HDC, HGLRC, const int*);
前面的代码定义了一个wglCreatecontextAttribsARB
的函数指针类型。除此之外,还有一些#define
常量,用于创建 OpenGL 3.3 核心上下文。本书的示例将启用vsynch
,可以通过wglSwapIntervalEXT
来实现。
正如您猜到的那样,这个函数也需要使用 OpenGL 的扩展机制加载。它还需要两个额外的支持函数:wglGetExtensionStringEXT
和wglGetSwapIntervalEXT
。这三个函数都可以在wgl.h
中找到,该文件由 Khronos 在先前链接的 OpenGL 注册表中托管。
不要包含wgl.h
,而是将以下代码添加到WinMain.cpp
中。该代码定义了wglGetExtensionStringEXT
、wglSwapIntervalEXT
和wglGetSwapIntervalEXT
的函数指针签名,从wgl.h
中复制出来:
typedef const char*
(WINAPI* PFNWGLGETEXTENSIONSSTRINGEXTPROC) (void);
typedef BOOL(WINAPI* PFNWGLSWAPINTERVALEXTPROC) (int);
typedef int (WINAPI* PFNWGLGETSWAPINTERVALEXTPROC) (void);
前面的代码是必须的,用于与 OpenGL 一起工作。通常会复制代码,而不是直接包含这些头文件。在下一节中,您将开始处理实际的窗口。
全局变量
需要两个全局变量以便轻松清理窗口:指向当前运行应用程序的指针和全局 OpenGL 顶点数组对象(VAO)的句柄。不是每个绘制调用都有自己的 VAO,整个示例的持续时间将绑定一个 VAO。
为此,请创建以下全局变量:
Application* gApplication = 0;
GLuint gVertexArrayObject = 0;
在本书的其余部分,将不会有其他全局变量。全局变量可能会使程序状态更难以跟踪。这两个存在的原因是稍后在应用程序关闭时轻松引用它们。接下来,您将开始实现WinMain
函数以打开一个新窗口。
打开一个窗口
接下来,您需要实现窗口入口函数WinMain
。此函数将负责创建窗口类,注册窗口类并打开一个新窗口:
- 通过创建
Application
类的新实例并将其存储在全局指针中来开始定义WinMain
的定义:
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE
hPrevInstance, PSTR szCmdLine,
int iCmdShow) {
gApplication = new Application();
- 接下来,需要填写
WNDCLASSEX
的一个实例。这里没有什么特别的,它只是一个标准的窗口定义。唯一需要注意的是WndProc
函数是否设置正确:
WNDCLASSEX wndclass;
wndclass.cbSize = sizeof(WNDCLASSEX);
wndclass.style = CS_HREDRAW | CS_VREDRAW;
wndclass.lpfnWndProc = WndProc;
wndclass.cbClsExtra = 0;
wndclass.cbWndExtra = 0;
wndclass.hInstance = hInstance;
wndclass.hIcon = LoadIcon(NULL, IDI_APPLICATION);
wndclass.hIconSm = LoadIcon(NULL, IDI_APPLICATION);
wndclass.hCursor = LoadCursor(NULL, IDC_ARROW);
wndclass.hbrBackground = (HBRUSH)(COLOR_BTNFACE + 1);
wndclass.lpszMenuName = 0;
wndclass.lpszClassName = "Win32 Game Window";
RegisterClassEx(&wndclass);
- 一个新的应用程序窗口应该在监视器的中心启动。为此,使用
GetSystemMetrics
来找到屏幕的宽度和高度。然后,调整windowRect
到屏幕中心的所需大小:
int screenWidth = GetSystemMetrics(SM_CXSCREEN);
int screenHeight = GetSystemMetrics(SM_CYSCREEN);
int clientWidth = 800;
int clientHeight = 600;
RECT windowRect;
SetRect(&windowRect,
(screenWidth / 2) - (clientWidth / 2),
(screenHeight / 2) - (clientHeight / 2),
(screenWidth / 2) + (clientWidth / 2),
(screenHeight / 2) + (clientHeight / 2));
- 要确定窗口的大小,不仅仅是客户区域,需要知道窗口的样式。以下代码示例创建了一个可以最小化或最大化但不能调整大小的窗口。要调整窗口的大小,使用位或(
|
)运算符与WS_THICKFRAME
定义:
DWORD style = (WS_OVERLAPPED | WS_CAPTION |
WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX);
// | WS_THICKFRAME to resize
- 一旦定义了所需的窗口样式,调用
AdjustWindowRectEx
函数来调整客户区矩形的大小,以包括所有窗口装饰在其大小中。当最终大小已知时,可以使用CreateWindowEx
来创建实际的窗口。窗口创建完成后,存储对其设备上下文的引用:
AdjustWindowRectEx(&windowRect, style, FALSE, 0);
HWND hwnd = CreateWindowEx(0, wndclass.lpszClassName,
"Game Window", style, windowRect.left,
windowRect.top, windowRect.right -
windowRect.left, windowRect.bottom -
windowRect.top, NULL, NULL,
hInstance, szCmdLine);
HDC hdc = GetDC(hwnd);
- 现在窗口已经创建,接下来你将创建一个 OpenGL 上下文。为此,你首先需要找到正确的像素格式,然后将其应用到窗口的设备上下文中。以下代码向你展示了如何做到这一点:
PIXELFORMATDESCRIPTOR pfd;
memset(&pfd, 0, sizeof(PIXELFORMATDESCRIPTOR));
pfd.nSize = sizeof(PIXELFORMATDESCRIPTOR);
pfd.nVersion = 1;
pfd.dwFlags = PFD_SUPPORT_OPENGL | PFD_DRAW_TO_WINDOW
| PFD_DOUBLEBUFFER;
pfd.iPixelType = PFD_TYPE_RGBA;
pfd.cColorBits = 24;
pfd.cDepthBits = 32;
pfd.cStencilBits = 8;
pfd.iLayerType = PFD_MAIN_PLANE;
int pixelFormat = ChoosePixelFormat(hdc, &pfd);
SetPixelFormat(hdc, pixelFormat, &pfd);
- 设置了像素格式后,使用
wglCreateContext
创建一个临时的 OpenGL 上下文。这个临时上下文只是用来获取指向wglCreateContextAttribsARB
的指针,它将用于创建一个现代上下文:
HGLRC tempRC = wglCreateContext(hdc);
wglMakeCurrent(hdc, tempRC);
PFNWGLCREATECONTEXTATTRIBSARBPROC
wglCreateContextAttribsARB = NULL;
wglCreateContextAttribsARB =
(PFNWGLCREATECONTEXTATTRIBSARBPROC)
wglGetProcAddress("wglCreateContextAttribsARB");
- 存在并绑定了一个临时的 OpenGL 上下文,所以下一步是调用
wglCreateContextAttribsARB
函数。这个函数将返回一个 OpenGL 3.3 Core 上下文配置文件,绑定它,并删除旧的上下文:
const int attribList[] = {
WGL_CONTEXT_MAJOR_VERSION_ARB, 3,
WGL_CONTEXT_MINOR_VERSION_ARB, 3,
WGL_CONTEXT_FLAGS_ARB, 0,
WGL_CONTEXT_PROFILE_MASK_ARB,
WGL_CONTEXT_CORE_PROFILE_BIT_ARB,
0, };
HGLRC hglrc = wglCreateContextAttribsARB(
hdc, 0, attribList);
wglMakeCurrent(NULL, NULL);
wglDeleteContext(tempRC);
wglMakeCurrent(hdc, hglrc);
- 在激活 OpenGL 3.3 Core 上下文后,可以使用
glad
来加载所有 OpenGL 3.3 Core 函数。调用gladLoadGL
来实现这一点:
if (!gladLoadGL()) {
std::cout << "Could not initialize GLAD\n";
}
else {
std::cout << "OpenGL Version " <<
GLVersion.major << "." << GLVersion.minor <<
"\n";
}
- 现在应该已经初始化了一个 OpenGL 3.3 Core 上下文,并加载了所有核心 OpenGL 函数。接下来,你将在窗口上启用
vsynch
。vsynch
不是一个内置函数;它是一个扩展,因此需要使用wglGetExtensionStringEXT
来查询对它的支持。vsynch
的扩展字符串是WGL_EXT_swap_control
。检查它是否在扩展字符串列表中:
PFNWGLGETEXTENSIONSSTRINGEXTPROC
_wglGetExtensionsStringEXT =
(PFNWGLGETEXTENSIONSSTRINGEXTPROC)
wglGetProcAddress("wglGetExtensionsStringEXT");
bool swapControlSupported = strstr(
_wglGetExtensionsStringEXT(),
"WGL_EXT_swap_control") != 0;
- 如果
WGL_EXT_swap_control
扩展可用,需要加载它。实际的函数是wglSwapIntervalEXT
,可以在wgl.h
中找到。向wglSwapIntervalEXT
传递参数可以打开vsynch
:
int vsynch = 0;
if (swapControlSupported) {
PFNWGLSWAPINTERVALEXTPROC wglSwapIntervalEXT =
(PFNWGLSWAPINTERVALEXTPROC)
wglGetProcAddress("wglSwapIntervalEXT");
PFNWGLGETSWAPINTERVALEXTPROC
wglGetSwapIntervalEXT =
(PFNWGLGETSWAPINTERVALEXTPROC)
wglGetProcAddress("wglGetSwapIntervalEXT");
if (wglSwapIntervalEXT(1)) {
std::cout << "Enabled vsynch\n";
vsynch = wglGetSwapIntervalEXT();
}
else {
std::cout << "Could not enable vsynch\n";
}
}
else { // !swapControlSupported
cout << "WGL_EXT_swap_control not supported\n";
}
- 还有一点小事情要做,以完成 OpenGL 启用窗口的设置。OpenGL 3.3 Core 要求在所有绘制调用中绑定一个 VAO。你将创建一个全局 VAO,在
WinMain
中绑定它,并在窗口被销毁之前永远不解绑。以下代码创建了这个 VAO 并绑定它:
glGenVertexArrays(1, &gVertexArrayObject);
glBindVertexArray(gVertexArrayObject);
- 调用
ShowWindow
和UpdateWindow
函数来显示当前窗口;这也是初始化全局应用程序的好地方。根据应用程序的Initialize
函数所做的工作量,窗口可能会在一小段时间内出现冻结:
ShowWindow(hwnd, SW_SHOW);
UpdateWindow(hwnd);
gApplication->Initialize();
- 现在你已经准备好实现实际的游戏循环了。你需要跟踪上一帧的时间,以计算帧之间的时间差。除了游戏逻辑,循环还需要处理窗口事件,通过查看当前消息堆栈并相应地分派消息:
DWORD lastTick = GetTickCount();
MSG msg;
while (true) {
if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
if (msg.message == WM_QUIT) {
break;
}
TranslateMessage(&msg);
DispatchMessage(&msg);
}
- 处理完窗口事件后,
Application
实例需要更新和渲染。首先,找到上一帧和当前帧之间的时间差,将其转换为秒。例如,以 60 FPS 运行的游戏应该有 16.6 毫秒或 0.0166 秒的时间差:
DWORD thisTick = GetTickCount();
float dt = float(thisTick - lastTick) * 0.001f;
lastTick = thisTick;
if (gApplication != 0) {
gApplication->Update(dt);
}
- 渲染当前运行的应用程序只需要更多的维护工作。每帧都要用
glViewport
设置 OpenGL 视口,并清除颜色、深度和模板缓冲区。除此之外,确保在渲染之前所有的 OpenGL 状态都是正确的。这意味着正确的 VAO 被绑定,深度测试和面剔除被启用,并且设置了适当的点大小:
if (gApplication != 0) {
RECT clientRect;
GetClientRect(hwnd, &clientRect);
clientWidth = clientRect.right -
clientRect.left;
clientHeight = clientRect.bottom -
clientRect.top;
glViewport(0, 0, clientWidth, clientHeight);
glEnable(GL_DEPTH_TEST);
glEnable(GL_CULL_FACE);
glPointSize(5.0f);
glBindVertexArray(gVertexArrayObject);
glClearColor(0.5f, 0.6f, 0.7f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT |
GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
float aspect = (float)clientWidth /
(float)clientHeight;
gApplication->Render(aspect);
}
- 当前
Application
实例更新和渲染后,需要呈现后备缓冲区。这是通过调用SwapBuffers
来完成的。如果启用了vsynch
,则需要在SwapBuffers
之后立即调用glFinish
:
if (gApplication != 0) {
SwapBuffers(hdc);
if (vsynch != 0) {
glFinish();
}
}
- 窗口循环到此结束。窗口循环退出后,可以安全地从
WinMain
函数返回:
} // End of game loop
if (gApplication != 0) {
std::cout << "Expected application to
be null on exit\n";
delete gApplication;
}
return (int)msg.wParam;
}
如果要使用 OpenGL 的其他版本而不是 3.3,调整 Step 8 中attribList
变量中的主要和次要值。即使WinMain
函数已经编写,你仍然无法编译这个文件;因为WndProc
从未被定义过。WndProc
函数处理诸如鼠标移动或窗口调整大小等事件。在下一节中,你将实现WndProc
函数。
创建事件处理程序
为了拥有一个正常运行的窗口,甚至编译应用程序,在这一点上,事件处理函数WndProc
必须被定义。这里的实现将非常简单,主要关注如何销毁窗口:
- 在
WinMain.cpp
中开始实现WndProc
函数:
LRESULT CALLBACK WndProc(HWND hwnd, UINT iMsg,
WPARAM wParam, LPARAM lParam) {
switch (iMsg) {
- 当接收到
WM_CLOSE
消息时,需要关闭Application
类并发出销毁窗口消息。应用程序关闭后,不要忘记删除它:
case WM_CLOSE:
if (gApplication != 0) {
gApplication->Shutdown();
delete gApplication;
gApplication = 0;
DestroyWindow(hwnd);
}
else {
std::cout << "Already shut down!\n";
}
break;
- 当接收到销毁消息时,窗口的 OpenGL 资源需要被释放。这意味着删除全局顶点数组对象,然后删除 OpenGL 上下文:
case WM_DESTROY:
if (gVertexArrayObject != 0) {
HDC hdc = GetDC(hwnd);
HGLRC hglrc = wglGetCurrentContext();
glBindVertexArray(0);
glDeleteVertexArrays(1, &gVertexArrayObject);
gVertexArrayObject = 0;
wglMakeCurrent(NULL, NULL);
wglDeleteContext(hglrc);
ReleaseDC(hwnd, hdc);
PostQuitMessage(0);
}
else {
std::cout << "Multiple destroy messages\n";
}
break;
- 绘制和擦除背景消息是安全忽略的,因为 OpenGL 正在管理对窗口的渲染。如果收到的消息不是已经处理的消息之一,将其转发到默认的窗口消息函数:
case WM_PAINT:
case WM_ERASEBKGND:
return 0;
}
return DefWindowProc(hwnd, iMsg, wParam, lParam);
}
现在你已经编写了窗口事件循环,应该能够编译和运行一个空白窗口。在接下来的部分,你将探索本书的可下载示例。
探索样本
本书中提供的所有代码都可以在书的可下载内容中找到。有一个名为AllChapters
的大型示例,其中包含单个应用程序中的每个示例。有一个Bin
ZIP 文件,其中包含AllChapters
示例的预编译可执行文件。
每个章节还包含多个子文件夹的单独文件夹。每个章节都包含Sample00
,这是书中编写的代码,没有额外的内容。随后编号的示例添加了内容。
AllChapters
示例看起来与各个章节文件夹中的示例有些不同。该应用程序使用 Nuklear (github.com/vurtun/nuklear
) 来显示其用户界面。显示的用户界面部分是屏幕右上角的统计计数器。它看起来像这样:
图 1.5:AllChapters 示例的统计计数器
顶部框包含有关应用程序打开的显示器的一些常规信息。这些信息包括显示频率、是否启用vsynch
以及以毫秒为单位的帧预算。
下面的第二个框包含高级帧定时。如果在最近的 60 帧中有一帧过时,显示的时间将变成红色。一些过时的帧是不可避免的;如果帧速率降至 59.9,文本将在一秒钟内显示为红色。偶尔在这里看到红色是可以接受的;只有当数字完全变成红色时才会引起关注。
第三个框中包含两个 GPU 定时器;这些定时器测量样本在 GPU 上的运行速度。这对于调试任何繁重的绘制调用非常有用。最后一个框包含 CPU 定时器,有助于找出问题的哪个阶段存在瓶颈。
重要说明
在整本书中,您将使用 C++ stl
容器。标准库在调试模式下有点慢,主要是由于错误检查。建议仅在发布模式下对任何示例进行性能分析。
这些示例应该很好地演示了您将在接下来的每一章中学到的内容。它们还为您提供了一个可以与您的代码进行比较的示例。
摘要
在本章中,您探讨了设置新的 Win32 窗口的过程。建立了一个 OpenGL 3.3 核心上下文来渲染窗口,并启用了vsynch
。您了解了 OpenGL 加载器以及glad
如何加载所有相关的 OpenGL 函数。
这个窗口将作为您构建的基础;所有未来的示例都是基于您在本章中创建的框架。在下一章中,您将开始探索渲染和动画所需的一些数学知识。
第二章:实现向量
在本章中,您将学习向量数学的基础知识。本书的其余部分大部分编码都依赖于对向量有很好的理解。向量将用于表示位移和方向。
在本章结束时,您将实现一个强大的向量库,并能够执行各种向量操作,包括分量和非分量操作。
本章将涵盖以下主题:
-
引入向量
-
创建一个向量
-
理解分量操作
-
理解非分量操作
-
插值向量
-
比较向量
-
探索更多向量
重要信息:
在本章中,您将学习如何以直观、可视的方式实现向量,这依赖于代码而不是数学公式。如果您对数学公式感兴趣,或者想尝试一些交互式示例,请访问gabormakesgames.com/vectors.html
。
引入向量
什么是向量?向量是一个 n 元组的数字。它表示作为大小和方向测量的位移。向量的每个元素通常表示为下标,例如(V0,V1,V2,… VN)。在游戏的背景下,向量通常有两个、三个或四个分量。
例如,三维向量测量三个独特轴上的位移:x、y和z。向量的元素通常用表示它们代表的轴的下标,而不是索引。(VX,VY,VZ)和(V0,V1,V2)可以互换使用。
在可视化向量时,它们通常被绘制为箭头。箭头的基部位置并不重要,因为向量测量的是位移,而不是位置。箭头的末端遵循每个轴上的位移。
例如,以下图中的所有箭头代表相同的向量:
图 2.1:在多个位置绘制的向量(2, 5)
每个箭头的长度相同,指向相同的方向,无论它们的位置如何。在下一节中,您将开始实现将在本书的其余部分中使用的向量结构。
创建一个向量
向量将被实现为结构,而不是类。向量结构将包含一个匿名联合,允许以数组或单独元素的形式访问向量的分量。
要声明vec3
结构和函数头,请创建一个新文件vec3.h
。在此文件中声明新的vec3
结构。vec3
结构需要三个构造函数——一个默认构造函数,一个以每个分量作为元素的构造函数,以及一个以浮点数组指针作为参数的构造函数:
#ifndef _H_VEC3_
#define _H_VEC3_
struct vec3 {
union {
struct {
float x;
float y;
float z;
};
float v[3];
};
inline vec3() : x(0.0f), y(0.0f), z(0.0f) { }
inline vec3(float _x, float _y, float _z) :
x(_x), y(_y), z(_z) { }
inline vec3(float *fv) :
x(fv[0]), y(fv[1]), z(fv[2]) { }
};
#endif
vec3
结构中的匿名联合允许使用.x
、.y
和.z
表示法访问数据,或者使用.v
表示法作为连续数组访问。在继续实现在vec3
结构上工作的函数之前,您需要考虑比较浮点数以及是否使用 epsilon 值。
Epsilon
比较浮点数是困难的。您需要使用一个 epsilon 来比较两个浮点数,而不是直接比较它们。epsilon 是一个任意小的正数,是两个数字需要具有的最小差异,才能被视为不同的数字。在vec3.h
中声明一个 epsilon 常量:
#define VEC3_EPSILON 0.000001f
重要提示:
您可以在bitbashing.io/comparing-floats.html
了解更多关于浮点数比较的信息
通过创建vec3
结构和定义vec3
epsilon,您已经准备好开始实现一些常见的向量操作。在下一节中,您将开始学习和实现几种分量操作。
理解分量操作
几个向量操作只是分量操作。分量操作是指对向量的每个分量或两个向量的相似分量进行的操作。相似的分量是具有相同下标的分量。您将要实现的分量操作如下:
-
向量相加
-
向量减法
-
向量缩放
-
向量相乘
-
点积
让我们更详细地看看这些。
向量相加
将两个向量相加会产生一个第三个向量,它具有两个输入向量的合并位移。向量相加是一种分量操作;要执行它,您需要添加相似的分量。
要可视化两个向量的相加,将第二个向量的基部放在第一个向量的尖端。接下来,从第一个向量的基部到第二个向量的尖端画一个箭头。这个箭头代表了相加的结果向量:
图 2.2:向量相加
要在代码中实现向量相加,添加输入向量的相似分量。创建一个新文件vec3.cpp
。这是您将定义与vec3
结构相关的函数的地方。不要忘记包含vec3.h
。重载+运算符
以执行向量相加。不要忘记将函数签名添加到vec3.h
中:
vec3 operator+(const vec3 &l, const vec3 &r) {
return vec3(l.x + r.x, l.y + r.y, l.z + r.z);
}
在考虑向量相加时,请记住向量表示位移。当添加两个向量时,结果是两个输入向量的合并位移。
向量减法
与添加向量一样,减去向量也是一种分量操作。您可以将减去向量视为将第二个向量的负值添加到第一个向量。当可视化为箭头时,减法指向从第二个向量的尖端到第一个向量的尖端。
为了直观地减去向量,将两个向量放置在同一个起点。从第二个箭头的尖端到第一个箭头的尖端画一个向量。得到的箭头就是减法结果向量:
图 2.3:向量减法
要实现向量减法,减去相似的分量。通过在vec3.cpp
中重载-
运算符来实现减法函数。不要忘记将函数声明添加到vec3.h
中:
vec3 operator-(const vec3 &l, const vec3 &r) {
return vec3(l.x - r.x, l.y - r.y, l.z - r.z);
}
步骤和逻辑与向量相加非常相似。将向量减法视为添加一个负向量可能会有所帮助。
缩放向量
当向量被缩放时,它只在大小上改变,而不改变方向。与加法和减法一样,缩放是一种分量操作。与加法和减法不同,向量是由标量而不是另一个向量进行缩放的。
在视觉上,一个缩放的向量指向与原始向量相同的方向,但长度不同。下图显示了两个向量:(2, 1)和(2, 4)。两个向量具有相同的方向,但第二个向量的大小更长:
图 2.4:向量缩放
要实现向量缩放,将向量的每个分量乘以给定的标量值。
通过在vec3.cpp
中重载*
运算符来实现缩放函数。不要忘记将函数声明添加到vec3.h
中:
vec3 operator*(const vec3 &v, float f) {
return vec3(v.x * f, v.y * f, v.z * f);
}
通过将向量缩放-1来对向量取反。当对向量取反时,向量保持其大小,但改变其方向。
向量相乘
向量乘法可以被认为是一种非均匀缩放。与将向量的每个分量乘以标量不同,要将两个向量相乘,需要将向量的每个分量乘以另一个向量的相似分量。
您可以通过在vec3.cpp
中重载*
运算符来实现向量乘法。不要忘记将函数声明添加到vec3.h
中:
vec3 operator*(const vec3 &l, const vec3 &r) {
return vec3(l.x * r.x, l.y * r.y, l.z * r.z);
}
通过将两个向量相乘生成的结果将具有不同的方向和大小。
点积
点积用于衡量两个向量的相似程度。给定两个向量,点积返回一个标量值。点积的结果具有以下属性:
-
如果向量指向相同的方向,则为正。
-
如果向量指向相反的方向,则为负。
-
如果向量垂直,则为0。
如果两个输入向量都具有单位长度(您将在本章的法向量部分了解单位长度向量),点积将具有-1到1的范围。
两个向量A和B之间的点积等于A的长度乘以B的长度乘以两个向量之间的角的余弦:
计算点积的最简单方法是对输入向量中相似的分量进行求和:
在vec3.cpp
中实现dot
函数。不要忘记将函数定义添加到vec3.h
中:
float dot(const vec3 &l, const vec3 &r) {
return l.x * r.x + l.y * r.y + l.z * r.z;
}
点积是视频游戏中最常用的操作之一。它经常用于检查角度和光照计算。
通过点积,您已经实现了向量的常见分量操作。接下来,您将了解一些可以在向量上执行的非分量操作。
理解非分量操作
并非所有向量操作都是分量式的;一些操作需要更多的数学。在本节中,您将学习如何实现不基于分量的常见向量操作。这些操作如下:
-
如何找到向量的长度
-
法向量是什么
-
如何对向量进行归一化
-
如何找到两个向量之间的角度
-
如何投影向量以及拒绝是什么
-
如何反射向量
-
叉积是什么以及如何实现它
让我们更详细地看看每一个。
向量长度
向量表示方向和大小;向量的大小是它的长度。找到向量长度的公式来自三角学。在下图中,一个二维向量被分解为平行和垂直分量。注意这如何形成一个直角三角形,向量是斜边:
图 2.5:一个向量分解为平行和垂直分量
直角三角形的斜边长度可以用毕达哥拉斯定理找到,A2 + B2 = C2。通过简单地添加一个Z分量,这个函数可以扩展到三维—X2 + Y2 + Z2 = length2\。
您可能已经注意到了一个模式;一个向量的平方长度等于其分量的和。这可以表示为一个点积—Length2(A) = dot(A, A):
重要说明:
找到向量的长度涉及平方根运算,应尽量避免。在检查向量的长度时,可以在平方空间中进行检查以避免平方根。例如,如果您想要检查向量A的长度是否小于5,可以表示为(dot(A, A) < 5 * 5)。
- 要实现平方长度函数,求出向量的每个分量的平方的和。在
vec3.cpp
中实现lenSq
函数。不要忘记将函数声明添加到vec3.h
中:
float lenSq(const vec3& v) {
return v.x * v.x + v.y * v.y + v.z * v.z;
}
- 要实现长度函数,取平方长度函数的结果的平方根。注意不要用
sqrtf
调用0
。在vec3.cpp
中实现lenSq
函数。不要忘记将函数声明添加到vec3.h
中:
float len(const vec3 &v) {
float lenSq = v.x * v.x + v.y * v.y + v.z * v.z;
if (lenSq < VEC3_EPSILON) {
return 0.0f;
}
return sqrtf(lenSq);
}
重要说明:
您可以通过取它们之间的差的长度来找到两个向量之间的距离。例如,float distance = len(vec1 - vec2)。
归一化向量
长度为1的向量称为法向量(或单位向量)。通常,单位向量用于表示没有大小的方向。两个单位向量的点积总是在-1到1的范围内。
除了0向量外,任何向量都可以通过将向量按其长度的倒数进行缩放来归一化:
- 在
vec3.cpp
中实现normalize
函数。不要忘记将函数声明添加到vec3.h
中:
void normalize(vec3 &v) {
float lenSq = v.x * v.x + v.y * v.y + v.z * v.z;
if (lenSq < VEC3_EPSILON) { return; }
float invLen = 1.0f / sqrtf(lenSq);
v.x *= invLen;
v.y *= invLen;
v.z *= invLen;
}
- 在
vec3.cpp
中实现normalized
函数。不要忘记将函数声明添加到vec3.h
中:
vec3 normalized(const vec3 &v) {
float lenSq = v.x * v.x + v.y * v.y + v.z * v.z;
if (lenSq < VEC3_EPSILON) { return v; }
float invLen = 1.0f / sqrtf(lenSq);
return vec3(
v.x * invLen,
v.y * invLen,
v.z * invLen
);
}
normalize
函数接受一个向量的引用并就地对其进行归一化。另一方面,normalized
函数接受一个常量引用并不修改输入向量。相反,它返回一个新的向量。
向量之间的角度
如果两个向量是单位长度,它们之间的角度是它们的点积的余弦:
如果两个向量未被归一化,则点积需要除以两个向量长度的乘积:
要找到实际角度,而不仅仅是其余弦,我们需要在两侧取余弦的反函数,即反余弦函数:
在vec3.cpp
中实现angle
函数。不要忘记将函数声明添加到vec3.h
中:
float angle(const vec3 &l, const vec3 &r) {
float sqMagL = l.x * l.x + l.y * l.y + l.z * l.z;
float sqMagR = r.x * r.x + r.y * r.y + r.z * r.z;
if (sqMagL<VEC3_EPSILON || sqMagR<VEC3_EPSILON) {
return 0.0f;
}
float dot = l.x * r.x + l.y * r.y + l.z * r.z;
float len = sqrtf(sqMagL) * sqrtf(sqMagR);
return acosf(dot / len);
}
重要说明:
acosf
函数以弧度返回角度。要将弧度转换为度数,乘以57.2958f
。要将度数转换为弧度,乘以0.0174533f
。
向量投影和拒绝
将向量A投影到向量B上会产生一个新的向量,该向量在B的方向上具有A的长度。直观地理解向量投影的好方法是想象向量A投射到向量B上的阴影,如图所示:
图 2.6:向量 A 投射到向量 B 上的阴影
要计算A在B上的投影(projB A),必须将向量A分解为相对于向量B的平行和垂直分量。平行分量是A在B方向上的长度,这就是投影。垂直分量是从A中减去平行分量,这就是拒绝:
图 2.7:向量投影和拒绝显示平行和垂直向量
如果被投影的向量(在这个例子中是向量B)是一个法向量,那么在B方向上的A的长度可以通过A和B的点积来简单计算。然而,如果两个输入向量都没有被归一化,点积需要除以向量B的长度(被投影的向量)。
现在,相对于B的平行分量已知,向量B可以被这个分量缩放。同样,如果B不是单位长度,结果将需要除以向量B的长度。
拒绝是投影的反面。要找到A在B上的拒绝,从向量A中减去A在B上的投影:
- 在
vec3.cpp
中实现project
函数。不要忘记将函数声明添加到vec3.h
中:
vec3 project(const vec3 &a, const vec3 &b) {
float magBSq = len(b);
if (magBSq < VEC3_EPSILON) {
return vec3();
}
float scale = dot(a, b) / magBSq;
return b * scale;
}
- 在
vec3.cpp
中实现reject
函数。不要忘记在vec3.h
中声明这个函数:
vec3 reject(const vec3 &a, const vec3 &b) {
vec3 projection = project(a, b);
return a - projection;
}
向量投影和拒绝通常用于游戏编程。重要的是它们在一个健壮的向量库中得到实现。
向量反射
向量反射可以有两种意思:镜像反射或弹跳反射。以下图显示了不同类型的反射:
图 2.8:镜像和弹跳反射的比较
反弹反射比镜面反射更有用和直观。要使反弹投影起作用,将向量A投影到向量B上。这将产生一个指向反射相反方向的向量。对这个投影取反,并从向量 A 中减去两次。以下图演示了这一点:
图 2.9:可视化反弹反射
在vec3.cpp
中实现reflect
函数。不要忘记将函数声明添加到vec3.h
中:
vec3 reflect(const vec3 &a, const vec3 &b) {
float magBSq = len(b);
if (magBSq < VEC3_EPSILON) {
return vec3();
}
float scale = dot(a, b) / magBSq;
vec3 proj2 = b * (scale * 2);
return a - proj2;
}
矢量反射对物理学和人工智能很有用。我们不需要用反射来进行动画,但是最好实现这个功能以防需要时使用。
叉积
给定两个输入向量,叉积返回一个垂直于两个输入向量的第三个向量。叉积的长度等于两个向量形成的平行四边形的面积。
以下图展示了叉积在视觉上的样子。输入向量不一定要相隔 90 度,但以这种方式可更容易地将它们可视化:
图 2.10:可视化叉积
找到叉积涉及一些矩阵运算,这将在下一章中更深入地介绍。现在,您需要创建一个 3x3 矩阵,其中顶行是结果向量。第二行和第三行应该填入输入向量。结果向量的每个分量的值是矩阵中该元素的次要。
3x3 矩阵中元素的次要是什么?它是较小的 2x2 子矩阵的行列式。假设你想要找到第一个分量的值,忽略第一行和第一列,得到一个较小的 2x2 子矩阵。以下图显示了每个分量的较小子矩阵:
图 2.11:每个分量的子矩阵
要找到 2x2 矩阵的行列式,需要进行叉乘。将左上角和右下角的元素相乘,然后减去右上角和左下角元素的乘积。以下图显示了结果向量的每个元素的情况:
图 2.12:结果向量中每个分量的行列式
在vec3.cpp
中实现cross
乘积。不要忘记将函数声明添加到vec3.h
中:
vec3 cross(const vec3 &l, const vec3 &r) {
return vec3(
l.y * r.z - l.z * r.y,
l.z * r.x - l.x * r.z,
l.x * r.y - l.y * r.x
);
}
点积与两个向量之间的夹角的余弦有关,而叉积与两个向量之间的正弦有关。两个向量之间的叉积的长度是两个向量的长度乘积,乘以它们之间的正弦值:
在下一节中,您将学习如何使用三种不同的技术在向量之间进行插值。
插值向量
两个向量可以通过缩放两个向量之间的差异并将结果添加回原始向量来进行线性插值。这种线性插值通常缩写为lerp
。lerp
的量是介于0和1之间的归一化值;这个归一化值通常用字母t表示。以下图显示了两个向量之间的lerp
,以及* t *的几个值:
图 2.13:线性插值
当t = 0时,插值向量与起始向量相同。当t = 1时,插值向量与结束向量相同。
在vec3.cpp
中实现lerp
函数。不要忘记将函数声明添加到vec3.h
中:
vec3 lerp(const vec3 &s, const vec3 &e, float t) {
return vec3(
s.x + (e.x - s.x) * t,
s.y + (e.y - s.y) * t,
s.z + (e.z - s.z) * t
);
}
在两个向量之间进行线性插值将始终采用从一个向量到另一个向量的最短路径。有时,最短路径并不是最佳路径;您可能需要在最短弧线上插值两个向量。在最短弧线上插值被称为球面线性插值(slerp
)。下图显示了几个t值的slerp
和lerp
过程之间的差异:
图 2.14:比较 slerp 和 lerp
要实现slerp
,找到两个输入向量之间的角度。假设角度已知,则slerp
的公式如下
在vec3.cpp
中实现slerp
函数。不要忘记将函数声明添加到vec3.h
中。要注意当t的值接近0时,slerp
会产生意外的结果。当t的值接近0时,可以退回到lerp
或归一化的 lerp(下一节将介绍):
vec3 slerp(const vec3 &s, const vec3 &e, float t) {
if (t < 0.01f) {
return lerp(s, e, t);
}
vec3 from = normalized(s);
vec3 to = normalized(e);
float theta = angle(from, to);
float sin_theta = sinf(theta);
float a = sinf((1.0f - t) * theta) / sin_theta;
float b = sinf(t * theta) / sin_theta;
return from * a + to * b;
}
最后一个要介绍的插值方法是nlerp
。nlerp
是对slerp
的近似。与slerp
不同,nlerp
在速度上不是恒定的。nlerp
比slerp
快得多,实现起来更容易;只需对lerp
的结果进行归一化。下图比较了lerp
、slerp
和nlerp
,其中t = 0.25:
图 2.15:比较 lerp、slerp 和 nlerp
在vec3.cpp
中实现nlerp
函数。不要忘记将函数声明添加到vec3.h
中:
vec3 nlerp(const vec3 &s, const vec3 &e, float t) {
vec3 linear(
s.x + (e.x - s.x) * t,
s.y + (e.y - s.y) * t,
s.z + (e.z - s.z) * t
);
return normalized(linear);
}
一般来说,nlerp
比slerp
更好。它是一个非常接近的近似,计算成本更低。唯一需要使用slerp
的情况是如果需要恒定的插值速度。在本书中,您将使用lerp
和nlerp
来在向量之间进行插值。
在下一节中,您将学习如何使用 epsilon 值来比较向量的相等和不相等。
比较向量
需要实现的最后一个操作是向量比较。比较是一个逐分量的操作;每个元素都必须使用一个 epsilon 进行比较。另一种衡量两个向量是否相同的方法是将它们相减。如果它们相等,相减将产生一个长度为零的向量。
在vec3.cpp
中重载==
和!=
运算符。不要忘记将函数声明添加到vec3.h
中:
bool operator==(const vec3 &l, const vec3 &r) {
vec3 diff(l - r);
return lenSq(diff) < VEC3_EPSILON;
}
bool operator!=(const vec3 &l, const vec3 &r) {
return !(l == r);
}
重要提示:
找到用于比较操作的正确 epsilon 值是困难的。在本章中,您将0.000001f
声明为 epsilon。这个值是一些试验的结果。要了解更多关于比较浮点值的信息,请访问bitbashing.io/comparing-floats.html
。
在下一节中,您将实现具有两个和四个分量的向量。这些向量将仅用作存储数据的便捷方式;它们实际上不需要在其上实现任何数学操作。
探索更多向量
在本书的后面某个时候,您还需要使用两个和四个分量的向量。两个和四个分量的向量不需要定义任何数学函数,因为它们将被专门用作传递数据到 GPU 的容器。
与您实现的三分量向量不同,两个和四个分量的向量需要同时存在为整数和浮点向量。为了避免重复代码,将使用模板来实现这两种结构:
- 创建一个新文件
vec2.h
,并添加vec2
结构的定义。所有vec2
构造函数都是内联的;不需要cpp
文件。TVec2
结构是模板化的,使用typedef
声明vec2
和ivec2
:
template<typename T>
struct TVec2 {
union {
struct {
T x;
T y;
};
T v[2];
};
inline TVec2() : x(T(0)), y(T(0)) { }
inline TVec2(T _x, T _y) :
x(_x), y(_y) { }
inline TVec2(T* fv) :
x(fv[0]), y(fv[1]) { }
};
typedef TVec2<float> vec2;
typedef TVec2<int> ivec2;
- 同样地,创建一个
vec4.h
文件,其中将保存vec4
结构:
template<typename T>
struct TVec4 {
union {
struct {
T x;
T y;
T z;
T w;
};
T v[4];
};
inline TVec4<T>(): x((T)0),y((T)0),z((T)0),w((T)0){}
inline TVec4<T>(T _x, T _y, T _z, T _w) :
x(_x), y(_y), z(_z), w(_w) { }
inline TVec4<T>(T* fv) :
x(fv[0]), y(fv[ ]), z(fv[2]), w(fv[3]) { }
};
typedef TVec4<float> vec4;
typedef TVec4<int> ivec4;
typedef TVec4<unsigned int> uivec4;
vec2
,ivec2
,vec4
和ivec4
结构的声明与vec3
结构的声明非常相似。所有这些结构都可以使用组件下标或作为线性内存数组的指针来访问。它们的构造函数也非常相似。
摘要
在本章中,您已经学会了创建强大动画系统所需的向量数学知识。动画是一个数学密集型的主题;本章中学到的技能是完成本书其余部分所必需的。您已经为三维向量实现了所有常见的向量运算。vec2
和vec4
结构没有像vec3
那样的完整实现,但它们只用于将数据发送到 GPU。
在下一章中,您将继续学习关于游戏相关数学的知识,学习关于矩阵的知识。
第三章:实现矩阵
在游戏动画的背景下,矩阵代表一个仿射变换。它将点从一个空间线性映射到另一个空间。一个网格由顶点表示,这些顶点只是空间中的点。通过将它们乘以一个矩阵,这些顶点被移动。
在本章中,您将学习矩阵数学以及如何在代码中实现矩阵。到本章结束时,您将建立一个强大的矩阵库,可以在任何项目中使用。矩阵很重要;它们在图形管线中扮演着重要角色。没有使用矩阵,很难渲染任何东西。
您只需要实现一个 4 x 4 的方阵。到本章结束时,您应该能够做到以下几点:
-
了解矩阵是什么
-
了解列主要矩阵存储
-
将矩阵相乘
-
反转矩阵
-
通过使用矩阵来转换点和向量
-
了解如何创建矩阵以查看三维世界
重要信息
在本章中,您将实现一个 4 x 4 的矩阵。矩阵的实现将依赖于代码来演示概念,而不是通过数学定义的格式。如果您对矩阵背后的正式数学感兴趣,请查看gabormakesgames.com/matrices.html
。
技术要求
本章的可下载材料中提供了两个示例。Sample00
显示了整个章节中编写的矩阵代码。Sample01
显示了一个使用显式低阶矩阵来实现矩阵逆函数的替代实现。
什么是矩阵?
矩阵是一个二维数组。一个方阵是宽度和高度相同的矩阵。在本章中,您将实现一个 4 x 4 的矩阵;也就是说,一个有四行四列的矩阵。这个矩阵的元素将被存储为一个线性数组。
一个 4 x 4 的矩阵可以被看作是有四个分量的四个向量,或者是一个vec4s
数组。如果这些向量代表矩阵的列,那么矩阵是列主要的。如果这些向量代表矩阵的行,那么它是行主要的。
假设一个 4 x 4 的矩阵包含字母* A,B,C,D … P 的字母表,它可以被构造为行主要或列主要矩阵。这在下面的图 3.1*中有所示:
图 3.1:比较行主要和列主要矩阵
大多数数学书籍和 OpenGL 使用列主要矩阵。在本章中,您也将实现列主要矩阵。了解矩阵中包含的内容是很重要的。矩阵的对角线包含缩放信息,最后一列包含平移:
图 3.2:矩阵中存储了什么?
上面的 3 x 3 子矩阵包含三个向量;每个向量都是矩阵旋转的基向量。基向量是存储在矩阵中的上、右和前方向。您可能已经注意到旋转和比例组件在矩阵中占据了相同的空间。
矩阵存储
现在您知道矩阵布局将是列矩阵,下一个问题是如何存储实际的矩阵。矩阵存储是一个令人困惑的话题。
由于矩阵在内存中存储为线性数组,让我们弄清楚应该把元素放在哪里。行主要矩阵在内存中一次存储一行。列主要矩阵一次存储一列。
由于行主要和列主要矩阵都包含相同的向量,最终的线性映射结果是相同的,无论矩阵的主要性如何。下面的图 3.3演示了这一点:
图 3.3:矩阵存储映射到线性数组
您将要构建的矩阵类是一个列主矩阵,使用列存储;这意味着矩阵的物理内存布局与其元素的逻辑放置之间会有差异。很容易将具有线性内存布局的矩阵视为行矩阵,但请记住,这些行中的每一行实际上都是一列。
重要说明
将二维网格映射到线性存储的典型方法是"行 * 列数 + 列"
。这种映射对于存储列主要矩阵是行不通的。当查看矩阵时,列 2,行 3 的元素应该具有线性索引 7,但是先前的映射得到的是 14。为了适应列主存储,映射公式是"列 * 行数 + 行"
。
了解矩阵在内存中的存储方式很重要,它将影响数据的存储方式以及 API 如何访问这些数据。在下一节中,您将开始实现一个矩阵结构。
创建矩阵
在本节中,您将创建一个新的 4x4 矩阵。这个矩阵将以一个包含 16 个浮点数的数组的形式存储。将使用一个联合来以更易于使用的方式访问矩阵中的数据:
重要说明
单位矩阵是一个特殊的矩阵,它将任何东西乘以单位矩阵的结果都是原始矩阵。单位矩阵不进行映射。单位矩阵中所有元素都包含 0,除了主对角线,它完全由 1 组成。
-
创建一个新文件,
mat4.h
。这个文件需要声明mat4
结构。 -
将以下结构声明添加到
mat4.h
,它通过声明一个由 16 个元素组成的平面数组作为联合的第一个成员来开始一个联合:
struct mat4 {
union {
float v[16];
- 联合的下一个成员是
vec4
变量的结构。每个vec4
变量代表矩阵的一列;它们以存储在这些列中的基向量命名:
struct {
vec4 right;
vec4 up;
vec4 forward;
vec4 position;
};
- 根据基向量的元素访问成员可能是有用的。以下结构包含了命名对;第一个字母代表基向量,第二个字母代表该向量的分量:
struct {
// row 1 row 2 row 3 row 4
/*col 1*/float xx;float xy;float xz;float xw;
/*col 2*/float yx;float yy;float yz;float yw;
/*col 3*/float zx;float zy;float zz;float zw;
/*col 4*/float tx;float ty;float tz;float tw;
};
- 下一个结构将允许您使用列-行表示法访问矩阵:
struct {
float c0r0;float c0r1;float c0r2;float c0r3;
float c1r0;float c1r1;float c1r2;float c1r3;
float c2r0;float c2r1;float c2r2;float c2r3;
float c3r0;float c3r1;float c3r2;float c3r3;
};
- 最后的结构将允许您使用行-列表示法访问矩阵:
struct {
float r0c0;float r1c0;float r2c0;float r3c0;
float r0c1;float r1c1;float r2c1;float r3c1;
float r0c2;float r1c2;float r2c2;float r3c2;
float r0c3;float r1c3;float r2c3;float r3c3;
};
}; // End union
- 添加一个
inline
构造函数,可以创建单位矩阵:
inline mat4() :
xx(1), xy(0), xz(0), xw(0),
yx(0), yy(1), yz(0), yw(0),
zx(0), zy(0), zz(1), zw(0),
tx(0), ty(0), tz(0), tw(1) {}
- 添加一个
inline
构造函数,可以从一个浮点数组创建矩阵:
inline mat4(float *fv) :
xx( fv[0]), xy( fv[1]), xz( fv[2]), xw( fv[3]),
yx( fv[4]), yy( fv[5]), yz( fv[6]), yw( fv[7]),
zx( fv[8]), zy( fv[9]), zz(fv[10]), zw(fv[11]),
tx(fv[12]), ty(fv[13]), tz(fv[14]), tw(fv[15]) { }
- 添加一个
inline
构造函数,可以通过指定矩阵中的每个元素来创建矩阵:
inline mat4(
float _00, float _01, float _02, float _03,
float _10, float _11, float _12, float _13,
float _20, float _21, float _22, float _23,
float _30, float _31, float _32, float _33) :
xx(_00), xy(_01), xz(_02), xw(_03),
yx(_10), yy(_11), yz(_12), yw(_13),
zx(_20), zy(_21), zz(_22), zw(_23),
tx(_30), ty(_31), tz(_32), tw(_33) { }
}; // end mat4 struct
您刚刚声明的矩阵结构是最终的mat4
结构;匿名联合提供了访问矩阵数据的五种不同方式。矩阵数据可以作为一个平面数组访问,作为四个列分别存储为vec4
,或作为三个助记符之一访问。这三个助记符使用它们的基向量、它们的行然后列,或它们的列然后行来命名元素。
接下来,您将开始编写操作mat4
结构的函数。您将实现常见的矩阵操作,如添加、缩放和相乘矩阵,并了解如何使用矩阵来转换向量和点。
常见的矩阵操作
在本节中,您将学习如何实现一些常见的矩阵操作。这些操作将在本书的后面章节中用于显示动画模型。具体来说,本节将涵盖如何比较、添加、缩放和相乘矩阵,以及如何使用矩阵来转换向量和点。
比较矩阵
比较矩阵是一个逐分量的操作。只有当两个矩阵的所有分量都相同时,它们才相同。要比较两个矩阵,循环遍历并比较它们的所有分量。由于比较的是浮点数,应该使用一个 epsilon。
创建一个新文件 mat4.cpp
。在这个文件中实现矩阵的相等和不相等运算符。相等运算符应该检查两个矩阵是否相同;不相等运算符返回相等运算符的相反值。不要忘记将函数声明添加到 mat4.h
中:
bool operator==(const mat4& a, const mat4& b) {
for (int i = 0; i < 16; ++i) {
if (fabsf(a.v[i] - b.v[i]) > MAT4_EPSILON) {
return false;
}
}
return true;
}
bool operator!=(const mat4& a, const mat4& b) {
return !(a == b);
}
重要提示
MAT4_EPSILON
常量应该在 mat4.h
中定义。0.000001f
是一个很好的默认值。
当按组件比较矩阵时,您正在检查字面上的相等。还有其他定义矩阵相等的方法;例如,可以使用它们的行列式来比较两个矩阵的体积,而不考虑形状。矩阵的行列式将在本章后面介绍。
在下一节中,您将学习如何将矩阵相加。
矩阵相加
两个矩阵可以按组件相加。要将两个矩阵相加,求出它们各自的分量之和,并将结果存储在一个新矩阵中。矩阵加法可以与标量乘法一起使用,以在多个矩阵之间进行插值或混合。稍后,您将学习如何使用这个属性来实现动画蒙皮。
在 mat4.cpp
中实现矩阵加法函数。不要忘记将函数声明添加到 mat4.h
中:
mat4 operator+(const mat4& a, const mat4& b) {
return mat4(
a.xx+b.xx, a.xy+b.xy, a.xz+b.xz, a.xw+b.xw,
a.yx+b.yx, a.yy+b.yy, a.yz+b.yz, a.yw+b.yw,
a.zx+b.zx, a.zy+b.zy, a.zz+b.zz, a.zw+b.zw,
a.tx+b.tx, a.ty+b.ty, a.tz+b.tz, a.tw+b.tw
);
}
矩阵加法很简单,但在显示动画网格中起着重要作用。在下一节中,您将学习如何将矩阵按标量值进行缩放。
矩阵缩放
矩阵可以通过浮点数进行缩放;这种缩放是一种按组件的操作。要缩放一个矩阵,将每个元素乘以提供的浮点数。
在 mat4.cpp
中实现矩阵缩放。不要忘记将函数声明添加到 mat4.h
中:
mat4 operator*(const mat4& m, float f) {
return mat4(
m.xx * f, m.xy * f, m.xz * f, m.xw * f,
m.yx * f, m.yy * f, m.yz * f, m.yw * f,
m.zx * f, m.zy * f, m.zz * f, m.zw * f,
m.tx * f, m.ty * f, m.tz * f, m.tw * f
);
}
先缩放矩阵,然后将它们相加,可以让您在多个矩阵之间进行"lerp"或"mix",只要这些矩阵都表示线性变换。在下一节中,您将学习如何将矩阵相乘。
矩阵乘法
矩阵乘法将两个矩阵的变换合并为一个矩阵。只有当两个矩阵的内部维度相同时,才能将两个矩阵相乘。以下是一些例子:
-
一个 4 x 4 矩阵和一个 4 x 4 矩阵可以相乘,因为内部维度都是 4。
-
一个 4 x 4 矩阵和一个 4 x 1 矩阵可以相乘,因为内部维度都是 4。
-
一个 4 x 4 矩阵和一个 1 x 4 矩阵不能相乘,因为内部维度 4 和 1 不匹配。
矩阵乘法的结果矩阵将具有相乘在一起的矩阵的外部维度。以下是一个例子:
-
一个 4 x 4 矩阵和一个 4 x 4 矩阵将产生一个 4 x 4 矩阵。
-
一个 4 x 4 矩阵和一个 4 x 1 矩阵将产生一个 4 x 1 矩阵。
-
一个 1 x 4 矩阵和一个 4 x 2 矩阵将产生一个 1 x 2 矩阵。
假设有两个矩阵,A 和 B。矩阵 A 在 X 轴上平移 10 个单位。矩阵 B 绕 Y 轴旋转 30 度。如果这两个矩阵相乘为 A * B,得到的矩阵将绕 Y 轴旋转 30 度,然后在 X 轴上平移 10 个单位。
矩阵乘法不是累积的。考虑上一个例子,但是将 B * A 相乘。当相乘 B * A 时,得到的矩阵将在 X 轴上平移 10 个单位,然后绕 Y 轴旋转 30 度。乘法顺序很重要;A * B 不同于 B * A。
这带来了一个新问题——矩阵应该以什么顺序相乘?如果 M = A * B * C,那么这些矩阵应该以什么顺序连接?A,B,然后 C 还是 C,B,然后 A?如果是 A,B,然后 C,矩阵乘法被定义为从左到右。但如果是 C,B,然后 A,矩阵乘法是从右到左。
为了与 OpenGL 保持一致,在本章中,您将实现从右到左的矩阵乘法。但是两个矩阵如何相乘呢?矩阵的每个元素都有一行和一列。任何元素的结果值都是左矩阵的该行与右矩阵的该列的点积。
例如,假设您想要找到两个矩阵相乘时第 2 行第 3 列的元素的值。这意味着取左侧矩阵的第 2 行和右侧矩阵的第 3 列进行点乘。图 3.4演示了这一点:
图 3.4:矩阵相乘
您可能已经注意到,在前面的图中,即使矩阵是列主序的,元素的下标也是先行后列。下标引用了矩阵的物理拓扑结构;它与矩阵中存储的内容或矩阵的布局方式无关。无论矩阵的主序是什么,下标索引都保持不变。执行以下步骤来实现矩阵乘法:
- 为了使矩阵相乘的代码保持简洁,您需要创建一个辅助宏。该宏将假定有两个矩阵
a
和b
。该宏将取两个数字,a
的行和b
的列,进行点乘,结果将是这两者的点积。在mat4.cpp
中定义M4D
宏:
#define M4D(aRow, bCol) \
a.v[0 * 4 + aRow] * b.v[bCol * 4 + 0] + \
a.v[1 * 4 + aRow] * b.v[bCol * 4 + 1] + \
a.v[2 * 4 + aRow] * b.v[bCol * 4 + 2] + \
a.v[3 * 4 + aRow] * b.v[bCol * 4 + 3]
- 在
mat4.cpp
中放置了M4D
宏后,实现矩阵乘法函数。不要忘记将函数声明添加到mat4.h
中。记住,例如(2, 1)
元素应该取矩阵a
的第 2 行和矩阵b
的第 1 列进行点乘:
mat4 operator*(const mat4 &a, const mat4 &b) {
return mat4(
M4D(0,0), M4D(1,0), M4D(2,0), M4D(3,0),//Col 0
M4D(0,1), M4D(1,1), M4D(2,1), M4D(3,1),//Col 1
M4D(0,2), M4D(1,2), M4D(2,2), M4D(3,2),//Col 2
M4D(0,3), M4D(1,3), M4D(2,3), M4D(3,3) //Col 3
);
}
矩阵相乘最重要的特性是将编码在两个矩阵中的变换合并为一个单独的矩阵。这很有用,因为您可以预先乘以某些矩阵,以执行更少的每帧乘法。接下来,您将了解矩阵如何将其变换数据应用于向量和点。
变换向量和点
点和向量的变换方式与矩阵相乘的方式相同。实际上,被变换的向量可以被视为具有 4 列 1 行的矩阵。这意味着变换向量就是将一个 4 x 4 矩阵和一个 4 x 1 矩阵相乘的问题。
当矩阵变换向量时,它会影响向量的方向和大小。当矩阵变换点时,它只是在空间中平移点。那么,向量和点之间有什么区别呢?向量的w分量为0,点的W分量为1。以下步骤将指导您实现矩阵-向量乘法:
- 为了使矩阵-向量乘法更易于阅读,您需要再次创建一个宏。该宏将取矩阵的行并对该行与提供的列向量进行点积。在
mat4.cpp
中实现M4VD
宏:
#define M4V4D(mRow, x, y, z, w) \
x * m.v[0 * 4 + mRow] + \
y * m.v[1 * 4 + mRow] + \
z * m.v[2 * 4 + mRow] + \
w * m.v[3 * 4 + mRow]
- 在
mat4.cpp
中放置了M4V4D
宏后,实现矩阵-向量乘法函数。不要忘记将函数定义添加到mat4.h
中:
vec4 operator*(const mat4& m, const vec4& v) {
return vec4(
M4V4D(0, v.x, v.y, v.z, v.w),
M4V4D(1, v.x, v.y, v.z, v.w),
M4V4D(2, v.x, v.y, v.z, v.w),
M4V4D(3, v.x, v.y, v.z, v.w)
);
}
-
本书中的大部分数据将被存储为三分量向量,而不是四分量。每次需要通过矩阵进行变换时,都无需创建一个新的四分量向量;相反,您将为此创建一个专门的函数。
-
在
mat4.cpp
中定义一个新函数:transformVector
。不要忘记将函数声明添加到mat4.h
中。该函数将使用提供的矩阵对vec3
进行变换,假设该向量表示方向和大小:
vec3 transformVector(const mat4& m, const vec3& v) {
return vec3(
M4V4D(0, v.x, v.y, v.z, 0.0f),
M4V4D(1, v.x, v.y, v.z, 0.0f),
M4V4D(2, v.x, v.y, v.z, 0.0f)
);
}
- 接下来,在
mat4.cpp
中定义transformPoint
函数。它应该将向量和矩阵相乘,假设向量的 W 分量为 1:
vec3 transformPoint(const mat4& m, const vec3& v) {
return vec3(
M4V4D(0, v.x, v.y, v.z, 1.0f),
M4V4D(1, v.x, v.y, v.z, 1.0f),
M4V4D(2, v.x, v.y, v.z, 1.0f)
);
}
- 为
transformPoint
定义一个重载,它带有额外的W分量。W分量是一个引用——它是可读写的。函数执行后,w分量将保存W的值,如果输入向量是vec4
的话:
vec3 transformPoint(const mat4& m, const vec3& v, float& w) {
float _w = w;
w = M4V4D(3, v.x, v.y, v.z, _w);
return vec3(
M4V4D(0, v.x, v.y, v.z, _w),
M4V4D(1, v.x, v.y, v.z, _w),
M4V4D(2, v.x, v.y, v.z, _w)
);
}
在本书的其余部分,大多数数据都存储在vec3
结构中。这意味着将使用transformVector
和transformPoint
,而不是重载的乘法运算符。这应有助于减少对被转换数据的歧义。接下来,您将学习如何求矩阵的逆。
求逆矩阵
将矩阵乘以其逆矩阵总是会得到单位矩阵。逆矩阵具有非逆矩阵的相反映射。并非所有矩阵都有逆矩阵。只有行列式非零的矩阵才能被求逆。
求逆矩阵是一个重要的操作;用于将三维对象转换为屏幕上显示的视图矩阵是相机位置和旋转的逆矩阵。另一个逆矩阵变得重要的地方是蒙皮,这将在第十章**,网格蒙皮中介绍。
找到矩阵的逆矩阵相当复杂,因为它需要其他支持函数(如转置和伴随矩阵)。在本节中,您将首先构建这些支持函数,然后在它们都构建完成后构建逆函数。因此,首先需要转置矩阵。
转置
要转置矩阵,需要沿着其主对角线翻转矩阵的每个元素。例如,2, 1元素将变为1, 2元素。两个下标都相同的元素,如1, 1,将保持不变:
- 在
mat4.cpp
中实现transpose
函数。不要忘记将函数声明添加到mat4.h
中:
#define M4SWAP(x, y) \
{float t = x; x = y; y = t; }
void transpose(mat4 &m) {
M4SWAP(m.yx, m.xy);
M4SWAP(m.zx, m.xz);
M4SWAP(m.tx, m.xw);
M4SWAP(m.zy, m.yz);
M4SWAP(m.ty, m.yw);
M4SWAP(m.tz, m.zw);
}
- 在
mat4.cpp
中创建一个transposed
函数。transposed
函数修改传入的矩阵。不要忘记将函数声明添加到mat4.h
中:
mat4 transposed(const mat4 &m) {
return mat4(
m.xx, m.yx, m.zx, m.tx,
m.xy, m.yy, m.zy, m.ty,
m.xz, m.yz, m.zz, m.tz,
m.xw, m.yw, m.zw, m.tw
);
}
如果需要将矩阵从行优先顺序转换为列优先顺序,或者反之,则转置矩阵是有用的。在下一节中,您将学习如何计算方阵的行列式。
行列式和低阶矩阵的小数
要找到 4 x 4 矩阵的行列式,首先要了解低阶矩阵的行列式和小数是什么。行列式函数是递归的;要找到 4 x 4 矩阵的行列式,我们需要找到几个 3 x 3 和 2 x 2 矩阵的行列式。
矩阵的行列式始终是一个标量值;只有方阵有行列式。如果矩阵被转置,其行列式保持不变。
在接下来的几节中,您将学习如何找到 2 x 2 矩阵的行列式,任意大小矩阵的小数矩阵以及任意大小矩阵的余子式。这些方法是拉普拉斯展开的基本组成部分,您将用它们来找到任意大小矩阵的行列式。
2 x 2 行列式
要找到 2 x 2 矩阵的行列式,需要减去对角线元素的乘积。以下图示了这一点:
图 3.5:2 x 2 矩阵和行列式的公式
小数
矩阵中的每个元素都有一个小数。元素的小数是消除该元素的行和列后得到的较小矩阵的行列式。例如,考虑一个 3 x 3 矩阵——元素2, 1的小数是什么?
首先,从矩阵中消除第 2 行和第 1 列。这将导致一个较小的 2 x 2 矩阵。这个 2 x 2 矩阵的行列式就是元素2, 1的小数。以下图示了这一点:
图 3.6:3 x 3 矩阵中元素 2, 1 的小数
这个公式也适用于更高维度的矩阵。例如,4x4 矩阵中一个元素的余子式是一些较小的 3x3 矩阵的行列式。余子式矩阵是一个矩阵,其中每个元素都是输入矩阵对应元素的余子式。
余子式
要找到矩阵的余子式,首先计算余子式矩阵。得到余子式矩阵后,将矩阵中的每个元素(i, j)乘以-1的i+j次幂。加-1(i+j)power 的值形成一个方便的棋盘格图案,其中+始终位于左上角:
图 3.7:-1 到 i+j 次幂的棋盘格图案
前面的图表显示了 Add -1(i+j)创建的棋盘格图案。请注意,图案始终从左上角的正元素开始。
拉普拉斯展开
任何方阵的行列式(如果存在)都可以通过拉普拉斯展开来找到。要执行此操作,首先找到余子式矩阵。接下来,将原始矩阵的第一行中的每个元素乘以余子式矩阵中相应的第一行的元素。行列式是这些乘积的总和:
伴随矩阵
在您可以反转矩阵之前的最后一个操作是找到矩阵的伴随矩阵。矩阵的伴随矩阵是余子式矩阵的转置。实现伴随矩阵很简单,因为您已经知道如何找到矩阵的余子式以及如何对矩阵进行转置。
逆
要找到矩阵的逆,需要将矩阵的伴随矩阵除以其行列式。由于标量矩阵除法未定义,因此需要将伴随矩阵乘以行列式的倒数。
重要说明
在本章中,您将构建一个矩阵乘法函数,该函数使用宏来避免对低阶矩阵的需求。本书的可下载材料中的Chapter03/Sample01
示例提供了一个实现,该实现利用了低阶矩阵,并且更容易通过调试器进行调试。
要实现矩阵的逆函数,首先需要能够找到 4x4 矩阵的行列式和伴随矩阵。这两个函数都依赖于能够找到矩阵中元素的余子式:
- 在
mat4.cpp
中创建一个新的宏。该宏将找到矩阵中一个元素的余子式,给定一个浮点数数组,以及从矩阵中切割的三行和三列:
#define M4_3X3MINOR(x, c0, c1, c2, r0, r1, r2) \
(x[c0*4+r0]*(x[c1*4+r1]*x[c2*4+r2]-x[c1*4+r2]* \
x[c2*4+r1])-x[c1*4+r0]*(x[c0*4+r1]*x[c2*4+r2]- \
x[c0*4+r2]*x[c2*4+r1])+x[c2*4+r0]*(x[c0*4+r1]* \
x[c1*4+r2]-x[c0*4+r2]*x[c1*4+r1]))
- 使用定义的
M4_3X3MINOR
宏,在mat4.cpp
中实现determinant
函数。由于行列式将每个元素乘以余子式,因此需要对一些值进行取反。不要忘记将函数声明添加到mat4.h
中:
float determinant(const mat4& m) {
return m.v[0] *M4_3X3MINOR(m.v, 1, 2, 3, 1, 2, 3)
- m.v[4] *M4_3X3MINOR(m.v, 0, 2, 3, 1, 2, 3)
+ m.v[8] *M4_3X3MINOR(m.v, 0, 1, 3, 1, 2, 3)
- m.v[12]*M4_3X3MINOR(m.v, 0, 1, 2, 1, 2, 3);
}
- 接下来,在
mat4.cpp
中实现adjugate
函数。不要忘记将函数声明添加到mat4.h
中。使用M4_3X3MINOR
宏找到余子式矩阵,然后对适当的元素取反以创建余子式矩阵。最后,返回余子式矩阵的转置:
mat4 adjugate(const mat4& m) {
//Cof (M[i, j]) = Minor(M[i, j]] * pow(-1, i + j)
mat4 cofactor;
cofactor.v[0] = M4_3X3MINOR(m.v, 1, 2, 3, 1, 2, 3);
cofactor.v[1] =-M4_3X3MINOR(m.v, 1, 2, 3, 0, 2, 3);
cofactor.v[2] = M4_3X3MINOR(m.v, 1, 2, 3, 0, 1, 3);
cofactor.v[3] =-M4_3X3MINOR(m.v, 1, 2, 3, 0, 1, 2);
cofactor.v[4] =-M4_3X3MINOR(m.v, 0, 2, 3, 1, 2, 3);
cofactor.v[5] = M4_3X3MINOR(m.v, 0, 2, 3, 0, 2, 3);
cofactor.v[6] =-M4_3X3MINOR(m.v, 0, 2, 3, 0, 1, 3);
cofactor.v[7] = M4_3X3MINOR(m.v, 0, 2, 3, 0, 1, 2);
cofactor.v[8] = M4_3X3MINOR(m.v, 0, 1, 3, 1, 2, 3);
cofactor.v[9] =-M4_3X3MINOR(m.v, 0, 1, 3, 0, 2, 3);
cofactor.v[10]= M4_3X3MINOR(m.v, 0, 1, 3, 0, 1, 3);
cofactor.v[11]=-M4_3X3MINOR(m.v, 0, 1, 3, 0, 1, 2);
cofactor.v[12]=-M4_3X3MINOR(m.v, 0, 1, 2, 1, 2, 3);
cofactor.v[13]= M4_3X3MINOR(m.v, 0, 1, 2, 0, 2, 3);
cofactor.v[14]=-M4_3X3MINOR(m.v, 0, 1, 2, 0, 1, 3);
cofactor.v[15]= M4_3X3MINOR(m.v, 0, 1, 2, 0, 1, 2);
return transposed(cofactor);
}
- 现在
determinant
和adjugate
函数已经完成,实现 4x4 矩阵的inverse
函数应该很简单。在mat4.cpp
中实现inverse
函数。不要忘记将函数声明添加到mat4.h
中:
mat4 inverse(const mat4& m) {
float det = determinant(m);
if (det == 0.0f) {
cout << " Matrix determinant is 0\n";
return mat4();
}
mat4 adj = adjugate(m);
return adj * (1.0f / det);
}
inverse
函数接受一个常量矩阵引用,并返回一个新的矩阵,该矩阵是提供矩阵的逆矩阵。在mat4.cpp
中实现一个invert
便利函数。这个便利函数将内联地反转矩阵,修改参数。不要忘记将函数声明添加到mat4.h
中:
void invert(mat4& m) {
float det = determinant(m);
if (det == 0.0f) {
std::cout << "Matrix determinant is 0\n";
m = mat4();
return;
}
m = adjugate(m) * (1.0f / det);
}
矩阵的求逆是一个相对昂贵的函数。只编码位置和旋转的矩阵可以更快地求逆,因为 3x3 旋转矩阵的逆矩阵与其转置矩阵相同。
在实现lookAt
函数时,您将学习如何实现这个快速的逆函数。
创建相机矩阵
矩阵也用于相机变换,包括透视变换。透视变换将视锥体映射到 NDC 空间。NDC 空间通常在所有轴上的范围为-1 到+1。与世界/眼坐标不同,NDC 空间是左手坐标系。
在本节中,您将学习如何创建相机变换矩阵。第一个相机矩阵是一个视锥体,看起来像一个顶部被切掉的金字塔。视锥体代表相机可见的一切。您还将学习如何创建不同的投影,并实现一个“look at”函数,让您轻松创建视图矩阵。
视锥体
在视觉上,视锥体看起来像一个顶部被切掉的金字塔。视锥体有六个面;它代表相机可以看到的空间。在mat4.cpp
中创建frustum
函数。该函数接受 left、right、bottom、top、near 和 far 值:
mat4 frustum(float l, float r, float b,
float t, float n, float f) {
if (l == r || t == b || n == f) {
std::cout << "Invalid frustum\n";
return mat4(); // Error
}
return mat4(
(2.0f * n) / (r - l),0, 0, 0,
0, (2.0f * n) / (t - b), 0, 0,
(r+l)/(r-l), (t+b)/(t-b), (-(f+n))/(f-n), -1,
0, 0, (-2 * f * n) / (f - n), 0
);
}
重要提示
推导视锥体矩阵的细节超出了本书的范围。有关如何推导该函数的更多信息,请查看www.songho.ca/opengl/gl_projectionmatrix.html
。
frustum
函数可用于构建视锥体,但函数参数不直观。在下一节中,您将学习如何从更直观的参数创建视锥体。
透视
透视矩阵是由视野(通常以度为单位)、宽高比和近远距离构建的。它是创建视锥体的一种简单方式。
在mat4.cpp
中实现perspective
函数。不要忘记将函数声明添加到mat4.h
中:
mat4 perspective(float fov, float aspect, float n,float f){
float ymax = n * tanf(fov * 3.14159265359f / 360.0f);
float xmax = ymax * aspect;
return frustum(-xmax, xmax, -ymax, ymax, n, f);
}
perspective
函数将在本书其余部分的几乎所有视觉图形演示中使用。这是创建视锥体的一种非常方便的方式。
正交
正交投影没有透视效果。正交投影线性映射到 NDC 空间。正交投影通常用于二维游戏。它经常用于实现等距透视。
在mat4.cpp
中实现ortho
函数。不要忘记将函数声明添加到mat4.h
中:
mat4 ortho(float l, float r, float b, float t,
float n, float f) {
if (l == r || t == b || n == f) {
return mat4(); // Error
}
return mat4(
2.0f / (r - l), 0, 0, 0,
0, 2.0f / (t - b), 0, 0,
0, 0, -2.0f / (f - n), 0,
-((r+l)/(r-l)),-((t+b)/(t-b)),-((f+n)/(f-n)), 1
);
}
正交视图投影通常用于显示 UI 或其他二维元素。
观察
视图矩阵是相机变换的逆矩阵(相机的位置、旋转和缩放)。您将实现一个lookAt
函数,直接生成该矩阵,而不是创建相机的变换矩阵然后求逆。
lookAt
函数通常接受一个位置
、相机所看的目标点
和一个参考上方向
。其余的工作是找到倒置的基向量,并确定位置在哪里。
由于基向量是正交的,它们的逆矩阵与它们的转置矩阵相同。位置可以通过将位置列向量与倒置的基向量的点积取反来计算。
在mat4.cpp
中实现lookAt
函数。不要忘记将函数声明添加到mat4.h
中。记住,视图矩阵将游戏世界映射到正Z轴:
mat4 lookAt(const vec3& position, const vec3& target,
const vec3& up) {
vec3 f = normalized(target - position) * -1.0f;
vec3 r = cross(up, f); // Right handed
if (r == vec3(0, 0, 0)) {
return mat4(); // Error
}
normalize(r);
vec3 u = normalized(cross(f, r)); // Right handed
vec3 t = vec3(
-dot(r, position),
-dot(u, position),
-dot(f, position)
);
return mat4(
// Transpose upper 3x3 matrix to invert it
r.x, u.x, f.x, 0,
r.y, u.y, f.y, 0,
r.z, u.z, f.z, 0,
t.x, t.y, t.z, 1
);
}
lookAt
函数是构建视图矩阵最方便的方法。本书其余部分的所有代码示例都将使用lookAt
函数来设置视图矩阵。
总结
在本章中,您学习了处理四维方阵所需的数学知识,并实现了一个可重用的矩阵库。矩阵通常用于编码变换信息;它们几乎在图形管线的每一步都被用来在屏幕上显示模型。
在下一章中,您将学习如何使用四元数编码旋转数据。
第四章:实现四元数
在本章中,您将学习有关四元数的知识。四元数用于编码旋转。四元数是以xi + yj + zk + w形式的复数。想象一下i,j,
和k作为每个代表三维轴的占位符。w是一个实数。虽然四元数不直接编码角轴对,但很容易将它们想象为
就像那样——围绕任意轴旋转。
在本章结束时,您应该对四元数是什么以及如何使用它们有很强的理解,并且您将在代码中实现了一个强大的四元数类。本章将涵盖以下主题:
-
创建四元数的不同方法
-
检索四元数的角度和轴
-
基本的分量操作
-
两个四元数的长度和点积
-
反转四元数
-
组合四元数
-
通过四元数变换向量
-
在四元数之间插值
-
将四元数和矩阵转换
为什么四元数很重要?大多数人形动画只使用旋转——不需要平移或缩放。例如,想象一下肘关节。肘部的自然运动只是旋转。如果您想要将肘部平移到空间中,您需要旋转肩膀。四元数编码旋转,并且它们插值得很好。
重要信息:
在本章中,您将以直观的代码优先方法实现四元数。如果您对四元数背后更正式的数学感兴趣,请查看gabormakesgames.com/quaternions.html
。
创建四元数
四元数用于编码旋转数据。在代码中,四元数将有四个分量。它们类似于vec4
,因为它们有x
、y
、z
和w
分量。
与vec4
一样,w
分量最后出现。
quat
结构应该有两个构造函数。默认构造函数创建一个单位四元数,(0, 0, 0, 1)
。(0, 0, 0, 1)
单位四元数就像1
。任何数乘以1
仍然保持不变。同样,任何四元数乘以单位四元数仍然保持不变:
创建一个新文件quat.h
,声明四元数结构。quat
结构将在本书的其余部分中用于表示旋转:
#ifndef _H_QUAT_
#define _H_QUAT_
#include "vec3.h"
#include "mat4.h"
struct quat {
union {
struct {
float x;
float y;
float z;
float w;
};
struct {
vec3 vector;
float scalar;
};
float v[4];
};
inline quat() :
x(0), y(0), z(0), w(1) { }
inline quat(float _x, float _y, float _z, float _w)
: x(_x), y(_y), z(_z), w(_w) {}
};
#endif
quat
结构内的匿名联合将允许您通过X
、Y
、Z
和W
下标符号访问四元数内的数据,作为矢量和标量对,或作为浮点值数组。
接下来,您将学习如何开始创建四元数。
角轴
四元数通常使用旋转轴和角度创建。关于轴的旋转θ可以在球面上表示为任何有向弧,其长度为,位于垂直于旋转轴的平面上。正角度产生绕轴的逆时针旋转。
创建一个新文件quat.cpp
。在quat.cpp
中实现angleAxis
函数。不要忘记将函数声明添加到quat.h
中:
#include "quat.h"
#include <cmath>
quat angleAxis(float angle, const vec3& axis) {
vec3 norm = normalized(axis);
float s = sinf(angle * 0.5f);
return quat(norm.x * s,
norm.y * s,
norm.z * s,
cosf(angle * 0.5f)
);
}
为什么!?四元数可以跟踪两个完整的旋转,即720度。这使得四元数的周期为720度。sin/cos 的周期是360度。将θ除以2将四元数的范围映射到 sin/cos 的范围。
在本节中,您学习了如何编码旋转的角度和轴
四元数。在下一节中,您将学习如何构建一个角度和一个轴
用于两个向量之间的旋转,并将其编码为四元数。
从一个向量到另一个向量创建旋转
任何两个单位向量都可以表示球面上的点。这些点之间的最短弧位于包含这两个点和球心的平面上。这个平面
垂直于这两个向量之间的旋转轴。
要找到旋转轴,需要对输入向量进行归一化。找到输入向量的叉积。这就是旋转轴。找到输入向量之间的角度。从第二章,实现向量中,两个向量之间角度的公式为。由于两个输入向量都被归一化了,这简化为,这意味着θ的余弦是输入向量的点积:
你会记得从第二章,实现向量中,点积与两个向量之间夹角的余弦有关,而叉积与两个向量之间夹角的正弦有关。在创建四元数时,点积和叉积具有以下属性:
叉积可以扩展为x、y和z分量,前面的方程开始看起来像是从角度和旋转轴创建四元数的代码。找到两个向量之间的角度会很昂贵,但可以计算出半角而不知道角度是多少。
要找到半角,找到v1和v2输入向量之间的中间向量。使用v1和这个中间向量构造一个四元数。这将创建一个导致所需旋转的四元数。
有一个特殊情况——当v1和v2平行时会发生什么?或者如果v1== -v2?用于找到旋转轴的叉积会产生一个0向量。如果发生这种特殊情况,找到两个向量之间最垂直的向量来创建一个纯四元数。
执行以下步骤来实现fromTo
函数:
- 开始在
quat.cpp
中实现fromTo
函数,并在quat.h
中添加函数声明。首先对from
和to
向量进行归一化,确保它们不是相同的向量:
quat fromTo(const vec3& from, const vec3& to) {
vec3 f = normalized(from);
vec3 t = normalized(to);
if (f == t) {
return quat();
}
- 接下来,检查两个向量是否互为相反。如果是的话,
from
向量的最正交轴可以用来创建一个纯四元数:
else if (f == t * -1.0f) {
vec3 ortho = vec3(1, 0, 0);
if (fabsf(f.y) <fabsf(f.x)) {
ortho = vec3(0, 1, 0);
}
if (fabsf(f.z)<fabs(f.y) && fabs(f.z)<fabsf(f.x)){
ortho = vec3(0, 0, 1);
}
vec3 axis = normalized(cross(f, ortho));
return quat(axis.x, axis.y, axis.z, 0);
}
- 最后,创建一个
from
和to
向量之间的半向量。使用半向量和起始向量的叉积来计算旋转轴,使用两者的点积来找到旋转角度:
vec3 half = normalized(f + t);
vec3 axis = cross(f, half);
return quat(axis.x, axis.y, axis.z, dot(f, half));
}
fromTo
函数是创建四元数的最直观方式之一。接下来,你将学习如何检索定义四元数的角度和轴。
检索四元数数据
由于可以从角度和轴创建四元数,因此可以合理地期望能够从四元数中检索相同的角度和轴。要检索旋转轴,需要对四元数的向量部分进行归一化。旋转角度是实部的反余弦的两倍。
在quat.cpp
中实现getAngle
和getAxis
函数,并在quat.h
中为两个函数添加函数声明:
vec3 getAxis(const quat& quat) {
return normalized(vec3(quat.x, quat.y, quat.z));
}
float getAngle(const quat& quat) {
return 2.0f * acosf(quat.w);
}
能够检索定义四元数的角度和轴将在以后一些四元数操作中需要。
接下来,你将学习常用的四元数分量操作。
常见的四元数操作
与向量一样,四元数也有分量操作。常见的
分量操作包括加法、减法、乘法或否定
四元数。分量乘法将四元数相乘
通过单个标量值。
由于这些函数是分量操作,它们只是对输入四元数的相似分量执行适当的操作。在quat.cpp
中实现这些函数,并在quat.h
中为每个函数添加声明:
quat operator+(const quat& a, const quat& b) {
return quat(a.x+b.x, a.y+b.y, a.z+b.z, a.w+b.w);
}
quat operator-(const quat& a, const quat& b) {
return quat(a.x-b.x, a.y-b.y, a.z-b.z, a.w-b.w);
}
quat operator*(const quat& a, float b) {
return quat(a.x * b, a.y * b, a.z * b, a.w * b);
}
quat operator-(const quat& q) {
return quat(-q.x, -q.y, -q.z, -q.w);
}
这些分量级的操作本身并没有太多实际用途。它们是构建四元数功能的基本组件。接下来,您将学习有关比较四元数的不同方法。
比较操作
比较两个四元数可以逐分量进行。即使两个四元数在分量级别上不相同,它们仍然可以表示相同的旋转。这是因为一个四元数及其逆旋转到相同的位置,但它们采取不同的路径。
- 在
quat.cpp
中重载==
和!=
运算符。将这些函数的声明添加到quat.h
中:
bool operator==(const quat& left, const quat& right) {
return (fabsf(left.x - right.x) <= QUAT_EPSILON &&
fabsf(left.y - right.y) <= QUAT_EPSILON &&
fabsf(left.z - right.z) <= QUAT_EPSILON &&
fabsf(left.w - right.w) <= QUAT_EPSILON);
}
bool operator!=(const quat& a, const quat& b) {
return !(a == b);
}
- 要测试两个四元数是否代表相同的旋转,需要测试两者之间的绝对差异。在
quat.cpp
中实现sameOrientation
函数。将函数声明添加到quat.h
中:
bool sameOrientation(const quat&l, const quat&r) {
return (fabsf(l.x - r.x) <= QUAT_EPSILON &&
fabsf(l.y - r.y) <= QUAT_EPSILON &&
fabsf(l.z - r.z) <= QUAT_EPSILON &&
fabsf(l.w - r.w) <= QUAT_EPSILON) ||
(fabsf(l.x + r.x) <= QUAT_EPSILON &&
fabsf(l.y + r.y) <= QUAT_EPSILON &&
fabsf(l.z + r.z) <= QUAT_EPSILON &&
fabsf(l.w + r.w) <= QUAT_EPSILON);
}
大多数情况下,您将希望使用相等运算符来比较四元数。sameOrientation
函数不太有用,因为四元数的旋转可以在四元数被反转时发生变化。
在下一节中,您将学习如何实现四元数点积。
点积
与向量一样,点积测量两个四元数的相似程度。实现与向量实现相同。相乘相同的分量并求和结果。
在quat.cpp
中实现四元数点积函数,并将其声明添加到quat.h
中:
float dot(const quat& a, const quat& b) {
return a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w;
}
与向量一样,四元数的长度是四元数与自身的点积。在下一节中,您将学习如何找到四元数的平方长度和长度。
长度和平方长度
与向量一样,四元数的平方长度与四元数与自身的点积相同。四元数的长度是平方长度的平方根:
- 在
quat.cpp
中实现lenSq
函数,并在quat.h
中声明该函数:
float lenSq(const quat& q) {
return q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w;
}
- 在
quat.cpp
中实现len
函数。不要忘记将函数声明添加到quat.h
中:
float len(const quat& q) {
float lenSq = q.x*q.x + q.y*q.y + q.z*q.z + q.w*q.w;
if (lenSq< QUAT_EPSILON) {
return 0.0f;
}
return sqrtf(lenSq);
}
代表旋转的四元数应始终具有1的长度。在下一节中,您将了解始终具有1长度的单位四元数。
四元数
四元数可以像向量一样被归一化。归一化的四元数只代表旋转,而非归一化的四元数会引入扭曲。在游戏动画的背景下,应该对四元数进行归一化,以避免给变换添加扭曲。
要归一化一个四元数,将四元数的每个分量除以其长度。结果四元数的长度将为1。可以实现如下:
- 在
quat.cpp
中实现normalize
函数,并在quat.h
中声明它:
void normalize(quat& q) {
float lenSq = q.x*q.x + q.y*q.y + q.z*q.z + q.w*q.w;
if (lenSq < QUAT_EPSILON) {
return;
}
float i_len = 1.0f / sqrtf(lenSq);
q.x *= i_len;
q.y *= i_len;
q.z *= i_len;
q.w *= i_len;
}
- 在
quat.cpp
中实现normalized
函数,并在quat.h
中声明它:
quat normalized(const quat& q) {
float lenSq = q.x*q.x + q.y*q.y + q.z*q.z + q.w*q.w;
if (lenSq < QUAT_EPSILON) {
return quat();
}
float il = 1.0f / sqrtf(lenSq); // il: inverse length
return quat(q.x * il, q.y * il, q.z * il,q.w * il);
}
有一种快速的方法可以求任意单位四元数的倒数。在下一节中,您将学习如何找到四元数的共轭和倒数,以及它们在单位四元数方面的关系。
共轭和逆
游戏大多使用归一化的四元数,在反转四元数时非常方便。归一化四元数的逆是它的共轭。共轭
四元数的翻转其旋转轴:
- 在
quat.cpp
中实现conjugate
函数,并记得在quat.h
中声明该函数:
quat conjugate(const quat& q) {
return quat(
-q.x,
-q.y,
-q.z,
q.w
);
}
- 四元数的逆是四元数的共轭除以四元数的平方长度。在
quat.cpp
中实现四元数inverse
函数。将函数声明添加到quat.h
中:
quat inverse(const quat& q) {
float lenSq = q.x*q.x + q.y*q.y + q.z*q.z + q.w*q.w;
if (lenSq < QUAT_EPSILON) {
return quat();
}
float recip = 1.0f / lenSq;
return quat(-q.x * recip,
-q.y * recip,
-q.z * recip,
q.w * recip
);
}
如果您需要找出一个四元数是否已经归一化,可以检查平方长度。归一化四元数的平方长度始终为1。如果四元数已经归一化,其共轭和逆将是相同的。这意味着您可以使用更快的conjugate
函数,而不是inverse
函数。在下一节中,您将学习如何将两个四元数相乘。
乘法四元数
两个四元数可以通过将它们相乘来连接。与矩阵类似,操作是从右到左进行的;首先应用右四元数的旋转,然后是左四元数的。
假设有两个四元数q和p。它们带有0
、1
、2
和3
下标,分别对应X
、Y
、Z
和W
分量。这些四元数可以用ijk符号表示,如下所示:
要将这两个四元数相乘,将p的各个分量分配给q的各个分量。分配实部很简单。将p3 分配给q会是这样的:
分配虚部看起来非常相似。实部和虚部分别组合;虚部的顺序很重要。例如,将poi分配给q会是这样的:
完全分配p给q看起来是这样的:
开始简化虚数平方的情况。虚数的平方根是-1。如果将-1提高到-1的幂,结果也是-1。这意味着任何* i2、j2或k2的实例都可以被替换为-1,如下所示:
其他虚数呢?在谈论四元数时,
ijk= -1,每个分量的平方值也是-1,这意味着
i2= j2= k2=ijk。四元数的这个性质可以用来简化方程的其余部分。
以jk为例。从ijk= -1开始,尝试将jk隔离到方程的一边。
为此,将两边都乘以i,得到i(ijk)= -i。分配i,得到i2 jk= -i。你已经知道i2 的值是-1。将其代入得到
-jk= -i。两边都乘以-1,就找到了jk的值—jk=i。
可以以类似的方式找到ki和ij的值;它们分别是ki=j和k=ij。现在可以用j替换任何ki的实例,用k替换ij的实例,用i替换jk的实例。代入这些值后得到:
剩下的虚数是ik、ji和kj。就像叉乘一样,顺序很重要:ik= -ki。由此可推断ik= -j,ji= -k,kj= -1。代入这些值后得到:
具有不同虚部的数字不能相加。重新排列前面的公式,使相似的虚部相邻。这导致四元数乘法的最终方程式:
要在代码中实现这个公式,需要从下标化的ijk符号改回带有X
、Y
、Z
和W
下标的向量表示。在quat.cpp
中实现四元数乘法函数,并不要忘记将函数声明添加到quat.h
中:
quat operator*(const quat& Q1, const quat& Q2) {
return quat(
Q2.x*Q1.w + Q2.y*Q1.z - Q2.z*Q1.y + Q2.w*Q1.x,
-Q2.x*Q1.z + Q2.y*Q1.w + Q2.z*Q1.x + Q2.w*Q1.y,
Q2.x*Q1.y - Q2.y*Q1.x + Q2.z*Q1.w + Q2.w*Q1.z,
-Q2.x*Q1.x - Q2.y*Q1.y - Q2.z*Q1.z + Q2.w*Q1.w
);
}
观察前面的代码时,请注意四元数的实部有一个正分量,但向量部分有一个负分量。重新排列四元数,使负数始终在最后。使用向量表示写下来:
qpx= px qw+ pw qx+ py qz- pz qy
qpy= py qw+ pw qy+ pz qx- px qz
qpz= pz qw+ pw qz+ px qy- py qx
qpw= pw qw- px qx- py qy- pz qz
在前述等式中有两个有趣的部分。如果你仔细观察前三行的最后两列,减法的列是叉乘。前两列只是通过其他四元数的标量部分来缩放每个四元数的向量部分。
如果你看最后一行,点积和点积的负数都在其中。最后一行基本上是将两个四元数的实部相乘,然后减去它们的向量部分的点积。这意味着另一种乘法实现可能是这样的:
quat operator*(const quat& Q1, const quat& Q2) {
quat result;
result.scalar = Q2.scalar * Q1.scalar -
dot(Q2.vector, Q1.vector);
result.vector = (Q1.vector * Q2.scalar) +
(Q2.vector * Q1.scalar)+cross(Q2.vector, Q1.vector);
return result;
}
原始实现稍微更高效,因为它不需要调用其他函数。本书的示例代码将使用第一种实现。
接下来,你将学习如何通过四元数来转换向量。
转换向量
要将向量和四元数相乘,首先必须将向量转换为纯四元数。什么是纯四元数?它是一个其W
分量为0
且向量部分被归一化的四元数。假设你有一个四元数q和一个向量v。首先,将v转换为纯四元数,表示为v':
接下来,将q乘以v',然后将结果乘以q的逆。这个乘法的结果是一个纯四元数,其向量部分包含了旋转后的向量。四元数变成了以下形式:
为什么v'要先乘以q,然后再乘以q-1?乘以*q*会使向量旋转的角度是*q*的两倍。乘以*q*-1 会将向量带回到预期的范围内。这个公式可以进一步简化。
推导这个公式超出了本书的范围。给定一个四元数q和
对于向量v,简化的向量四元数乘法公式如下所示。
qv 指的是四元数的向量部分,qs 指的是实数(或标量)部分:
在quat.cpp
中实现前述四元数向量乘法公式。不要忘记将函数声明添加到quat.h
中:
vec3 operator*(const quat& q, const vec3& v) {
return q.vector * 2.0f * dot(q.vector, v) +
v * (q.scalar * q.scalar - dot(q.vector, q.vector)) +
cross(q.vector, v) * 2.0f * q.scalar;
}
将向量乘以四元数总是会得到一个被四元数旋转的向量。在下一节中,你将学习如何在四元数之间进行插值。
插值四元数
四元数可以以类似的方式进行插值,用于在两个关键帧之间旋转。由于大多数骨骼动画是通过随时间旋转关节来实现的,因此在四元数之间进行插值将是一个非常常见的操作。
一个非常常见的操作。
邻域
四元数代表的是旋转,而不是方向。从球的一部分旋转到另一部分可以通过两种旋转中的一种来实现。旋转可以采取最短或最长的弧。通常,使四元数沿着最短的弧旋转是可取的。在两个四元数之间进行插值时,将采取哪种路径——最短的弧还是最长的弧?
这个问题被称为邻域问题。要解决它,检查被插值的四元数的点积。如果点积是正的,将采取较短的弧。如果点积是负的,将采取较长的弧。
如果点积是负的,如何纠正插值以采取最短的弧?答案是对其中一个四元数取反。以下是四元数邻域化的一个示例代码:
quat SampleFunction(const quat& a, const quat& b) {
if (dot(a, b) < 0.0f) {
b = -b;
}
return slerp(a, b, 0.5f);
}
只有在插值两个四元数时才需要邻域。接下来,你将学习如何混合线性插值(lerp)、归一化线性插值(nlerp)和球形线性插值(slerp)四元数。请记住,这些函数期望四元数已经处于所需的邻域内。
理解 mix 函数
当混合两个或多个四元数时,每个四元数都会被某个权重值缩放,然后将结果缩放的四元数相加。所有输入四元数的权重值必须加起来等于1。
如果所有输入四元数的长度都为单位长度,那么结果四元数也将是单位长度。这个函数实现了与lerp
相同的结果,但它并不是真正的lerp
函数,因为四元数仍然沿着弧线移动。为避免混淆,这个函数将被称为mix
,而不是lerp
。
mix
函数假设输入四元数在所需的邻域内。在quat.cpp
中实现mix
函数,并不要忘记将函数声明添加到quat.h
中:
quat mix(const quat& from, const quat& to, float t) {
return from * (1.0f - t) + to * t;
}
理解 nlerp 函数
四元数之间的nlerp
是球面插值的一种快速且良好的近似。它的实现几乎与vec3
类的nlerp
实现相同。
像mix
一样,nlerp
也假设输入向量在所需的邻域内。在quat.cpp
中实现nlerp
函数,并不要忘记将函数声明添加到quat.h
中:
quat nlerp(const quat& from, const quat& to, float t) {
return normalized(from + (to - from) * t);
}
slerp 简介
只有在需要一致速度时才应该使用slerp
。在大多数情况下,nlerp
将是更好的插值方法。根据插值步长的不同,slerp
最终可能会回退到nlerp
。
为了在两个四元数之间进行球面插值,创建两者之间的增量四元数。调整增量四元数的角度,然后使用四元数乘法将其与起始四元数连接起来。
如何调整四元数的角度?要调整四元数的角度,将其提升到所需的幂。例如,要将四元数调整为只旋转一半,可以将其提升到0.5的幂。
幂
要将四元数提升到某个幂,需要将其分解为一个角度和一个轴。然后,可以通过幂和调整的角度构建一个新的四元数。如果一个四元数围绕v轴旋转θ角度,将其提升到某个幂t,可以按照以下方式进行:
在quat.cpp
中实现power operator
。不要忘记将函数声明添加到quat.h
中:
quat operator^(const quat& q, float f) {
float angle = 2.0f * acosf(q.scalar);
vec3 axis = normalized(q.vector);
float halfCos = cosf(f * angle * 0.5f);
float halfSin = sinf(f * angle * 0.5f);
return quat(axis.x * halfSin,
axis.y * halfSin,
axis.z * halfSin,
halfCos
);
}
实现 slerp
现在您知道如何将四元数提升到幂,实现slerp
就变得简单了。如果起始和结束四元数非常接近,slerp
往往会产生意外的结果。如果起始和结束四元数接近,就回退到nlerp
。
要在两个四元数之间进行插值,找到从起始旋转到结束旋转的增量四元数。这个增量四元数就是插值路径。将角度提升到两个四元数之间插值的幂(通常表示为t),然后将起始四元数相乘。
在quat.cpp
中实现slerp
函数。不要忘记将函数声明添加到quat.h
中。与其他插值函数一样,slerp
假设被插值的四元数在所需的邻域内:
quat slerp(const quat& start, const quat& end, float t) {
if (fabsf(dot(start, end)) > 1.0f - QUAT_EPSILON) {
return nlerp(start, end, t);
}
quat delta = inverse(start) * end;
return normalized((delta ^ t) * start);
}
slerp
的输入向量应该是归一化的,这意味着在slerp
函数中可以使用conjugate
而不是inverse
。大多数情况下,nlerp
将会被用于slerp
。在下一节中,您将学习如何创建一个指向特定方向的四元数。
观察旋转
给定一个方向和一个指示向上方向的参考,可以创建一个朝向该方向并具有正确方向的四元数。这个函数将被称为lookRotation
,而不是lookAt
,以避免与矩阵lookAt
函数混淆。
要实现lookRotation
函数,找到一个将旋转到所需方向的四元数。为此,创建一个世界forward
向量(0, 0, 1)和desired direction
之间的四元数。这个四元数将旋转到right
目标,但不考虑up
可能的方向。
要纠正这个四元数的up
方向,首先必须找到一个垂直于当前前向方向和期望的up
方向的向量。这可以通过这两个向量的叉积来实现。
这个叉积的结果将用于构造三个正交向量——前向向量、这个新向量和一个指向上的向量。你刚刚找到的将指向右边。
接下来,您需要找到一个既垂直于forward
又垂直于right
方向的向量;这将是正交的up
向量。要找到这个向量,可以取方向和这个right
向量的叉积,结果就是物体空间的up
向量。
找到一个从期望的up
向量旋转到物体up
向量的四元数。将旋转到目标方向的四元数和从desired up
到object up
的四元数相乘。
在quat.cpp
中实现lookRotation
函数。不要忘记将函数声明添加到quat.h
中:
quat lookRotation(const vec3& direction, const vec3& up) {
// Find orthonormal basis vectors
vec3 f = normalized(direction); // Object Forward
vec3 u = normalized(up); // Desired Up
vec3 r = cross(u, f); // Object Right
u = cross(f, r); // Object Up
// From world forward to object forward
quat worldToObject = fromTo(vec3(0, 0, 1), f);
// what direction is the new object up?
vec3 objectUp = worldToObject * vec3(0, 1, 0);
// From object up to desired up
quat u2u = fromTo(objectUp, u);
// Rotate to forward direction first
// then twist to correct up
quat result = worldToObject * u2u;
// Don't forget to normalize the result
return normalized(result);
}
矩阵lookAt
函数创建一个视图矩阵,这是相机变换的逆。这意味着lookAt
的旋转和lookRotation
的结果将互为逆运算。在下一节中,您将学习如何将矩阵转换为四元数,以及四元数转换为矩阵。
在矩阵和四元数之间进行转换
由于矩阵和四元数都可以用于编码旋转数据,因此能够在它们之间进行转换将非常有用。为了使在两者之间进行转换更容易,您必须开始考虑基向量的旋转,这些向量代表了x、y和z轴。
4x4 矩阵的上 3x3 子矩阵包含三个基向量。第一列是right
向量,第二列是up
向量,第三列是forward
向量。只使用forward
和up
向量,lookRotation
函数可以将矩阵转换为四元数。
要将四元数转换为矩阵,只需将世界基向量(世界的x、y和z轴)乘以四元数。将结果向量存储在矩阵的相应分量中:
- 在
quat.cpp
中实现quatToMat4
函数。不要忘记将函数声明添加到quat.h
中:
mat4 quatToMat4(const quat& q) {
vec3 r = q * vec3(1, 0, 0);
vec3 u = q * vec3(0, 1, 0);
vec3 f = q * vec3(0, 0, 1);
return mat4(r.x, r.y, r.z, 0,
u.x, u.y, u.z, 0,
f.x, f.y, f.z, 0,
0 , 0 , 0 , 1
);
}
- 矩阵使用相同的组件存储旋转和缩放数据。为了解决这个问题,基向量需要被归一化,并且需要使用叉积来确保结果向量是正交的。在
quat.cpp
中实现mat4ToQuat
函数,不要忘记将函数声明添加到quat.h
中:
quat mat4ToQuat(const mat4& m) {
vec3 up = normalized(vec3(m.up.x, m.up.y, m.up.z));
vec3 forward = normalized(
vec3(m.forward.x, m.forward.y, m.forward.z));
vec3 right = cross(up, forward);
up = cross(forward, right);
return lookRotation(forward, up);
}
能够将四元数转换为矩阵将在以后需要将旋转数据传递给着色器时非常有用。着色器不知道四元数是什么,但它们内置了处理矩阵的功能。将矩阵转换为四元数对于调试和在外部数据源只提供矩阵旋转的情况下也将非常有用。
总结
在本章中,您实现了一个强大的四元数库。四元数对本书的其余部分非常重要,因为所有动画旋转数据都记录为四元数。您学会了如何创建四元数和常见的四元数操作,通过乘法组合四元数,通过四元数转换向量,插值四元数和实用函数来创建四元数,给定前向和上方向,并在矩阵和四元数之间进行转换。
在下一章中,您将使用向量、矩阵和四元数的综合知识来定义一个变换对象。
第五章:实现变换
在本章中,您将实现一个包含位置、旋转和缩放数据的结构。这个结构就是一个变换。变换将一个空间映射到另一个空间。位置、旋转和缩放也可以存储在 4x4 矩阵中,那么为什么要使用显式的变换结构而不是矩阵?答案是插值。矩阵的插值效果不好,但变换结构可以。
在两个矩阵之间进行插值是困难的,因为旋转和缩放存储在矩阵的相同组件中。因此,在两个矩阵之间进行插值不会产生您期望的结果。变换通过分别存储位置、旋转和缩放组件来解决了这个问题。
在本章中,您将实现一个变换结构以及您需要执行的常见操作。在本章结束时,您应该能够做到以下事情:
-
理解什么是变换
-
理解如何组合变换
-
在变换和矩阵之间进行转换
-
理解如何将变换应用到点和向量
重要信息
在本章中,您将实现一个表示位置、旋转和缩放的变换结构。要了解更多关于变换,它们与矩阵的关系以及它们如何适应游戏层次结构,请访问gabormakesgames.com/transforms.html
。
创建变换。
变换是简单的结构。一个变换包含一个位置、旋转和缩放。位置和缩放是向量,旋转是四元数。变换可以按层次结构组合,但这种父子关系不应该是实际变换结构的一部分。以下步骤将指导您创建一个变换结构:
-
创建一个新文件,
Transform.h
。这个文件是必需的,用来声明变换结构。 -
在这个新文件中声明
Transform
结构。从变换的属性—position
、rotation
和scale
开始:
struct Transform {
vec3 position;
quat rotation;
vec3 scale;
- 创建一个构造函数,它接受一个位置、旋转和缩放。这个构造函数应该将这些值分配给
Transform
结构的适当成员:
Transform(const vec3& p, const quat& r, const vec3& s) :
position(p), rotation(r), scale(s) {}
- 空变换不应该有位置或旋转,缩放为 1。默认情况下,
scale
组件将被创建为(0, 0, 0)
。为了解决这个问题,Transform
结构的默认构造函数需要将scale
初始化为正确的值:
Transform() :
position(vec3(0, 0, 0)),
rotation(quat(0, 0, 0, 1)),
scale(vec3(1, 1, 1))
{}
}; // End of transform struct
Transform
结构非常简单;它的所有成员都是公共的。一个变换有一个位置、旋转和缩放。默认构造函数将位置向量设置为0,将旋转四元数设置为单位,将缩放向量设置为1。默认构造函数创建的变换没有效果。
在下一节中,您将学习如何以与矩阵或四元数类似的方式组合变换。
组合变换
以骨架为例。在每个关节处,您可以放置一个变换来描述关节的运动。当您旋转肩膀时,连接到该肩膀的肘部也会移动。要将肩部变换应用于所有连接的关节,必须将每个关节上的变换与其父关节的变换相结合。
变换可以像矩阵和四元数一样组合,并且两个变换的效果可以组合成一个变换。为保持一致,组合变换应保持从右到左的组合顺序。与矩阵和四元数不同,这个combine
函数不会被实现为一个乘法函数。
组合两个变换的缩放和旋转很简单—将它们相乘。组合位置有点困难。组合位置需要受到rotation
和scale
组件的影响。在找到组合位置时,记住变换的顺序:先缩放,然后旋转,最后平移。
创建一个新文件,Transform.cpp
。实现combine
函数,并不要忘记将函数声明添加到Transform.h
中:
Transform combine(const Transform& a, const Transform& b) {
Transform out;
out.scale = a.scale * b.scale;
out.rotation = b.rotation * a.rotation;
out.position = a.rotation * (a.scale * b.position);
out.position = a.position + out.position;
return out;
}
在后面的章节中,combine
函数将用于将变换组织成层次结构。在下一节中,你将学习如何反转变换,这与反转矩阵和四元数类似。
反转变换
你已经知道变换将一个空间映射到另一个空间。可以反转该映射,并将变换映射回原始空间。与矩阵和四元数一样,变换也可以被反转。
在反转缩放时,请记住 0 不能被反转。缩放为 0 的情况需要特殊处理。
在Transform.cpp
中实现inverse
变换方法。不要忘记在Transform.h
中声明该方法:
Transform inverse(const Transform& t) {
Transform inv;
inv.rotation = inverse(t.rotation);
inv.scale.x = fabs(t.scale.x) < VEC3_EPSILON ?
0.0f : 1.0f / t.scale.x;
inv.scale.y = fabs(t.scale.y) < VEC3_EPSILON ?
0.0f : 1.0f / t.scale.y;
inv.scale.z = fabs(t.scale.z) < VEC3_EPSILON ?
0.0f : 1.0f / t.scale.z;
vec3 invTrans = t.position * -1.0f;
inv.position = inv.rotation * (inv.scale * invTrans);
return inv;
}
反转变换可以消除一个变换对另一个变换的影响。考虑一个角色在关卡中移动。一旦关卡结束,你可能希望将角色移回原点,然后开始下一个关卡。你可以将角色的变换乘以它的逆变换。
在下一节中,你将学习如何将两个或多个变换混合在一起。
混合变换
你有代表两个特定时间点的关节的变换。为了使模型看起来动画化,你需要在这些帧的变换之间进行插值或混合。
可以在向量和四元数之间进行插值,这是变换的构建块。因此,也可以在变换之间进行插值。这个操作通常被称为混合。当将两个变换混合在一起时,线性插值输入变换的位置、旋转和缩放。
在Transform.cpp
中实现mix
函数。不要忘记在Transform.h
中声明该函数:
Transform mix(const Transform& a,const Transform& b,float t){
quat bRot = b.rotation;
if (dot(a.rotation, bRot) < 0.0f) {
bRot = -bRot;
}
return Transform(
lerp(a.position, b.position, t),
nlerp(a.rotation, bRot, t),
lerp(a.scale, b.scale, t));
}
能够将变换混合在一起对于创建动画之间的平滑过渡非常重要。在这里,你实现了变换之间的线性混合。在下一节中,你将学习如何将transform
转换为mat4
。
将变换转换为矩阵
着色器程序与矩阵配合得很好。它们没有本地表示变换结构。你可以将变换代码转换为 GLSL,但这不是最好的解决方案。相反,你可以在将变换提交为着色器统一之前将变换转换为矩阵。
由于变换编码了可以存储在矩阵中的数据,因此可以将变换转换为矩阵。要将变换转换为矩阵,需要考虑矩阵的向量。
首先,通过将全局基向量的方向乘以变换的旋转来找到基向量。接下来,通过变换的缩放来缩放基向量。这将产生填充上 3x3 子矩阵的最终基向量。位置直接进入矩阵的最后一列。
在Transform.cpp
中实现from Transform
方法。不要忘记将该方法声明到Transform.h
中:
mat4 transformToMat4(const Transform& t) {
// First, extract the rotation basis of the transform
vec3 x = t.rotation * vec3(1, 0, 0);
vec3 y = t.rotation * vec3(0, 1, 0);
vec3 z = t.rotation * vec3(0, 0, 1);
// Next, scale the basis vectors
x = x * t.scale.x;
y = y * t.scale.y;
z = z * t.scale.z;
// Extract the position of the transform
vec3 p = t.position;
// Create matrix
return mat4(
x.x, x.y, x.z, 0, // X basis (& Scale)
y.x, y.y, y.z, 0, // Y basis (& scale)
z.x, z.y, z.z, 0, // Z basis (& scale)
p.x, p.y, p.z, 1 // Position
);
}
图形 API 使用矩阵而不是变换。在后面的章节中,变换将在发送到着色器之前转换为矩阵。在下一节中,你将学习如何做相反的操作,即将矩阵转换为变换。
将矩阵转换为变换
外部文件格式可能将变换数据存储为矩阵。例如,glTF 可以将节点的变换存储为位置、旋转和缩放,或者作为单个 4x4 矩阵。为了使变换代码健壮,你需要能够将矩阵转换为变换。
将矩阵转换为变换比将变换转换为矩阵更困难。提取矩阵的旋转很简单;你已经实现了将 4x4 矩阵转换为四元数的函数。提取位置也很简单;将矩阵的最后一列复制到一个向量中。提取比例尺更困难。
回想一下,变换的操作顺序是先缩放,然后旋转,最后平移。这意味着如果你有三个矩阵——S、R和T——分别代表缩放、旋转和平移,它们将组合成一个变换矩阵M,如下所示:
M = SRT
要找到比例尺,首先忽略矩阵的平移部分M(将平移向量归零)。这样你就得到M = SR。要去除矩阵的旋转部分,将M乘以R的逆。这样应该只剩下比例尺部分。嗯,并不完全是这样。结果会留下一个包含比例尺和一些倾斜信息的矩阵。
我们从这个比例尺-倾斜矩阵中提取比例尺的方法是简单地将主对角线作为比例尺-倾斜矩阵。虽然这在大多数情况下都有效,但并不完美。获得的比例尺应该被视为有损的比例尺,因为该值可能包含倾斜数据,这使得比例尺不准确。
重要提示
将矩阵分解为平移、旋转、缩放、倾斜和行列式的符号是可能的。然而,这种分解是昂贵的,不太适合实时应用。要了解更多,请查看 Ken Shoemake 和 Tom Duff 的Matrix Animation and Polar Decomposition research.cs.wisc.edu/graphics/Courses/838-s2002/Papers/polar-decomp.pdf
。
在Transform.cpp
中实现toTransform
函数。不要忘记将函数声明添加到Transform.h
中:
Transform mat4ToTransform(const mat4& m) {
Transform out;
out.position = vec3(m.v[12], m.v[13], m.v[14]);
out.rotation = mat4ToQuat(m);
mat4 rotScaleMat(
m.v[0], m.v[1], m.v[2], 0,
m.v[4], m.v[5], m.v[6], 0,
m.v[8], m.v[9], m.v[10], 0,
0, 0, 0, 1
);
mat4 invRotMat = quatToMat4(inverse(out.rotation));
mat4 scaleSkewMat = rotScaleMat * invRotMat;
out.scale = vec3(
scaleSkewMat.v[0],
scaleSkewMat.v[5],
scaleSkewMat.v[10]
);
return out;
}
能够将矩阵转换为变换是很重要的,因为你并不总是能控制你处理的数据以什么格式呈现。例如,一个模型格式可能存储矩阵而不是变换。
到目前为止,你可能已经注意到变换和矩阵通常可以做相同的事情。在下一节中,你将学习如何使用变换来对点和向量进行变换,类似于使用矩阵的方式。
变换点和向量
Transform
结构可用于在空间中移动点和向量。想象一个球上下弹跳。球的弹跳是由Transform
结构派生的,但你如何知道每个球的顶点应该移动到哪里?你需要使用Transform
结构(或矩阵)来正确显示球的所有顶点。
使用变换来修改点和向量就像组合两个变换。要变换一个点,首先应用缩放,然后旋转,最后是变换的平移。要变换一个向量,遵循相同的步骤,但不要添加位置:
- 在
Transform.cpp
中实现transformPoint
函数。不要忘记将函数声明添加到Transform.h
中:
vec3 transformPoint(const Transform& a, const vec3& b) {
vec3 out;
out = a.rotation * (a.scale * b);
out = a.position + out;
return out;
}
- 在
Transform.cpp
中实现transformVector
函数。不要忘记将函数声明添加到Transform.h
中:
vec3 transformVector(const Transform& a, const vec3& b) {
vec3 out;
out = a.rotation * (a.scale * b);
return out;
}
transformPoint
函数做的就是一个一个步骤地将矩阵和点相乘。首先应用scale
,然后是rotation
,最后是translation
。当处理向量而不是点时,同样的顺序适用,只是忽略了平移。
总结
在本章中,你学会了将变换实现为一个包含位置、旋转和比例尺的离散结构。在许多方面,Transform
类保存了你通常会存储在矩阵中的相同数据。
你学会了如何组合、反转和混合变换,以及如何使用变换来移动点和旋转向量。变换在未来将是至关重要的,因为它们用于动画游戏模型的骨骼或骨架。
你需要一个显式的Transform
结构的原因是矩阵不太容易插值。对变换进行插值对于动画非常重要。这是你创建中间姿势以显示两个给定关键帧的方式。
在下一章中,你将学习如何在 OpenGL 之上编写一个轻量级的抽象层,以使未来章节中的渲染更容易。
第六章:构建抽象渲染器
本书侧重于动画,而不是渲染。然而,渲染动画模型是很重要的。为了避免陷入任何特定的图形 API 中,在本章中,您将在 OpenGL 之上构建一个抽象层。这将是一个薄的抽象层,但它将让您在后面的章节中处理动画,而无需执行任何特定于 OpenGL 的操作。
本章中您将实现的抽象渲染器非常轻量。它没有很多功能,只有您需要显示动画模型的功能。这应该使得将渲染器移植到其他 API 变得简单。
在本章结束时,您应该能够使用您创建的抽象渲染代码在窗口中渲染一些调试几何体。在更高的层次上,您将学到以下内容:
-
如何创建着色器
-
如何在缓冲区中存储网格数据
-
如何将这些缓冲区绑定为着色器属性
-
如何向着色器发送统一数据
-
如何使用索引缓冲区进行渲染
-
如何加载纹理
-
基本的 OpenGL 概念
-
创建和使用简单的着色器
技术要求
对 OpenGL 的一些了解将使本章更容易理解。OpenGL、光照模型和着色器技巧不在本书的范围之内。有关这些主题的更多信息,请访问learnopengl.com/
。
使用着色器
抽象层中最重要的部分是Shader
类。要绘制某物,您必须绑定一个着色器并将一些属性和统一附加到它上。着色器描述了被绘制的东西应该如何变换和着色,而属性定义了正在被绘制的内容。
在本节中,您将实现一个Shader
类,它可以编译顶点和片段着色器。Shader
类还将返回统一和属性索引。
着色器类声明
在实现Shader
类时,您需要声明几个受保护的辅助函数。这些函数将保持类的公共 API 清晰;它们用于诸如将文件读入字符串或调用 OpenGL 代码来编译着色器的操作:
- 创建一个新文件来声明
Shader
类,命名为Shader.h
。Shader
类应该有一个指向 OpenGL 着色器对象的句柄,以及属性和统一索引的映射。这些字典有一个字符串作为键(属性或统一的名称)和一个unsigned int
作为值(统一或属性的索引):
class Shader {
private:
unsigned int mHandle;
std::map<std::string, unsigned int> mAttributes;
std::map<std::string, unsigned int> mUniforms;
Shader
类的复制构造函数和赋值运算符应该被禁用。Shader
类不打算通过值进行复制,因为它持有一个 GPU 资源的句柄:
private:
Shader(const Shader&);
Shader& operator=(const Shader&);
- 接下来,您需要在
Shader
类中声明辅助函数。ReadFile
函数将文件内容读入std::string
中。CompileVertexShader
和CompileFragmentShader
函数编译着色器源代码并返回 OpenGL 句柄。LinkShader
函数将两个着色器链接成一个着色器程序。PopulateAttribute
和PopulateUniform
函数将填充属性和统一字典:
private:
std::string ReadFile(const std::string& path);
unsigned int CompileVertexShader(
const std::string& vertex);
unsigned int CompileFragmentShader(
const std::string& fragment);
bool LinkShaders(unsigned int vertex,
unsigned int fragment);
void PopulateAttributes();
void PopulateUniforms();
- 类的默认构造函数将创建一个空的
Shader
对象。重载构造函数将调用Load
方法,从文件加载着色器并编译它们。析构函数将释放Shader
类持有的 OpenGL 着色器句柄:
public:
Shader();
Shader(const std::string& vertex,
const std::string& fragment);
~Shader();
void Load(const std::string& vertex,
const std::string& fragment);
- 在使用着色器之前,需要使用
Bind
函数绑定它。同样,在不再使用时,可以使用UnBind
函数解绑它。GetAttribute
和GetUniform
函数在适当的字典中执行查找。GetHandle
函数返回着色器的 OpenGL 句柄:
void Bind();
void UnBind();
unsigned int GetAttribute(const std::string& name);
unsigned int GetUniform(const std::string& name);
unsigned int GetHandle();
};
现在Shader
类声明完成后,您将在下一节中实现它。
实现着色器类
创建一个新文件Shader.cpp
,来实现Shader
类。Shader
类的实现几乎将所有实际的 OpenGL 代码隐藏在调用者之外。因为大多数 OpenGL 调用都是通过这种方式抽象的,在后面的章节中,您只需要调用抽象层,而不是直接调用 OpenGL 函数。
本书中始终使用统一数组。当在着色器中遇到统一数组(例如modelMatrices[120]
),glGetActiveUniform
返回的统一名称是数组的第一个元素。在这个例子中,那将是modelMatrices[0]
。当遇到统一数组时,您希望循环遍历所有数组索引,并为每个元素获取显式的统一索引,但您还希望存储没有任何下标的统一名称:
- 两个
Shader
构造函数必须通过调用glCreateProgram
创建一个新的着色器程序句柄。接受两个字符串的构造函数变体调用Load
函数处理这些字符串。由于mHandle
始终是一个程序句柄,析构函数需要删除该句柄:
Shader::Shader() {
mHandle = glCreateProgram();
}
Shader::Shader(const std::string& vertex,
const std::string& fragment) {
mHandle = glCreateProgram();
Load(vertex, fragment);
}
Shader::~Shader() {
glDeleteProgram(mHandle);
}
ReadFile
辅助函数使用std::ifstream
将文件转换为字符串,以读取文件的内容到std::stringstream
中。字符串流可用于将文件内容作为字符串返回:
std::string Shader::ReadFile(const std::string& path) {
std::ifstream file;
file.open(path);
std::stringstream contents;
contents << file.rdbuf();
file.close();
return contents.str();
}
CompileVertexShader
函数是用于编译 OpenGL 顶点着色器的样板代码。首先,使用glCreateShader
创建着色器对象,然后使用glShaderSource
为着色器设置源。最后,使用glCompileShader
编译着色器。使用glGetShaderiv
检查错误:
unsigned int Shader::CompileVertexShader(
const string& vertex) {
unsigned int v = glCreateShader(GL_VERTEX_SHADER);
const char* v_source = vertex.c_str();
glShaderSource(v, 1, &v_source, NULL);
glCompileShader(v);
int success = 0;
glGetShaderiv(v, GL_COMPILE_STATUS, &success);
if (!success) {
char infoLog[512];
glGetShaderInfoLog(v, 512, NULL, infoLog);
std::cout << "Vertex compilation failed.\n";
std::cout << "\t" << infoLog << "\n";
glDeleteShader(v);
return 0;
};
return v;
}
CompileFragmentShader
函数与CompileVertexShader
函数几乎完全相同。唯一的真正区别是glCreateShader
的参数,表明您正在创建一个片段着色器,而不是顶点着色器:
unsigned int Shader::CompileFragmentShader(
const std::string& fragment) {
unsigned int f = glCreateShader(GL_FRAGMENT_SHADER);
const char* f_source = fragment.c_str();
glShaderSource(f, 1, &f_source, NULL);
glCompileShader(f);
int success = 0;
glGetShaderiv(f, GL_COMPILE_STATUS, &success);
if (!success) {
char infoLog[512];
glGetShaderInfoLog(f, 512, NULL, infoLog);
std::cout << "Fragment compilation failed.\n";
std::cout << "\t" << infoLog << "\n";
glDeleteShader(f);
return 0;
};
return f;
}
LinkShaders
辅助函数也是样板。将着色器附加到构造函数创建的着色器程序句柄。通过调用glLinkProgram
链接着色器,并使用glGetProgramiv
检查错误。一旦着色器被链接,您只需要程序;可以使用glDeleteShader
删除各个着色器对象:
bool Shader::LinkShaders(unsigned int vertex,
unsigned int fragment) {
glAttachShader(mHandle, vertex);
glAttachShader(mHandle, fragment);
glLinkProgram(mHandle);
int success = 0;
glGetProgramiv(mHandle, GL_LINK_STATUS, &success);
if (!success) {
char infoLog[512];
glGetProgramInfoLog(mHandle, 512, NULL, infoLog);
std::cout << "ERROR: Shader linking failed.\n";
std::cout << "\t" << infoLog << "\n";
glDeleteShader(vertex);
glDeleteShader(fragment);
return false;
}
glDeleteShader(vertex);
glDeleteShader(fragment);
return true;
}
PopulateAttributes
函数枚举存储在着色器程序中的所有属性,然后将它们存储为键值对,其中键是属性的名称,值是其位置。您可以使用glGetProgramiv
函数计算着色器程序中活动属性的数量,将GL_ACTIVE_ATTRIBUTES
作为参数名称传递。然后,通过索引循环遍历所有属性,并使用glGetActiveAttrib
获取每个属性的名称。最后,调用glGetAttribLocation
获取每个属性的位置:
void Shader::PopulateAttributes() {
int count = -1;
int length;
char name[128];
int size;
GLenum type;
glUseProgram(mHandle);
glGetProgramiv(mHandle, GL_ACTIVE_ATTRIBUTES,
&count);
for (int i = 0; i < count; ++i) {
memset(name, 0, sizeof(char) * 128);
glGetActiveAttrib(mHandle, (GLuint)i, 128,
&length, &size, &type, name);
int attrib = glGetAttribLocation(mHandle, name);
if (attrib >= 0) {
mAttributes[name] = attrib;
}
}
glUseProgram(0);
}
PopulateUniforms
辅助函数与PopulateAttributes
辅助函数非常相似。glGetProgramiv
需要以GL_ACTIVE_UNIFORMS
作为参数名称,并且您需要调用glGetActiveUniform
和glGetUniformLocation
:
void Shader::PopulateUniforms() {
int count = -1;
int length;
char name[128];
int size;
GLenum type;
char testName[256];
glUseProgram(mHandle);
glGetProgramiv(mHandle, GL_ACTIVE_UNIFORMS, &count);
for (int i = 0; i < count; ++i) {
memset(name, 0, sizeof(char) * 128);
glGetActiveUniform(mHandle, (GLuint)i, 128,
&length, &size, &type, name);
int uniform=glGetUniformLocation(mHandle, name);
if (uniform >= 0) { // Is uniform valid?
- 当遇到有效的统一时,您需要确定该统一是否是一个数组。为此,在统一名称中搜索数组括号(
[
)。如果找到括号,则该统一是一个数组:
std::string uniformName = name;
// if name contains [, uniform is array
std::size_t found = uniformName.find('[');
if (found != std::string::npos) {
- 如果遇到一个统一数组,从
[
开始擦除字符串中的所有内容。这将使您只剩下统一的名称。然后,进入一个循环,尝试通过将[ + index + ]
附加到统一名称来检索数组中的每个索引。一旦找到第一个无效的索引,就打破循环:
uniformName.erase(uniformName.begin() +
found, uniformName.end());
unsigned int uniformIndex = 0;
while (true) {
memset(testName,0,sizeof(char)*256);
sprintf(testName, "%s[%d]",
uniformName.c_str(),
uniformIndex++);
int uniformLocation =
glGetUniformLocation(
mHandle, testName);
if (uniformLocation < 0) {
break;
}
mUniforms[testName]=uniformLocation;
}
}
- 此时,
uniformName
包含统一的名称。如果该统一是一个数组,则名称的[0]
部分已被移除。按名称将统一索引存储在mUniforms
中:
mUniforms[uniformName] = uniform;
}
}
glUseProgram(0);
}
- 最后一个辅助函数是
Load
函数,负责加载实际的着色器。此函数接受两个字符串,可以是文件名或内联着色器定义。一旦读取了着色器,调用Compile
、Link
和Populate
辅助函数来加载着色器:
void Shader::Load(const std::string& vertex,
const std::string& fragment) {
std::ifstream f(vertex.c_str());
bool vertFile = f.good();
f.close();
f = std::ifstream(vertex.c_str());
bool fragFile = f.good();
f.close();
std::string v_source = vertex;
if (vertFile) {
v_source = ReadFile(vertex);
}
std::string f_source = fragment;
if (fragFile) {
f_source = ReadFile(fragment);
}
unsigned int vert = CompileVertexShader(v_source);
unsigned int f = CompileFragmentShader(f_source);
if (LinkShaders(vert, frag)) {
PopulateAttributes();
PopulateUniforms();
}
}
Bind
函数需要将当前着色器程序设置为活动状态,而UnBind
应确保没有活动的Shader
对象。GetHandle
辅助函数返回Shader
对象的 OpenGL 句柄:
void Shader::Bind() {
glUseProgram(mHandle);
}
void Shader::UnBind() {
glUseProgram(0);
}
unsigned int Shader::GetHandle() {
return mHandle;
}
- 最后,您需要一种方法来检索属性和统一的绑定槽。
GetAttribute
函数将检查给定的属性名称是否存在于属性映射中。如果存在,则返回表示它的整数。如果没有,则返回0
。0
是有效的属性索引,因此在出现错误的情况下,还会记录错误消息:
unsigned int Shader::GetAttribute(
const std::string& name) {
std::map<std::string, unsigned int>::iterator it =
mAttributes.find(name);
if (it == mAttributes.end()) {
cout << "Bad attrib index: " << name << "\n";
return 0;
}
return it->second;
}
GetUniform
函数的实现几乎与GetAttribute
函数相同,只是它不是在属性映射上工作,而是在统一映射上工作:
unsigned int Shader::GetUniform(const std::string& name){
std::map<std::string, unsigned int>::iterator it =
mUniforms.find(name);
if (it == mUniforms.end()) {
cout << "Bad uniform index: " << name << "\n";
return 0;
}
return it->second;
}
Shader
类有方法来检索统一和属性的索引。在下一节中,您将开始实现一个Attribute
类来保存传递给着色器的顶点数据。
使用缓冲区(属性)
属性是图形管道中的每个顶点数据。一个顶点由属性组成。例如,一个顶点有一个位置和一个法线,这两个都是属性。最常见的属性如下:
-
位置:通常在局部空间中
-
法线:顶点指向的方向
-
UV 或纹理坐标:纹理上的标准化(x,y)坐标
-
颜色:表示顶点颜色的
vector3
属性可以具有不同的数据类型。在本书中,您将实现对整数、浮点数和矢量属性的支持。对于矢量属性,将支持二维、三维和四维向量。
Attribute
类声明
创建一个新文件Attribute.h
。Attribute
类将在这个新文件中声明。Attribute
类将被模板化。这将确保如果一个属性被认为是vec3
,您不能意外地将vec2
加载到其中:
- 属性类将包含两个成员变量,一个用于 OpenGL 属性句柄,一个用于计算
Attribute
类包含的数据量。由于属性数据存储在 GPU 上,您不希望有多个句柄指向相同的数据,因此应禁用复制构造函数和赋值运算符
:
template<typename T>
class Attribute {
protected:
unsigned int mHandle;
unsigned int mCount;
private:
Attribute(const Attribute& other);
Attribute& operator=(const Attribute& other);
SetAttribPointer
函数很特殊,因为它需要为每种支持的属性类型实现一次。这将在.cpp
文件中明确完成:
void SetAttribPointer(unsigned int slot);
- 将
Attribute
类的构造函数和析构函数声明为公共函数:
public:
Attribute();
~Attribute();
Attribute
类需要一个Set
函数,它将数组数据上传到 GPU。数组中的每个元素表示一个顶点的属性。我们需要一种从着色器定义的绑定槽中绑定和解绑属性的方法,以及属性的计数和句柄的访问器:
void Set(T* inputArray, unsigned int arrayLength);
void Set(std::vector<T>& input);
void BindTo(unsigned int slot);
void UnBindFrom(unsigned int slot);
unsigned int Count();
unsigned int GetHandle();
};
现在您已经声明了Attribute
类,您将在下一节中实现它。
实现Attribute
类
创建一个新文件Attribtue.cpp
。您将在此文件中实现Attribute
类如下:
Attribute
类是模板的,但它的函数都没有标记为内联。每种属性类型的模板特化将存在于Attribute.cpp
文件中。为整数、浮点数、vec2
、vec3
、vec4
和ivec4
类型添加特化:
template Attribute<int>;
template Attribute<float>;
template Attribute<vec2>;
template Attribute<vec3>;
template Attribute<vec4>;
template Attribute<ivec4>;
- 构造函数应生成一个 OpenGL 缓冲区并将其存储在
Attribute
类的句柄中。析构函数负责释放Attribute
类持有的句柄:
template<typename T>
Attribute<T>::Attribute() {
glGenBuffers(1, &mHandle);
mCount = 0;
}
template<typename T>
Attribute<T>::~Attribute() {
glDeleteBuffers(1, &mHandle);
}
Attribute
类有两个简单的 getter,一个用于检索计数,一个用于检索 OpenGL 句柄。计数表示总共有多少个属性:
template<typename T>
unsigned int Attribute<T>::Count() {
return mCount;
}
template<typename T>
unsigned int Attribute<T>::GetHandle() {
return mHandle;
}
Set
函数接受一个数组和一个长度。然后绑定Attribute
类持有的缓冲区,并使用glBufferData
填充缓冲区数据。有一个方便的Set
函数,它接受一个向量引用而不是数组。它调用实际的Set
函数:
template<typename T>
void Attribute<T>::Set(T* inputArray,
unsigned int arrayLength) {
mCount = arrayLength;
unsigned int size = sizeof(T);
glBindBuffer(GL_ARRAY_BUFFER, mHandle);
glBufferData(GL_ARRAY_BUFFER, size * mCount,
inputArray, GL_STREAM_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
}
template<typename T>
void Attribute<T>::Set(std::vector<T>& input) {
Set(&input[0], (unsigned int)input.size());
}
SetAttribPointer
函数包装了glVertesAttribPointer
或glVertesAttribIPointer
。根据Attribute
类的类型,参数和要调用的函数是不同的。为了消除任何歧义,为所有支持的模板类型提供显式实现。首先实现int
、ivec4
和float
类型:
template<>
void Attribute<int>::SetAttribPointer(unsigned int s) {
glVertexAttribIPointer(s, 1, GL_INT, 0, (void*)0);
}
template<>
void Attribute<ivec4>::SetAttribPointer(unsigned int s){
glVertexAttribIPointer(s, 4, GL_INT, 0, (void*)0);
}
template<>
void Attribute<float>::SetAttribPointer(unsigned int s){
glVertexAttribPointer(s,1,GL_FLOAT,GL_FALSE,0,0);
}
- 接下来实现
vec2
、vec3
和vec4
类型。这些都与float
类型非常相似。唯一的区别是glVertexAttribPointer
的第二个参数:
template<>
void Attribute<vec2>::SetAttribPointer(unsigned int s) {
glVertexAttribPointer(s,2,GL_FLOAT,GL_FALSE,0,0);
}
template<>
void Attribute<vec3>::SetAttribPointer(unsigned int s){
glVertexAttribPointer(s,3,GL_FLOAT,GL_FALSE,0,0);
}
template<>
void Attribute<vec4>::SetAttribPointer(unsigned int s){
glVertexAttribPointer(s,4,GL_FLOAT,GL_FALSE,0,0);
}
Attribute
类的最后两个函数需要将属性绑定到Shader
类中指定的槽位,并解除绑定。由于Attribute
类的模板类型不同,Bind
将调用SetAttribPointer
辅助函数:
template<typename T>
void Attribute<T>::BindTo(unsigned int slot) {
glBindBuffer(GL_ARRAY_BUFFER, mHandle);
glEnableVertexAttribArray(slot);
SetAttribPointer(slot);
glBindBuffer(GL_ARRAY_BUFFER, 0);
}
template<typename T>
void Attribute<T>::UnBindFrom(unsigned int slot) {
glBindBuffer(GL_ARRAY_BUFFER, mHandle);
glDisableVertexAttribArray(slot);
glBindBuffer(GL_ARRAY_BUFFER, 0);
}
Attribute
数据每个顶点都会发生变化。您需要设置另一种类型的数据:uniforms。与属性不同,uniforms 在着色器程序执行期间保持不变。您将在下一节中实现 uniforms。
使用 uniforms
与属性不同,uniforms 是常量数据;它们只设置一次。uniform 的值对所有处理的顶点保持不变。uniforms 可以创建为数组,这是您将在后续章节中用来实现网格蒙皮的功能。
与Attribute
类一样,Uniform
类也将是模板化的。但与属性不同,永远不会有Uniform
类的实例。它只需要公共静态函数。对于每种 uniform 类型,有三个函数:一个用于设置单个 uniform 值,一个用于设置一组 uniform 值,一个便利函数用于设置一组值,但使用向量作为输入。
Uniform 类声明
创建一个新文件,Uniform.h
。您将在这个新文件中实现Uniform
类。Uniform
类永远不会被实例化,因为不会有这个类的实例。禁用构造函数和复制构造函数、赋值运算符和析构函数。该类将具有三个静态Set
函数的重载。Set
函数需要为每种模板类型指定:
template <typename T>
class Uniform {
private:
Uniform();
Uniform(const Uniform&);
Uniform& operator=(const Uniform&);
~Uniform();
public:
static void Set(unsigned int slot, const T& value);
static void Set(unsigned int slot,T* arr,unsigned int len);
static void Set(unsigned int slot, std::vector<T>& arr);
};
您刚刚完成了Uniform
类的声明。在下一节中,您将开始实现Uniform
类。
实现 Uniform 类
创建一个新文件,Uniform.cpp
。您将在这个新文件中实现Uniform
类。与Attribute
类一样,Uniform
类也是模板化的。
在 OpenGL 中,uniforms 是使用glUniform***
系列函数设置的。有不同的函数用于整数、浮点数、向量、矩阵等。您希望为每种类型的Set
方法提供实现,但避免编写几乎相同的代码。
为了避免编写几乎相同的代码,您将声明一个#define
宏。这个宏将接受三个参数——要调用的 OpenGL 函数,Uniform 类的模板类型和 OpenGL 函数的数据类型:
- 添加以下代码以定义支持的 uniform 类型的模板规范:
template Uniform<int>;
template Uniform<ivec4>;
template Uniform<ivec2>;
template Uniform<float>;
template Uniform<vec2>;
template Uniform<vec3>;
template Uniform<vec4>;
template Uniform<quat>;
template Uniform<mat4>;
- 您只需要为每种类型实现一个
Set
方法,即接受数组和长度的方法。其他Set
方法重载是为了方便起见。实现两个便利重载——一个用于设置单个 uniform,另一个用于设置向量。两个重载应该只调用Set
函数:
template <typename T>
void Uniform<T>::Set(unsigned int slot,const T& value){
Set(slot, (T*)&value, 1);
}
template <typename T>
void Uniform<T>::Set(unsigned int s,std::vector<T>& v){
Set(s, &v[0], (unsigned int)v.size());
}
- 创建一个
UNIFORM_IMPL
宏。第一个参数是要调用的 OpenGL 函数,第二个是正在使用的结构类型,最后一个参数是相同结构的数据类型。UNIFORM_IMPL
宏将这些信息组装成一个函数声明:
#define UNIFORM_IMPL(gl_func, tType, dType) \
template<> void Uniform<tType>::Set(unsigned int slot,\
tType* data, unsigned int length) {\
gl_func(slot, (GLsizei)length, (dType*)&data[0]); \
}
- 为每种 uniform 数据类型调用
UNIFORM_IMPL
宏以生成适当的Set
函数。这种方法无法适用于mat4
数据类型:
UNIFORM_IMPL(glUniform1iv, int, int)
UNIFORM_IMPL(glUniform4iv, ivec4, int)
UNIFORM_IMPL(glUniform2iv, ivec2, int)
UNIFORM_IMPL(glUniform1fv, float, float)
UNIFORM_IMPL(glUniform2fv, vec2, float)
UNIFORM_IMPL(glUniform3fv, vec3, float)
UNIFORM_IMPL(glUniform4fv, vec4, float)
UNIFORM_IMPL(glUniform4fv, quat, float)
- 矩阵的
Set
函数需要手动指定;否则,UNIFORM_IMPL
宏将无法工作。这是因为glUniformMatrix4fv
函数需要一个额外的布尔参数,询问矩阵是否应该被转置。将转置布尔值设置为false
:
template<> void Uniform<mat4>::Set(unsigned int slot,
mat4* inputArray, unsigned int arrayLength) {
glUniformMatrix4fv(slot, (GLsizei)arrayLength,
false, (float*)&inputArray[0]);
}
在本节中,你在统一的概念上构建了一个抽象层。在下一节中,你将实现类似属性的索引缓冲区。
使用索引缓冲区
索引缓冲区是一种属性。与属性不同,索引缓冲区绑定到GL_ELEMENT_ARRAY_BUFFER
,可以用于绘制基本图元。因此,你将在它们自己的类中实现索引缓冲区,而不是重用Attribute
类。
IndexBuffer 类声明
创建一个新文件,IndexBuffer.h
。你将在这个新文件中添加IndexBuffer
类的声明。像Attribute
对象一样,IndexBuffer
将包含一个 OpenGL 句柄和一个计数,同时有 getter 函数。
为了避免多个IndexBuffer
对象引用同一个 OpenGL 缓冲区,需要禁用复制构造函数和赋值运算符。Set
函数接受一个无符号整数数组和数组的长度,但也有一个方便的重载,接受一个向量:
class IndexBuffer {
public:
unsigned int mHandle;
unsigned int mCount;
private:
IndexBuffer(const IndexBuffer& other);
IndexBuffer& operator=(const IndexBuffer& other);
public:
IndexBuffer();
~IndexBuffer();
void Set(unsigned int* rr, unsigned int len);
void Set(std::vector<unsigned int>& input);
unsigned int Count();
unsigned int GetHandle();
};
在本节中,你声明了一个新的IndexBuffer
类。在下一节中,你将开始实现实际的索引缓冲区。
实现 IndexBuffer 类
索引缓冲区允许你使用索引几何体渲染模型。想象一个人体模型;网格中几乎所有的三角形都是相连的。这意味着许多三角形可能共享一个顶点。而不是存储每个单独的顶点,只存储唯一的顶点。索引到唯一顶点列表的缓冲区,即索引缓冲区,用于从唯一顶点创建三角形,如下所示:
- 创建一个新文件,
IndexBuffer.cpp
。你将在这个文件中实现IndexBuffer
类。构造函数需要生成一个新的 OpenGL 缓冲区,析构函数需要删除该缓冲区:
IndexBuffer::IndexBuffer() {
glGenBuffers(1, &mHandle);
mCount = 0;
}
IndexBuffer::~IndexBuffer() {
glDeleteBuffers(1, &mHandle);
}
IndexBuffer
对象内部的计数和 OpenGL 句柄的 getter 函数是微不足道的:
unsigned int IndexBuffer::Count() {
return mCount;
}
unsigned int IndexBuffer::GetHandle() {
return mHandle;
}
IndexBuffer
类的Set
函数需要绑定GL_ELEMENT_ARRAY_BUFFER
。除此之外,逻辑与属性的逻辑相同:
void IndexBuffer::Set(unsigned int* inputArray, unsigned int arrayLengt) {
mCount = arrayLengt;
unsigned int size = sizeof(unsigned int);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, mHandle);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, size * mCount, inputArray, GL_STATIC_DRAW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
}
void IndexBuffer::Set(std::vector<unsigned int>& input) {
Set(&input[0], (unsigned int)input.size());
}
在本节中,你围绕索引缓冲区构建了一个抽象。在下一节中,你将学习如何使用索引缓冲区和属性来渲染几何体。
渲染几何体
你已经有了处理顶点数据、统一和索引缓冲区的类,但没有任何代码来绘制它们。绘制将由四个全局函数处理。你将有两个Draw
函数和两个DrawInstanced
函数。你将能够使用或不使用索引缓冲区来绘制几何体。
创建一个新文件,Draw.h
。你将在这个文件中实现Draw
函数,如下所示:
- 声明一个
enum
类,定义绘制时应该使用的基本图元。大多数情况下,你只需要线、点或三角形,但有些额外的类型可能也会有用:
enum class DrawMode {
Points,
LineStrip,
LineLoop,
Lines,
Triangles,
TriangleStrip,
TriangleFan
};
- 接下来,声明
Draw
函数。Draw
函数有两个重载——一个接受索引缓冲区和绘制模式,另一个接受顶点数量和绘制模式:
void Draw(IndexBuffer& inIndexBuffer, DrawMode mode);
void Draw(unsigned int vertexCount, DrawMode mode);
- 像
Draw
一样,声明两个DrawInstanced
函数。这些函数具有类似的签名,但多了一个参数——instanceCount
。这个instanceCount
变量控制着几何体的实例数量将被渲染:
void DrawInstanced(IndexBuffer& inIndexBuffer,
DrawMode mode, unsigned int instanceCount);
void DrawInstanced(unsigned int vertexCount,
DrawMode mode, unsigned int numInstances);
创建一个新文件,Draw.cpp
。你将在这个文件中实现与绘制相关的功能,如下所示:
- 你需要能够将
DrawMode
枚举转换为GLenum
。我们将使用一个静态辅助函数来实现这一点。这个函数唯一需要做的事情就是弄清楚输入的绘制模式是什么,并返回适当的GLenum
值:
static GLenum DrawModeToGLEnum(DrawMode input) {
switch (input) {
case DrawMode::Points: return GL_POINTS;
case DrawMode::LineStrip: return GL_LINE_STRIP;
case DrawMode::LineLoop: return GL_LINE_LOOP;
case DrawMode::Lines: return GL_LINES;
case DrawMode::Triangles: return GL_TRIANGLES;
case DrawMode::TriangleStrip:
return GL_TRIANGLE_STRIP;
case DrawMode::TriangleFan:
return GL_TRIANGLE_FAN;
}
cout << "DrawModeToGLEnum unreachable code hit\n";
return 0;
}
- 接受顶点数的
Draw
和DrawInstanced
函数很容易实现。Draw
需要调用glDrawArrays
,而DrawInstanced
需要调用glDrawArraysInstanced
:
void Draw(unsigned int vertexCount, DrawMode mode) {
glDrawArrays(DrawModeToGLEnum(mode), 0, vertexCount);
}
void DrawInstanced(unsigned int vertexCount,
DrawMode mode, unsigned int numInstances) {
glDrawArraysInstanced(DrawModeToGLEnum(mode),
0, vertexCount, numInstances);
}
- 接受索引缓冲区的
Draw
和Drawinstanced
函数需要将索引缓冲区绑定到GL_ELEMENT_ARRAY_BUFFER
,然后调用glDrawElements
和glDrawElementsInstanced
:
void Draw(IndexBuffer& inIndexBuffer, DrawMode mode) {
unsigned int handle = inIndexBuffer.GetHandle();
unsigned int numIndices = inIndexBuffer.Count();
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, handle);
glDrawElements(DrawModeToGLEnum(mode),
numIndices, GL_UNSIGNED_INT, 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
}
void DrawInstanced(IndexBuffer& inIndexBuffer,
DrawMode mode, unsigned int instanceCount) {
unsigned int handle = inIndexBuffer.GetHandle();
unsigned int numIndices = inIndexBuffer.Count();
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, handle);
glDrawElementsInstanced(DrawModeToGLEnum(mode),
numIndices, GL_UNSIGNED_INT, 0, instanceCount);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
}
到目前为止,您已经编写了加载着色器、创建和绑定 GPU 缓冲区以及将统一变量传递给着色器的代码。现在绘图代码也已实现,您可以开始显示几何图形了。
在下一节中,您将学习如何使用纹理使渲染的几何图形看起来更有趣。
使用纹理
本书中编写的所有着色器都假定正在渲染的漫反射颜色来自纹理。纹理将从.png
文件加载。所有图像加载都将通过stb_image
完成。
Stb
是一组单文件公共领域库。我们只会使用图像加载器;您可以在 GitHub 上找到整个stb
集合github.com/nothings/stb
。
添加 stb_image
您将使用stb_image
加载纹理。您可以从github.com/nothings/stb/blob/master/stb_image.h
获取头文件的副本。将stb_image.h
头文件添加到项目中。
创建一个新文件stb_image.cpp
。这个文件只需要声明stb_image
实现宏并包含头文件。它应该是这样的:
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
纹理类声明
创建一个新文件Texture.h
。您将在这个文件中声明Texture
类。Texture
类只需要一些重要的函数。它需要能够从文件加载纹理,将纹理索引绑定到统一索引,并取消激活纹理索引。
除了核心函数之外,该类还应该有一个默认构造函数、一个方便的构造函数(接受文件路径)、一个析构函数和一个获取Texture
类内包含的 OpenGL 句柄的 getter。复制构造函数和赋值运算符应该被禁用,以避免两个Texture
类引用相同的 OpenGL 纹理句柄:
class Texture {
protected:
unsigned int mWidth;
unsigned int mHeight;
unsigned int mChannels;
unsigned int mHandle;
private:
Texture(const Texture& other);
Texture& operator=(const Texture& other);
public:
Texture();
Texture(const char* path);
~Texture();
void Load(const char* path);
void Set(unsigned int uniform, unsigned int texIndex);
void UnSet(unsigned int textureIndex);
unsigned int GetHandle();
};
实现纹理类
创建一个新文件Texture.cpp
。Texture
类的定义将放在这个文件中。Texture
类的默认构造函数需要将所有成员变量设置为0
,然后生成一个 OpenGL 句柄。
Load
函数可能是Texture
类中最重要的函数;它负责加载图像文件。图像文件的实际解析将由stbi_load
处理:
- 方便的构造函数生成一个新的句柄,然后调用
Load
函数,该函数将初始化Texture
类的其余成员变量,因为Texture
类的每个实例都持有一个有效的纹理句柄:
Texture::Texture() {
mWidth = 0;
mHeight = 0;
mChannels = 0;
glGenTextures(1, &mHandle);
}
Texture::Texture(const char* path) {
glGenTextures(1, &mHandle);
Load(path);
}
Texture::~Texture() {
glDeleteTextures(1, &mHandle);
}
stbi_load
需要一个图像文件的路径以及图像的宽度、高度和通道数的引用。最后一个参数指定每个像素的组件数。通过将其设置为4
,所有纹理都将以 RGBA 通道加载。接下来,使用glTexImage2D
将纹理上传到 GPU,并使用glGenerateMipmap
生成图像的适当 mipmap。将包装模式设置为重复:
void Texture::Load(const char* path) {
glBindTexture(GL_TEXTURE_2D, mHandle);
int width, height, channels;
unsigned char* data = stbi_load(path, &width,
&height,
&channels, 4);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width,
height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
stbi_image_free(data);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S,
GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T,
GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,
GL_NEAREST_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,
GL_LINEAR);
glBindTexture(GL_TEXTURE_2D, 0);
mWidth = width;
mHeight = height;
mChannels = channels;
}
Set
函数需要激活一个纹理单元,将Texture
类包含的句柄绑定到该纹理单元,然后将指定的统一索引设置为当前绑定的纹理单元。Unset
函数取消绑定指定纹理单元的当前纹理:
void Texture::Set(unsigned int uniformIndex,
unsigned int textureIndex) {
glActiveTexture(GL_TEXTURE0 + textureIndex);
glBindTexture(GL_TEXTURE_2D, mHandle);
glUniform1i(uniformIndex, textureIndex);
}
void Texture::UnSet(unsigned int textureIndex) {
glActiveTexture(GL_TEXTURE0 + textureIndex);
glBindTexture(GL_TEXTURE_2D, 0);
glActiveTexture(GL_TEXTURE0);
}
GetHandle
获取函数很简单:
unsigned int Texture::GetHandle() {
return mHandle;
}
Texture
类将始终使用相同的 mipmap 级别和包装参数加载纹理。对于本书中的示例,这应该足够了。您可能希望尝试为这些属性添加 getter 和 setter。
在下一节中,您将实现顶点和片段着色器程序,这是绘制所需的最后一步。
简单的着色器
渲染抽象已完成。在绘制任何东西之前,您需要编写着色器来指导绘制的方式。在本节中,您将编写一个顶点着色器和一个片段着色器。片段着色器将在本书的其余部分中使用,而本书后面部分使用的顶点着色器将是这里介绍的一个变体。
顶点着色器
顶点着色器负责将模型的每个顶点通过模型、视图和投影管道,并将任何所需的光照数据传递给片段着色器。创建一个新文件,static.vert
。您将在这个文件中实现顶点着色器。
顶点着色器需要三个 uniform 变量——模型、视图和投影矩阵。这些 uniform 变量需要用来转换顶点。每个单独的顶点由三个属性组成——位置、法线和一些纹理坐标。
顶点着色器将三个变量输出到片段着色器中,即世界空间中的法线和片段位置,以及纹理坐标:
#version 330 core
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
in vec3 position;
in vec3 normal;
in vec2 texCoord;
out vec3 norm;
out vec3 fragPos;
out vec2 uv;
void main() {
gl_Position = projection * view * model *
vec4(position, 1.0);
fragPos = vec3(model * vec4(position, 1.0));
norm = vec3(model * vec4(normal, 0.0f));
uv = texCoord;
}
这是一个最小的顶点着色器;它只将顶点通过模型视图和投影管道。这个着色器可以用来显示静态几何图形或 CPU 蒙皮网格。在下一节中,您将实现一个片段着色器。
片段着色器
创建一个新文件,lit.frag
。这个文件中的片段着色器将在本书的其余部分中使用。一些章节将介绍新的顶点着色器,但片段着色器始终保持不变。
片段着色器从纹理中获取对象的漫反射颜色,然后应用单向光。光照模型只是N点L。由于光没有环境项,模型的某些部分可能会呈现全黑:
#version 330 core
in vec3 norm;
in vec3 fragPos;
in vec2 uv;
uniform vec3 light;
uniform sampler2D tex0;
out vec4 FragColor;
void main() {
vec4 diffuseColor = texture(tex0, uv);
vec3 n = normalize(norm);
vec3 l = normalize(light);
float diffuseIntensity = clamp(dot(n, l), 0, 1);
FragColor = diffuseColor * diffuseIntensity;
}
重要信息:
想了解更多关于 OpenGL 中光照模型的知识?请访问learnopengl.com/Lighting/Basic-Lighting
。
这是一个简单的片段着色器;漫反射颜色是通过对纹理进行采样获得的,强度是一个简单的定向光。
总结
在本章中,您学会了如何在 OpenGL API 的顶层编写一个抽象层。在本书的大部分时间里,您将使用这些类来绘制东西,但是一些零散的 OpenGL 调用可能会在我们的代码中找到它们的位置。
以这种方式抽象化 OpenGL 将让未来的章节专注于动画,而不必担心底层 API。将这个 API 移植到其他后端也应该很简单。
本章有两个示例——Chapter06/Sample00
,这是到目前为止使用的代码,以及Chapter06/Sample01
,显示一个简单的纹理和光照平面在原地旋转。Sample01
是如何使用到目前为止编写的代码的一个很好的例子。
Sample01
还包括一个实用类DebugDraw
,本书不会涉及。该类位于DebugDraw.h
和DebugDraw.cpp
中。DebugDraw
类可以用于快速绘制调试线,具有简单的 API。DebugDraw
类效率不高;它只用于调试目的。
在下一章中,您将开始探索 glTF 文件格式。glTF 是一种可以存储网格和动画数据的标准格式。这是本书其余部分将使用的格式。
第七章:探索 glTF 文件格式
在本章中,我们将探索 glTF,这是一个包含显示动画模型所需的一切的文件格式。这是大多数三维内容创建应用程序可以导出的标准格式,并允许您加载任意模型。
本章重点介绍文件格式本身。后续章节将重点介绍实现加载 glTF 文件部分,以使其变得相关。通过本章结束时,您应该对 glTF 文件格式有扎实的理解。
本章将专注于构建以下技能:
-
了解 glTF 文件中的数据
-
使用 cgltf 实现 glTF 加载
-
学习如何从 Blender 导出 glTF 文件
技术要求
本章将涵盖您需要加载和显示动画模型的 glTF 文件的每个概念。然而,本章不是文件格式的完整指南。在阅读本章之前,请花几分钟时间通过阅读www.khronos.org/files/gltf20-reference-guide.pdf
上的参考指南来熟悉 glTF 格式。
您将使用 cgltf (github.com/jkuhlmann/cgltf
)来解析 glTF 文件。如果 glTF 文件显示不正常,可能是一个坏文件。如果您怀疑文件可能有问题,请在gltf-viewer.donmccurdy.com/
上检查 glTF 参考查看器。
探索 glTF 文件的存储方式
glTF 文件存储为纯文本 JSON 文件或更紧凑的二进制表示。纯文本变体通常具有.gltf
扩展名,而二进制变体通常具有.glb
扩展名。
可能会有多个文件。glTF 文件可以选择嵌入大块的二进制数据,甚至纹理,或者可以选择将它们存储在外部文件中。这在下面的 Blender3D 的 glTF 导出选项的截图中反映出来:
图 7.1:Blender3D 的 glTF 导出选项
本书提供的可下载内容的示例文件存储为 glTF 嵌入文件(.gltf
)。这是可以用任何文本编辑器检查的纯文本变体。更重要的是,它是一个要跟踪的单个文件。尽管本书提供的文件是以 glTF 嵌入格式提供的,但最终的代码将支持加载二进制格式和单独的文件(.bin
)。
现在您已经探索了 glTF 文件存储的不同方式,让我们准备好学习 glTF 文件内部存储的内容。glTF 文件旨在存储整个场景,而不仅仅是单个模型。在下一节中,您将探索 glTF 文件的预期用途。
glTF 文件存储场景,而不是模型
重要的是要知道,glTF 文件旨在表示整个三维场景,而不仅仅是单个动画模型。因此,glTF 支持您不需要用于动画的功能,例如相机和 PBR 材质。对于动画,我们只关心使用受支持功能的一个小子集。让我们概述一下它们是什么。
glTF 文件可以包含不同类型的网格。它包含静态网格,例如道具。这些网格只能通过它们附加到的节点的动画来移动;它可以包含变形目标。变形动画可以用于诸如面部表情之类的事物。
glTF 文件也可以包含蒙皮网格。这些是您将用来为角色设置动画的网格。蒙皮网格描述了模型的顶点如何受到模型的变换层次结构(或骨骼)的影响。使用蒙皮网格,网格的每个顶点可以绑定到层次结构中的一个关节。随着层次结构的动画,网格会被变形。
glTF 旨在描述一个场景,而不是单个模型,这将使一些加载代码变得有些棘手。在下一节中,您将开始从高层次的角度探索 glTF 文件的实际内容。
探索 glTF 格式
glTF 文件的根是场景。一个 glTF 文件可以包含一个或多个场景。一个场景包含一个或多个节点。一个节点可以附加皮肤、网格、动画、相机、光线或混合权重。网格、皮肤和动画在缓冲区中存储大量信息。要访问缓冲区,它们包含一个包含缓冲区的缓冲区视图,缓冲区视图又包含缓冲区。
通过文本提供的描述可能很难理解。以下图表说明了所描述的文件布局。由于 glTF 是一种场景描述格式,有许多数据类型我们不必关心。下一节将探讨这些内容:
图 7.2:glTF 文件的内容
现在您已经了解了 glTF 文件中存储的内容,接下来的部分将探讨蒙皮动画所需的文件格式部分。
需要用于动画的部分
使用 glTF 文件加载动画模型时,文件的必需组件是场景、节点、网格和皮肤。这是一个要处理的小子集;以下图表突出显示了这些部分及其关系。这些数据类型之间的关系可以描述如下:
图 7.3:用于蒙皮动画的 glTF 文件的部分
前面的图省略了每个数据结构中的大部分数据,而是只关注您需要实现蒙皮动画的内容。在下一节中,我们将探讨 glTF 文件中不需要用于蒙皮动画的部分。
不需要用于动画的部分
要实现蒙皮动画,您不需要灯光、相机、材质、纹理、图像和采样器。在下一节中,您将探索如何从 glTF 文件中实际读取数据。
访问数据
访问数据有点棘手,但并不太困难。网格、皮肤和动画对象都包含一个 glTF 访问器。这个访问器引用一个缓冲区视图,而缓冲区视图引用一个缓冲区。以下图表展示了这种关系:
图 7.4:访问 glTF 文件中的数据
在这三个单独的步骤中,如何访问缓冲区数据?在下一节中,您将学习如何使用缓冲区视图和最终访问器从缓冲区中解释数据。
缓冲区
将缓冲区视为 OpenGL 缓冲区。它只是一个大的、线性的值数组。这类似于您在第六章《构建抽象渲染器》中构建的Attributes
类。Attributes
类的Set
函数调用glBufferData
,其签名如下:
void glBufferData(GLenum target, GLsizeiptr size,
void * data, GLenum usage);
glTF 中的缓冲区包含调用glBufferData
函数所需的所有信息。它包含大小、void 指针和可选的偏移量,这些偏移量只修改源指针和大小。将 glTF 缓冲区视为填充 OpenGL 缓冲区所需的所有内容。
在下一节中,您将学习如何将缓冲区视图与缓冲区一起使用。
缓冲区视图
缓冲区只是一些大块的数据。没有上下文来描述缓冲区内存储的内容。这就是缓冲区视图的作用。缓冲区视图描述了缓冲区中的内容。如果缓冲区包含glBufferData
的信息,那么缓冲区视图包含调用glVertexAttribPointer
的一些参数。glVertexAttribPointer
函数的签名如下:
void glVertexAttribPointer(GLuint index, GLint size,
GLenum type, GLboolean normalized,
GLsizei stride, void * pointer);
缓冲区视图包含type
,它确定视图是顶点缓冲区还是索引缓冲区。这很重要,因为顶点缓冲区绑定到GL_ARRAY_BUFFER
,而索引缓冲区绑定到GL_ELEMENT_ARRAY_BUFFER
。在第六章,构建抽象渲染器中,我们为这些不同的缓冲区类型构建了两个不同的类。
与缓冲区一样,缓冲区视图还包含一些可选的偏移量,进一步修改源指针的位置和大小。在接下来的部分中,您将探讨如何使用描述缓冲区视图内容的 accessor。
accessor
accessor 存储更高级别的信息。最重要的是,accessor 描述了您正在处理的数据类型,比如scalar
、vec2
、vec3
或vec4
。使用这些数据来确定glVertexAttribPointer
的size
参数。
accessor 回答了诸如数据是否规范化以及数据的存储模式是什么等问题。accessor 还包含了关于缓冲区和缓冲区视图已经包含的附加偏移量、大小和步幅信息。
下一节将演示如何从 glTF 文件中将数据加载到线性标量数组中。
例子
即使 accessor、buffer view 和 buffer 的关系已经确定,解析数据可能仍然有点混乱。为了尝试澄清一下,让我们探讨一下如何将 accessor 转换为浮点值的平面列表。以下代码旨在作为示例;它将不会在本书的其余部分中使用:
vector<float> GetPositions(const GLTFAccessor& accessor) {
// Accessors and sanity checks
assert(!accessor.isSparse);
const GLTFBufferView& bufferView = accessor.bufferView;
const GLTFBuffer& buffer = bufferView.buffer;
// Resize result
// GetNumComponents Would return 3 for a vec3, etc.
uint numComponents = GetNumComponents(accessor);
vector<float> result;
result.resize(accessor.count * numComponents);
// Loop trough every element in the accessor
for (uint i = 0; i < accessor.count; ++i) {
// Find where in the buffer the data actually starts
uint offset = accessor.offset + bufferView.offset;
uint8* data = buffer.data;
data += offset + accessor.stride * i;
// Loop trough every component of current element
float* target = result[i] * componentCount;
for (uint j = 0; j < numComponents; ++j) {
// Omitting normalization
// Omitting different storage types
target[j] = data + componentCount * j;
} // End loop of every component of current element
} // End loop of every accessor element
return result;
}
解析 glTF 文件的代码可能会变得冗长;在前面的代码示例中,glTF 文件已经被解析。加载 glTF 文件的大部分工作实际上是解析二进制或 JSON 数据。在下一节中,我们将探讨如何使用 cgltf 库来解析 glTF 文件。
探索 cgltf
在上一节中,我们探讨了将 glTF accessor 转换为浮点数的线性数组需要做些什么。代码省略了一些更复杂的任务,比如规范化数据或处理不同的存储类型。
提供的示例代码还假定数据已经从 JSON(或二进制)格式中解析出来。编写 JSON 解析器不在本书的范围内,但处理 glTF 文件是在范围内的。
为了帮助管理加载 glTF 文件的一些复杂性,以及避免从头开始编写 JSON 解析器,下一节将教您如何使用 cgltf 加载 JSON 文件。Cgltf 是一个单头文件的 glTF 加载库;您可以在 GitHub 上找到它github.com/jkuhlmann/cgltf
。在下一节中,我们将开始将 cgltf 集成到我们的项目中。
集成 cgltf
要将 cgltf 集成到项目中,从 GitHub 上下载头文件github.com/jkuhlmann/cgltf/blob/master/cgltf.h
。然后,将此头文件添加到项目中。接下来,向项目添加一个新的.c
文件,并将其命名为cgltf.c
。该文件应包含以下代码:
#pragma warning(disable : 26451)
#define _CRT_SECURE_NO_WARNINGS
#define CGLTF_IMPLEMENTATION
#include "cgltf.h"
CGLTF 现在已经集成到项目中。在本章中,您将实现解析 glTF 文件的代码。如何将 glTF 文件的内容加载到运行时数据将在以后的章节中进行覆盖,因为那时的运行时数据的代码已经编写好了。在接下来的部分,我们将学习如何实现 glTF 解析代码。
创建一个 glTF 加载器
在本节中,我们将探讨如何使用 cgltf 加载 glTF 文件。将文件加载到运行时数据结构cgltf_data
中的代码很简单。在以后的章节中,您将学习如何解析这个cgltf_data
结构的内容。
要加载一个文件,你需要创建一个cgltf_options
的实例。你不需要设置任何选项标志;只需用0
实例化cgltf_options
结构的所有成员值。接下来,声明一个cgltf_data
指针。这个指针的地址将被传递给cgltf_parse_file
。在cgltf_parse_file
填充了cgltf_data
结构之后,你就可以解析文件的内容了。要稍后释放cgltf_data
结构,调用cgltf_free
:
- 创建一个新文件
GLTFLoader.h
,其中包括cgltf.h
。为LoadGLTFFile
和FreeGLTFFile
函数添加函数声明:
#ifndef _H_GLTFLOADER_
#define _H_GLTFLOADER_
#include "cgltf.h"
cgltf_data* LoadGLTFFile(const char* path);
void FreeGLTFFile(cgltf_data* handle);
#endif
- 创建一个新文件
GLTFLoader.cpp
。这个函数接受一个路径并返回一个cgltf_data
指针。在内部,该函数调用cgltf_parse_file
从文件中加载 glTF 数据。cgltf_load_buffers
用于加载任何外部缓冲区数据。最后,cgltf_validate
确保刚刚加载的 glTF 文件是有效的:
cgltf_data* LoadGLTFFile(const char* path) {
cgltf_options options;
memset(&options, 0, sizeof(cgltf_options));
cgltf_data* data = NULL;
cgltf_result result = cgltf_parse_file(&options,
path, &data);
if (result != cgltf_result_success) {
cout << "Could not load: " << path << "\n";
return 0;
}
result = cgltf_load_buffers(&options, data, path);
if (result != cgltf_result_success) {
cgltf_free(data);
cout << "Could not load: " << path << "\n";
return 0;
}
result = cgltf_validate(data);
if (result != cgltf_result_success) {
cgltf_free(data);
cout << "Invalid file: " << path << "\n";
return 0;
}
return data;
}
- 在
GLTFLoader.cpp
中实现FreeGLTFFile
函数。这个函数很简单;如果输入指针不是null
,它需要调用cgltf_free
:
void FreeGLTFFile(cgltf_data* data) {
if (data == 0) {
cout << "WARNING: Can't free null data\n";
}
else {
cgltf_free(data);
}
}
在后面的章节中,你将通过引入加载网格、姿势和动画的函数来扩展 glTF Loader
函数。在下一节中,你将探索如何从 Blender3D 导出 glTF 文件。
探索示例资产
你将在本书中使用的示例文件是来自 Quaternius 的 CC0、公共领域许可的资产。你可以在quaternius.com/assets.html
找到类似风格的其他资产。
此外,后面的章节还包括了 GDQuest 的开放式三维 Mannequin 的屏幕截图,这些屏幕截图在github.com/GDQuest/godot-3d-mannequin
以 MIT 许可证的形式提供。
一些资产已经以 glTF 格式提供,但有些可能是.blend
、.fbx
或其他格式。当发生这种情况时,很容易将模型导入 Blender 并导出 glTF 文件。下一节将指导你如何从 Blender 导出 glTF 文件。
从 Blender 导出
Blender 是一个免费的三维内容创作工具。你可以从www.blender.org/
下载 Blender。以下说明是针对 Blender 2.8 编写的,但在更新的版本中也应该可以使用。
如果你要导入的模型已经是.blend
文件,只需双击它,它就会在 Blender 中加载。
如果模型是以不同的格式,比如.DAE
或.FBX
,你需要导入它。要这样做,打开 Blender,你应该看到默认场景加载。这个默认场景有一个立方体、一个灯光和一个摄像头:
图 7.5:默认的 Blender3D 场景
通过左键单击选择立方体,然后悬停在三维视口上,按下删除键删除立方体。左键单击选择摄像头,然后按下删除键删除摄像头。对于灯光也是一样。
现在你应该有一个空场景。从文件菜单中,选择文件|导入,然后选择适当的模型格式进行导入。找到你的文件,双击导入它。一旦模型被导入,选择文件|导出 glTF 2.0。将导出格式设置为 glTF(文本文件)或 glb(二进制文件)。
总结
在本章中,你了解了什么是 glTF 文件,glTF 格式的哪些部分对于蒙皮动画是有用的,以及如何使用 cglTF 来加载 glTF 文件。如果这个格式还有点令人困惑,不要担心;当你开始解析 cgltf 文件中的各种数据时,它会变得更加清晰。使用 cgltf 将让你专注于将 glTF 数据转换为有用的运行时结构,而不必担心手动解析 JSON 文件。在下一章中,你将开始实现动画的构建块,包括曲线、帧和轨道。
第八章:创建曲线、帧和轨道
在 21 世纪初,游戏通常会采用在 3D 内容创建工具(如 Blender 或 Maya)中制作的动画,播放动画,并在设置的间隔内对动画中每个关节的变换进行采样。一旦对动画进行了采样,游戏的运行时会在采样帧之间进行线性插值。
虽然这种方法可行(并且可以在 glTF 文件中实现),但这并不是播放动画的最准确方式。它通过包含实际上不需要存在的帧来浪费内存。在 3D 内容创建工具中,动画是使用曲线创建的,例如以下截图中显示的曲线:
图 8.1:Blender 3D 曲线编辑器
现代游戏和动画系统直接评估这些曲线。直接评估动画曲线可以节省内存,但在处理能力方面曲线会更昂贵一些。在本章结束时,您应该能够做到以下几点:
-
了解立方 Bézier 样条以及如何评估它们
-
了解立方 Hermite 样条以及如何评估它们
-
了解常见的插值方法
-
能够创建立方、线性和恒定关键帧
-
了解关键帧如何组成立方、线性或恒定轨道
-
能够评估立方、线性和恒定轨道
-
能够将三个独立轨道合并为一个变换轨道
了解立方 Bézier 样条
要实现游戏动画,您需要对曲线有一定的了解。让我们从基础知识开始——立方 Bézier 样条。Bézier 样条有两个要插值的点和两个控制点,帮助生成曲线。这就是立方 Bézier 样条的样子:
图 8.2:立方 Bézier 样条
给定两个点和两个控制点,如何生成曲线?让我们探索为给定时间t插值曲线。首先从P1到C1画一条线,从C1到C2,从C2到P2。然后,沿着这些线线性插值值t:
图 8.3:在点和控制点之间进行线性插值
从P1到C1的插值点是A,从C2到P2是B,从C1到C2是C。接下来,您需要重复这个过程,画线并从A到C和从C到B进行插值。让我们称这些新插值点为 E 和 F:
图 8.4:线性插值图 8.3 的结果
重复一次,从E到F画一条线,并且也按照t在该线上进行插值。让我们称得到的点为R。这个点R在 Bézier 样条上的某个位置。如果您计算从t=0到t=1的所有点,您可以绘制出曲线:
图 8.5:线性插值图 8.4 的结果
让我们探索绘制 Bézier 样条所需的代码。本书中不会在其他地方使用 Bézier 样条,因此不需要实现以下代码来跟随本书的其余部分:
- 首先,您需要定义什么是 Bézier 样条。创建一个包含两个点和两个控制点的新模板类:
template<typename T>
class Bezier {
public:
T P1; // Point 1
T C1; // Control 1
T P2; // Point 2
T C2; // Control 2
};
- 接下来,实现
Interpolate
函数。该函数接受一个 Bézier 样条引用和一个值t
,用于插值样条。假设t
大于或等于0
且小于或等于1
:
template<typename T>
inline T Interpolate(Bezier<T>& curve, float t) {
T A = lerp(curve.P1, curve.C1, t);
T B = lerp(curve.C2, curve.P2, t);
T C = lerp(curve.C1, curve.C2, t);
T D = lerp(A, C, t);
T E = lerp(C, B, t);
T R = lerp(D, E, t);
return R;
}
以下代码示例演示了如何使用 Bezier 类和Interpolate
函数来绘制 Bézier 样条:
- 首先,您需要创建将要绘制的数据:
Bezier<vec3> curve;
curve.P1 = vec3(-5, 0, 0);
curve.P2 = vec3(5, 0, 0);
curve.C1 = vec3(-2, 1, 0);
curve.C2 = vec3(2, 1, 0);
vec3 red = vec3(1, 0, 0);
vec3 green = vec3(0, 1, 0);
vec3 blue = vec3(0, 0, 1);
vec3 magenta = vec3(1, 0, 1);
- 接下来,绘制点和控制点:
// Draw all relevant points
DrawPoint(curve.P1, red);
DrawPoint(curve.C1, green);
DrawPoint(curve.P2, red);
DrawPoint(curve.C2, green);
// Draw handles
DrawLine(curve.P1, curve.C1, blue);
DrawLine(curve.P2, curve.C2, blue);
- 最后,绘制样条线:
// Draw the actual curve
// Resolution is 200 steps since last point is i + 1
for (int i = 0; i < 199; ++i) {
float t0 = (float)i / 199.0f;
float t1 = (float)(i + 1) / 199.0f;
vec3 thisPoint = Interpolate(curve, t0);
vec3 nextPoint = Interpolate(curve, t1);
DrawLine(thisPoint, nextPoint, magenta);
}
在前面的示例代码中,您可以看到可以通过使用六次线性插值来实现 BézierInterpolate
函数。要理解 Bézier 样条的工作原理,您需要将lerp
函数扩展到实际情况。线性插值,lerp(a, b, t)
,扩展为(1-t) * a + t * b
:
- 重写
Interpolate
函数,以便展开所有的lerp
调用:
template<typename T>
inline T Interpolate(const Bezier<T>& curve, float t) {
T A = curve.P1 * (1.0f - t) + curve.C1 * t;
T B = curve.C2 * (1.0f - t) + curve.P2 * t;
T C = curve.C1 * (1.0f - t) + curve.C2 * t;
T D = A * (1.0f - t) + C * t;
T E = C * (1.0f - t) + B * t;
T R = D * (1.0f - t) + E * t;
return R;
}
- 没有改变,但您不再需要调用
lerp
函数。只要定义了T operator*(const T& t, float f)
,这对于任何数据类型T
都适用。让我们试着在数学上简化这个。不要使用A
、B
、C
、D
、E
和R
变量,将这些方程展开为以下形式:
((P1 * (1 - t) + C1 * t) * (1 - t) + (C1 * (1 - t)
+ C2 * t) * t) * (1 - t) + ((C1 * (1 - t) + C2 * t)
* (1 - t) + (C2 * (1 - t) + P2 * t) * t) * t
- 这相当于手动内联所有的
lerp
函数。结果代码有点难以阅读:
template<typename T>
inline T Interpolate(const Bezier<T>& c, float t) {
return
((c.P1 * (1.0f - t) + c.C1 * t) * (1.0f - t) +
(c.C1 * (1.0f - t) + c.C2 * t) * t) * (1.0f - t)
+ ((c.C1 * (1.0f - t) + c.C2 * t) * (1.0f - t) +
(c.C2 * (1.0f - t) + c.P2 * t) * t) * t;
}
- 为什么要费这么大劲?为了开始简化数学,让我们从合并类似项开始:
-P1t3 + 3P1t2 - 3P1t + P1 + 3C1t3 - 6C1t2 + 3C1t - 3C2t3 + 3C2t2 + P2t3
- 现在这开始看起来像一个方程了!这个简化的方程也可以用代码表示:
template<typename T>
inline T Interpolate(const Bezier<T>& curve, float t) {
return
curve.P1 * (t * t * t) * -1.0f +
curve.P1 * 3.0f * (t * t) -
curve.P1 * 3.0f * t +
curve.P1 +
curve.C1 * 3.0f * (t * t * t) -
curve.C1 * 6.0f * (t * t) +
curve.C1 * 3.0f * t -
curve.C2 * 3.0f * (t * t * t) +
curve.C2 * 3.0f * (t * t) +
curve.P2 * (t * t * t);
}
- 通过隔离一些项来进一步简化这个简化:
P1( -t3 + 3t2 - 3t + 1) +
C1( 3t3 - 6t2 + 3t)+
C2(-3t3 + 3t2)+
P2( t3)
- 在代码中,这表示为:
template<typename T>
inline T Interpolate(const Bezier<T>& c, float t) {
float ttt = t * t * t;
float tt = t * t;
return
c.P1 * (-1.0f * ttt + 3.0f * tt - 3.0f * t + 1.0f) +
c.C1 * (3.0f * ttt - 6.0f * tt + 3.0f * t) +
c.C2 * (-3.0f * ttt + 3.0f * tt) +
c.P2 * ttt;
}
- 再次简化函数:
P1((1-t)3) +
C1(3(1-t)2t) +
C2(3(1-t)t2) +
P2(t3)
- 最终简化的代码如下所示:
template<typename T>
inline T Interpolate(const Bezier<T>& curve, float t) {
return curve.P1 * ((1 - t) * (1 - t) * (1 - t)) +
curve.C1 * (3.0f * ((1 - t) * (1 - t)) * t) +
curve.C2 * (3.0f * (1 - t) * (t * t)) +
curve.P2 *(t * t * t);
}
如果将这些最终方程用t从0
到1
绘制出来,您将得到以下图形:
图 8.6:Bézier 样条的基础函数
这些是三次 Bézier 样条的点基础函数。它们表达了样条值随时间的变化。例如,P1 的影响随时间减小;在t=0时,影响是完整的—它的值为 1。然而,到了t=1,P1 的影响消失了—它的值为 0。
在本节中,您经历了简化 Bézier 样条评估函数的练习,以得到样条的基础函数。对于 Bézier 样条,很容易遵循这种逻辑,因为您可以从一个易于理解的实现开始,该实现只使用六个 lerp 函数。对于其他曲线,没有一个容易的起点。
在下一节中,我们将探讨另一种三次样条——三次 Hermite 样条。使用本节学到的知识,您将能够仅使用基础函数图实现 Hermite 评估函数。
理解三次 Hermite 样条
在游戏动画中最常用的样条类型是三次 Hermite 样条。与 Bézier 不同,Hermite 样条不使用空间中的点作为控制点;相反,它使用样条上的点的切线。您仍然有四个值,就像 Bézier 样条一样,但它们的解释方式不同。
对于 Hermite 样条,您不是有两个点和两个控制点;相反,您有两个点和两个斜率。这些斜率也被称为切线—在本章的其余部分,斜率和切线术语将互换使用。Hermite 样条的点基础函数如下所示:
图 8.7:Hermite 样条的点基础函数
当给定点基础函数时,您可以实现类似于实现 Bézier 插值函数的样条评估函数:
template<typename T>
T Hermite(float t, T& p1, T& s1, T& p2, T& s2) {
return
p1 * ((1.0f + 2.0f * t) * ((1.0f - t) * (1.0f - t))) +
s1 * (t * ((1.0f - t) * (1.0f - t))) +
p2 * ((t * t) * (3.0f - 2.0f * t)) +
s2 * ((t * t) * (t - 1.0f));
}
可以在 Bézier 和 Hermite 样条之间切换,但这超出了您需要了解的动画范围。一些 3D 内容创建应用程序,如 Maya,允许动画师使用 Hermite 样条创建动画,而其他应用程序,如 Blender 3D,使用 Bézier 曲线。
了解这些函数的工作原理是有用的,无论哪种函数驱动您的动画系统。当然,还有更多的曲线类型,但 Bézier 和 Hermite 是最常见的。
glTF 文件格式支持常数、线性和三次插值类型。您刚刚学会了如何进行三次插值,但仍需要实现常数和线性插值。
插值类型
通常,在定义动画曲线时,遵循三种插值方法之一——常数、线性或三次。三次曲线可以使用任何三次方程来表示,例如 Bézier 曲线(Blender 使用的方法)或 Hermite 样条线(Maya 使用的方法)。本书使用 Hermite 样条线来表示三次曲线。
常数曲线保持其值不变,直到下一个关键帧。有时,这种类型的曲线被称为阶跃曲线。在视觉上,常数曲线如下所示:
图 8.8:常数曲线
线性曲线以线性方式在两个帧之间进行插值(即直线)。正如您之前在采样曲线近似示例中看到的那样,如果线性轨迹的样本足够接近,它也可以开始近似其他类型的曲线。线性曲线如下所示:
图 8.9:线性曲线
三次曲线允许您根据值和切线定义曲线。三次曲线的好处是您可以用很少的数据表示复杂的曲线。缺点是插值变得有点昂贵。三次曲线如下所示(切线是从关键帧出来的线):
图 8.10:三次曲线
插值类型可以表示为简单的enum
类。创建一个新文件—Interpolation.h
。添加头文件保护并添加以下enum
类声明:
enum class Interpolation {
Constant,
Linear,
Cubic
};
这也是 glTF 支持的三种插值类型。在下一节中,您将开始通过创建Frame
结构来存储关键帧数据来实现动画轨迹。
创建 Frame 结构
数据帧是什么?这取决于插值类型。如果插值是常数(阶跃)或线性的,则帧只是时间和值。当插值为三次时,您还需要存储切线。
Hermite 曲线是通过连接 Hermite 样条线制成的。每个控制点由时间、值、入射切线和出射切线组成。如果使用控制点与其前面的点进行评估,则使用入射切线。如果使用控制点与其后面的点进行评估,则使用出射切线。
帧中存储的时间值是标量的,但数据和切线呢?这些值应该是标量、矢量还是四元数?要做出这个决定,您必须考虑如何将一组帧组织成曲线。
有两种选择策略。您可以创建一个标量曲线对象,其中数据和切线是标量值。然后,当您需要一个矢量曲线时,可以将几个标量曲线对象组合成一个矢量曲线对象。
具有标量轨道并从中合成高阶轨道的优势在于矢量或四元数曲线的每个分量可以以不同的方式进行插值。它还可以节省内存,因为曲线的每个分量可以具有不同数量的帧。缺点是额外的实现工作。
另一种策略是使用专门的帧和曲线类型,例如标量帧、矢量帧和四元数帧。同样,您可以创建单独的类来表示标量曲线、矢量曲线和四元数曲线。
使用专门的帧和曲线的优势在于其易于实现。您可以利用使用模板来避免编写重复的代码。glTF 文件也以这种方式存储动画轨迹。缺点是内存;曲线的每个分量都需要具有相同数量的关键帧。
在本书中,你将实现显式帧和曲线(轨迹)。Frame
类将包含时间、值和入射和出射切线。如果插值类型不需要切线,你可以简单地忽略它们。帧可以是任意大小(如标量、二维向量、三维向量、四元数等)。它包含的时间始终是标量,但值和切线长度可以是任何值:
- 创建一个新文件
Frame.h
。将Frame
类的声明添加到这个新文件中。Frame
类需要值和入射和出射切线的数组,以及一个时间标量。使用模板来指定每个帧的大小:
template<unsigned int N>
class Frame {
public:
float mValue[N];
float mIn[N];
float mOut[N];
float mTime;
};
- 为常见的帧类型创建
typedef
数据类型:
typedef Frame<1> ScalarFrame;
typedef Frame<3> VectorFrame;
typedef Frame<4> QuaternionFrame;
你刚刚实现的Frame
类用于存储动画轨迹中的关键帧。动画轨迹是关键帧的集合。在下一节中,你将学习如何实现Track
类。
创建 Track 类
Track
类是一组帧。对轨迹进行插值返回轨迹的数据类型;结果是轨迹在特定时间点上定义的曲线上的值。轨迹必须至少有两个帧进行插值。
如创建 Frame 结构部分所述,通过遵循本书中的示例,你将实现显式的帧和轨迹类型。将为标量、向量和四元数轨迹创建单独的类。这些类是模板的,以避免编写重复的代码。例如,vec3
轨迹包含Frame<3>
类型的帧。
因为轨迹有一个明确的类型,所以你不能在vec3
轨迹的X分量中创建关键帧,而不同时为Y和Z分量添加关键帧。
这可能会占用更多的内存,如果你有一个不变的组件。例如,注意在下图中,Z组件有许多帧,即使它是一条直线,两个应该足够了。这并不是一个很大的折衷;所占用的额外内存是微不足道的:
图 8.11:vec3 轨迹的组件
对于蒙皮网格渲染,动画轨迹总是对关节变换进行动画。然而,动画轨迹也可以用于在游戏中动画其他值,比如光的强度或在二维精灵之间切换以产生翻书效果。在下一节中,你将创建一个新的头文件并开始声明实际的Track
类。
声明 Track 类
轨迹是一组帧。Frame
类是模板的,所以Track
类也需要是模板的。Track
类需要两个模板参数——第一个是类型(预期是float
、vec3
、quat
等),另一个是类型包含的组件数:
Track
类只需要两个成员——帧的向量和插值类型。创建一个新文件Track.h
,并将Track
类的声明添加到这个文件中:
template<typename T, int N>
class Track {
protected:
std::vector<Frame<N>> mFrames;
Interpolation mInterpolation;
Track
类只需要一个默认构造函数来初始化mInterpolation
变量。生成的复制构造函数、赋值运算符和析构函数都很好:
public:
Track();
- 为轨迹的帧数、插值类型以及起始和结束时间创建获取器和设置器函数:
void Resize(unsigned int size);
unsigned int Size();
Interpolation GetInterpolation();
void SetInterpolation(Interpolation interp);
float GetStartTime();
float GetEndTime();
Track
类需要一种在给定时间采样轨迹的方法。这个Sample
方法应该接受一个时间值和轨迹是否循环的参数。重载[]运算符
以检索帧的引用:
T Sample(float time, bool looping);
Frame<N>& operator[](unsigned int index);
- 接下来,你需要声明一些辅助函数。轨迹可以是常量、线性或立方体。只需要一个
Sample
函数来处理这三种情况。不要创建一个庞大、难以阅读的函数,为每种插值类型创建一个辅助函数:
protected:
T SampleConstant(float time, bool looping);
T SampleLinear(float time, bool looping);
T SampleCubic(float time, bool looping);
- 添加一个辅助函数来评估 Hermite 样条:
T Hermite(float time, const T& p1, const T& s1,
const T& p2, const T& s2);
- 添加一个函数来检索给定时间的帧索引。这是请求的时间之前的最后一帧。另外,添加一个辅助函数,该函数接受轨道范围之外的输入时间,并将其调整为轨道上的有效时间:
int FrameIndex(float time, bool looping);
float AdjustTimeToFitTrack(float t, bool loop);
- 您需要一种将浮点数组(帧内的数据)转换为轨道模板类型的方法。该函数针对每种类型的轨道进行了专门化:
T Cast(float* value); // Will be specialized
};
- 与
Frame
类一样,为常见的Track
类型添加typedef
数据类型:
typedef Track<float, 1> ScalarTrack;
typedef Track<vec3, 3> VectorTrack;
typedef Track<quat, 4> QuaternionTrack;
Track
类的 API 很小,这使得该类易于使用。但是,Track
类存在许多隐藏的复杂性;毕竟,这个类是您正在构建的动画系统的核心。在下一节中,您将开始实现实际的Track
类。
实现 Track 类
Track
类是模板化的,但不打算在动画系统之外使用。在Track.cpp
中为float
、vec3
和quat
轨道添加模板定义。这样做可以使编译器在 CPP 文件中生成这些模板的代码:
template Track<float, 1>;
template Track<vec3, 3>;
template Track<quat, 4>;
对于角色动画,vec3
和quat
轨道类型就足够了。如果需要添加新类型的轨道,请不要忘记将模板类型添加到Track.cpp
文件中。在接下来的部分中,您将开始实现加载轨道数据的辅助函数。
实现辅助函数
Track
类是模板化的,以避免为所有轨道类型编写重复的代码。但是,某些功能需要特定于Track
类的类型。除了Cast
函数之外,所有特定于类型的函数都驻留在一个新的命名空间TrackHelpers
中。
这些辅助函数不是Track
类的一部分;它们依赖于函数重载,以确保调用正确版本的辅助函数。这些辅助类的关键职责之一是确保四元数被归一化并处于正确的邻域。因为这段代码插值四元数,所以邻域是一个关注点:
- 要使轨道进行线性插值,您需要为每种轨道类型创建插值函数。在
Track.cpp
中添加以下辅助函数,为轨道可能包含的每种数据类型提供正确的插值方法。这些函数属于TrackHelpers
命名空间。
namespace TrackHelpers {
inline float Interpolate(float a, float b, float t) {
return a + (b - a) * t;
}
inline vec3 Interpolate(const vec3& a, const vec3& b,
float t) {
return lerp(a, b, t);
}
inline quat Interpolate(const quat& a, const quat& b,
float t) {
quat result = mix(a, b, t);
if (dot(a, b) < 0) { // Neighborhood
result = mix(a, -b, t);
}
return normalized(result); //NLerp, not slerp
}
- 当插值 Hermite 样条时,如果输入类型是四元数,则结果需要被归一化。您可以创建仅归一化四元数的辅助函数,而不是提供 Hermite 函数的四元数规范:
inline float AdjustHermiteResult(float f) {
return f;
}
inline vec3 AdjustHermiteResult(const vec3& v) {
return v;
}
inline quat AdjustHermiteResult(const quat& q) {
return normalized(q);
}
- 还需要一个常见的
Neighborhood
操作,以确保两个四元数处于正确的邻域。该函数对其他数据类型应该不做任何操作:
inline void Neighborhood(const float& a, float& b){}
inline void Neighborhood(const vec3& a, vec3& b){}
inline void Neighborhood(const quat& a, quat& b) {
if (dot(a, b) < 0) {
b = -b;
}
}
}; // End Track Helpers namespace
这些辅助函数存在的原因是为了避免制作插值函数的专门版本。相反,通用插值函数调用这些辅助方法,并且函数重载确保调用正确的函数。这意味着如果添加新类型的轨道,则需要添加新的辅助函数。在下一节中,您将开始实现一些Track
函数。
实现 Track 函数
在本节中,您将开始实现Track
类的成员函数。Track
类有几个不重要的函数,要么需要调用辅助函数,要么只是获取器和设置器函数。首先使用这些函数开始实现Track
类:
Track
构造函数需要设置轨道的插值类型。轨道的开始和结束时间的获取器和设置器函数很简单:
template<typename T, int N>
Track<T, N>::Track() {
mInterpolation = Interpolation::Linear;
}
template<typename T, int N>
float Track<T, N>::GetStartTime() {
return mFrames[0].mTime;
}
template<typename T, int N>
float Track<T, N>::GetEndTime() {
return mFrames[mFrames.size() - 1].mTime;
}
Sample
函数需要调用SampleConstant
、SampleLinear
或SampleCubic
,具体取决于轨道类型。[]
operator
返回对指定帧的引用:
template<typename T, int N>
T Track<T, N>::Sample(float time, bool looping) {
if (mInterpolation == Interpolation::Constant) {
return SampleConstant(time, looping);
}
else if (mInterpolation == Interpolation::Linear) {
return SampleLinear(time, looping);
}
return SampleCubic(time, looping);
}
template<typename T, int N>
Frame<N>& Track<T, N>::operator[](unsigned int index) {
return mFrames[index];
}
Resize
和Size
函数是围绕帧向量的大小的简单获取器和设置器:
template<typename T, int N>
void Track<T, N>::Resize(unsigned int size) {
mFrames.resize(size);
}
template<typename T, int N>
unsigned int Track<T, N>::Size() {
return mFrames.size();
}
- 轨道的插值类型也有简单的获取器和设置器函数:
template<typename T, int N>
Interpolation Track<T, N>::GetInterpolation() {
return mInterpolation;
}
template<typename T, int N>
void Track<T, N>::SetInterpolation(Interpolation interpolation) {
mInterpolation = interpolation;
}
Hermite
函数实现了本章理解三次 Hermite 样条部分涵盖的基本函数。第二点可能需要通过Neighborhood
辅助函数取反。四元数也需要被归一化。邻域化和归一化都是由辅助函数执行的:
template<typename T, int N>
T Track<T, N>::Hermite(float t, const T& p1, const T& s1,
const T& _p2, const T& s2) {
float tt = t * t;
float ttt = tt * t;
T p2 = _p2;
TrackHelpers::Neighborhood(p1, p2);
float h1 = 2.0f * ttt - 3.0f * tt + 1.0f;
float h2 = -2.0f * ttt + 3.0f * tt;
float h3 = ttt - 2.0f * tt + t;
float h4 = ttt - tt;
T result = p1 * h1 + p2 * h2 + s1 * h3 + s2 * h4;
return TrackHelpers::AdjustHermiteResult(result);
}
在接下来的几节中,您将实现Track
类的一些更难的函数,从FrameIndex
函数开始。
实现FrameIndex
函数
FrameIndex
函数以时间作为参数;它应该返回该时间之前的帧(在左侧)。这种行为取决于轨道是否打算循环采样。按照以下步骤实现FrameIndex
函数:
- 如果轨道只有一帧或更少,那么它是无效的。如果遇到无效的轨道,返回
-1
:
template<typename T, int N>
int Track<T, N>::FrameIndex(float time, bool looping) {
unsigned int size = (unsigned int)mFrames.size();
if (size <= 1) {
return -1;
}
- 如果轨道被循环采样,输入时间需要调整,使其落在起始和结束帧之间。这意味着您需要知道轨道第一帧的时间、轨道帧的时间和轨道的持续时间:
if (looping) {
float startTime = mFrames[0].mTime;
float endTime = mFrames[size - 1].mTime;
float duration = endTime - startTime;
- 由于轨道循环,
time
需要调整,使其在有效范围内。为此,通过从起始时间中减去time
并将结果与持续时间取模来使time
相对于持续时间。如果time
为负数,则加上持续时间。不要忘记将起始时间加回time
中:
time = fmodf(time - startTime,
endTime - startTime);
if (time < 0.0f) {
time += endTime - startTime;
}
time = time + startTime;
}
- 如果轨道不循环,任何小于起始帧的
time
值应该被夹到0
,任何大于倒数第二帧的time
值应该被夹到倒数第二帧的索引:
else {
if (time <= mFrames[0].mTime) {
return 0;
}
if (time >= mFrames[size - 2].mTime) {
return (int)size - 2;
}
}
- 现在时间在有效范围内,循环遍历每一帧。最接近时间的帧(但仍然较小)是应该返回的帧的索引。可以通过向后循环遍历轨道的帧并返回第一个时间小于查找时间的索引来找到这一帧:
for (int i = (int)size - 1; i >= 0; --i) {
if (time >= mFrames[i].mTime) {
return i;
}
}
// Invalid code, we should not reach here!
return -1;
} // End of FrameIndex
如果一个轨道不循环并且时间大于最后一帧的时间,则使用倒数第二帧的索引。为什么使用倒数第二帧而不是最后一帧?Sample
函数总是需要当前帧和下一帧,下一帧是通过将FrameIndex
函数的结果加1
来找到的。当time
等于最后一帧的时间时,需要插值的两帧仍然是倒数第二帧和最后一帧。
在下一节中,您将实现AdjustTimeToFitTrack
函数。这个函数用于确保任何采样的时间都有一个有效的值。有效的值是指在轨道的起始时间和结束时间之间的任何时间。
实现AdjustTimeToFitTrack
函数
要实现的下一个函数是AdjustTimeToFitTrack
。给定一个时间,这个函数需要调整时间,使其落在轨道的起始/结束帧的范围内。当然,这取决于轨道是否循环。按照以下步骤实现AdjustTimeToFitTrack
函数:
- 如果一个轨道少于一帧,那么这个轨道是无效的。如果使用了无效的轨道,返回
0
:
template<typename T, int N>
float Track<T, N>::AdjustTimeToFitTrack(float time,
bool looping) {
unsigned int size = (unsigned int)mFrames.size();
if (size <= 1) {
return 0.0f;
}
- 找到轨道的起始时间、结束时间和持续时间。起始时间是第一帧的时间,结束时间是最后一帧的时间,持续时间是两者之间的差异。如果轨道持续时间为
0
,则无效——返回0
:
float startTime = mFrames[0].mTime;
float endTime = mFrames[size - 1].mTime;
float duration = endTime - startTime;
if (duration <= 0.0f) {
return 0.0f;
}
- 如果轨道循环,通过轨道的持续时间调整时间:
if (looping) {
time = fmodf(time - startTime,
endTime - startTime);
if (time < 0.0f) {
time += endTime - startTime;
}
time = time + startTime;
}
- 如果轨道不循环,将时间夹到第一帧或最后一帧。返回调整后的时间:
else {
if (time <= mFrames[0].mTime) {
time = startTime;
}
if (time >= mFrames[size - 1].mTime) {
time = endTime;
}
}
return time;
}
AdjustTimeToFitTrack
函数很有用,因为它保持了动画采样时间在范围内。这个函数旨在在动画播放时间改变时调用。考虑以下例子:
Track<float, 1> t;
float mAnimTime = 0.0f;
void Update(float dt) { // dt: delta time of frame
mAnimTime = t. AdjustTimeToFitTrack (mAnimTime + dt);
}
在示例中每次调用Update
函数时,mAnimTime
变量都会增加frame
的deltaTime
。然而,由于增加的时间在分配之前传递给AdjustTimeToFitTrack
,因此它永远不会有无效的动画时间值。
在接下来的部分中,您将实现Track
类的Cast
函数。Cast
函数用于接受一个浮点数组,并将其转换为Track
类的模板类型。
实现 Cast 函数
Cast
函数是专门的;需要为每种类型的轨迹提供一个实现。Cast
函数接受一个浮点数组,并返回Track
类的模板类型T
。支持的类型有float
、vec3
和quat
:
template<> float Track<float, 1>::Cast(float* value) {
return value[0];
}
template<> vec3 Track<vec3, 3>::Cast(float* value) {
return vec3(value[0], value[1], value[2]);
}
template<> quat Track<quat, 4>::Cast(float* value) {
quat r = quat(value[0], value[1], value[2], value[3]);
return normalized(r);
}
这个Cast
函数很重要,因为它可以将存储在Frame
类中的float
数组转换为Frame
类表示的数据类型。例如,Frame<3>
被转换为vec3
。在接下来的部分中,您将使用Cast
函数来返回采样Track
类时的正确数据类型。
常量轨迹采样
在本节中,您将为Track
类实现三个采样函数中的第一个——FrameIndex
辅助函数。确保帧是有效的,然后将该帧的值转换为正确的数据类型并返回:
template<typename T, int N>
T Track<T, N>::SampleConstant(float t, bool loop) {
int frame = FrameIndex(t, loop);
if (frame < 0 || frame >= (int)mFrames.size()) {
return T();
}
return Cast(&mFrames[frame].mValue[0]);
}
常量采样通常用于诸如可见标志之类的东西,其中一个变量的值从一帧到下一帧的变化没有任何实际的插值是有意义的。在接下来的部分中,您将学习如何实现线性轨迹采样。线性采样非常常见;大多数内容创建应用程序提供了一个“采样”导出选项,可以导出线性插值的轨迹。
线性轨迹采样
第二种采样类型,FrameIndex
函数,你永远不应该处于当前帧是轨迹的最后一帧且下一帧无效的情况。
一旦你知道了当前帧、下一帧以及它们之间的时间差,你就可以进行插值。调用AdjustTimeToFitTrack
确保时间有效,从第一帧的时间中减去它,并将结果除以帧间隔。这将得到插值值t
。
知道插值值后,调用TrackHelpers::Interpolate
函数进行插值:
template<typename T, int N>
T Track<T, N>::SampleLinear(float time, bool looping) {
int thisFrame = FrameIndex(time, looping);
if (thisFrame < 0 || thisFrame >= mFrames.size() - 1) {
return T();
}
int nextFrame = thisFrame + 1;
float trackTime = AdjustTimeToFitTrack(time, looping);
float thisTime = mFrames[thisFrame].mTime;
float frameDelta = mFrames[nextFrame].mTime – thisTime;
if (frameDelta <= 0.0f) {
return T();
}
float t = (trackTime - thisTime) / frameDelta;
T start = Cast(&mFrames[thisFrame].mValue[0]);
T end = Cast(&mFrames[nextFrame].mValue[0]);
return TrackHelpers::Interpolate(start, end, t);
}
线性采样通常用于许多 3D 内容创建应用程序,这些应用程序提供了一个选项,可以通过在固定间隔处对动画曲线进行采样来近似。在接下来的部分中,您将学习如何进行曲线的三次插值。三次插值存储的数据比线性插值少,但计算成本更高。
三次轨迹采样
最后一种采样类型,Hermite
辅助函数进行插值。
如果你把time
想象成轨道上的播放头,它在第一个点的右边和第二个点的左边。因此,你需要第一个点的外斜率(因为播放头正在远离它),以及第二个点的内斜率(因为播放头正在朝向它)。两个斜率都需要乘以帧间隔:
template<typename T, int N>
T Track<T, N>::SampleCubic(float time, bool looping) {
int thisFrame = FrameIndex(time, looping);
if (thisFrame < 0 || thisFrame >= mFrames.size() - 1) {
return T();
}
int nextFrame = thisFrame + 1;
float trackTime = AdjustTimeToFitTrack(time, looping);
float thisTime = mFrames[thisFrame].mTime;
float frameDelta = mFrames[nextFrame].mTime - thisTime;
if (frameDelta <= 0.0f) {
return T();
}
float t = (trackTime - thisTime) / frameDelta;
size_t fltSize = sizeof(float);
T point1 = Cast(&mFrames[thisFrame].mValue[0]);
T slope1;// = mFrames[thisFrame].mOut * frameDelta;
memcpy(&slope1, mFrames[thisFrame].mOut, N * fltSize);
slope1 = slope1 * frameDelta;
T point2 = Cast(&mFrames[nextFrame].mValue[0]);
T slope2;// = mFrames[nextFrame].mIn[0] * frameDelta;
memcpy(&slope2, mFrames[nextFrame].mIn, N * fltSize);
slope2 = slope2 * frameDelta;
return Hermite(t, point1, slope1, point2, slope2);
}
为什么斜率使用memcpy
而不是Cast
函数?这是因为Cast
函数会对四元数进行归一化,这是不好的,因为斜率不应该是四元数。使用memcpy
而不是Cast
直接复制值,避免了归一化。
在下一节中,您将学习如何将矢量和四元数轨迹合并成一个TransformTrack
。实际的动画框架将在TransformTrack
类上工作,这些类将不是模板化的。
创建 TransformTrack 类
对于任何动画变换,您不希望维护单独的向量和四元数轨道;相反,您构建一个更高级的结构——变换轨道。变换轨道封装了三个轨道——一个用于位置,一个用于旋转,一个用于缩放。您可以在任何点对变换轨道进行采样,并获得完整的变换,即使组件轨道的持续时间或开始时间不同。
要考虑的一件事是如何将这些变换轨道与动画模型相关联。模型的骨架包含几个骨骼。您可以存储一个变换轨道的向量——每个骨骼一个——或者您可以将骨骼 ID 添加为变换轨道的成员,并且只存储所需数量的骨骼。
这很重要,因为一个角色可能有很多骨骼,但并非所有动画都会对所有这些骨骼进行动画。如果为每个骨骼存储一个变换轨道,会浪费内存,但对动画进行采样会更快。如果只存储所需数量的变换轨道,采样会变得更昂贵,但内存消耗会减少。
实现选择往往最终成为内存与速度之间的权衡。在现代系统上,任一轴上的增量应该是微不足道的。在本节中,您将为变换轨道添加一个骨骼 ID,并且只存储所需数量的轨道。
声明 TransformTrack 类
TransformTrack
类将需要保存一个表示轨道将影响哪个骨骼(关节)的整数。它还需要实际的位置、旋转和缩放轨道。这四个信息应该足以对关节的位置、旋转和缩放进行动画。
与Track
类一样,TransformTrack
类有获取和设置变换轨道的开始和结束时间的函数。变换轨道的开始和结束时间取决于其组件轨道。组件轨道是位置、旋转和缩放轨道。
在三个轨道中,最低的开始时间被用作变换轨道的开始时间。三个轨道中最大的结束时间被用作变换轨道的结束时间。
变换轨道中的不是所有组件轨道都需要有效。例如,如果只有变换的位置是动画的,那么旋转和缩放组件轨道可以保持无效。只要其组件轨道中至少有一个有效,变换轨道就是有效的。
因为不是所有组件轨道都保证有效,TransformTrack
类的Sample
函数需要获取一个引用变换。采取以下步骤声明TransformTrack
类:
- 创建一个新文件
TransformTrack.h
,并开始通过定义成员变量来添加TransformTrack
的定义:
class TransformTrack {
protected:
unsigned int mId;
VectorTrack mPosition;
QuaternionTrack mRotation;
VectorTrack mScale;
- 公共 API 很简单。您需要默认构造函数来为轨道的关节 ID 分配默认值。您还需要获取 ID、组件轨道、开始/结束时间、持续时间和有效性的函数,以及 ID 需要一个设置函数;组件获取函数返回可变引用:
public:
TransformTrack();
unsigned int GetId();
void SetId(unsigned int id);
VectorTrack& GetPositionTrack();
QuaternionTrack& GetRotationTrack();
VectorTrack& GetScaleTrack();
float GetStartTime();
float GetEndTime();
bool IsValid();
Transform Sample(const Transform& ref, float time, bool looping);
};
在下一节中,您将开始实现TransfromTrack
的函数。
实现 TransformTrack 类
按照以下步骤实现TransformTrack
类:
- 创建一个新文件
TransformTrack.cpp
,以实现TransformTrack
类。TransformTrack
类的构造函数并不重要;为变换轨道表示的关节分配一个默认值。轨道 ID 的获取和设置函数也很简单:
TransformTrack::TransformTrack() {
mId = 0;
}
unsigned int TransformTrack::GetId() {
return mId;
}
void TransformTrack::SetId(unsigned int id) {
mId = id;
}
- 接下来,实现函数来访问存储在变换轨道中的不同组件轨道。这些函数需要返回一个引用,以便您可以改变返回的轨道:
VectorTrack& TransformTrack::GetPositionTrack() {
return mPosition;
}
QuaternionTrack& TransformTrack::GetRotationTrack() {
return mRotation;
}
VectorTrack& TransformTrack::GetScaleTrack() {
return mScale;
}
IsValid
辅助函数只有在存储在TransformTrack
类中的组件轨道中至少有一个有效时才应返回true
。要使轨道有效,需要有两个或更多帧:
bool TransformTrack::IsValid() {
return mPosition.Size() > 1 ||
mRotation.Size() > 1 ||
mScale.Size() > 1;
}
GetStartTime
函数应该返回三个组件轨道中最小的开始时间。如果没有一个组件是有效的(即它们都只有一个或没有帧),那么TransformTrack
就无效。在这种情况下,只需返回0
:
float TransformTrack::GetStartTime() {
float result = 0.0f;
bool isSet = false;
if (mPosition.Size() > 1) {
result = mPosition.GetStartTime();
isSet = true;
}
if (mRotation.Size() > 1) {
float rotationStart = mRotation.GetStartTime();
if (rotationStart < result || !isSet) {
result = rotationStart;
isSet = true;
}
}
if (mScale.Size() > 1) {
float scaleStart = mScale.GetStartTime();
if (scaleStart < result || !isSet) {
result = scaleStart;
isSet = true;
}
}
return result;
}
GetEndTime
函数类似于GetStartTime
函数。唯一的区别是这个函数寻找最大的轨道结束时间:
float TransformTrack::GetEndTime() {
float result = 0.0f;
bool isSet = false;
if (mPosition.Size() > 1) {
result = mPosition.GetEndTime();
isSet = true;
}
if (mRotation.Size() > 1) {
float rotationEnd = mRotation.GetEndTime();
if (rotationEnd > result || !isSet) {
result = rotationEnd;
isSet = true;
}
}
if (mScale.Size() > 1) {
float scaleEnd = mScale.GetEndTime();
if (scaleEnd > result || !isSet) {
result = scaleEnd;
isSet = true;
}
}
return result;
}
Sample
函数只在其组件轨道有两个或更多帧时对其进行采样。由于TransformTrack
类只能对一个组件进行动画,比如位置,因此这个函数需要将一个参考变换作为参数。如果变换轨道没有对其中一个变换组件进行动画,那么将使用参考变换的值:
Transform TransformTrack::Sample(const Transform& ref,
float time, bool loop) {
Transform result = ref; // Assign default values
if (mPosition.Size() > 1) { // Only if valid
result.position = mPosition.Sample(time, loop);
}
if (mRotation.Size() > 1) { // Only if valid
result.rotation = mRotation.Sample(time, loop);
}
if (mScale.Size() > 1) { // Only if valid
result.scale = mScale.Sample(time, loop);
}
return result;
}
因为并非所有动画都包含相同的轨道,重置正在采样的姿势是很重要的。这可以确保参考变换始终是正确的。要重置姿势,将其分配为与休息姿势相同。
总结
在本章中,您了解了动画的基本组件,一个数据帧中包含什么,几个帧如何组成一个轨道,以及几个轨道如何使一个变换发生动画。您探索了不同的插值方法,用于插值动画轨道,并使这些方法适用于标量、向量和四元数轨道。
本章中构建的类将作为下一章中创建动画剪辑的基本组件。在下一章中,您将实现动画剪辑和姿势。动画剪辑将由TransformTrack
对象组成。这些轨道是现代动画系统的核心。
本书的可下载内容的Chapter08
文件夹中有两个示例。Sample00
包含到目前为止在书中使用的所有代码,Sample01
创建了几个轨道并将它们全部绘制在屏幕上。在视觉上绘制轨道是一个好主意,因为它可以帮助及早解决调试问题。
第九章:实现动画片段
动画片段是TransformTrack
对象的集合。动画片段在时间上对一组变换进行动画处理,被动画处理的变换集合称为姿势。将姿势视为动画角色在特定时间点的骨架。姿势是一组变换的层次结构。每个变换的值都会影响其所有子节点。
让我们来看看生成游戏角色动画一帧的姿势需要做些什么。当对动画片段进行采样时,结果是一个姿势。动画片段由动画轨道组成,每个动画轨道由一个或多个帧组成。这种关系看起来像这样:
图 9.1:生成姿势的依赖关系
在本章结束时,您应该能够从 glTF 文件中加载动画片段,并将这些片段采样为姿势。
实现姿势
为了存储变换之间的父子层次关系,需要维护两个并行向量——一个填充有变换,另一个填充有整数。整数数组包含每个关节的父变换的索引。并非所有关节都有父节点;如果一个关节没有父节点,其父节点值为负数。
在考虑骨骼或姿势时,很容易想到一个具有一个根节点和许多分支节点的层次结构。实际上,拥有两个或三个根节点并不罕见。有时,文件格式以骨骼的第一个节点作为根节点,但也有一个所有蒙皮网格都是其子节点的根节点。这些层次结构通常看起来像这样:
图 9.2:一个文件中的多个根节点
动画角色有三种常见的姿势——当前姿势、绑定姿势和静止姿势。静止姿势是所有骨骼的默认配置。动画描述了每个骨骼随时间的变换。在时间上对动画进行采样会得到当前姿势,用于对角色进行蒙皮。绑定姿势将在下一章中介绍。
并非所有动画都会影响角色的每根骨骼或关节;这意味着有些动画可能不会改变关节的值。请记住,在这种情况下,关节表示为Transform
对象。如果动画1
播放了,但动画B没有?以下列表显示了结果:
-
如果只播放A或B,一切都很好。
-
如果先播放B,然后播放A,一切都很好。
-
如果先播放A,然后播放B,情况会有点混乱。
在上一个示例中,播放动画1
会保持其从动画Pose
类中最后修改的变换。
声明 Pose 类
Pose
类需要跟踪要动画的角色骨架中每个关节的变换。它还需要跟踪每个关节的父关节。这些数据保存在两个并行向量中。
在对新的动画片段进行采样之前,需要将当前角色的姿势重置为静止姿势。Pose
类实现了复制构造函数和赋值运算符,以尽可能快地复制姿势。按照以下步骤声明Pose
类:
- 创建一个新的头文件
Pose.h
。在这个文件中添加Pose
类的定义,从关节变换和它们的父节点的并行向量开始:
class Pose {
protected:
std::vector<Transform> mJoints;
std::vector<int> mParents;
- 添加默认构造函数和复制构造函数,并重载赋值运算符。
Pose
类还有一个方便的构造函数,它以关节数作为参数:
public:
Pose();
Pose(const Pose& p);
Pose& operator=(const Pose& p);
Pose(unsigned int numJoints);
- 为姿势的关节数添加获取器和设置器函数。当使用设置器函数时,需要调整
mJoints
和mParents
向量的大小:
void Resize(unsigned int size);
unsigned int Size();
- 为关节的父级添加获取和设置函数。这两个函数都需要以关节的索引作为参数:
int GetParent(unsigned int index);
void SetParent(unsigned int index, int parent);
Pose
类需要提供一种获取和设置关节的本地变换的方法,以及检索关节的全局变换。重载[]运算符
以返回关节的全局变换:
Transform GetLocalTransform(unsigned int index);
void SetLocalTransform(unsigned int index,
const Transform& transform);
Transform GetGlobalTransform(unsigned int index);
Transform operator[](unsigned int index);
- 要将
Pose
类传递给 OpenGL,需要将其转换为矩阵的线性数组。GetMatrixPalette
函数执行此转换。该函数接受矩阵向量的引用,并用姿势中每个关节的全局变换矩阵填充它:
void GetMatrixPalette(std::vector<mat4>& out);
- 通过重载等式和不等式运算符完成
Pose
类的设置:
bool operator==(const Pose& other);
bool operator!=(const Pose& other);
};
Pose
类用于保存动画层次结构中每个骨骼的变换。将其视为动画中的一帧;Pose
类表示给定时间的动画状态。在接下来的部分中,您将实现Pose
类。
实现 Pose 类
创建一个新文件,Pose.cpp
。您将在此文件中实现Pose
类。采取以下步骤来实现Pose
类:
- 默认构造函数不必执行任何操作。复制构造函数调用赋值运算符。方便构造函数调用
Resize
方法:
Pose::Pose() { }
Pose::Pose(unsigned int numJoints) {
Resize(numJoints);
}
Pose::Pose(const Pose& p) {
*this = p;
}
- 赋值运算符需要尽快复制姿势。您需要确保姿势没有分配给自己。接下来,确保姿势具有正确数量的关节和父级。然后,进行内存复制以快速复制所有父级和姿势数据:
Pose& Pose::operator=(const Pose& p) {
if (&p == this) {
return *this;
}
if (mParents.size() != p.mParents.size()) {
mParents.resize(p.mParents.size());
}
if (mJoints.size() != p.mJoints.size()) {
mJoints.resize(p.mJoints.size());
}
if (mParents.size() != 0) {
memcpy(&mParents[0], &p.mParents[0],
sizeof(int) * mParents.size());
}
if (mJoints.size() != 0) {
memcpy(&mJoints[0], &p.mJoints[0],
sizeof(Transform) * mJoints.size());
}
return *this;
}
- 由于父级和关节向量是平行的,
Resize
函数需要设置两者的大小。size
获取函数可以返回任一向量的大小:
void Pose::Resize(unsigned int size) {
mParents.resize(size);
mJoints.resize(size);
}
unsigned int Pose::Size() {
return mJoints.size();
}
- 本地变换的获取和设置方法很简单:
Transform Pose::GetLocalTransform(unsigned int index) {
return mJoints[index];
}
void Pose::SetLocalTransform(unsigned int index, const Transform& transform) {
mJoints[index] = transform;
}
- 从当前变换开始,
GetGlobalTransform
方法需要将所有变换组合到父级链中,直到达到根骨骼。请记住,变换连接是从右到左进行的。重载的[]运算符
应被视为GetGlobalTransform
的别名:
Transform Pose::GetGlobalTransform(unsigned int i) {
Transform result = mJoints[i];
for (int p = mParents[i]; p >= 0; p = mParents[p]) {
result = combine(mJoints[p], result);
}
return result;
}
Transform Pose::operator[](unsigned int index) {
return GetGlobalTransform(index);
}
- 要将
Pose
类转换为矩阵的向量,请循环遍历姿势中的每个变换。对于每个变换,找到全局变换,将其转换为矩阵,并将结果存储在矩阵的向量中。此函数尚未经过优化;您将在以后的章节中对其进行优化:
void Pose::GetMatrixPalette(std::vector<mat4>& out) {
unsigned int size = Size();
if (out.size() != size) {
out.resize(size);
}
for (unsigned int i = 0; i < size; ++i) {
Transform t = GetGlobalTransform(i);
out[i] = transformToMat4(t);
}
}
- 父关节索引的获取和设置方法很简单:
int Pose::GetParent(unsigned int index) {
return mParents[index];
}
void Pose::SetParent(unsigned int index, int parent) {
mParents[index] = parent;
}
- 在比较两个姿势时,您需要确保两个姿势中的所有关节变换和父索引都是相同的:
bool Pose::operator==(const Pose& other) {
if (mJoints.size() != other.mJoints.size()) {
return false;
}
if (mParents.size() != other.mParents.size()) {
return false;
}
unsigned int size = (unsigned int)mJoints.size();
for (unsigned int i = 0; i < size; ++i) {
Transform thisLocal = mJoints[i];
Transform otherLocal = other.mJoints[i];
int thisParent = mParents[i];
int otherParent = other.mParents[i];
if (thisParent != otherParent) { return false; }
if (thisLocal.position != otherLocal.position) {
return false; }
if (thisLocal.rotation != otherLocal.rotation {
return false; }
if (thisLocal.scale != otherLocal.scale {
return false; }
}
return true;
}
bool Pose::operator!=(const Pose& other) {
return !(*this == other);
}
一个动画角色通常会有多个活动姿势并不罕见。考虑一个角色同时奔跑和开枪的情况。很可能会播放两个动画——一个影响下半身的run动画,一个影响上半身的shoot动画。这些姿势混合在一起形成最终姿势,用于显示动画角色。这种动画混合在第十二章中有所涵盖,动画之间的混合。
在接下来的部分中,您将实现动画剪辑。动画剪辑包含姿势中所有动画关节的动画随时间的变化。Clip
类用于对动画进行采样并生成用于显示的姿势。
实现剪辑
动画剪辑是动画轨道的集合;每个轨道描述了一个关节随时间的运动,所有轨道组合描述了动画模型随时间的运动。如果对动画剪辑进行采样,您将得到一个姿势,该姿势描述了动画剪辑中每个关节在指定时间的配置。
对于基本的剪辑类,您只需要一个Clip
类的向量,该类还应该跟踪元数据,例如剪辑的名称,剪辑是否循环,以及有关剪辑的时间或持续时间的信息。
声明 Clip 类
Clip
类需要维护一个变换轨迹的向量。这是剪辑包含的最重要的数据。除了轨迹之外,剪辑还有一个名称、开始时间和结束时间,剪辑应该知道它是否循环。
Clip
类的循环属性可以转移到管道中更深的构造(例如动画组件或类似物)。但是,在实现基本的动画系统时,这是放置循环属性的好地方:
- 创建一个新文件,
Clip.h
,并开始声明Clip
类:
class Clip {
protected:
std::vector<TransformTrack> mTracks;
std::string mName;
float mStartTime;
float mEndTime;
bool mLooping;
- 剪辑的采样方式与轨迹的采样方式相同。提供的采样时间可能超出剪辑的范围。为了处理这个问题,您需要实现一个辅助函数,调整提供的采样时间,使其在当前动画剪辑的范围内:
protected:
float AdjustTimeToFitRange(float inTime);
Clip
类需要一个默认构造函数来为其某些成员分配默认值。在这里,编译器生成的析构函数、复制构造函数和赋值运算符应该是可以的:
public:
Clip();
Clip
类应提供一种获取剪辑包含的关节数量以及特定轨迹索引的关节 ID 的方法。您还需要有一个基于剪辑中关节索引的关节 ID 设置器:
unsigned int GetIdAtIndex(unsigned int index);
void SetIdAtIndex(unsigned int idx, unsigned int id);
unsigned int Size();
- 从剪辑中检索数据可以通过两种方式之一完成。
[]运算符
返回指定关节的变换轨迹。如果指定关节没有轨迹,则会创建一个并返回。Sample
函数接受Pose
引用和时间,并返回一个也是时间的float
值。此函数在提供的时间内对动画剪辑进行采样,并将结果分配给Pose
引用:
float Sample(Pose& outPose, float inTime);
TransformTrack& operator[](unsigned int index);
- 我们需要一个公共辅助函数来确定动画剪辑的开始和结束时间。
RecalculateDuration
函数循环遍历所有TransformTrack
对象,并根据组成剪辑的轨迹设置动画剪辑的开始/结束时间。此函数旨在由从文件格式加载动画剪辑的代码调用。
void RecalculateDuration();
- 最后,
Clip
类需要简单的 getter 和 setter 函数:
std::string& GetName();
void SetName(const std::string& inNewName);
float GetDuration();
float GetStartTime();
float GetEndTime();
bool GetLooping();
void SetLooping(bool inLooping);
};
此处实现的Clip
类可用于对任何内容进行动画化;不要觉得自己受限于人类和类人动画。在接下来的部分,您将实现Clip
类。
实现 Clip 类
创建一个新文件,Clip.cpp
。您将在这个新文件中实现Clip
类。按照以下步骤实现Clip
类:
- 默认构造函数需要为
Clip
类的成员分配一些默认值:
Clip::Clip() {
mName = "No name given";
mStartTime = 0.0f;
mEndTime = 0.0f;
mLooping = true;
}
- 要实现
Sample
函数,请确保剪辑有效,并且时间在剪辑范围内。然后,循环遍历所有轨迹。获取轨迹的关节 ID,对轨迹进行采样,并将采样值分配回Pose
引用。如果变换的某个组件没有动画,将使用引用组件提供默认值。然后函数返回调整后的时间:
float Clip::Sample(Pose& outPose, float time) {
if (GetDuration() == 0.0f) {
return 0.0f;
}
time= AdjustTimeToFitRange(time);
unsigned int size = mTracks.size();
for (unsigned int i = 0; i < size; ++i) {
unsigned int j = mTracks[i].GetId(); // Joint
Transform local = outPose.GetLocalTransform(j);
Transform animated = mTracks[i].Sample(
local, time, mLooping);
outPose.SetLocalTransform(j, animated);
}
return time;
}
AdjustTimeToFitRange
函数应该循环,其逻辑与您为模板化的Track
类实现的AdjustTimeToFitTrack
函数相同:
float Clip::AdjustTimeToFitRange(float inTime) {
if (mLooping) {
float duration = mEndTime - mStartTime;
if (duration <= 0) { 0.0f; }
inTime = fmodf(inTime - mStartTime,
mEndTime - mStartTime);
if (inTime < 0.0f) {
inTime += mEndTime - mStartTime;
}
inTime = inTime + mStartTime;
}
else {
if (inTime < mStartTime) {
inTime = mStartTime;
}
if (inTime > mEndTime) {
inTime = mEndTime;
}
}
return inTime;
}
RecalculateDuration
函数将mStartTime
和mEndTime
设置为0
的默认值。接下来,这些函数循环遍历动画剪辑中的每个TransformTrack
对象。如果轨迹有效,则检索轨迹的开始和结束时间。存储最小的开始时间和最大的结束时间。剪辑的开始时间可能不是0
;可能有一个从任意时间点开始的剪辑:
void Clip::RecalculateDuration() {
mStartTime = 0.0f;
mEndTime = 0.0f;
bool startSet = false;
bool endSet = false;
unsigned int tracksSize = mTracks.size();
for (unsigned int i = 0; i < tracksSize; ++i) {
if (mTracks[i].IsValid()) {
float startTime = mTracks[i].GetStartTime();
float endTime = mTracks[i].GetEndTime();
if (startTime < mStartTime || !startSet) {
mStartTime = startTime;
startSet = true;
}
if (endTime > mEndTime || !endSet) {
mEndTime = endTime;
endSet = true;
}
}
}
}
[] operator
用于检索剪辑中特定关节的TransformTrack
对象。此函数主要由从文件加载动画剪辑的任何代码使用。该函数通过所有轨道进行线性搜索,以查看它们中的任何一个是否针对指定的关节。如果找到符合条件的轨道,则返回对其的引用。如果找不到符合条件的轨道,则创建并返回一个新的:
TransformTrack& Clip::operator[](unsigned int joint) {
for (int i = 0, s = mTracks.size(); i < s; ++i) {
if (mTracks[i].GetId() == joint) {
return mTracks[i];
}
}
mTracks.push_back(TransformTrack());
mTracks[mTracks.size() - 1].SetId(joint);
return mTracks[mTracks.size() - 1];
}
Clip
类的其余 getter 函数都很简单:
std::string& Clip::GetName() {
return mName;
}
unsigned int Clip::GetIdAtIndex(unsigned int index) {
return mTracks[index].GetId();
}
unsigned int Clip::Size() {
return (unsigned int)mTracks.size();
}
float Clip::GetDuration() {
return mEndTime - mStartTime;
}
float Clip::GetStartTime() {
return mStartTime;
}
float Clip::GetEndTime() {
return mEndTime;
}
bool Clip::GetLooping() {
return mLooping;
}
- 同样,
Clip
类的其余 setter 函数都很简单:
void Clip::SetName(const std::string& inNewName) {
mName = inNewName;
}
void Clip::SetIdAtIndex(unsigned int index, unsigned int id) {
return mTracks[index].SetId(id);
}
void Clip::SetLooping(bool inLooping) {
mLooping = inLooping;
}
动画剪辑始终修改相同的关节。没有必要重新设置每帧采样到的姿势,使其成为绑定姿势。但是,当切换动画时,不能保证两个剪辑将对相同的轨道进行动画。最好在切换动画剪辑时重置每帧采样到的姿势,使其成为绑定姿势!
在接下来的部分中,您将学习如何从 glTF 文件中加载角色的静止姿势。静止姿势很重要;这是角色在没有动画时的姿势。
glTF - 加载静止姿势
在本书中,我们将假设一个 glTF 文件只包含一个动画角色。可以安全地假设 glTF 文件的整个层次结构可以视为模型的骨架。这使得加载静止姿势变得容易,因为静止姿势成为其初始配置中的层次结构。
在加载静止姿势之前,您需要创建几个帮助函数。这些函数是 glTF 加载器的内部函数,不应在头文件中公开。在GLTFLoader.cpp
中创建一个新的命名空间,并将其命名为GLTFHelpers
。所有帮助函数都在此命名空间中创建。
按照以下步骤实现加载 glTF 文件中静止姿势所需的帮助函数:
- 首先,实现一个帮助函数来获取
cgltf_node
的本地变换。节点可以将其变换存储为矩阵或单独的位置、旋转和缩放组件。如果节点将其变换存储为矩阵,请使用mat4ToTransform
分解函数;否则,根据需要创建组件:
// Inside the GLTFHelpers namespace
Transform GLTFHelpers::GetLocalTransform(cgltf_node& n){
Transform result;
if (n.has_matrix) {
mat4 mat(&n.matrix[0]);
result = mat4ToTransform(mat);
}
if (n.has_translation) {
result.position = vec3(n.translation[0],
n.translation[1], n.translation[2]);
}
if (n.has_rotation) {
result.rotation = quat(n.rotation[0],
n.rotation[1], n.rotation[2], n.rotation[3]);
}
if (n.has_scale) {
result.scale = vec3(n.scale[0], n.scale[1],
n.scale[2]);
}
return result;
}
- 接下来,实现一个帮助函数,从数组中获取
cgltf_node
的索引。GLTFNodeIndex
函数可以通过循环遍历.gltf
文件中的所有节点来执行简单的线性查找,并返回您正在搜索的节点的索引。如果找不到索引,则返回-1
以表示无效索引:
// Inside the GLTFHelpers namespace
int GLTFHelpers::GetNodeIndex(cgltf_node* target,
cgltf_node* allNodes, unsigned int numNodes) {
if (target == 0) {
return -1;
}
for (unsigned int i = 0; i < numNodes; ++i) {
if (target == &allNodes[i]) {
return (int)i;
}
}
return -1;
}
- 有了这些帮助函数,加载静止姿势需要很少的工作。循环遍历当前 glTF 文件中的所有节点。对于每个节点,将本地变换分配给将返回的姿势。您可以使用
GetNodeIndex
帮助函数找到节点的父节点,如果节点没有父节点,则返回-1
:
Pose LoadRestPose(cgltf_data* data) {
unsigned int boneCount = data->nodes_count;
Pose result(boneCount);
for (unsigned int i = 0; i < boneCount; ++i) {
cgltf_node* node = &(data->nodes[i]);
Transform transform =
GLTFHelpers::GetLocalTransform(data->nodes[i]);
result.SetLocalTransform(i, transform);
int parent = GLTFHelpers::GetNodeIndex(
node->parent, data->nodes,
boneCount);
result.SetParent(i, parent);
}
return result;
}
在接下来的部分中,您将学习如何从 glTF 文件中加载关节名称。这些关节名称按照静止姿势关节的顺序出现。了解关节名称可以帮助调试骨骼的外观。关节名称还可以用于通过其他方式而不是索引来检索关节。本书中构建的动画系统不支持按名称查找关节,只支持索引。
glTF - 加载关节名称
在某个时候,您可能想要知道每个加载的关节分配的名称。这可以帮助更轻松地进行调试或构建工具。要加载与静止姿势中加载关节的顺序相同的每个关节的名称,请循环遍历关节并使用名称访问器。
在GLTFLoader.cpp
中实现LoadJointNames
函数。不要忘记将函数声明添加到GLTFLoader.h
中:
std::vector<std::string> LoadJointNames(cgltf_data* data) {
unsigned int boneCount = (unsigned int)data->nodes_count;
std::vector<std::string> result(boneCount, "Not Set");
for (unsigned int i = 0; i < boneCount; ++i) {
cgltf_node* node = &(data->nodes[i]);
if (node->name == 0) {
result[i] = "EMPTY NODE";
}
else {
result[i] = node->name;
}
}
return result;
}
关节名称对于调试非常有用。它们让您将关节的索引与名称关联起来,这样您就知道数据代表什么。在接下来的部分中,您将学习如何从 glTF 文件中加载动画剪辑。
glTF - 加载动画剪辑
要在运行时生成姿势数据,您需要能够加载动画剪辑。与静止姿势一样,这需要一些辅助函数。
您需要实现的第一个辅助函数GetScalarValues
读取gltf
访问器的浮点值。这可以通过cgltf_accessor_read_float
辅助函数完成。
下一个辅助函数TrackFromChannel
承担了大部分的重活。它将 glTF 动画通道转换为VectorTrack
或QuaternionTrack
。glTF 动画通道的文档位于github.com/KhronosGroup/glTF-Tutorials/blob/master/gltfTutorial/gltfTutorial_007_Animations.md
。
LoadAnimationClips
函数应返回剪辑对象的向量。这并不是最佳的做法;这样做是为了使加载 API 更易于使用。如果性能是一个问题,请考虑将结果向量作为引用传递。
按照以下步骤从 glTF 文件中加载动画:
- 在
GLTFLoader.cpp
文件的GLTFHelpers
命名空间中实现GetScalarValues
辅助函数:
// Inside the GLTFHelpers namespace
void GLTFHelpers::GetScalarValues( vector<float>& out,
unsigned int compCount,
const cgltf_accessor& inAccessor) {
out.resize(inAccessor.count * compCount);
for (cgltf_size i = 0; i < inAccessor.count; ++i) {
cgltf_accessor_read_float(&inAccessor, i,
&out[i * compCount],
compCount);
}
}
- 在
GLTFLoader.cpp
中实现TrackFromChannel
辅助函数。通过设置Track
插值来开始函数的实现。为此,请确保轨迹的Interpolation
类型与采样器的cgltf_interpolation_type
类型匹配:
// Inside the GLTFHelpers namespace
template<typename T, int N>
void GLTFHelpers::TrackFromChannel(Track<T, N>& result,
const cgltf_animation_channel& channel) {
cgltf_animation_sampler& sampler = *channel.sampler;
Interpolation interpolation =
Interpolation::Constant;
if (sampler.interpolation ==
cgltf_interpolation_type_linear) {
interpolation = Interpolation::Linear;
}
else if (sampler.interpolation ==
cgltf_interpolation_type_cubic_spline) {
interpolation = Interpolation::Cubic;
}
bool isSamplerCubic = interpolation ==
Interpolation::Cubic;
result.SetInterpolation(interpolation);
- 采样器输入是动画时间轴的访问器。采样器输出是动画值的访问器。使用
GetScalarValues
将这些访问器转换为浮点数的线性数组。帧的数量等于采样器输入中的元素数量。每帧的组件数量(vec3
或quat
)等于值元素数量除以时间轴元素数量。调整轨迹的大小以存储所有帧:
std::vector<float> time; // times
GetScalarValues(time, 1, *sampler.input);
std::vector<float> val; // values
GetScalarValues(val, N, *sampler.output);
unsigned int numFrames = sampler.input->count;
unsigned int compCount = val.size() / time.size();
result.Resize(numFrames);
- 将
time
和value
数组解析为帧结构,循环遍历采样器中的每一帧。对于每一帧,设置时间,然后读取输入切线、值,然后输出切线。如果采样器是立方的,则输入和输出切线是可用的;如果不是,则应默认为0
。需要使用本地offset
变量来处理立方轨迹,因为输入和输出切线的大小与组件的数量一样大:
for (unsigned int i = 0; i < numFrames; ++i) {
int baseIndex = i * compCount;
Frame<N>& frame = result[i];
int offset = 0;
frame.mTime = time[i];
for (int comp = 0; comp < N; ++comp) {
frame.mIn[comp] = isSamplerCubic ?
val[baseIndex + offset++] : 0.0f;
}
for (int comp = 0; comp < N; ++comp) {
frame.mValue[comp] = val[baseIndex +
offset++];
}
for (int comp = 0; comp < N; ++comp) {
frame.mOut[comp] = isSamplerCubic ?
val[baseIndex + offset++] : 0.0f;
}
}
} // End of TrackFromChannel function
- 在
GLTFLoader.cpp
中实现LoadAnimationClips
函数;不要忘记将该函数的声明添加到GLTFLoader.h
中。循环遍历提供的gltf_data
中的所有剪辑。对于每个剪辑,设置其名称。循环遍历剪辑中的所有通道,并找到当前通道影响的节点的索引:
std::vector<Clip> LoadAnimationClips(cgltf_data* data) {
unsigned int numClips = data->animations_count;
unsigned int numNodes = data->nodes_count;
std::vector<Clip> result;
result.resize(numClips);
for (unsigned int i = 0; i < numClips; ++i) {
result[i].SetName(data->animations[i].name);
unsigned int numChannels =
data->animations[i].channels_count;
for (unsigned int j = 0; j < numChannels; ++j){
cgltf_animation_channel& channel =
data->animations[i].channels[j];
cgltf_node* target = channel.target_node;
int nodeId = GLTFHelpers::GetNodeIndex(
target, data->nodes, numNodes);
- glTF 文件的每个通道都是一个动画轨迹。一些节点可能只会动画它们的位置,而其他节点可能会动画位置、旋转和缩放。检查解析的通道类型,并调用
TrackFromChannel
辅助函数将其转换为动画轨迹。Track
类的[]操作符
可以检索当前轨迹或创建一个新的轨迹。这意味着正在解析的节点的TransformTrack
函数始终有效:
if (channel.target_path ==
cgltf_animation_path_type_translation){
VectorTrack& track =
result[i][nodeId].GetPositionTrack();
GLTFHelpers::TrackFromChannel<vec3, 3>
(track, channel);
}
else if (channel.target_path ==
cgltf_animation_path_type_scale) {
VectorTrack& track =
result[i][nodeId].GetScaleTrack();
GLTFHelpers::TrackFromChannel<vec3, 3>
(track, channel);
}
else if (channel.target_path ==
cgltf_animation_path_type_rotation) {
QuaternionTrack& track =
result[i][nodeId].GetRotationTrack();
GLTFHelpers::TrackFromChannel<quat, 4>
(track, channel);
}
} // End num channels loop
- 在剪辑中的所有轨迹都被填充后,调用剪辑的
ReclaculateDuration
函数。这确保了播放发生在适当的时间范围内:
result[i].RecalculateDuration();
} // End num clips loop
return result;
} // End of LoadAnimationClips function
能够加载动画剪辑并将其采样为姿势是动画编程中约一半的工作。您可以加载动画剪辑,在应用程序更新时对其进行采样,并使用调试线来绘制姿势。结果是一个动画骨架。在下一章中,您将学习如何使用这个动画骨架来变形网格。
总结
在本章中,您实现了Pose
和Clip
类。您学会了如何从 glTF 文件中加载静止姿势,以及如何加载动画剪辑。您还学会了如何对动画剪辑进行采样以生成姿势。
本书的可下载内容可以在 GitHub 上找到:github.com/PacktPublishing/Game-Animation-Programming
。第九章的示例Chapter09/Sample01
加载了一个 glTF 文件,并使用DebugDraw
函数来绘制静止姿势和当前动画姿势。要使用调试线绘制骨骼,请从关节的位置绘制一条线到其父级的位置。
请记住,并非所有剪辑都会使每个姿势的关节发生动画。每当您正在采样的动画剪辑发生变化时,它被采样到的姿势都需要被重置。重置姿势很容易——将其赋值为静止姿势的值。这在本章的代码示例中有所展示。
在下一章中,您将学习如何对动画网格进行蒙皮。一旦您知道如何对网格进行蒙皮,您就能够显示一个动画模型。
第十章:网格皮肤
将网格变形以匹配动画姿势称为皮肤。为了实现皮肤,首先需要声明一个网格类。一旦声明了网格类,就可以使用着色器(GPU 皮肤)或仅使用 C++代码(CPU 皮肤)对其进行变形。本章涵盖了这两种皮肤方法。在本章结束时,您应该能够做到以下事情:
-
理解有皮肤的网格与无皮肤的网格有何不同
-
理解整个皮肤管道
-
实现骨架类
-
从 glTF 文件加载骨架的绑定姿势
-
实现一个有皮肤的网格类
-
从 glTF 文件加载有皮肤的网格
-
实现 CPU 皮肤
-
实现 GPU 皮肤
探索网格
一个网格由多个顶点组成。通常,每个顶点至少有一个位置、一个法线,也许还有一个纹理坐标。这是一个简单静态网格的顶点定义。这个定义有以下顶点组件:
-
位置(
vec3
) -
法线(
vec3
) -
纹理坐标(
vec2
)
重要信息:
本章中用于演示皮肤的模型是来自 GDQuest 的 Godot 模特。这是一个 MIT 许可的模型,您可以在 GitHub 上找到它a t https://github.com/GDQuest/godot-3d-mannequin。
当一个网格被建模时,它是在特定的姿势中建模的。对于角色来说,这通常是T形或A形。建模的网格是静态的。下图显示了 Godot 模特的T形姿势:
图 10.1:Godot 模特的 T 形姿势
当一个网格被建模时,骨架被创建在网格中。网格中的每个顶点都分配给骨架的一个或多个骨骼。这个过程称为装配。骨架是在适合网格内的姿势中创建的;这是模型的绑定姿势。
图 10.2:可视化网格和骨架的绑定姿势
绑定姿势和静止姿势通常是相同的,但并非总是如此。在本书中,我们将把这两者视为不同的姿势。前面的图显示了骨架的绑定姿势渲染在角色网格的顶部。在下一节中,您将探索如何对这样的网格进行皮肤处理。
理解皮肤
皮肤是指定哪个顶点应该由哪个骨骼变形的过程。一个顶点可以受到多个骨骼的影响。刚性皮肤是指将每个顶点与一个骨骼关联。平滑皮肤将顶点与多个骨骼关联。
通常,顶点到骨骼的映射是按顶点进行的。这意味着每个顶点都知道它属于哪些骨骼。一些文件格式以相反的方式存储这种关系,其中每个骨骼包含它影响的顶点列表。这两种方法都是有效的;在本书的其余部分,映射是按顶点进行的。
为了(刚性)皮肤一个网格,将每个顶点分配给一个骨骼。要在代码中为顶点分配关节,需要为每个顶点添加一个新属性。这个属性只是一个保存着变形顶点的骨骼索引的整数。在下图中,所有应该分配给左下臂骨骼的三角形都比网格的其余部分颜色更深:
图 10.3:隔离下臂
让我们花点时间更详细地审查一下顶点变换管道。在这里,引入了空间的概念。空间指的是通过矩阵对顶点进行变换。例如,如果有一个投影矩阵,它会将一个顶点变换为 NDC 空间。顶点变换管道如下:
-
当一个网格被创建时,它的所有顶点都处于所谓的模型空间中。
-
模型空间顶点乘以模型矩阵,将其放入世界空间中。
-
世界空间顶点乘以视图矩阵,将其放入相机空间。
-
相机空间顶点乘以投影矩阵,将其移动到 NDC 空间。
要对网格进行蒙皮,需要在顶点变换流程中添加一个新的蒙皮步骤。蒙皮步骤将顶点从皮肤空间移动到模型空间。这意味着新步骤在变换流程中位于任何其他步骤之前。
如果将皮肤空间顶点乘以当前动画姿势,则可以将其移回模型空间。这个转换在本章的实现 CPU 蒙皮部分中有详细介绍。一旦顶点回到模型空间,它应该已经被动画化。动画姿势矩阵转换实际上进行了动画。动画化顶点转换流程如下:
-
加载一个网格,所有顶点都在模型空间中。
-
模型空间顶点乘以皮肤矩阵,将其移动到皮肤空间。
-
皮肤空间顶点乘以姿势矩阵,将其移回模型空间。
-
模型空间顶点乘以模型矩阵,将其放入世界空间。
-
世界空间顶点乘以视图矩阵,将其放入相机空间。
-
相机空间顶点乘以投影矩阵,将其移动到 NDC 空间。
要对网格进行蒙皮,需要将每个顶点转换为皮肤空间。当皮肤空间中的顶点通过其所属关节的世界变换进行变换时,假设使用的姿势是绑定姿势,顶点应该最终位于模型空间中。
在接下来的部分中,您将通过实际示例探索蒙皮流程。
探索刚性蒙皮
要对网格进行蒙皮,需要将每个顶点乘以其所属关节的逆绑定姿势变换。要找到关节的逆绑定姿势变换,需要找到关节的世界变换,然后对其求逆。当矩阵(或变换)乘以其逆时,结果总是单位矩阵。
将皮肤空间网格的顶点乘以绑定姿势中关节的世界空间变换可以撤消原始的逆绑定姿势乘法,逆绑定姿势 * 绑定姿势 = 单位矩阵
。然而,乘以不同的姿势会导致顶点相对于绑定姿势的偏移。
让我们看看顶点如何在皮肤空间中移动。例如,将 Godot 模特前臂中的所有顶点乘以前臂骨骼的逆绑定姿势,只将前臂三角形放入皮肤空间。这使得网格看起来如下图所示:
图 10.4:逆绑定姿势转换的下臂网格
要将顶点从皮肤空间转换回模型空间,需要依次应用姿势中每个骨骼的变换,直到达到目标骨骼。下图演示了从根骨骼到前臂骨骼需要进行的六个步骤:
图 10.5:可视化到下臂的变换链
在代码中,可以使用矩阵乘法累积需要进行的所有变换。或者,如果使用Transform
结构,可以使用 combine 方法。将顶点移回模型空间只需使用累积的矩阵或变换一次。
通过将每个顶点乘以其所属关节的逆绑定姿势来将网格转换为皮肤空间。如何获得骨骼的逆绑定姿势矩阵?使用绑定姿势,找到骨骼的世界变换,将其转换为矩阵,然后求逆矩阵。
下图显示了 Godot 模型在皮肤空间中的情况。看到这样的网格表明了蒙皮管道中的错误。出现这种网格的最常见原因是逆绑定姿势和动画姿势的乘法顺序出现错误:
图 10.6:整个网格乘以逆绑定姿势
到目前为止讨论的蒙皮实现称为刚性蒙皮。使用刚性蒙皮时,每个顶点只受一个骨骼的影响。在接下来的部分中,您将开始探索平滑蒙皮,通过将多个骨骼的影响分配给单个顶点来使蒙皮网格看起来更好。
刚性蒙皮管道
让我们探索每个顶点必须经历的管道。下图显示了静态网格与刚性蒙皮网格的变换管道。以下图中的步骤顺序从左到右,沿着箭头进行:
图 10.7:顶点蒙皮管道
在前面的图中显示的刚性蒙皮顶点管道的工作方式如下:
-
通过将顶点乘以其所分配的关节的逆绑定姿势矩阵将其移动到皮肤空间中。
-
将蒙皮顶点乘以动画关节的世界矩阵。这将导致顶点再次处于本地空间,但它会被变形到动画姿势。
-
一旦顶点处于动画本地位置,就将其通过正常的模型视图投影变换。
-
探索平滑蒙皮
刚性蒙皮的问题在于弯曲关节。由于每个顶点属于一个骨骼,因此在肘部等关节处的顶点不会自然弯曲。在肘部等关节处的网格断裂可以通过将三角形的不同顶点分配给不同的骨骼来避免。由此产生的网格无法很好地保持其体积,并且看起来很尴尬。
刚性蒙皮并不是免费的;它为每个顶点引入了额外的矩阵乘法。这可以优化为只有一个额外的乘法,这将在下一章中介绍。在接下来的部分中,您将探索平滑蒙皮。
探索平滑蒙皮
刚性蒙皮的主要问题是它可能在网格中产生视觉断裂,如下图所示。即使这些伪影得到了解决,平滑蒙皮时可弯曲关节周围的变形看起来也不好:
图 10.8:刚性蒙皮的可见伪影
平滑蒙皮比刚性蒙皮具有更少的伪影,并且能更好地保持其体积。平滑蒙皮的理念是一个顶点可以受到多个骨骼的影响。每个影响还有一个权重。权重用于将蒙皮顶点混合成一个组合的最终顶点。所有权重必须加起来等于 1。
将顶点视为在网格上进行多次蒙皮并混合结果。一个骨骼可以有多少影响在这里有很大的影响。一般来说,超过四根骨骼后,每根额外的骨骼的影响就不可见了。这很方便,因为它可以让您使用ivec4
和vec4
结构向顶点添加影响和权重。
下图显示了一个网格,其中中间顶点附在左侧的顶部骨骼和右侧的底部骨骼上。这是需要混合的两个蒙皮位置。如果每个姿势的权重为0.5
,最终插值顶点位置将在两个顶点之间。这在下图的中间图中显示:
图 10.9:将多个关节分配给一个顶点
在顶点上平均关节影响被称为平滑蒙皮,或线性混合蒙皮(LBS)。它有一些缺陷,但这是皮肤角色的标准方式。目前,LBS 是实现蒙皮动画最流行的方式。
在添加对平滑蒙皮的支持后,最终的顶点结构现在如下所示:
-
位置(
vec3
) -
法线(
vec3
) -
纹理坐标(
vec2
) -
关节影响(
ivec4
) -
影响权重(
vec4
)
重要信息
glTF 支持将蒙皮网格附加到任意节点,并且这些节点可以进行动画。这增加了计算蒙皮矩阵的额外步骤。为了避免这一额外步骤,我们将忽略网格中心点,并假设所有网格节点的全局变换都在原点。只要假定单个 glTF 文件只包含一个蒙皮网格,这就是一个安全的假设。
平滑蒙皮目前是游戏动画中使用的标准形式。大多数游戏每个顶点使用四个骨骼,并且与本章中将要实现的方式类似。在接下来的部分,你将实现一个Skeleton
类来帮助跟踪皮肤网格所需的一些不同数据。
实现骨骼
在对模型进行动画时,有几件事情需要跟踪,比如动画姿势或逆绑定姿势。骨骼的概念是将在动画模型之间共享的数据组合成一个单一的结构。
角色的绑定姿势和逆绑定姿势在所有角色实例之间共享。也就是说,如果屏幕上有 15 个角色,它们每个都有一个独特的动画姿势,但它们都共享相同的静止姿势、绑定姿势、逆绑定姿势和关节名称。
在接下来的部分,你将实现一个新的类——Skeleton
类。这个Skeleton
类包含两个动画网格可能需要的所有共享数据。它还跟踪静止姿势、绑定姿势、逆绑定姿势和关节名称。一些引擎将骨骼称为骨架或绑定。
骨骼类声明
Skeleton
类包含角色的静止姿势和绑定姿势,角色的每个关节的名称,以及最重要的逆绑定姿势。由于逆绑定姿势涉及矩阵求逆,因此应该只计算一次。按照以下步骤声明新的Skeleton
类:
- 创建一个新文件
Skeleton.h
。在这个文件中声明Skeleton
类。在Skeleton
类中添加当前动画模型的静止姿势、绑定姿势、逆绑定姿势和关节名称。逆绑定姿势应该实现为一个矩阵的向量:
class Skeleton {
protected:
Pose mRestPose;
Pose mBindPose;
std::vector<mat4> mInvBindPose;
std::vector<std::string> mJointNames;
- 添加一个辅助函数
UpdateInverseBindPose
。这个函数在设置绑定姿势时更新逆绑定姿势矩阵:
protected:
void UpdateInverseBindPose();
- 声明一个默认构造函数和一个便利构造函数。还要声明方法来设置骨骼的静止姿势、绑定姿势和关节名称,以及辅助函数来检索骨骼的所有变量的引用:
public:
Skeleton();
Skeleton(const Pose& rest, const Pose& bind,
const std::vector<std::string>& names);
void Set(const Pose& rest, const Pose& bind,
const std::vector<std::string>& names);
Pose& GetBindPose();
Pose& GetRestPose();
std::vector<mat4>& GetInvBindPose();
std::vector<std::string>& GetJointNames();
std::string& GetJointName(unsigned int index);
}; // End Skeleton class
将Skeleton
类视为一个辅助类——它将绑定姿势、逆绑定姿势、静止姿势和关节名称放入一个易于管理的对象中。骨骼是共享的;你可以有许多角色,每个角色都有一个独特的动画姿势,但它们都可以共享相同的骨骼。在接下来的部分,你将实现Skeleton
类。
骨骼类的实现
逆绑定姿势存储在骨骼中作为矩阵数组。每当骨骼的绑定姿势更新时,逆绑定姿势也应该重新计算。要找到逆绑定姿势,找到骨骼中每个关节的世界空间矩阵,然后求逆世界空间关节矩阵。创建一个新文件Skeleton.cpp
。然后,实现骨骼构造函数。采取以下步骤来实现:
- 创建两个构造函数——默认构造函数不执行任何操作。另一个便利构造函数接受一个静止姿势、一个绑定姿势和关节名称。它调用
Set
方法:
Skeleton::Skeleton() { }
Skeleton::Skeleton(const Pose& rest, const Pose& bind,
const std::vector<std::string>& names) {
Set(rest, bind, names);
}
- 创建
Set
方法,应该设置骨骼的内部姿势、绑定姿势和关节名称。一旦绑定姿势设置好,调用UpdateInverseBindPose
函数来填充逆绑定姿势矩阵调色板:
void Skeleton::Set(const Pose& rest, const Pose& bind,
const std::vector<std::string>& names) {
mRestPose = rest;
mBindPose = bind;
mJointNames = names;
UpdateInverseBindPose();
}
- 接下来实现
UpdateInverseBindPose
函数。确保矩阵向量的大小正确,然后循环遍历绑定姿势中的所有关节。获取每个关节的世界空间变换,将其转换为矩阵,并对矩阵进行反转。这个反转的矩阵就是关节的逆绑定姿势矩阵:
void Skeleton::UpdateInverseBindPose() {
unsigned int size = mBindPose.Size();
mInvBindPose.resize(size);
for (unsigned int i = 0; i < size; ++i) {
Transform world = mBindPose.GetGlobalTransform(i);
mInvBindPose[i] = inverse(transformToMat4(world));
}
}
- 在
Skeleton
类中实现简单的 getter 和 setter 函数:
Pose& Skeleton::GetBindPose() {
return mBindPose;
}
Pose& Skeleton::GetRestPose() {
return mRestPose;
}
std::vector<mat4>& Skeleton::GetInvBindPose() {
return mInvBindPose;
}
std::vector<std::string>& Skeleton::GetJointNames() {
return mJointNames;
}
std::string& Skeleton::GetJointName(unsigned int idx) {
return mJointNames[idx];
}
通过提供显式的 getter 函数来避免返回引用是可能的,比如Transform GetBindPoseTransform(unsigned int index)
。在你学习如何优化动画数据的下一章之后再这样做更有意义。现在,能够访问这些引用并且不修改它们更有价值。
生成逆绑定姿势矩阵时,你不必将变换转换为矩阵然后再反转它;你可以反转变换然后将其转换为矩阵。两者之间的性能差异是微不足道的。
Skeleton
类跟踪动画模型的绑定姿势、逆绑定姿势和关节名称。这些数据可以在模型的所有动画实例之间共享。在下一节中,你将实现从 glTF 文件加载绑定姿势。glTF 格式不存储实际的绑定姿势。
glTF - 加载绑定姿势
现在你已经准备好从 glTF 文件中加载绑定姿势了,但是有一个问题。glTF 文件不存储绑定姿势。相反,对于 glTF 文件包含的每个蒙皮,它存储一个矩阵数组,其中包含影响蒙皮的每个关节的逆绑定姿势矩阵。
像这样存储逆绑定姿势矩阵对于优化是有好处的,这在下一章中会更有意义,但现在我们必须处理这个问题。那么,如何获取绑定姿势呢?
获取绑定姿势,加载休息姿势并将休息姿势中的每个变换转换为世界空间变换。这样可以确保如果皮肤没有为关节提供逆绑定姿势矩阵,就可以使用一个良好的默认值。
接下来,循环遍历.gltf
文件中的每个蒙皮网格。对于每个蒙皮网格,反转每个关节的逆绑定姿势矩阵。反转逆绑定姿势矩阵会得到绑定姿势矩阵。将绑定姿势矩阵转换为可以在绑定姿势中使用的变换。
这样做是有效的,但是所有关节变换都是在世界空间中。你需要将每个关节转换为相对于其父级的位置。按照以下步骤实现GLTFLoader.cpp
中的LoadBindPose
函数:
- 通过构建一个变换向量来开始实现
LoadBindPose
函数。用休息姿势中每个关节的全局变换填充变换向量:
Pose LoadBindPose(cgltf_data* data) {
Pose restPose = LoadRestPose(data);
unsigned int numBones = restPose.Size();
std::vector<Transform> worldBindPose(numBones);
for (unsigned int i = 0; i < numBones; ++i) {
worldBindPose[i] = restPose.GetGlobalTransform(i);
}
- 循环遍历 glTF 文件中的每个蒙皮网格。将
inverse_bind_matrices
访问器读入一个大的浮点值向量中。该向量需要包含contain numJoints * 16
个元素,因为每个矩阵都是一个 4x4 矩阵:
unsigned int numSkins = data->skins_count;
for (unsigned int i = 0; i < numSkins; ++i) {
cgltf_skin* skin = &(data->skins[i]);
std::vector<float> invBindAccessor;
GLTFHelpers::GetScalarValues(invBindAccessor,
16, *skin->inverse_bind_matrices);
- 对于蒙皮中的每个关节,获取逆绑定矩阵。反转逆绑定姿势矩阵以获得绑定姿势矩阵。将绑定姿势矩阵转换为变换。将这个世界空间变换存储在
worldBindPose
向量中:
unsigned int numJoints = skin->joints_count;
for (int j = 0; j < numJoints; ++j) {
// Read the ivnerse bind matrix of the joint
float* matrix = &(invBindAccessor[j * 16]);
mat4 invBindMatrix = mat4(matrix);
// invert, convert to transform
mat4 bindMatrix = inverse(invBindMatrix);
Transform bindTransform =
mat4ToTransform(bindMatrix);
// Set that transform in the worldBindPose.
cgltf_node* jointNode = skin->joints[j];
int jointIndex = GLTFHelpers::GetNodeIndex(
jointNode, data->nodes, numBones);
worldBindPose[jointIndex] = bindTransform;
} // end for each joint
} // end for each skin
- 将每个关节转换为相对于其父级的位置。将一个关节移动到另一个关节的空间中,即使它相对于另一个关节,将关节的世界变换与其父级的逆世界变换相结合:
//Convert the world bind pose to a regular bind pose
Pose bindPose = restPose;
for (unsigned int i = 0; i < numBones; ++i) {
Transform current = worldBindPose[i];
int p = bindPose.GetParent(i);
if (p >= 0) { // Bring into parent space
Transform parent = worldBindPose[p];
current = combine(inverse(parent), current);
}
bindPose.SetLocalTransform(i, current);
}
return bindPose;
} // End LoadBindPose function
重建绑定姿势并不理想,但这是 glTF 的一个怪癖,你必须处理它。通过使用休息姿势作为默认关节值,任何没有逆绑定姿势矩阵的关节仍然具有有效的默认方向和大小。
在本节中,您学习了如何从 glTF 文件中加载动画网格的初始姿势。在下一节中,您将创建一个方便的函数,通过一个函数调用从 glTF 文件中加载骨骼。
glTF——加载骨骼
我们需要实现另一个加载函数——LoadSkeleton
函数。这是一个方便的函数,可以在不调用三个单独函数的情况下加载骨架。
在GLTFLoader.cpp
中实现LoadSkeleton
函数。不要忘记将函数声明添加到GLTFLoader.h
中。该函数通过调用现有的LoadPose
、LoadBindPose
和LoadJointNames
函数返回一个新的骨骼:
Skeleton LoadSkeleton(cgltf_data* data) {
return Skeleton(
LoadRestPose(data),
LoadBindPose(data),
LoadJointNames(data)
);
}
LoadSkeleton
函数只是一个辅助函数,允许您通过一个函数调用初始化骨骼。在下一节中,您将实现一个Mesh
类,它将允许您显示动画网格。
实现网格
网格的定义取决于实现它的游戏(或引擎)。在本书的范围之外实现一个全面的网格类。相反,在本节中,您将声明一个简单版本的网格,它在 CPU 和 GPU 上存储一些数据,并提供一种将两者同步的方法。
Mesh 类声明
网格的最基本实现是什么?每个顶点都有一个位置、一个法线和一些纹理坐标。为了对网格进行蒙皮,每个顶点还有四个可能影响它的骨骼和权重来确定每个骨骼对顶点的影响程度。网格通常使用索引数组,但这是可选的。
在本节中,您将同时实现 CPU 和 GPU 蒙皮。要在 CPU 上对网格进行蒙皮,您需要保留姿势和法线数据的额外副本,以及一个用于蒙皮的矩阵调色板。
创建一个新文件Mesh.h
,声明Mesh
类。按照以下步骤声明新的Mesh
类:
- 开始声明
Mesh
类。它应该在 CPU 和 GPU 上都维护网格数据的副本。存储位置、法线、纹理坐标、权重和影响力的向量来定义每个顶点。包括一个可选的索引向量:
class Mesh {
protected:
std::vector<vec3> mPosition;
std::vector<vec3> mNormal;
std::vector<vec2> mTexCoord;
std::vector<vec4> mWeights;
std::vector<ivec4> mInfluences;
std::vector<unsigned int> mIndices;
- 前面代码中列出的每个向量也需要设置适当的属性。为每个创建
Attribute
指针,以及一个索引缓冲区指针:
protected:
Attribute<vec3>* mPosAttrib;
Attribute<vec3>* mNormAttrib;
Attribute<vec2>* mUvAttrib;
Attribute<vec4>* mWeightAttrib;
Attribute<ivec4>* mInfluenceAttrib;
IndexBuffer* mIndexBuffer;
- 添加一个额外的姿势和法线数据的副本,以及一个用于 CPU 蒙皮的矩阵调色板:
protected:
std::vector<vec3> mSkinnedPosition;
std::vector<vec3> mSkinnedNormal;
std::vector<mat4> mPosePalette;
- 为构造函数、拷贝构造函数和赋值运算符以及析构函数添加声明:
public:
Mesh();
Mesh(const Mesh&);
Mesh& operator=(const Mesh&);
~Mesh();
- 为网格包含的所有属性声明 getter 函数。这些函数返回向量引用。向量引用不是只读的;在加载网格时使用这些引用来填充网格数据:
std::vector<vec3>& GetPosition();
std::vector<vec3>& GetNormal();
std::vector<vec2>& GetTexCoord();
std::vector<vec4>& GetWeights();
std::vector<ivec4>& GetInfluences();
std::vector<unsigned int>& GetIndices();
- 声明
CPUSkin
函数,应用 CPU 网格蒙皮。要对网格进行蒙皮,您需要骨架和动画姿势。声明UpdateOpenGLBuffers
函数,将持有数据的向量同步到 GPU:
void CPUSkin(Skeleton& skeleton, Pose& pose);
void UpdateOpenGLBuffers();
void Bind(int position, int normal, int texCoord,
int weight, int influence);
- 声明绑定、绘制和解绑网格的函数:
void Draw();
void DrawInstanced(unsigned int numInstances);
void UnBind(int position, int normal, int texCoord,
int weight, int influence);
};
这个Mesh
类还不是生产就绪的,但它很容易使用,并且将在本书的其余部分中使用。在下一节中,您将开始实现Mesh
类。
Mesh 类实现
Mesh
类包含相同数据的两个副本。它在 CPU 端使用向量保留所有顶点数据,并在 GPU 端使用顶点缓冲对象。这个类的预期用途是编辑 CPU 端的顶点,然后使用UpdateOpenGLBuffers
函数将更改同步到 GPU。
创建一个新文件Mesh.cpp
;您将在此文件中实现Mesh
类。按照以下步骤实现Mesh
类:
- 实现默认构造函数,需要确保所有属性(和索引缓冲区)都被分配:
Mesh::Mesh() {
mPosAttrib = new Attribute<vec3>();
mNormAttrib = new Attribute<vec3>();
mUvAttrib = new Attribute<vec2>();
mWeightAttrib = new Attribute<vec4>();
mInfluenceAttrib = new Attribute<ivec4>();
mIndexBuffer = new IndexBuffer();
}
- 实现拷贝构造函数。以与构造函数相同的方式创建缓冲区,然后调用赋值运算符:
Mesh::Mesh(const Mesh& other) {
mPosAttrib = new Attribute<vec3>();
mNormAttrib = new Attribute<vec3>();
mUvAttrib = new Attribute<vec2>();
mWeightAttrib = new Attribute<vec4>();
mInfluenceAttrib = new Attribute<ivec4>();
mIndexBuffer = new IndexBuffer();
*this = other;
}
- 实现赋值运算符,它将复制 CPU 端的成员(所有向量),然后调用
UpdateOpenGLBuffers
函数将属性数据上传到 GPU:
Mesh& Mesh::operator=(const Mesh& other) {
if (this == &other) {
return *this;
}
mPosition = other.mPosition;
mNormal = other.mNormal;
mTexCoord = other.mTexCoord;
mWeights = other.mWeights;
mInfluences = other.mInfluences;
mIndices = other.mIndices;
UpdateOpenGLBuffers();
return *this;
}
- 实现析构函数,确保删除构造函数分配的所有数据:
Mesh::~Mesh() {
delete mPosAttrib;
delete mNormAttrib;
delete mUvAttrib;
delete mWeightAttrib;
delete mInfluenceAttrib;
delete mIndexBuffer;
}
- 实现
Mesh
获取函数。这些函数返回向量的引用。预期在返回后对这些引用进行编辑:
std::vector<vec3>& Mesh::GetPosition() {
return mPosition;
}
std::vector<vec3>& Mesh::GetNormal() {
return mNormal;
}
std::vector<vec2>& Mesh::GetTexCoord() {
return mTexCoord;
}
std::vector<vec4>& Mesh::GetWeights() {
return mWeights;
}
std::vector<ivec4>& Mesh::GetInfluences() {
return mInfluences;
}
std::vector<unsigned int>& Mesh::GetIndices() {
return mIndices;
}
- 通过在每个属性对象上调用
Set
函数来实现UpdateOpenGLBuffers
函数。如果 CPU 端的向量之一的大小为0
,则没有需要设置的内容:
void Mesh::UpdateOpenGLBuffers() {
if (mPosition.size() > 0) {
mPosAttrib->Set(mPosition);
}
if (mNormal.size() > 0) {
mNormAttrib->Set(mNormal);
}
if (mTexCoord.size() > 0) {
mUvAttrib->Set(mTexCoord);
}
if (mWeights.size() > 0) {
mWeightAttrib->Set(mWeights);
}
if (mInfluences.size() > 0) {
mInfluenceAttrib->Set(mInfluences);
}
if (mIndices.size() > 0) {
mIndexBuffer->Set(mIndices);
}
}
- 实现
Bind
函数。这需要绑定槽索引的整数。如果绑定槽有效(即为0
或更大),则调用属性的BindTo
函数:
void Mesh::Bind(int position, int normal, int texCoord,
int weight, int influcence) {
if (position >= 0) {
mPosAttrib->BindTo(position);
}
if (normal >= 0) {
mNormAttrib->BindTo(normal);
}
if (texCoord >= 0) {
mUvAttrib->BindTo(texCoord);
}
if (weight >= 0) {
mWeightAttrib->BindTo(weight);
}
if (influcence >= 0) {
mInfluenceAttrib->BindTo(influcence);
}
}
- 实现
Draw
和DrawInstanced
函数,这些函数调用适当的全局::Draw
和::DrawInstanced
函数:
void Mesh::Draw() {
if (mIndices.size() > 0) {
::Draw(*mIndexBuffer, DrawMode::Triangles);
}
else {
::Draw(mPosition.size(), DrawMode::Triangles);
}
}
void Mesh::DrawInstanced(unsigned int numInstances) {
if (mIndices.size() > 0) {
::DrawInstanced(*mIndexBuffer,
DrawMode::Triangles, numInstances);
}
else {
::DrawInstanced(mPosition.size(),
DrawMode::Triangles, numInstances);
}
}
- 实现
UnBind
函数,该函数还接受整数绑定槽作为参数,但在属性对象上调用UnBindFrom
:
void Mesh::UnBind(int position, int normal, int texCoord,
int weight, int influence) {
if (position >= 0) {
mPosAttrib->UnBindFrom(position);
}
if (normal >= 0) {
mNormAttrib->UnBindFrom(normal);
}
if (texCoord >= 0) {
mUvAttrib->UnBindFrom(texCoord);
}
if (weight >= 0) {
mWeightAttrib->UnBindFrom(weight);
}
if (influcence >= 0) {
mInfluenceAttrib->UnBindFrom(influence);
}
}
Mesh
类包含用于保存 CPU 数据的向量和用于将数据复制到 GPU 的属性。它提供了一个简单的接口来渲染整个网格。在接下来的部分中,您将学习如何实现 CPU 蒙皮以对网格进行动画处理。
实现 CPU 蒙皮
通过首先在 CPU 上实现蒙皮,而无需担心着色器,可以更容易地理解蒙皮。在本节中,您将创建一个 CPU 蒙皮参考实现。GPU 蒙皮将在本章后面介绍。
重要信息:
如果您正在开发的平台具有有限数量的统一寄存器或小的统一缓冲区,则 CPU 蒙皮非常有用。
在实现 CPU 蒙皮时,您需要保留动画网格的两个副本。mPosition
和 mNormal
向量不会改变。蒙皮后的位置和法线的结果存储在 mSkinnedPosition
和 mSkinnedNormal
中。然后将这些向量同步到位置和法线属性以进行绘制。
要对顶点进行蒙皮,您需要计算蒙皮变换。蒙皮变换需要通过逆绑定姿势对顶点进行变换,然后再通过当前的动画姿势进行变换。您可以通过在绑定姿势变换上调用逆函数,然后将其与姿势变换组合来实现这一点。
对于每个顶点,存储在mInfluences
向量中的ivec4
包含影响顶点的关节 ID。您需要通过所有四个关节对顶点进行变换,这意味着您需要对影响顶点的每个骨骼进行四次蒙皮。
并非每个关节对最终顶点的贡献都相同。对于每个顶点,存储在mWeights
中的vec4
包含一个从0
到1
的标量值。这些值用于混合蒙皮顶点。如果一个关节不影响顶点,则其权重为0
,对最终蒙皮网格没有影响。
权重的内容预期被归一化,以便如果所有权重相加,它们等于1
。这样,权重可以用于混合,因为它们总和为1
。例如,(0.5
, 0.5
, 0
, 0
) 是有效的,但 (0.6
, 0.5
, 0
, 0
) 不是。
按照以下步骤实现 CPU 蒙皮:
- 开始实现
CPUSkin
函数。确保蒙皮向量有足够的存储空间,并从骨骼获取绑定姿势。接下来,循环遍历每个顶点:
void Mesh::CPUSkin(Skeleton& skeleton, Pose& pose) {
unsigned int numVerts = mPosition.size();
if (numVerts == 0) { return; }
mSkinnedPosition.resize(numVerts);
mSkinnedNormal.resize(numVerts);
Pose& bindPose = skeleton.GetBindPose();
for (unsigned int i = 0; i < numVerts; ++i) {
ivec4& joint = mInfluences[i];
vec4& weight = mWeights[i];
- 计算蒙皮变换。对第一个顶点和法线影响进行变换:
Transform skin0 = combine(pose[joint.x],
inverse(bindPose[joint.x]));
vec3 p0 = transformPoint(skin0, mPosition[i]);
vec3 n0 = transformVector(skin0, mNormal[i]);
- 对可能影响当前顶点的其他三个关节重复此过程:
Transform skin1 = combine(pose[joint.y],
inverse(bindPose[joint.y]));
vec3 p1 = transformPoint(skin1, mPosition[i]);
vec3 n1 = transformVector(skin1, mNormal[i]);
Transform skin2 = combine(pose[joint.z],
inverse(bindPose[joint.z]));
vec3 p2 = transformPoint(skin2, mPosition[i]);
vec3 n2 = transformVector(skin2, mNormal[i]);
Transform skin3 = combine(pose[joint.w],
inverse(bindPose[joint.w]));
vec3 p3 = transformPoint(skin3, mPosition[i]);
vec3 n3 = transformVector(skin3, mNormal[i]);
-
到这一步,您已经对顶点进行了四次蒙皮——分别对每个影响它的骨骼进行一次。接下来,您需要将这些合并成最终的顶点。
-
使用
mWeights
混合蒙皮位置和法线。将位置和法线属性设置为新更新的蒙皮位置和法线:
mSkinnedPosition[i] = p0 * weight.x +
p1 * weight.y +
p2 * weight.z +
p3 * weight.w;
mSkinnedNormal[i] = n0 * weight.x +
n1 * weight.y +
n2 * weight.z +
n3 * weight.w;
}
mPosAttrib->Set(mSkinnedPosition);
mNormAttrib->Set(mSkinnedNormal);
}
让我们解释一下这里发生了什么。这是基本的蒙皮算法。每个顶点都有一个名为权重的vec4
值和一个名为影响的ivec4
值。每个顶点有四个影响它的关节和四个权重。如果关节对顶点没有影响,权重可能是0
。
ivec4
的x
、y
、z
和w
分量影响动画姿势和逆绑定姿势矩阵数组中的索引。vec4
的x
、y
、z
和w
分量是要应用于ivec4
影响的相同分量的标量权重。
循环遍历所有顶点。对于每个顶点,通过影响该顶点的每个关节的蒙皮变换,变换顶点的位置和法线。蒙皮变换是逆绑定姿势和姿势变换的组合。这意味着你最终会对顶点进行四次蒙皮。按关节的权重缩放每个变换后的位置或法线,并将所有四个值相加。得到的总和就是蒙皮后的位置或法线。
这就是蒙皮算法;无论如何表达,它都是相同的。有几种表示关节变换的方式,比如使用Transform
对象、矩阵和双四元数。无论表示是什么,算法都是一样的。在接下来的部分,你将学习如何使用矩阵而不是Transform
对象来实现蒙皮算法。
使用矩阵进行蒙皮
对顶点进行蒙皮的常见方法是将矩阵线性混合成单个蒙皮矩阵,然后通过这个蒙皮矩阵变换顶点。为此,使用存储在骨骼中的逆绑定姿势,并从姿势中获取矩阵调色板。
要构建一个蒙皮矩阵,将姿势矩阵乘以逆绑定姿势。记住,顶点应该先被逆绑定姿势变换,然后是动画姿势。通过从右到左的乘法,这将把逆绑定姿势放在右侧。
对影响当前顶点的每个关节的矩阵进行相乘,然后按顶点的权重对结果矩阵进行缩放。一旦所有矩阵都被缩放,将它们相加。得到的矩阵就是可以用来变换顶点位置和法线的蒙皮矩阵。
以下代码重新实现了使用矩阵调色板蒙皮的CPUSkin
函数。这段代码与你需要实现的在 GPU 上运行蒙皮的着色器代码非常相似:
void Mesh::CPUSkin(Skeleton& skeleton, Pose& pose) {
unsigned int numVerts = (unsigned int)mPosition.size();
if (numVerts == 0) { return; }
mSkinnedPosition.resize(numVerts);
mSkinnedNormal.resize(numVerts);
pose.GetMatrixPalette(mPosePalette);
vector<mat4> invPosePalette = skeleton.GetInvBindPose();
for (unsigned int i = 0; i < numVerts; ++i) {
ivec4& j = mInfluences[i];
vec4& w = mWeights[i];
mat4 m0=(mPosePalette[j.x]*invPosePalette[j.x])*w.x;
mat4 m1=(mPosePalette[j.y]*invPosePalette[j.y])*w.y;
mat4 m2=(mPosePalette[j.z]*invPosePalette[j.z])*w.z;
mat4 m3=(mPosePalette[j.w]*invPosePalette[j.w])*w.w;
mat4 skin = m0 + m1 + m2 + m3;
mSkinnedPosition[i]=transformPoint(skin,mPosition[i]);
mSkinnedNormal[i] = transformVector(skin, mNormal[i]);
}
mPosAttrib->Set(mSkinnedPosition);
mNormAttrib->Set(mSkinnedNormal);
}
使用矩阵进行蒙皮的代码看起来有点不同,但蒙皮算法仍然是相同的。不再是对每个顶点进行四次变换并缩放结果,而是对矩阵进行缩放并相加。结果是一个单一的蒙皮矩阵。
即使顶点只被变换一次,也引入了四次新的矩阵乘法。所需操作的数量大致相同,那么为什么要实现矩阵调色板蒙皮?当你实现 GPU 蒙皮时,使用 GLSL 的内置矩阵就很容易了。
在这一部分,你实现了一个Mesh
类。Mesh 类使用以下顶点格式:
-
位置(
vec3
) -
普通(
vec3
) -
纹理坐标(
vec2
) -
影响(
ivec4
) -
权重(
vec4
)
有了这个定义,你可以渲染一个蒙皮网格。在接下来的部分,你将学习如何从 glTF 文件中加载网格。
glTF - 加载网格
现在你有了一个功能性的Mesh
类,理论上,你可以在 CPU 上对网格进行蒙皮。然而,有一个问题——你实际上还不能从 glTF 文件中加载网格。让我们接下来解决这个问题。
首先创建一个新的辅助函数MeshFromAttributes
。这只是一个辅助函数,所以不需要将其暴露给头文件。glTF 将网格存储为一组基元,每个基元都是一组属性。这些属性包含与我们的属性类相同的信息,如位置、法线、权重等。
MeshFromAttribute
辅助函数接受一个网格和一个cgltf_attribute
函数,以及解析所需的一些附加数据。该属性包含我们网格组件之一,例如位置、法线、UV 坐标、权重或影响。此属性提供适当的网格数据。
所有值都以浮点数形式读取,但影响顶点的关节影响以整数形式存储。不要直接将浮点数转换为整数;由于精度问题,转换可能会返回错误的数字。相反,通过加上 0.5 然后进行转换,将浮点数转换为整数。这样,整数截断总是将其带到正确的数字。
gLTF 将影响关节的索引存储为相对于正在解析的皮肤的关节数组,而不是节点层次结构。而“关节”数组又是指向节点的指针。您可以使用此节点指针,并使用GetNodeIndex
函数将其转换为节点层次结构中的索引。
按照以下步骤从 glTF 文件中实现网格加载:
- 在
GLTFHelpers
命名空间中实现MeshFromAttribute
函数。通过确定当前组件具有多少属性来开始实现:
// In the GLTFHelpers namespace
void GLTFHelpers::MeshFromAttribute(Mesh& outMesh,
cgltf_attribute& attribute,
cgltf_skin* skin, cgltf_node* nodes,
unsigned int nodeCount) {
cgltf_attribute_type attribType = attribute.type;
cgltf_accessor& accessor = *attribute.data;
unsigned int componentCount = 0;
if (accessor.type == cgltf_type_vec2) {
componentCount = 2;
}
else if (accessor.type == cgltf_type_vec3) {
componentCount = 3;
}
else if (accessor.type == cgltf_type_vec4) {
componentCount = 4;
}
- 使用
GetScalarValues
辅助函数从提供的访问器中解析数据。创建对网格的位置、法线、纹理坐标、影响和权重向量的引用;MeshFromAttribute
函数将写入这些引用:
std::vector<float> values;
GetScalarValues(values, componentCount, accessor);
unsigned int acessorCount = accessor.count;
std::vector<vec3>& positions = outMesh.GetPosition();
std::vector<vec3>& normals = outMesh.GetNormal();
std::vector<vec2>& texCoords = outMesh.GetTexCoord();
std::vector<ivec4>& influences =
outMesh.GetInfluences();
std::vector<vec4>& weights = outMesh.GetWeights();
- 循环遍历当前访问器中的所有值,并根据访问器类型将它们分配到适当的向量中。通过从值向量中读取数据并直接将其分配到网格中的适当向量中,可以找到位置、纹理坐标和权重分量:
for (unsigned int i = 0; i < acessorCount; ++i) {
int index = i * componentCount;
switch (attribType) {
case cgltf_attribute_type_position:
positions.push_back(vec3(values[index + 0],
values[index + 1],
values[index + 2]));
break;
case cgltf_attribute_type_texcoord:
texCoords.push_back(vec2(values[index + 0],
values[index + 1]));
break;
case cgltf_attribute_type_weights:
weights.push_back(vec4(values[index + 0],
values[index + 1],
values[index + 2],
values[index + 3]));
break;
- 在读取法线后,检查其平方长度。如果法线无效,则返回有效向量并考虑记录错误。如果法线有效,则在将其推入法线向量之前对其进行归一化:
case cgltf_attribute_type_normal:
{
vec3 normal = vec3(values[index + 0],
values[index + 1],
values[index + 2]);
if (lenSq(normal) < 0.000001f) {
normal = vec3(0, 1, 0);
}
normals.push_back(normalized(normal));
}
break;
- 读取影响当前顶点的关节。这些关节存储为浮点数。将它们转换为整数:
case cgltf_attribute_type_joints:
{
// These indices are skin relative. This
// function has no information about the
// skin that is being parsed. Add +0.5f to
// round, since we can't read integers
ivec4 joints(
(int)(values[index + 0] + 0.5f),
(int)(values[index + 1] + 0.5f),
(int)(values[index + 2] + 0.5f),
(int)(values[index + 3] + 0.5f)
);
- 使用
GetNodeIndex
辅助函数将关节索引转换,使其从相对于“关节”数组变为相对于骨骼层次结构:
joints.x = GetNodeIndex(
skin->joints[joints.x],
nodes, nodeCount);
joints.y = GetNodeIndex(
skin->joints[joints.y],
nodes, nodeCount);
joints.z = GetNodeIndex(
skin->joints[joints.z],
nodes, nodeCount);
joints.w = GetNodeIndex(
skin->joints[joints.w],
nodes, nodeCount);
- 确保即使无效节点也具有
0
的值。任何负关节索引都会破坏蒙皮实现:
joints.x = std::max(0, joints.x);
joints.y = std::max(0, joints.y);
joints.z = std::max(0, joints.z);
joints.w = std::max(0, joints.w);
influences.push_back(joints);
}
break;
}
}
}// End of MeshFromAttribute function
gLTF 中的网格由原始组成。原始包含诸如位置和法线之类的属性。自从迄今为止创建的框架中没有子网格的概念,因此 glTF 中的每个原始都表示为网格。
现在MeshFromAttribute
函数已完成,接下来实现LoadMeshes
函数。这是用于加载实际网格数据的函数;它需要在GLTFLoader.h
中声明,并在GLTFLoader.cpp
中实现。按照以下步骤实现LoadMeshes
函数:
- 要实现
LoadMeshes
函数,首先循环遍历 glTF 文件中的所有节点。只处理具有网格和皮肤的节点;应跳过任何其他节点:
std::vector<Mesh> LoadMeshes(cgltf_data* data) {
std::vector<Mesh> result;
cgltf_node* nodes = data->nodes;
unsigned int nodeCount = data->nodes_count;
for (unsigned int i = 0; i < nodeCount; ++i) {
cgltf_node* node = &nodes[i];
if (node->mesh == 0 || node->skin == 0) {
continue;
}
- 循环遍历 glTF 文件中的所有原始。为每个原始创建一个新网格。通过调用
MeshFromAttribute
辅助函数循环遍历原始中的所有属性,并通过调用MeshFromAttribute
辅助函数填充网格数据:
int numPrims = node->mesh->primitives_count;
for (int j = 0; j < numPrims; ++j) {
result.push_back(Mesh());
Mesh& mesh = result[result.size() - 1];
cgltf_primitive* primitive =
&node->mesh->primitives[j];
unsigned int ac=primitive->attributes_count;
for (unsigned int k = 0; k < ac; ++k) {
cgltf_attribute* attribute =
&primitive->attributes[k];
GLTFHelpers::MeshFromAttribute(mesh,
*attribute, node->skin,
nodes, nodeCount);
}
- 检查原始是否包含索引。如果是,网格的索引缓冲区也需要填充:
if (primitive->indices != 0) {
int ic = primitive->indices->count;
std::vector<unsigned int>& indices =
mesh.GetIndices();
indices.resize(ic);
for (unsigned int k = 0; k < ic; ++k) {
indices[k]=cgltf_accessor_read_index(
primitive->indices, k);
}
}
- 网格已完成。调用
UpdateOpenGLBuffers
函数以确保网格可以呈现,并返回结果网格的向量:
mesh.UpdateOpenGLBuffers();
}
}
return result;
} // End of the LoadMeshes function
由于 glTF 存储整个场景,而不仅仅是一个网格,它支持多个网格——每个网格由原语组成,原语是实际的三角形。在 glTF 中,原语可以被视为子网格。这里介绍的 glTF 加载器假设一个文件只包含一个模型。在下一节中,您将学习如何使用着色器将网格蒙皮从 CPU 移动到 GPU。
实现 GPU 蒙皮
您在第六章中创建了一些基本的着色器,构建抽象渲染器和 OpenGL——static.vert
着色器和lit.frag
着色器。static.vert
着色器可用于显示静态的、未经蒙皮的网格,该网格是使用LoadMeshes
函数加载的。static.vert
着色器甚至可以显示 CPU 蒙皮网格。
创建一个新文件,skinned.vert
。按照以下步骤实现一个可以执行矩阵调色板蒙皮的顶点着色器。代码与用于static.vert
的代码非常相似;不同之处已经突出显示:
- 每个顶点都会得到两个新的分量——影响顶点的关节索引和每个关节的权重。这些新的分量可以存储在
ivec4
和vec4
中:
#version 330 core
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
in vec3 position;
in vec3 normal;
in vec2 texCoord;
in vec4 weights;
in ivec4 joints;
- 接下来,在着色器中添加两个矩阵数组——每个数组的长度为
120
。这个长度是任意的;着色器只需要与蒙皮网格的关节数量一样多的新统一矩阵。您可以通过在代码中每次加载具有新骨骼数量的骨架时生成新的着色器字符串来自动配置这一点:
uniform mat4 pose[120];
uniform mat4 invBindPose[120];
out vec3 norm;
out vec3 fragPos;
out vec2 uv;
- 当着色器的主函数运行时,计算一个蒙皮矩阵。蒙皮矩阵的生成方式与 CPU 蒙皮示例的蒙皮矩阵相同。它使用相同的逻辑,只是在 GPU 上执行的着色器中:
void main() {
mat4 skin =(pose[joints.x]* invBindPose[joints.x])
* weights.x;
skin+=(pose[joints.y] * invBindPose[joints.y])
* weights.y;
skin+=(pose[joints.z] * invBindPose[joints.z])
* weights.z;
skin+=(pose[joints.w] * invBindPose[joints.w])
* weights.w;
- 网格在放置在世界之前应该发生变形。在应用模型矩阵之前,将顶点位置和法线乘以蒙皮矩阵。所有相关的代码都在这里突出显示:
gl_Position= projection * view * model *
skin * vec4(position,1.0);
fragPos = vec3(model * skin * vec4(position, 1.0));
norm = vec3(model * skin * vec4(normal, 0.0f));
uv = texCoord;
}
要将蒙皮支持添加到顶点着色器中,您需要为每个顶点添加两个新属性,表示最多四个可以影响顶点的关节。通过使用关节和权重属性,构建一个蒙皮矩阵。要对网格进行蒙皮,需要在应用顶点变换管线的其余部分之前,将顶点或法线乘以蒙皮矩阵。
摘要
在本章中,您学习了绑定姿势和静止姿势之间的区别。您还创建了一个包含它们两者的Skeleton
类。您了解了蒙皮的一般概念——刚性(每个顶点一个骨骼)和平滑(每个顶点多个骨骼)蒙皮。
在本章中,我们实现了一个基本的原始网格类,并介绍了在 CPU 和 GPU 上对网格进行蒙皮的过程,以及从不存储绑定姿势数据的 glTF 文件中加载绑定姿势。
您现在可以应用所学的技能。完成蒙皮代码后,您可以显示完全动画的模型。这些模型可以从 glTF 文件中加载,这是一种开放的文件格式规范。
本书的可下载示例中,Chapter10/Sample01
包含一个示例,绘制了静止姿势、绑定姿势和当前动画姿势。Chapter10/Sample02
演示了如何同时使用 GPU 和 CPU 蒙皮。
在下一章中,您将学习如何优化动画流水线的各个方面。这包括姿势生成和蒙皮以及缓存变换父级查找步骤。
第十一章:优化动画管线
到目前为止,您已经编写了一个完整的动画系统,可以加载标准文件格式 gLT,并在 CPU 或 GPU 上执行皮肤。动画系统对于大多数简单的动画表现得足够好。
在本章中,您将探讨优化动画系统的方法,使其更快且资源消耗更少。这涉及探索执行皮肤的替代方法,提高采样动画片段的速度,并重新审视如何生成矩阵调色板。
每个主题都是单独探讨的,您可以选择实现尽可能少或尽可能多的这些优化。所有这些都很简单,可以轻松地用来替换不太优化的管线版本。
本章将涵盖以下主题:
-
预生成皮肤矩阵
-
将皮肤调色板存储在纹理中
-
更快的采样
-
姿势调色板生成
-
探索
Pose::GetGlobalTransform
预生成皮肤矩阵
mat4
对象的一个较大问题是占用了四个统一槽位,而经过处理的顶点着色器目前有两个具有 120 个元素的矩阵数组。总共是 960 个统一槽位,这是过多的。
顶点着色器中的这两个矩阵数组会发生什么?它们会相互相乘,如下所示:
mat4 skin=(pose[joints.x]*invBindPose[joints.x])*weights.x;
skin += (pose[joints.y]*invBindPose[joints.y])*weights.y;
skin += (pose[joints.z]*invBindPose[joints.z])*weights.z;
skin += (pose[joints.w]*invBindPose[joints.w])*weights.w;
这里的一个简单优化是将pose * invBindPose
相乘,以便着色器只需要一个数组。这确实意味着一些皮肤过程被移回到了 CPU,但这个改变清理了 480 个统一槽位。
生成皮肤矩阵
生成皮肤矩阵不需要 API 调用-它很简单。使用Pose
类的GetMatrixPalette
函数从当前动画姿势生成矩阵调色板。然后,将调色板中的每个矩阵与相同索引的逆绑定姿势矩阵相乘。
显示网格的代码负责计算这些矩阵。例如,一个简单的更新循环可能如下所示:
void Sample::Update(float deltaTime) {
mPlaybackTime = mAnimClip.Sample(mAnimatedPose,
mPlaybackTime + deltaTime);
mAnimatedPose.GetMatrixPalette(mPosePalette);
vector<mat4>& invBindPose = mSkeleton.GetInvBindPose();
for (int i = 0; i < mPosePalette.size(); ++i) {
mPosePalette[i] = mPosePalette[i] * invBindPose[i];
}
if (mDoCPUSkinning) {
mMesh.CPUSkin(mPosePalette);
}
}
在前面的代码示例中,动画片段被采样到一个姿势中。姿势被转换为矩阵向量。该向量中的每个矩阵然后与相同索引的逆绑定姿势矩阵相乘。结果的矩阵向量就是组合的皮肤矩阵。
如果网格是 CPU 皮肤,这是调用CPUSkin
函数的好地方。这个函数需要重新实现以适应组合的皮肤矩阵。如果网格是 GPU 皮肤,需要编辑着色器以便只使用一个矩阵数组,并且需要更新渲染代码以便只传递一个统一数组。
在接下来的部分,您将探讨如何重新实现CPUSkin
函数,使其与组合的皮肤矩阵一起工作。这将稍微加快 CPU 皮肤过程。
CPU 皮肤
您需要一种新的皮肤方法,该方法尊重预乘的皮肤矩阵。此函数接受一个矩阵向量的引用。每个位置都由影响它的四个骨骼的组合皮肤矩阵进行变换。然后,这四个结果被缩放并相加。
将以下 CPU 皮肤函数添加到Mesh.cpp
。不要忘记将函数声明添加到Mesh.h
中:
- 通过确保网格有效来开始实现
CPUSkin
函数。有效的网格至少有一个顶点。确保mSkinnedPosition
和mSkinnedNormal
向量足够大,可以容纳所有顶点:
void Mesh::CPUSkin(std::vector<mat4>& animatedPose) {
unsigned int numVerts = mPosition.size();
if (numVerts == 0) {
return;
}
mSkinnedPosition.resize(numVerts);
mSkinnedNormal.resize(numVerts);
- 接下来,循环遍历网格中的每个顶点:
for (unsigned int i = 0; i < numVerts; ++i) {
ivec4& j = mInfluences[i];
vec4& w = mWeights[i];
- 将每个顶点按动画姿势变换四次,即每个影响顶点的关节变换一次。要找到经过处理的顶点,请将每个变换后的顶点按适当的权重进行缩放并将结果相加:
vec3 p0 = transformPoint(animatedPose[j.x],
mPosition[i]);
vec3 p1 = transformPoint(animatedPose[j.y],
mPosition[i]);
vec3 p2 = transformPoint(animatedPose[j.z],
mPosition[i]);
vec3 p3 = transformPoint(animatedPose[j.w],
mPosition[i]);
mSkinnedPosition[i] = p0 * w.x + p1 * w.y +
p2 * w.z + p3 * w.w;
- 以相同的方式找到顶点的经过处理的法线:
vec3 n0 = transformVector(animatedPose[j.x],
mNormal[i]);
vec3 n1 = transformVector(animatedPose[j.y],
mNormal[i]);
vec3 n2 = transformVector(animatedPose[j.z],
mNormal[i]);
vec3 n3 = transformVector(animatedPose[j.w],
mNormal[i]);
mSkinnedNormal[i] = n0 * w.x + n1 * w.y +
n2 * w.z + n3 * w.w;
}
- 通过将经过处理的顶点位置和经过处理的顶点法线上传到位置和法线属性来完成函数:
mPosAttrib->Set(mSkinnedPosition);
mNormAttrib->Set(mSkinnedNormal);
}
核心的皮肤算法保持不变;唯一改变的是如何生成变换后的位置。现在,这个函数可以直接使用已经组合好的矩阵,而不必再组合动画姿势和逆绑定姿势。
在下一节中,您将探索如何将这个皮肤函数移入顶点着色器。动画和逆绑定姿势的组合仍然在 CPU 上完成,但实际顶点的皮肤可以在顶点着色器中实现。
GPU 皮肤
在顶点着色器中实现预乘皮肤矩阵皮肤很简单。用新的预乘皮肤姿势替换姿势和逆绑定姿势的输入统一变量。使用这个新的统一数组生成皮肤矩阵。就是这样——其余的皮肤流程保持不变。
创建一个新文件preskinned.vert
,来实现新的预皮肤顶点着色器。将skinned.vert
的内容复制到这个新文件中。按照以下步骤修改新的着色器:
- 旧的皮肤顶点着色器具有姿势和逆绑定姿势的统一变量。这两个统一变量都是矩阵数组。删除这些统一变量:
uniform mat4 pose[120];
uniform mat4 invBindPose[120];
- 用新的
animated
统一替换它们。这是一个矩阵数组,数组中的每个元素都包含animated
姿势和逆绑定姿势矩阵相乘的结果。
uniform mat4 animated[120];
- 接下来,找到生成皮肤矩阵的位置。生成皮肤矩阵的代码如下:
mat4 skin = (pose[joints.x] * invBindPose[joints.x]) *
weights.x;
skin += (pose[joints.y] * invBindPose[joints.y]) *
weights.y;
skin += (pose[joints.z] * invBindPose[joints.z]) *
weights.z;
skin += (pose[joints.w] * invBindPose[joints.w]) *
weights.w;
- 用新的
animated
统一替换这个。对于影响顶点的每个关节,按适当的权重缩放animated
统一矩阵并求和结果:
mat4 skin = animated[joints.x] * weights.x +
animated[joints.y] * weights.y +
animated[joints.z] * weights.z +
animated[joints.w] * weights.w;
着色器的其余部分保持不变。您需要更新的唯一内容是着色器接受的统一变量以及如何生成skin
矩阵。在渲染时,animated
矩阵可以设置如下:
// mPosePalette Generated in the Update method!
int animated = mSkinnedShader->GetUniform("animated")
Uniform<mat4>::Set(animated, mPosePalette);
您可能已经注意到 CPU 皮肤实现和 GPU 皮肤实现是不同的。CPU 实现将顶点转换四次,然后缩放和求和结果。GPU 实现缩放和求和矩阵,只转换顶点一次。这两种实现都是有效的,它们都产生相同的结果。
在接下来的部分中,您将探索如何避免使用统一矩阵数组进行皮肤。
在纹理中存储皮肤调色板
预生成的皮肤矩阵可以减少所需的统一槽数量,但可以将所需的统一槽数量减少到一个。这可以通过在纹理中编码预生成的皮肤矩阵并在顶点着色器中读取该纹理来实现。
到目前为止,在本书中,您只处理了RGB24
和RGBA32
纹理。在这些格式中,每个像素的三个或四个分量使用每个分量 8 位编码。这只能容纳 256 个唯一值。这些纹理无法提供存储浮点数所需的精度。
这里还有另一种可能有用的纹理格式——FLOAT32
纹理。使用这种纹理格式,向量的每个分量都得到一个完整的 32 位浮点数支持,给您完整的精度。这种纹理可以通过一个特殊的采样器函数进行采样,该函数不对数据进行归一化。FLOAT32
纹理可以被视为 CPU 可以写入而 GPU 可以读取的缓冲区。
这种方法的好处是所需的统一槽数量变成了一个——所需的统一槽是FLOAT32
纹理的采样器。缺点是速度。对每个顶点进行纹理采样比快速统一数组查找更昂贵。请记住,每次采样查找都需要返回几个 32 位浮点数。这是大量的数据要传输。
我们不会在这里涵盖存储皮肤矩阵的纹理的实现,因为在第十五章“使用实例渲染大规模人群”中有一个专门讨论这个主题的大节,其中包括完整的代码实现。
更快的采样
当前的动画剪辑采样代码表现良好,只要每个动画持续时间不超过 1 秒。但是,对于多个长达一分钟的动画剪辑,比如过场动画,动画系统的性能开始受到影响。为什么随着动画时间的增长性能会变差呢?罪魁祸首是Track::FrameIndex
函数中的以下代码:
for (int i = (int)size - 1; i >= 0; --i) {
if (time >= mFrames[i].mTime) {
return i;
}
}
所呈现的循环遍历了轨道中的每一帧。如果动画有很多帧,性能就会变差。请记住,这段代码是针对动画剪辑中每个动画骨骼的每个动画组件执行的。
这个函数目前进行的是线性搜索,但可以通过更有效的搜索进行优化。由于时间只会增加,执行二分搜索是一个自然的优化。然而,二分搜索并不是最好的优化方法。可以将这个循环转换为常量查找。
采样动画的播放成本是统一的,不受长度的影响。它们在已知的采样间隔时间内计时每一帧,并且找到正确的帧索引只是将提供的时间归一化并将其移动到采样间隔范围内。不幸的是,这样的动画采样占用了大量内存。
如果你仍然按照给定的间隔对动画轨道进行采样,但是每个间隔不再包含完整的姿势,而是指向其左右的关键帧呢?采用这种方法,额外的内存开销是最小的,找到正确的帧是恒定的。
优化 Track 类
有两种方法可以优化Track
类。你可以创建一个具有大部分Track
类功能并维护已知采样时间的查找表的新类,或者扩展Track
类。本节采用后一种方法——我们将扩展Track
类。
FastTrack
子类包含一个无符号整数向量。Track
类以统一的时间间隔进行采样。对于每个时间间隔,播放头左侧的帧(即时间之前的帧)被记录到这个向量中。
所有新代码都添加到现有的Track.h
和Track.cpp
文件中。按照以下步骤实现FastTrack
类:
- 找到
Track
类的FrameIndex
成员函数,并将其标记为virtual
。这个改变允许新的子类重新实现FrameIndex
函数。更新后的声明应该是这样的:
template<typename T, int N>
class Track {
// ...
virtual int FrameIndex(float time, bool looping);
// ...
- 创建一个新类
FastTrack
,它继承自Track
。FastTrack
类包含一个无符号整数向量,重载的FrameIndex
函数和一个用于填充无符号整数向量的函数:
template<typename T, int N>
class FastTrack : public Track<T, N> {
protected:
std::vector<unsigned int> mSampledFrames;
virtual int FrameIndex(float time, bool looping);
public:
void UpdateIndexLookupTable();
};
- 为了使
FastTrack
类更易于使用,使用 typedef 为标量、向量和四元数类型创建别名:
typedef FastTrack<float, 1> FastScalarTrack;
typedef FastTrack<vec3, 3> FastVectorTrack;
typedef FastTrack<quat, 4> FastQuaternionTrack;
- 在
.cpp
文件中,为标量、向量和四元数的快速轨道添加模板声明:
template FastTrack<float, 1>;
template FastTrack<vec3, 3>;
template FastTrack<quat, 4>;
由于FastTrack
类是Track
的子类,现有的 API 都可以不变地工作。通过以这种方式实现轨道采样,当涉及的动画帧数更多时,性能提升更大。在下一节中,你将学习如何构建索引查找表。
实现 UpdateIndexLookupTable
UpdateIndexLookupTable
函数负责填充mSampledFrames
向量。这个函数需要以固定的时间间隔对动画进行采样,并记录每个间隔的动画时间之前的帧。
FastTrack
类应包含多少个样本?这个问题非常依赖于上下文,因为不同的游戏有不同的要求。对于本书的上下文来说,每秒 60 个样本应该足够了:
- 通过确保轨道有效来开始实现
UpdateIndexLookupTable
函数。有效的轨道至少有两帧:
template<typename T, int N>
void FastTrack<T, N>::UpdateIndexLookupTable() {
int numFrames = (int)this->mFrames.size();
if (numFrames <= 1) {
return;
}
- 接下来,找到所需的样本数。由于每秒动画类有
60
个样本,将持续时间乘以60
:
float duration = this->GetEndTime() -
this->GetStartTime();
unsigned int numSamples = duration * 60.0f;
mSampledFrames.resize(numSamples);
- 对于每个样本,找到沿着轨道的样本时间。要找到时间,将标准化迭代器乘以动画持续时间,并将动画的起始时间加上去:
for (unsigned int i = 0; i < numSamples; ++i) {
float t = (float)i / (float)(numSamples - 1);
float time = t*duration+this->GetStartTime();
- 最后,是时候为每个给定的时间找到帧索引了。找到在此迭代中采样时间之前的帧,并将其记录在
mSampledFrames
向量中。如果采样帧是最后一帧,则返回最后一个索引之前的索引。请记住,FrameIndex
函数永远不应返回最后一帧:
unsigned int frameIndex = 0;
for (int j = numFrames - 1; j >= 0; --j) {
if (time >= this->mFrames[j].mTime) {
frameIndex = (unsigned int)j;
if ((int)frameIndex >= numFrames - 2) {
frameIndex = numFrames - 2;
}
break;
}
}
mSampledFrames[i] = frameIndex;
}
}
UpdateIndexLookupTable
函数旨在在加载时调用。通过记住内部j
循环的上次使用的索引,可以优化它,因为在每次i
迭代时,帧索引只会增加。在下一节中,您将学习如何实现FrameIndex
以使用mSampledFrames
向量。
实现 FrameIndex
FrameIndex
函数负责找到给定时间之前的帧。优化的FastTrack
类使用查找数组而不是循环遍历轨道的每一帧。所有输入时间的性能成本非常相似。按照以下步骤重写FastTrack
类中的FrameIndex
函数:
- 通过确保轨道有效来开始实现
FrameIndex
函数。有效的轨道必须至少有两帧或更多:
template<typename T, int N>
int FastTrack<T,N>::FrameIndex(float time,bool loop){
std::vector<Frame<N>>& frames = this->mFrames;
unsigned int size = (unsigned int)frames.size();
if (size <= 1) {
return -1;
}
- 接下来,确保请求的采样时间落在轨道的起始时间和结束时间之间。如果轨道循环,使用
fmodf
来保持在有效范围内:
if (loop) {
float startTime = this->mFrames[0].mTime;
float endTime = this->mFrames[size - 1].mTime;
float duration = endTime - startTime;
time = fmodf(time - startTime,
endTime - startTime);
if (time < 0.0f) {
time += endTime - startTime;
}
time = time + startTime;
}
- 如果轨道不循环,将其夹紧到第一帧或倒数第二帧:
else {
if (time <= frames[0].mTime) {
return 0;
}
if (time >= frames[size - 2].mTime) {
return (int)size - 2;
}
}
- 找到标准化的采样时间和帧索引。帧索引是标准化的采样时间乘以样本数。如果索引无效,则返回
-1
;否则返回索引指向的帧:
float duration = this->GetEndTime() -
this->GetStartTime();
float t = time / duration;
unsigned int numSamples = (duration * 60.0f);
unsigned int index = (t * (float)numSamples);
if (index >= mSampledFrames.size()) {
return -1;
}
return (int)mSampledFrames[index];
}
FrameIndex
函数几乎总是在有效时间调用,因为它是一个受保护的辅助函数。这意味着找到帧索引所需的时间是均匀的,不管轨道中有多少帧。在下一节中,您将学习如何将未优化的Track
类转换为优化的FastTrack
类。
转换轨道
现在FastTrack
存在了,如何创建它呢?您可以创建一个新的加载函数,加载FastTrack
类而不是Track
。或者,您可以创建一个将现有的Track
类转换为FastTrack
类的函数。本章采用后一种方法。按照以下步骤创建一个将Track
对象转换为FastTrack
对象的函数:
- 在
FastTrack.h
中声明OptimizeTrack
函数。该函数是模板化的。它接受与Track
相同的模板类型:
template<typename T, int N>
FastTrack<T, N> OptimizeTrack(Track<T, N>& input);
- 在
FastTrack.cpp
中声明OptimizeTrack
函数的模板特化,以适用于跟踪到FastTrack
的所有三种类型。这意味着声明适用于标量、三维向量和四元数轨道的特化:
template FastTrack<float, 1>
OptimizeTrack(Track<float, 1>& input);
template FastTrack<vec3, 3>
OptimizeTrack(Track<vec3, 3>& input);
template FastTrack<quat, 4>
OptimizeTrack(Track<quat, 4>& input);
- 要实现
OptimizeTrack
函数,调整结果轨道的大小,使其与输入轨道的大小相同并匹配插值。可以使用重载的[]
运算符函数来复制每帧的数据:
template<typename T, int N>
FastTrack<T, N> OptimizeTrack(Track<T, N>& input) {
FastTrack<T, N> result;
result.SetInterpolation(input.GetInterpolation());
unsigned int size = input.Size();
result.Resize(size);
for (unsigned int i = 0; i < size; ++i) {
result[i] = input[i];
}
result.UpdateIndexLookupTable();
return result;
}
仅仅将Track
类优化为FastTrack
还不够。TransformTrack
类也需要改变。它需要包含新的、优化的FastTrack
类。在下一节中,您将更改TransformTrack
类,使其成为模板,并且可以包含Track
或FastTrack
。
创建 FastTransformTrack
使用Track
类的高级结构,如TransformTrack
,需要适应新的FastTrack
子类。FastTrack
类与Track
类具有相同的签名。因为类的签名相同,很容易将TransformTrack
类模板化,以便它可以使用这两个类中的任何一个。
在这一部分,您将把TransformTrack
类的名称更改为TTransformTrack
并对类进行模板化。然后,您将将模板特化 typedef 为TransformTrack
和FastTransformTrack
。这样,TransformTrack
类保持不变,优化的变换轨迹使用相同的代码:
- 将
TransformTrack
类的名称更改为TTransformTrack
并对类进行模板化。模板接受两个参数——要使用的矢量轨迹的类型和四元数轨迹的类型。更新mPosition
、mRotation
和mScale
轨迹以使用新的模板类型:
template <typename VTRACK, typename QTRACK>
class TTransformTrack {
protected:
unsigned int mId;
VTRACK mPosition;
QTRACK mRotation;
VTRACK mScale;
public:
TTransformTrack();
unsigned int GetId();
void SetId(unsigned int id);
VTRACK& GetPositionTrack();
QTRACK& GetRotationTrack();
VTRACK& GetScaleTrack();
float GetStartTime();
float GetEndTime();
bool IsValid();
Transform Sample(const Transform& r,float t,bool l);
};
- 将这个类 typedef 为
TransformTrack
,使用VectorTrack
和QuaternionTrack
作为参数。再次将其 typedef 为FastTransformTrack
,使用FastVectorTrack
和FastQuaternionTrack
作为模板参数:
typedef TTransformTrack<VectorTrack,
QuaternionTrack> TransformTrack;
typedef TTransformTrack<FastVectorTrack,
FastQuaternionTrack> FastTransformTrack;
- 声明将
TransformTrack
转换为FastTransformTrack
的优化函数:
FastTransformTrack OptimizeTransformTrack(
TransformTrack& input);
- 在
TransformTrack.cpp
中为typedef
函数添加模板规范:
template TTransformTrack<VectorTrack, QuaternionTrack>;
template TTransformTrack<FastVectorTrack,
FastQuaternionTrack>;
- 实现
OptimizeTransformTrack
函数。复制轨迹 ID,然后通过值复制各个轨迹:
FastTransformTrack OptimizeTransformTrack(
TransformTrack& input) {
FastTransformTrack result;
result.SetId(input.GetId());
result.GetPositionTrack()= OptimizeTrack<vec3, 3> (
input.GetPositionTrack());
result.GetRotationTrack() = OptimizeTrack<quat, 4>(
input.GetRotationTrack());
result.GetScaleTrack() = OptimizeTrack<vec3, 3> (
input.GetScaleTrack());
return result;
}
因为OptimizeTransformTrack
通过值复制实际轨迹数据,所以它可能会有点慢。这个函数打算在初始化时调用。在下一节中,您将对Clip
类进行模板化,类似于您对Transform
类的操作,以创建FastClip
。
创建 FastClip
这个动画系统的用户与Clip
对象进行交互。为了适应新的FastTrack
类,Clip
类同样被模板化并分成了Clip
和FastClip
。您将实现一个函数来将Clip
对象转换为FastClip
对象。按照以下步骤对Clip
类进行模板化:
- 将
Clip
类的名称更改为TClip
并对类进行模板化。模板只接受一种类型——TClip
类包含的变换轨迹的类型。更改mTracks
的类型和[] operator
的返回类型,使其成为模板类型:
template <typename TRACK>
class TClip {
protected:
std::vector<TRACK> mTracks;
std::string mName;
float mStartTime;
float mEndTime;
bool mLooping;
public:
TClip();
TRACK& operator[](unsigned int index);
// ...
- 使用
TransformTrack
类型将TClip
typedef 为Clip
。使用FastTransformTrack
类型将TClip
typedef 为FastClip
。这样,Clip
类不会改变,而FastClip
类可以重用所有现有的代码:
typedef TClip<TransformTrack> Clip;
typedef TClip<FastTransformTrack> FastClip;
- 声明一个将
Clip
对象转换为FastClip
对象的函数:
FastClip OptimizeClip(Clip& input);
- 在
Clip.cpp
中声明这些 typedef 类的模板特化:
template TClip<TransformTrack>;
template TClip<FastTransformTrack>;
- 要实现
OptimizeClip
函数,复制输入剪辑的名称和循环值。对于剪辑中的每个关节,调用其轨迹上的OptimizeTransformTrack
函数。在返回副本之前,不要忘记计算新的FastClip
对象的持续时间:
FastClip OptimizeClip(Clip& input) {
FastClip result;
result.SetName(input.GetName());
result.SetLooping(input.GetLooping());
unsigned int size = input.Size();
for (unsigned int i = 0; i < size; ++i) {
unsigned int joint = input.GetIdAtIndex(i);
result[joint] =
OptimizeTransformTrack(input[joint]);
}
result.RecalculateDuration();
return result;
}
与其他转换函数一样,OptimizeClip
只打算在初始化时调用。在接下来的部分,您将探讨如何优化Pose
调色板的生成。
姿势调色板生成
您应该考虑的最终优化是从Pose
生成矩阵调色板的过程。如果您查看Pose
类,下面的代码将一个姿势转换为矩阵的线性数组:
void Pose::GetMatrixPalette(std::vector<mat4>& out) {
unsigned int size = Size();
if (out.size() != size) {
out.resize(size);
}
for (unsigned int i = 0; i < size; ++i) {
Transform t = GetGlobalTransform(i);
out[i] = transformToMat4(t);
}
}
单独看,这个函数并不太糟糕,但GetGlobalTransform
函数会循环遍历每个关节,一直到根关节的指定关节变换链。这意味着该函数会浪费大量时间来查找在上一次迭代期间已经找到的变换矩阵。
要解决这个问题,您需要确保Pose
类中关节的顺序是升序的。也就是说,所有父关节在mJoints
数组中的索引必须低于它们的子关节。
一旦设置了这个顺序,你可以遍历所有的关节,并知道当前索引处的关节的父矩阵已经找到。这是因为所有的父元素的索引都比它们的子节点小。为了将该关节的局部矩阵与其父关节的全局矩阵合并,你只需要将之前找到的世界矩阵和局部矩阵相乘。
不能保证输入数据可以信任地按照特定顺序列出关节。为了解决这个问题,你需要编写一些代码来重新排列Pose
类的关节。在下一节中,你将学习如何改进GetMatrixPalette
函数,使其在可能的情况下使用优化的方法,并在不可能的情况下退回到未优化的方法。
改变 GetMatrixPalette 函数
在本节中,你将修改GetMatrixPalette
函数,以便在当前关节的父索引小于关节时预缓存全局矩阵。如果这个假设被打破,函数需要退回到更慢的计算模式。
GetMatrixPalette
函数中将有两个循环。第一个循环找到并存储变换的全局矩阵。如果关节的父节点索引小于关节,就使用优化的方法。如果关节的父节点不小,第一个循环中断,并给第二个循环一个运行的机会。
在第二个循环中,每个关节都会退回到调用缓慢的GetWorldTransform
函数来找到它的世界变换。如果优化的循环执行到最后,这个第二个循环就不会执行:
void Pose::GetMatrixPalette(std::vector<mat4>& out) {
int size = (int)Size();
if ((int)out.size() != size) { out.resize(size); }
int i = 0;
for (; i < size; ++i) {
int parent = mParents[i];
if (parent > i) { break; }
mat4 global = transformToMat4(mJoints[i]);
if (parent >= 0) {
global = out[parent] * global;
}
out[i] = global;
}
for (; i < size; ++i) {
Transform t = GetGlobalTransform(i);
out[i] = transformToMat4(t);
}
}
这个改变对GetMatrixPalette
函数的开销非常小,但很快就能弥补。它使得矩阵调色板计算运行快速,如果可能的话,但即使不可能也会执行。在接下来的部分,你将学习如何重新排列加载模型的关节,以便GetMatrixPalette
函数始终采用快速路径。
重新排序关节
并非所有的模型都会格式良好;因此,它们不都能够利用优化的GetMatrixPalette
函数。在本节中,你将学习如何重新排列模型的骨骼,以便它可以利用优化的GetMatrixPalette
函数。
创建一个新文件RearrangeBones.h
。使用一个字典,其键值对是骨骼索引和重新映射的骨骼索引。RearrangeSkeleton
函数生成这个字典,并重新排列骨骼的绑定、逆绑定和静止姿势。
一旦RearrangeSkeleton
函数生成了BoneMap
,你可以使用它来处理任何影响当前骨骼的网格或动画片段。按照以下步骤重新排序关节,以便骨骼始终可以利用优化的GetMatrixPalette
路径:
- 将以下函数声明添加到
RearrangeBones.h
文件中:
typedef std::map<int, int> BoneMap;
BoneMap RearrangeSkeleton(Skeleton& skeleton);
void RearrangeMesh(Mesh& mesh, BoneMap& boneMap);
void RearrangeClip(Clip& clip, BoneMap& boneMap);
void RearrangeFastclip(FastClip& clip, BoneMap& boneMap);
- 在一个新文件
ReearrangeBones.cpp
中开始实现RearrangeSkeleton
函数。首先,创建对静止和绑定姿势的引用,然后确保你要重新排列的骨骼不是空的。如果是空的,就返回一个空的字典:
BoneMap RearrangeSkeleton(Skeleton& skeleton) {
Pose& restPose = skeleton.GetRestPose();
Pose& bindPose = skeleton.GetBindPose();
unsigned int size = restPose.Size();
if (size == 0) { return BoneMap(); }
- 接下来,创建一个二维整数数组(整数向量的向量)。外部向量的每个元素代表一个骨骼,该向量和绑定或静止姿势中的
mJoints
数组的索引是平行的。内部向量表示外部向量索引处的关节包含的所有子节点。循环遍历静止姿势中的每个关节:
std::vector<std::vector<int>> hierarchy(size);
std::list<int> process;
for (unsigned int i = 0; i < size; ++i) {
int parent = restPose.GetParent(i);
- 如果一个关节有父节点,将该关节的索引添加到父节点的子节点向量中。如果一个节点是根节点(没有父节点),直接将其添加到处理列表中。稍后将使用该列表来遍历地图深度:
if (parent >= 0) {
hierarchy[parent].push_back((int)i);
}
else {
process.push_back((int)i);
}
}
- 要弄清楚如何重新排序骨骼,你需要保留两个映射——一个从旧配置映射到新配置,另一个从新配置映射回旧配置:
BoneMap mapForward;
BoneMap mapBackward;
- 对于每个元素,如果它包含子元素,则将子元素添加到处理列表中。这样,所有的关节都被处理,层次结构中较高的关节首先被处理:
int index = 0;
while (process.size() > 0) {
int current = *process.begin();
process.pop_front();
std::vector<int>& children = hierarchy[current];
unsigned int numChildren = children.size();
for (unsigned int i = 0; i < numChildren; ++i) {
process.push_back(children[i]);
}
- 将正向映射的当前索引设置为正在处理的关节的索引。正向映射的当前索引是一个原子计数器。对于反向映射也是同样的操作,但是要交换键值对。不要忘记将空节点(
-1
)添加到两个映射中:
mapForward[index] = current;
mapBackward[current] = index;
index += 1;
}
mapForward[-1] = -1;
mapBackward[-1] = -1;
- 现在映射已经填充,您需要构建新的静止和绑定姿势,使其骨骼按正确的顺序排列。循环遍历原始静止和绑定姿势中的每个关节,并将它们的本地变换复制到新的姿势中。对于关节名称也是同样的操作:
Pose newRestPose(size);
Pose newBindPose(size);
std::vector<std::string> newNames(size);
for (unsigned int i = 0; i < size; ++i) {
int thisBone = mapForward[i];
newRestPose.SetLocalTransform(i,
restPose.GetLocalTransform(thisBone));
newBindPose.SetLocalTransform(i,
bindPose.GetLocalTransform(thisBone));
newNames[i] = skeleton.GetJointName(thisBone);
- 为每个关节找到新的父关节 ID 需要两个映射步骤。首先,将当前索引映射到原始骨架中的骨骼。这将返回原始骨架的父关节。将此父索引映射回新骨架。这就是为什么有两个字典,以便进行快速映射:
int parent = mapBackward[bindPose.GetParent(
thisBone)];
newRestPose.SetParent(i, parent);
newBindPose.SetParent(i, parent);
}
- 一旦找到新的静止和绑定姿势,并且关节名称已经相应地重新排列,通过调用公共的
Set
方法将这些数据写回骨架。骨架的Set
方法还会计算逆绑定姿势矩阵调色板:
skeleton.Set(newRestPose, newBindPose, newNames);
return mapBackward;
} // End of RearrangeSkeleton function
RearrangeSkeleton
函数重新排列骨架中的骨骼,以便骨架可以利用GetMatrixPalette
的优化版本。重新排列骨架是不够的。由于关节索引移动,引用该骨架的任何剪辑或网格现在都是损坏的。在下一节中,您将实现辅助函数来重新排列剪辑中的关节。
重新排序剪辑
要重新排列动画剪辑,循环遍历剪辑中的所有轨道。对于每个轨道,找到关节 ID,然后使用RearrangeSkeleton
函数返回的(反向)骨骼映射转换该关节 ID。将修改后的关节 ID 写回到轨道中:
void RearrangeClip(Clip& clip, BoneMap& boneMap) {
unsigned int size = clip.Size();
for (unsigned int i = 0; i < size; ++i) {
int joint = (int)clip.GetIdAtIndex(i);
unsigned int newJoint = (unsigned int)boneMap[joint];
clip.SetIdAtIndex(i, newJoint);
}
}
如果您之前在本章中实现了FastClip
优化,RearrangeClip
函数应该仍然有效,因为它是Clip
的子类。在下一节中,您将学习如何重新排列网格中的关节,这将是使用此优化所需的最后一步。
重新排序网格
要重新排列影响网格蒙皮的关节,循环遍历网格的每个顶点,并重新映射该顶点的影响属性中存储的四个关节索引。关节的权重不需要编辑,因为关节本身没有改变;只是其数组中的索引发生了变化。
以这种方式更改网格只会编辑网格的 CPU 副本。调用UpdateOpenGLBuffers
将新属性上传到 GPU:
void RearrangeMesh(Mesh& mesh, BoneMap& boneMap) {
std::vector<ivec4>& influences = mesh.GetInfluences();
unsigned int size = (unsigned int)influences.size();
for (unsigned int i = 0; i < size; ++i) {
influences[i].x = boneMap[influences[i].x];
influences[i].y = boneMap[influences[i].y];
influences[i].z = boneMap[influences[i].z];
influences[i].w = boneMap[influences[i].w];
}
mesh.UpdateOpenGLBuffers();
}
实现了RearrangeMesh
函数后,您可以加载一个骨架,然后调用RearrangeSkeleton
函数并存储它返回的骨骼映射。使用这个骨骼映射,您还可以使用RearrangeClip
和RearrangeMesh
函数修复引用骨架的任何网格或动画剪辑。经过这种方式处理后,GetMatrixPalette
始终采用优化路径。在下一节中,您将探索在层次结构中缓存变换。
探索 Pose::GetGlobalTransform
Pose
类的GetGlobalTransform
函数的一个特点是它总是计算世界变换。考虑这样一种情况,您请求一个节点的世界变换,然后立即请求其父节点的世界变换。原始请求计算并使用父节点的世界变换,但一旦下一个请求被发出,同样的变换就会再次计算。
解决这个问题的方法是向Pose
类添加两个新数组。一个是世界空间变换的向量,另一个包含脏标志。每当设置关节的本地变换时,关节的脏标志需要设置为true
。
当请求世界变换时,会检查变换及其所有父级的脏标志。如果该链中有脏变换,则重新计算世界变换。如果脏标志未设置,则返回缓存的世界变换。
本章不会实现这个优化。这个优化会给Pose
类的每个实例增加大量的内存。除了逆向运动学的情况,GetGlobalTransform
函数很少被使用。对于蒙皮,GetMatrixPalette
函数用于检索世界空间矩阵,而该函数已经被优化过了。
总结
在本章中,你探索了如何针对几种情况优化动画系统。这些优化减少了顶点蒙皮着色器所需的统一变量数量,加快了具有许多关键帧的动画的采样速度,并更快地生成了姿势的矩阵调色板。
请记住,没有一种大小适合所有的解决方案。如果游戏中的所有动画都只有几个关键帧,那么通过查找表优化动画采样所增加的开销可能不值得额外的内存。然而,改变采样函数以使用二分查找可能是值得的。每种优化策略都存在类似的利弊;你必须选择适合你特定用例的方案。
在查看本章的示例代码时,Chapter11/Sample00
包含了本章的全部代码。Chapter11/Sample01
展示了如何使用预蒙皮网格,Chapter11/Sample02
展示了如何使用FastTrack
类进行更快的采样,Chapter11/Sample03
展示了如何重新排列骨骼以加快调色板的生成。
在下一章中,你将探索如何混合动画以平滑地切换两个动画。本章还将探讨修改现有动画的混合技术。
第十二章:动画之间的混合
从一个动画过渡到另一个动画可能会很突兀。想象一下,如果一个角色正在进行一次拳击,玩家决定开始奔跑。如果动画直接从跳跃片段切换到奔跑片段,过渡将会很生硬和不自然。
动画混合可以通过生成两个动画的平均中间帧来修复这个问题。这种淡入通常很短——不到一秒钟。这种短混合产生的平滑动画过渡提供了更好的观感体验。
本章探讨了如何实现动画混合和附加动画混合,以及如何设置交叉淡入淡出控制器来管理混合队列。将涵盖以下主题:
-
姿势混合
-
交叉淡入淡出动画
-
附加混合
姿势混合
动画混合是在每个关节的本地空间中两个姿势之间的线性混合。可以将其视为lerp
或mix
函数,但应用于整个姿势。这种技术不是混合动画片段;而是混合这些片段被采样到的姿势。
在混合两个姿势时,不需要整个姿势都进行混合。假设有两个动画——奔跑循环和攻击。如果玩家按下攻击按钮,攻击姿势的上半部分在短时间内混合进来,保持在整个动画中的权重为1
,然后在动画结束时淡出。
这是一个使用姿势混合来创建奔跑攻击动画的示例,而无需对攻击动画的腿部进行动画处理。攻击动画可以在行走动画的基础上混合。动画混合可用于平滑地过渡动画或将多个动画组合成一个新动画。
在接下来的部分,您将为Pose
类声明一个Blend
函数。这个Blend
函数将在两个姿势之间进行线性插值,类似于向量lerp
的工作方式。该函数需要两个姿势和一个插值值,通常表示为t
,其范围为0
到1
。
声明混合函数
Blend
函数接受两个姿势——混合值和根节点作为参数。当混合值为0
时,Blend
函数返回第一个姿势,当为1
时,返回第二个姿势。对于介于0
和1
之间的任何值,姿势都会被混合。根节点决定了第二个动画的哪个节点(及其子节点)应该混合到第一个动画中。
为了适应指定从哪个骨骼节点开始混合,需要一种方法来检查一个节点是否在另一个节点的层次结构中。IsInHierarchy
函数接受一个Pose
类,一个作为根节点的节点和一个作为搜索节点的节点。如果搜索节点是根节点的后代,则函数返回true
:
bool IsInHierarchy(Pose& pose, unsigned int root,
unsigned int search);
void Blend(Pose& output,Pose& a,Pose& b,float t,int root);
当混合两个姿势时,假设这些姿势是相似的。相似的姿势具有相同数量的关节,并且每个关节在姿势之间具有相同的父级索引。在接下来的部分,您将实现Blend
函数。
实现混合功能
为了使混合有效,它必须在本地空间中进行,这对于在两个姿势之间进行混合非常方便。循环遍历输入姿势中的所有关节,并在正在混合的两个姿势中插值关节的本地变换。对于位置和比例,使用向量lerp
函数,对于旋转,使用四元数nlerp
函数。
为了支持动画根节点,检查当前变换是否是混合根的后代。如果是,进行混合。如果不是,则跳过混合,并保持第一个输入姿势的变换值。按照以下步骤实现层次结构检查和Blend
函数:
- 要检查一个关节是否是另一个关节的后代,沿着后代关节一直向上遍历层次结构,直到根节点。如果在这个层次结构中遇到的任何节点都是您要检查的节点,则返回
true
:
bool IsInHierarchy(Pose& pose, unsigned int parent,
unsigned int search) {
if (search == parent) {
return true;
}
int p = pose.GetParent(search);
while (p >= 0) {
if (p == (int)parent) {
return true;
}
p = pose.GetParent(p);
}
return false;
}
- 为了将两个姿势混合在一起,循环遍历每个姿势的关节。如果当前关节不在混合根的层次结构中,则不进行混合。否则,使用您在第五章中编写的
mix
函数来混合Transform
对象。mix
函数考虑四元数邻域:
void Blend(Pose& output, Pose& a, Pose& b,
float t, int root) {
unsigned int numJoints = output.Size();
for (unsigned int i = 0; i < numJoints; ++i) {
if (root >= 0) {
if (!IsInHierarchy(output, root, i)) {
continue;
}
}
output.SetLocalTransform(i, mix(
a.GetLocalTransform(i),
b.GetLocalTransform(i), t)
);
}
}
如果使用整个层次结构混合两个动画,则Blend
的根参数将为负数。对于混合根的负关节,Blend
函数会跳过IsInHierarchy
检查。在接下来的部分,您将探索如何在两个动画之间进行淡入淡出以实现平滑过渡。
淡入淡出动画
混合动画的最常见用例是在两个动画之间进行淡入淡出。淡入淡出是从一个动画快速混合到另一个动画。淡入淡出的目标是隐藏两个动画之间的过渡。
一旦淡入淡出完成,活动动画需要被正在淡入的动画替换。如果您正在淡入多个动画,则它们都会被评估。最先结束的动画首先被移除。请求的动画被添加到列表中,已经淡出的动画被从列表中移除。
在接下来的部分,您将构建一个CrossFadeController
类来处理淡入淡出逻辑。这个类提供了一个简单直观的 API,只需一个函数调用就可以简单地在动画之间进行淡入淡出。
创建辅助类
当将动画淡入到已经采样的姿势中时,您需要知道正在淡入的动画是什么,它的当前播放时间,淡入持续时间的长度以及淡入的当前时间。这些值用于执行实际的混合,并包含有关混合状态的数据。
创建一个新文件并命名为CrossFadeTarget.h
,以实现CrossFadeTarget
辅助类。这个辅助类包含了之前描述的变量。默认构造函数应将所有值设置为0
。还提供了一个方便的构造函数,它接受剪辑指针、姿势引用和持续时间:
struct CrossFadeTarget {
Pose mPose;
Clip* mClip;
float mTime;
float mDuration;
float mElapsed;
inline CrossFadeTarget()
: mClip(0), mTime(0.0f),
mDuration(0.0f), mElapsed(0.0f) { }
inline CrossFadeTarget(Clip* target,Pose& pose,float dur)
: mClip(target), mTime(target->GetStartTime()),
mPose(pose), mDuration(dur), mElapsed(0.0f) { }
};
CrossFadeTarget
辅助类的mPose
、mClip
和mTime
变量在每一帧都用于采样正在淡入的动画。mDuration
和mElapsed
变量用于控制动画应该淡入多少。
在下一节中,您将实现一个控制动画播放和淡入淡出的类。
声明淡入淡出控制器
跟踪当前播放的剪辑并管理淡入淡出是新的CrossFadeController
类的工作。创建一个新文件CrossFadeController.h
,声明新的类。这个类需要包含一个骨架、一个姿势、当前播放时间和一个动画剪辑。它还需要一个控制动画混合的CrossFadeTarget
对象的向量。
CrossFadeController
和CrossFadeTarget
类都包含指向动画剪辑的指针,但它们不拥有这些指针。因为这两个类都不拥有指针的内存,所以生成的构造函数、复制构造函数、赋值运算符和析构函数应该可以正常使用。
CrossFadecontroller
类需要函数来设置当前骨架、检索当前姿势和检索当前剪辑。当前动画可以使用Play
函数设置。可以使用FadeTo
函数淡入新动画。由于CrossFadeController
类管理动画播放,它需要一个Update
函数来采样动画剪辑:
class CrossFadeController {
protected:
std::vector<CrossFadeTarget> mTargets;
Clip* mClip;
float mTime;
Pose mPose;
Skeleton mSkeleton;
bool mWasSkeletonSet;
public:
CrossFadeController();
CrossFadeController(Skeleton& skeleton);
void SetSkeleton(Skeleton& skeleton);
void Play(Clip* target);
void FadeTo(Clip* target, float fadeTime);
void Update(float dt);
Pose& GetCurrentPose();
Clip* GetcurrentClip();
};
整个mTargets
列表在每一帧都会被评估。每个动画都会被评估并混合到当前播放的动画中。
在接下来的部分,您将实现CrossFadeController
类。
实现淡出控制器
创建一个新文件,CrossFadeController.cpp
。在这个新文件中实现CrossFadeController
。按照以下步骤实现CrossFadeController
:
- 在默认构造函数中,为当前剪辑和时间设置默认值
0
,并将骨骼标记为未设置。还有一个方便的构造函数,它接受一个骨骼引用。方便的构造函数应调用SetSkeleton
函数:
CrossFadeController::CrossFadeController() {
mClip = 0;
mTime = 0.0f;
mWasSkeletonSet = false;
}
CrossFadeController::CrossFadeController(Skeleton& skeleton) {
mClip = 0;
mTime = 0.0f;
SetSkeleton(skeleton);
}
- 实现
SetSkeleton
函数,将提供的骨骼复制到CrossFadeController
中。它标记该类的骨骼已设置,并将静止姿势复制到交叉淡出控制器的内部姿势中:
void CrossFadeController::SetSkeleton(
Skeleton& skeleton) {
mSkeleton = skeleton;
mPose = mSkeleton.GetRestPose();
mWasSkeletonSet = true;
}
- 实现
Play
函数。此函数应清除任何活动的交叉淡出。它应设置剪辑和播放时间,但还需要将当前姿势重置为骨骼的静止姿势:
void CrossFadeController::Play(Clip* target) {
mTargets.clear();
mClip = target;
mPose = mSkeleton.GetRestPose();
mTime = target->GetStartTime();
}
- 实现
FadeTo
函数,该函数应检查请求的淡出目标是否有效。淡出目标仅在不是淡出列表中的第一个或最后一个项目时才有效。假设满足这些条件,FadeTo
函数将提供的动画剪辑和持续时间添加到淡出列表中:
void CrossFadeController::FadeTo(Clip* target,
float fadeTime) {
if (mClip == 0) {
Play(target);
return;
}
if (mTargets.size() >= 1) {
Clip* clip=mTargets[mTargets.size()-1].mClip;
if (clip == target) {
return;
}
}
else {
if (mClip == target) {
return;
}
}
mTargets.push_back(CrossFadeTarget(target,
mSkeleton.GetRestPose(), fadeTime));
}
- 实现
Update
函数以播放活动动画并混合任何在淡出列表中的其他动画:
void CrossFadeController::Update(float dt) {
if (mClip == 0 || !mWasSkeletonSet) {
return;
}
- 将当前动画设置为目标动画,并在动画淡出完成时移除淡出对象。每帧只移除一个目标。如果要移除所有已淡出的目标,请将循环改为反向:
unsigned int numTargets = mTargets.size();
for (unsigned int i = 0; i < numTargets; ++i) {
float duration = mTargets[i].mDuration;
if (mTargets[i].mElapsed >= duration) {
mClip = mTargets[i].mClip;
mTime = mTargets[i].mTime;
mPose = mTargets[i].mPose;
mTargets.erase(mTargets.begin() + i);
break;
}
}
- 将淡出列表与当前动画混合。需要对当前动画和淡出列表中的所有动画进行采样:
numTargets = mTargets.size();
mPose = mSkeleton.GetRestPose();
mTime = mClip->Sample(mPose, mTime + dt);
for (unsigned int i = 0; i < numTargets; ++i) {
CrossFadeTarget& target = mTargets[i];
target.mTime = target.mClip->Sample(
target.mPose, target.mTime + dt);
target.mElapsed += dt;
float t = target.mElapsed / target.mDuration;
if (t > 1.0f) { t = 1.0f; }
Blend(mPose, mPose, target.mPose, t, -1);
}
}
- 使用
GetCurrentPose
和GetCurrentclip
辅助函数完成CrossFadeController
类的实现。这些都是简单的 getter 函数:
Pose& CrossFadeController::GetCurrentPose() {
return mPose;
}
Clip* CrossFadeController::GetcurrentClip() {
return mClip;
}
现在,您可以创建CrossFadeController
的实例来控制动画播放,而不是手动控制正在播放的动画。CrossFadeController
类在开始播放新动画时会自动淡出到新动画。在下一部分中,您将探索加法动画混合。
加法混合
加法动画用于通过添加额外的关节运动来修改动画。一个常见的例子是向左倾斜。如果有一个向左倾斜的动画,它只是简单地弯曲了角色的脊柱,它可以添加到行走动画中,以创建一个边走边倾斜的动画,奔跑动画,或者任何其他类型的动画。
并非所有动画都适合作为加法动画。加法动画通常是专门制作的。我已经在本章的示例代码中提供的Woman.gltf
文件中添加了一个Lean_Left
动画。这个动画是为了加法而制作的。它只弯曲了脊柱关节中的一个。
加法动画通常不是根据时间播放,而是根据其他输入播放。以向左倾斜为例——它应该由用户的操纵杆控制。操纵杆越靠近左侧,倾斜的动画就应该越进。将加法动画的播放与时间以外的其他内容同步是很常见的。
声明加法动画
加法混合的函数声明在Blending.h
中。第一个函数MakeAditivePose
在时间0
处对加法剪辑进行采样,生成一个输出姿势。这个输出姿势是用来将两个姿势相加的参考。
Add
函数执行两个姿势之间的加法混合过程。加法混合公式为result pose = input pose + (additive pose – additive base pose)。前两个参数,即输出姿势和输入姿势,可以指向同一个姿势。要应用加法姿势,需要加法姿势和加法姿势的引用:
Pose MakeAdditivePose(Skeleton& skeleton, Clip& clip);
void Add(Pose& output, Pose& inPose, Pose& addPose,
Pose& additiveBasePose, int blendroot);
MadeAdditivePose
辅助函数生成Add
函数用于其第四个参数的附加基础姿势。该函数旨在在初始化时调用。在下一节中,您将实现这些函数。
实现附加动画
在Blending.cpp
中实现MakeAdditivePose
函数。该函数仅在加载时调用。它应在剪辑的开始时间对提供的剪辑进行采样。该采样的结果是附加基础姿势:
Pose MakeAdditivePose(Skeleton& skeleton, Clip& clip) {
Pose result = skeleton.GetRestPose();
clip.Sample(result, clip.GetStartTime());
return result;
}
附加混合的公式为结果姿势 = 输入姿势 + (附加姿势 - 附加基础姿势)。减去附加基础姿势只应用于动画的第一帧和当前帧之间的附加动画增量。因此,您只能对一个骨骼进行动画,比如脊柱骨骼之一,并实现使角色向左倾斜的效果。
要实现附加混合,需要循环遍历每个姿势的关节。与常规动画混合一样,需要考虑blendroot
参数。使用每个关节的本地变换,按照提供的公式进行操作:
void Add(Pose& output, Pose& inPose, Pose& addPose,
Pose& basePose, int blendroot) {
unsigned int numJoints = addPose.Size();
for (int i = 0; i < numJoints; ++i) {
Transform input = inPose.GetLocalTransform(i);
Transform additive = addPose.GetLocalTransform(i);
Transform additiveBase=basePose.GetLocalTransform(i);
if (blendroot >= 0 &&
!IsInHierarchy(addPose, blendroot, i)) {
continue;
}
// outPose = inPose + (addPose - basePose)
Transform result(input.position +
(additive.position - additiveBase.position),
normalized(input.rotation *
(inverse(additiveBase.rotation) *
additive.rotation)),
input.scale + (additive.scale -
additiveBase.scale)
);
output.SetLocalTransform(i, result);
}
}
重要信息
四元数没有减法运算符。要从四元数A中移除四元数B的旋转,需要将B乘以A的逆。四元数的逆应用相反的旋转,这就是为什么四元数乘以其逆的结果是单位。
附加动画通常用于创建新的动画变体,例如,将行走动画与蹲姿混合以创建蹲行动画。所有动画都可以与蹲姿进行附加混合,以在程序中创建动画的蹲姿版本。
总结
在本章中,您学会了如何混合多个动画。混合动画可以混合整个层次结构或只是一个子集。您还构建了一个系统,用于管理在播放新动画时动画之间的淡入淡出。我们还介绍了附加动画,可以在给定关节角度的情况下用于创建新的运动。
本章的可下载材料中包括四个示例。Sample00
是本书到目前为止的所有代码。Sample01
演示了如何使用Blend
函数,通过定时器在行走和奔跑动画之间进行混合。Sample02
演示了交叉淡入淡出控制器的使用,通过交叉淡入淡出到随机动画。Sample03
演示了如何使用附加动画混合。
在下一章中,您将学习逆向运动学。逆向运动学允许您根据角色的末端位置来确定角色的肢体应该弯曲的方式。想象一下将角色的脚固定在不平整的地形上。
第十三章:实现逆运动学
逆运动学(IK)是解决一组关节应该如何定位以达到世界空间中指定点的过程。例如,您可以为角色指定一个触摸的点。通过使用 IK,您可以找出如何旋转角色的肩膀、肘部和手腕,使得角色的手指始终触摸特定点。
常用于 IK 的两种算法是 CCD 和 FABRIK。本章将涵盖这两种算法。通过本章结束时,您应该能够做到以下事情:
-
理解 CCD IK 的工作原理
-
实现 CCD 求解器
-
理解 FABRIK 的工作原理
-
实现 FABRIK 求解器
-
实现球和套约束
-
实现铰链约束
-
了解 IK 求解器在动画流水线中的位置和方式
创建 CCD 求解器
在本节中,您将学习并实现 CCD IK 算法。CCD代表循环坐标下降。该算法可用于以使链条上的最后一个关节尽可能接近触摸目标的方式来摆放一系列关节。您将能够使用 CCD 来创建需要使用目标点解决链条的肢体和其他 IK 系统。
CCD 有三个重要概念。首先是目标,即您试图触摸的空间点。接下来是IK 链,它是需要旋转以达到目标的所有关节的列表。最后是末端执行器,它是链条中的最后一个关节(需要触摸目标的关节)。
有了目标、链和末端执行器,CCD 算法的伪代码如下:
// Loop through all joints in the chain in reverse,
// starting with the joint before the end effecor
foreach joint in ikchain.reverse() {
// Find a vector from current joint to end effector
jointToEffector = effector.position - joint.position
// Find a vector from the current joint to the goal
jointToGoal = goal.position - joint.position
// Rotate the joint so the joint to effector vector
// matches the orientation of the joint to goal vector
joint.rotation = fromToRotation(jointToEffector,
jointToGoal) * joint.rotation
}
CCD 算法看起来很简单,但它是如何工作的呢?从末端执行器前面的关节开始。旋转执行器对链条没有影响。找到从执行器前面的关节到目标的向量,然后找到从关节到执行器的向量。旋转相关的关节,使得这两个向量对齐。对每个关节重复此过程,直到基本关节为止。
图 13.1:CCD 算法的可视化
观察图 13.1,末端执行器没有触摸目标。为什么?CCD 是一个迭代算法,前面的步骤描述了一个迭代。需要多次迭代才能实现收敛。在接下来的章节中,我们将学习如何声明 CCD 求解器,这将引导我们实现CCDSolver
类。
声明 CCD 求解器
在本节中,您将声明 CCD 求解器。这将让您有机会在实现之前,熟悉 API 并了解类在高层次上的工作方式。
创建一个新文件CCDSolver.h
,CCDSolver
类将在此文件中声明。CCDSolver
类应包含组成 IK 链的变换向量。假设 IK 链具有父子关系,其中每个索引都是前一个索引的子级,使 0 成为我们的根节点。因此,IK 链中的每个变换都是在本地空间中声明的。按照以下步骤声明 CCD IK 求解器:
- 首先声明
CCDSolver
类,包含三个变量:用于形成 IK 链的变换列表、要执行的迭代次数和可以用来控制目标与目标之间的距离的小增量。同时声明默认构造函数:
class CCDSolver {
protected:
std::vector<Transform> mIKChain;
unsigned int mNumSteps;
float mThreshold;
public:
CCDSolver();
- 为 IK 链的大小、步数和阈值值实现 getter 和 setter 函数。声明要使用的
[] operator
来获取和设置本地关节变换。声明GetGlobalTransform
函数,它将返回关节的全局变换:
unsigned int Size();
void Resize(unsigned int newSize);
Transform& operator[](unsigned int index);
Transform GetGlobalTransform(unsigned int index);
unsigned int GetNumSteps();
void SetNumSteps(unsigned int numSteps);
float GetThreshold();
void SetThreshold(float value);
- 声明
Solve
函数,用于解决 IK 链。提供一个变换,但只使用变换的位置分量。如果链被解决,则Solve
函数返回true
,否则返回false
:
bool Solve(const Transform& target);
};
mNumSteps
变量用于确保求解器不会陷入无限循环。不能保证末端执行器会达到目标。限制迭代次数有助于避免潜在的无限循环。在接下来的部分,您将开始实现 CCD 求解器。
实现 CCD 求解器
创建一个名为CCDSolver.cpp
的新文件,用于实现 CCD 求解器。按照以下步骤实现 CCD 求解器:
- 定义默认构造函数,为步数和阈值赋值。使用小阈值,如
0.0001f
。默认步数为15
:
CCDSolver::CCDSolver() {
mNumSteps = 15;
mThreshold = 0.00001f;
}
- 实现
Size
和Resize
函数,控制 IK 链的大小,[]运算符
包含链中每个关节的值:
unsigned int CCDSolver::Size() {
return mIKChain.size();
}
void CCDSolver::Resize(unsigned int newSize) {
mIKChain.resize(newSize);
}
Transform& CCDSolver::operator[](unsigned int index) {
return mIKChain[index];
}
- 为求解器包含的步数和阈值实现获取器和设置器函数:
unsigned int CCDSolver::GetNumSteps() {
return mNumSteps;
}
void CCDSolver::SetNumSteps(unsigned int numSteps) {
mNumSteps = numSteps;
}
float CCDSolver::GetThreshold() {
return mThreshold;
}
void CCDSolver::SetThreshold(float value) {
mThreshold = value;
}
- 实现
GetGlobalTransform
函数,这可能看起来很熟悉。它将指定关节的变换与所有父关节的变换连接起来,并返回指定关节的全局变换:
Transform CCDSolver::GetGlobalTransform(unsigned int x) {
unsigned int size = (unsigned int)mIKChain.size();
Transform world = mIKChain[x];
for (int i = (int) x - 1; i >= 0; --i) {
world = combine(mIKChain[i], world);
}
return world;
}
- 通过确保链的大小有效并存储最后一个元素的索引和目标位置的向量来实现
Solve
函数:
bool CCDSolver::Solve(const Transform& target) {
unsigned int size = Size();
if (size == 0) { return false; }
unsigned int last = size - 1;
float thresholdSq = mThreshold * mThreshold;
vec3 goal = target.position;
- 循环从
0
到mNumSteps
,执行正确数量的迭代。在每次迭代中,获取末端执行器的位置,并检查它是否足够接近目标。如果足够接近,提前返回:
for (unsigned int i = 0; i < mNumSteps; ++i) {
vec3 effector = GetGlobalTransform(last).position;
if (lenSq(goal - effector) < thresholdSq) {
return true;
}
- 在每次迭代中,循环遍历整个 IK 链。从
size - 2
开始迭代;因为size - 1
是最后一个元素,旋转最后一个元素对任何骨骼都没有影响:
for (int j = (int)size - 2; j >= 0; --j) {
- 对于 IK 链中的每个关节,获取关节的世界变换。找到从关节位置到末端执行器位置的向量。找到从当前关节位置到目标位置的另一个向量:
effector=GetGlobalTransform(last).position;
Transform world = GetGlobalTransform(j);
vec3 position = world.position;
quat rotation = world.rotation;
vec3 toEffector = effector - position;
vec3 toGoal = goal - position;
- 接下来,找到一个四元数,将位置到末端执行器的向量旋转到位置到目标向量。有一种特殊情况,指向末端执行器或目标的向量可能是零向量:
quat effectorToGoal;
if (lenSq(toGoal) > 0.00001f) {
effectorToGoal = fromTo(toEffector,
toGoal);
}
- 使用这个向量将关节旋转到世界空间中的正确方向。通过关节的上一个世界旋转的逆来旋转关节的世界空间方向,将四元数移回关节空间:
quat worldRotated =rotation *
effectorToGoal;
quat localRotate = worldRotated *
inverse(rotation);
mIKChain[j].rotation = localRotate *
mIKChain[j].rotation;
- 随着关节的移动,检查末端执行器在每次迭代中移动到目标的距离。如果足够接近,从函数中提前返回,返回值为
true
:
effector=GetGlobalTransform(last).position;
if (lenSq(goal - effector) < thresholdSq) {
return true;
}
}
}
- 如果未达到目标,则 IK 链无法解决,至少不是在指定的迭代次数内。简单地返回
false
以表示函数未能达到目标:
return false;
} // End CCDSolver::Solve function
这个 CCD 求解器可以用来解决具有一个起点和一个末端执行器的单链。然而,处理 IK 链的更高级方法是,一个单链可以有多个末端执行器。然而,由于额外的实现复杂性,这些方法要少得多。在下一节中,您将开始探索另一种 IK 算法,FABRIK。
创建一个 FABRIK 求解器
FABRIK(前向和后向逆运动学)具有更自然、类人的收敛性。与 CCD 一样,FABRIK 处理具有基础、末端执行器和要达到的目标的 IK 链。与 CCD 不同,FABRIK 处理的是位置,而不是旋转。FABRIK 算法更容易理解,因为它可以仅使用向量来实现。
在许多方面,FABRIK 可以被用作 CCD 的替代品。这两种算法解决了同样的问题,但它们采取了不同的方法来解决。FABRIK 倾向于更快地收敛,并且对于人形动画效果更好,因此您可能会将其用作角色肢体的求解器。
在处理人形角色绑定时,使用位置而不是旋转将无法很好地工作,因为需要通过旋转关节来进行动画。这可以通过向算法添加预处理和后处理步骤来解决。预处理步骤将把 IK 链中的所有变换转换为世界空间位置向量。后处理步骤将把这些向量转换为旋转数据。
FABRIK 算法有两个部分。首先,从末端执行器向基座进行反向迭代。在进行反向迭代时,将执行器移动到目标位置。接下来,移动每根骨骼,使它们相对于执行器保持不变;这将保持链的完整性。然后,将基座移回原始位置,并将每根骨骼相对于基座移动,以保持链的完整性。
在伪代码中,FABRIK 算法如下所示:
void Iterate(const Transform& goal) {
startPosition = chain[0]
// Iterate backwards
chain[size - 1] = goal.position;
for (i = size - 2; i >= 0; --i) {
current = chain[i]
next = chain[i + 1]
direction = normalize(current - next)
offset = direction * length[i + 1]
chain[i] = next + offset
}
// Iterate forwards
chain[0] = startPosition
for (i = 1; i < size; ++i) {
current = chain[i]
prev = chain[i - 1]
direction = normalize(current - prev)
offset = direction * length[i]
chain[i] = prev + offset
}
}
要可视化 FABRIK,将末端执行器设置到目标位置。找到从末端执行器到最后一个关节的向量。将最后一个关节移动到沿着这个向量的位置,保持其与末端执行器的距离。对每个关节重复此操作,直到达到基座。这将使基座关节移出位置。
要进行正向迭代,将基座放回原来的位置。找到到下一个关节的向量。将下一个关节放在这个向量上,保持其与基座的距离。沿着整个链重复这个过程:
图 13.2:可视化 FABRIK 算法
FABRIK 和 CCD 都会尝试解决 IK 链,但它们以不同的方式收敛到目标。CCD 倾向于卷曲,而 FABRIK 倾向于拉伸。FABRIK 通常为人形动画生成更自然的结果。在接下来的部分,您将开始声明FABRIKSolver
类,然后实现该类。
声明 FABRIK 求解器
FABRIK 求解器将需要更多的内存来运行,因为它必须将本地关节变换转换为全局位置。该算法可以分解为几个步骤,所有这些步骤都可以作为受保护的辅助函数实现。
创建一个新文件,FABRIKSolver.h
。这个文件将用于声明FABRIKSolver
类。按照以下步骤声明FABRIKSolver
类:
- 首先声明
FABRIKSolver
类,该类需要跟踪 IK 链、最大步数和一些距离阈值。声明一个世界空间位置向量和一个关节长度向量。这些向量是必需的,因为 FABRIK 算法不考虑旋转:
class FABRIKSolver {
protected:
std::vector<Transform> mIKChain;
unsigned int mNumSteps;
float mThreshold;
std::vector<vec3> mWorldChain;
std::vector<float> mLengths;
- 声明辅助函数,将 IK 链复制到世界位置向量中,进行正向迭代,进行反向迭代,并将最终的世界位置复制回 IK 链中:
protected:
void IKChainToWorld();
void IterateForward(const vec3& goal);
void IterateBackward(const vec3& base);
void WorldToIKChain();
- 声明默认构造函数,获取器和设置器函数用于链的大小、解决链所需的迭代次数以及末端关节需要与目标的距离的 epsilon 值:
public:
FABRIKSolver();
unsigned int Size();
void Resize(unsigned int newSize);
unsigned int GetNumSteps();
void SetNumSteps(unsigned int numSteps);
float GetThreshold();
void SetThreshold(float value);
- 声明用于存储 IK 链中本地变换的获取器和设置器函数。声明一个函数来检索关节的全局变换。最后,声明
Solve
函数,当给定一个目标时解决 IK 链:
Transform GetLocalTransform(unsigned int index);
void SetLocalTransform(unsigned int index,
const Transform& t);
Transform GetGlobalTransform(unsigned int index);
bool Solve(const Transform& target);
};
FABRIK 算法的实现比 CCD 算法更复杂,但步骤更容易分解为函数。在接下来的部分,您将开始实现FABRIKSolver
类的函数。
实现 FABRIK 求解器
FABRIK 算法基于世界空间位置。这意味着,每次迭代时,IK 链都需要将本地关节变换转换为世界位置并存储结果。解决链条后,世界位置向量需要转换回相对偏移并存储回 IK 链中。
创建一个新文件FABRIKSolver.cpp
;FABRIKSolver
类将在这个文件中实现。按照以下步骤实现FABRIKSolver
类:
- 实现
FABRIKSolver
类的构造函数。需要将步数和阈值设置为默认值:
FABRIKSolver::FABRIKSolver() {
mNumSteps = 15;
mThreshold = 0.00001f;
}
- 实现步数和阈值值的简单 getter 和 setter 函数:
unsigned int FABRIKSolver::GetNumSteps() {
return mNumSteps;
}
void FABRIKSolver::SetNumSteps(unsigned int numSteps) {
mNumSteps = numSteps;
}
float FABRIKSolver::GetThreshold() {
return mThreshold;
}
void FABRIKSolver::SetThreshold(float value) {
mThreshold = value;
}
- 实现链条大小的 getter 和 setter 函数。setter 函数需要设置链条的大小、世界链条和长度向量:
unsigned int FABRIKSolver::Size() {
return mIKChain.size();
}
void FABRIKSolver::Resize(unsigned int newSize) {
mIKChain.resize(newSize);
mWorldChain.resize(newSize);
mLengths.resize(newSize);
}
- 实现获取和设置 IK 链中元素的本地变换的方法:
Transform FABRIKSolver::GetLocalTransform(
unsigned int index) {
return mIKChain[index];
}
void FABRIKSolver::SetLocalTransform(unsigned int index,
const Transform& t) {
mIKChain[index] = t;
}
- 实现获取函数以检索全局变换,并将所有变换连接到根:
Transform FABRIKSolver::GetGlobalTransform(
unsigned int index) {
unsigned int size = (unsigned int)mIKChain.size();
Transform world = mIKChain[index];
for (int i = (int)index - 1; i >= 0; --i) {
world = combine(mIKChain[i], world);
}
return world;
}
- 实现
IKChainToWorld
函数,将 IK 链复制到世界变换向量中并记录段长度。长度数组存储了关节与其父节点之间的距离。这意味着根关节将始终包含长度0
。对于非根关节,索引i
处的距离是关节i
和i-1
之间的距离:
void FABRIKSolver::IKChainToWorld() {
unsigned int size = Size();
for (unsigned int i = 0; i < size; ++i) {
Transform world = GetGlobalTransform(i);
mWorldChain[i] = world.position;
if (i >= 1) {
vec3 prev = mWorldChain[i - 1];
mLengths[i] = len(world.position - prev);
}
}
if (size > 0) {
mLengths[0] = 0.0f;
}
}
- 接下来实现
WorldToIKChain
函数,它将把世界位置 IK 链转换回本地空间变换。循环遍历所有关节。对于每个关节,找到当前关节和下一个关节的世界空间变换。缓存当前关节的世界空间位置和旋转:
void FABRIKSolver::WorldToIKChain() {
unsigned int size = Size();
if (size == 0) { return; }
for (unsigned int i = 0; i < size - 1; ++i) {
Transform world = GetGlobalTransform(i);
Transform next = GetGlobalTransform(i + 1);
vec3 position = world.position;
quat rotation = world.rotation;
- 创建一个向量,指向当前关节到下一个关节的位置。这是当前节点和下一个节点之间的旋转:
vec3 toNext = next.position - position;
toNext = inverse(rotation) * toNext;
- 构造一个向量,指向下一个关节的世界空间 IK 链到当前位置的位置。这是当前节点和下一个节点之间的旋转:
vec3 toDesired = mWorldChain[i + 1] - position;
toDesired = inverse(rotation) * toDesired;
- 使用
fromTo
四元数函数将这两个向量对齐。将最终的增量旋转应用于当前关节的 IK 链旋转:
quat delta = fromTo(toNext, toDesired);
mIKChain[i].rotation = delta *
mIKChain[i].rotation;
}
}
- 接下来,实现
IterateBackward
函数,将链条中的最后一个元素设置为目标位置。这会打破 IK 链。使用存储的距离调整所有其他关节,以保持链条完整。执行此函数后,末端执行器始终位于目标位置,初始关节可能不再位于基底位置:
void FABRIKSolver::IterateBackward(const vec3& goal) {
int size = (int)Size();
if (size > 0) {
mWorldChain[size - 1] = goal;
}
for (int i = size - 2; i >= 0; --i) {
vec3 direction = normalized(mWorldChain[i] -
mWorldChain[i + 1]);
vec3 offset = direction * mLengths[i + 1];
mWorldChain[i] = mWorldChain[i + 1] + offset;
}
}
- 实现
IterateForward
函数。此函数重新排列 IK 链,使第一个链接从链的原点开始。此函数需要将初始关节设置为基底,并迭代所有其他关节,调整它们以保持 IK 链完整。执行此函数后,如果链条可解并且迭代次数足够,末端执行器可能位于目标位置:
void FABRIKSolver::IterateForward(const vec3& base) {
unsigned int size = Size();
if (size > 0) {
mWorldChain[0] = base;
}
for (int i = 1; i < size; ++i) {
vec3 direction = normalized(mWorldChain[i] -
mWorldChain[i - 1]);
vec3 offset = direction * mLengths[i];
mWorldChain[i] = mWorldChain[i - 1] + offset;
}
}
- 通过将 IK 链复制到世界位置向量并填充长度向量来开始实现
Solve
函数。可以使用IKChainToWorld
辅助函数完成。缓存基础和目标位置:
bool FABRIKSolver::Solve(const Transform& target) {
unsigned int size = Size();
if (size == 0) { return false; }
unsigned int last = size - 1;
float thresholdSq = mThreshold * mThreshold;
IKChainToWorld();
vec3 goal = target.position;
vec3 base = mWorldChain[0];
- 从
0
迭代到mNumSteps
。对于每次迭代,检查目标和末端执行器是否足够接近以解决链条问题。如果足够接近,则使用WorldToIKChain
辅助函数将世界位置复制回链条,并提前返回。如果它们不够接近,则通过调用IterateBackward
和IterateForward
方法进行迭代:
for (unsigned int i = 0; i < mNumSteps; ++i) {
vec3 effector = mWorldChain[last];
if (lenSq(goal - effector) < thresholdSq) {
WorldToIKChain();
return true;
}
IterateBackward(goal);
IterateForward(base);
}
- 迭代循环后,无论求解器是否能够解决链条问题,都将世界位置向量复制回 IK 链。最后再次检查末端执行器是否已经达到目标,并返回适当的布尔值:
WorldToIKChain();
vec3 effector = GetGlobalTransform(last).position;
if (lenSq(goal - effector) < thresholdSq) {
return true;
}
return false;
}
FABRIK 算法很受欢迎,因为它往往会快速收敛到最终目标,对于人形角色来说结果看起来不错,并且该算法易于实现。在下一节中,您将学习如何向 FABRIK 或 CCD 求解器添加约束。
实施约束
CCD 和 FABRIK 求解器都能产生良好的结果,但都不能产生可预测的结果。在本节中,您将学习约束是什么,IK 求解器约束可以应用在哪里,以及如何应用约束。这将让您构建更加逼真的 IK 求解器。
考虑一个应该代表腿的 IK 链。您希望确保每个关节的运动是可预测的,例如,膝盖可能不应该向前弯曲。
这就是约束有用的地方。膝盖关节是一个铰链;如果应用了铰链约束,腿的 IK 链看起来会更逼真。使用约束,您可以为 IK 链中的每个关节设置规则。
以下步骤将向您展示在 CCD 和 FABRIK 求解器中应用约束的位置:
- 约束可以应用于 CCD 和 FABRIK 求解器,并且必须在每次迭代后应用。对于 CCD,这意味着在这里插入一小段代码:
bool CCDSolver::Solve(const vec3& goal) {
// Local variables and size check
for (unsigned int i = 0; i < mNumSteps; ++i) {
// Check if we've reached the goal
for (int j = (int)size - 2; j >= 0; --j) {
// Iteration logic
// -> APPLY CONSTRAINTS HERE!
effector = GetGlobalTransform(last).position;
if (lenSq(goal - effector) < thresholdSq) {
return true;
}
}
}
// Last goal check
}
- 将约束应用于 FABRIK 求解器更加复杂。约束应用于每次迭代,并且 IK 链需要在每次迭代时在世界位置链和 IK 链之间转换。在将数据复制到变换链后,每次迭代都应用约束:
bool FABRIKSolver::Solve(const vec3& goal) {
// Local variables and size check
IKChainToWorld();
vec3 base = mWorldChain[0];
for (unsigned int i = 0; i < mNumSteps; ++i) {
// Check if we've reached the goal
IterateBackward(goal);
IterateForward(base);
WorldToIKChain();//NEW, NEEDED FOR CONSTRAINTS
// -> APPLY CONSTRAINTS HERE!
IKChainToWorld();//NEW, NEEDED FOR CONSTRAINTS
}
// Last goal check
}
Solve
函数是虚拟的原因是您可以将每个IKChain
类扩展为特定类型的链,例如LegIKChain
或ArmIKChain
,并直接将约束代码添加到解决方法中。在接下来的几节中,您将探索常见类型的约束。
球和插座约束
球和插座关节的工作原理类似于肩关节。关节可以在所有三个轴上旋转,但有一个角度约束阻止它自由旋转。图 13.3显示了球和插座约束的外观:
图 13.3:可视化的球和插座约束
要构建球和插座约束,您需要知道当前关节及其父关节的旋转。您可以从这些四元数构造前向矢量,并检查前向矢量的角度。如果角度大于提供的限制,需要调整旋转。
为了限制旋转,找到旋转轴。两个前向方向的叉乘垂直于两者;这是旋转轴。创建一个四元数,将角度限制沿着这个轴带入当前关节的局部空间,并将该四元数设置为关节的旋转:
void ApplyBallSocketConstraint(int i, float limit) {
quat parentRot = i == 0 ? mOffset.rotation :
GetWorldTransform(i - 1).rotation;
quat thisRot = GetWorldTransform(i).rotation;
vec3 parentDir = parentRot * vec3(0, 0, 1);
vec3 thisDir = thisRot * vec3(0, 0, 1);
float angle = ::angle(parentDir, thisDir);
if (angle > limit * QUAT_DEG2RAD) {
vec3 correction = cross(parentDir, thisDir);
quat worldSpaceRotation = parentRot *
angleAxis(limit * QUAT_DEG2RAD, correction);
mChain[i].rotation = worldSpaceRotation *
inverse(parentRot);
}
}
球和插座约束通常应用于角色的髋部或肩部关节。这些也往往是肢体 IK 链的根关节。在下一节中,您将探索另一种类型的约束,即铰链约束。
铰链约束
铰链约束类似于肘部或膝盖。它只允许在一个特定轴上旋转。图 13.4展示了铰链关节的外观:
图 13.4:可视化的铰链约束
要实施铰链约束,您需要知道当前关节和父关节的世界空间旋转。将轴法线分别乘以旋转四元数,并找到两者之间的四元数;这是您需要旋转以约束关节到一个轴的量。将此旋转带回关节空间并应用旋转:
void ApplyHingeSocketConstraint(int i, vec3 axis) {
Transform joint = GetWorldTransform(i);
Transform parent = GetWorldTransform(i - 1);
vec3 currentHinge = joint.rotation * axis;
vec3 desiredHinge = parent.rotation * axis;
mChain[i].rotation = mChain[i].rotation *
fromToRotation(currentHinge,
desiredHinge);
}
铰链约束通常用于肘部或膝盖关节。在下一节中,您将探讨如何使用 IK 将角色的脚对齐到地面。
使用 IK 将角色的脚对齐到地面
在本节中,您将学习如何使用 IK 来修改动画,使其看起来更加正确。具体来说,您将学习如何使用 IK 在行走时阻止角色的脚穿过不平整的地面。
现在,您可以使用 CCD 或 FABRIK 来解决 IK 链,让我们探讨这些求解器如何使用。IK 的两个常见用途是定位手部或脚部。在本节中,您将探讨在角色行走时如何将角色的脚夹紧在地面上的方法。
解决脚部夹紧问题,可以检查脚的最后全局位置与当前全局位置是否相符。如果脚部运动在途中碰到任何东西,就将脚固定在地面上。即使最琐碎的解决方案也有边缘情况:如果上升运动距离太远会发生什么?在动画循环的哪个时刻可以在固定和非固定位置之间进行插值?
为了使实现更容易,本章的地面夹紧策略将保持简单。首先,检查脚部是否与其上方的任何东西发生碰撞,例如穿过地形。为此,从角色的臀部到脚踝投射一条射线。
如果射线击中了任何东西,击中点将成为腿部 IK 链的目标。如果射线没有击中任何东西,则角色脚踝的当前位置将成为腿部 IK 链的目标。接下来,进行相同的射线投射,但不要停在角色的脚踝处;继续向下。
如果这条射线击中了任何东西,击中点将成为未来的 IK 目标。如果射线没有击中任何东西,则将未来的 IK 目标设置为当前的 IK 目标。现在有两个目标,一个自由运动,一个固定在地面上。
如果使用当前目标,角色的脚可能会突然贴在地面上。如果使用未来目标,角色将无法行走——它只会在地面上拖着脚。相反,您必须通过某个值在两个目标之间进行插值。
插值值应该来自动画本身。当角色的脚着地时,应使用当前目标;当脚抬起时,应使用未来目标。当角色的脚被抬起或放下时,目标位置应该进行插值。
有了 IK 目标后,IK 求解器可以计算出如何弯曲角色的腿。一旦腿部关节处于世界空间中,我们就调整脚的位置,使其始终在地形上,采取与解决腿部相似的步骤。
在接下来的章节中,您将更详细地探讨这里描述的每个步骤。然而,有一个小问题。大部分需要的值都是特定于用于渲染的模型的;不同的角色将需要不同调整的值。
寻找脚的目标
从角色的臀部下方一点到脚踝下方一点向下投射一条射线。这条射线应该直直地向下,沿着脚踝的位置。然而,射线应该从哪里开始,脚踝下方应该走多远,这取决于模型的具体情况:
图 13.5:射线投射以找到脚的目标
记录这条射线投射的结果,无论击中点有多远。这一点将被视为 IK 目标,始终被夹紧在地面上。检查射线是否击中了其起点和脚踝底部之间的任何东西。如果击中了,那将是脚踝的目标。如果没有击中,脚踝的目标将是脚踝的位置。
重要的是要记住,定位的是角色的脚踝,而不是脚底。因此,目标点需要上移脚踝到地面的距离:
图 13.6:偏移以定位角色的脚踝
这些脚部目标将控制 IK 系统如何覆盖动画。在行走时,如果脚部运动没有受到阻碍,IK 系统就不应该被注意到。在下一节中,您将学习如何控制脚部在动画和固定目标点之间的插值。
插值脚部目标
为了在当前和未来的 IK 目标之间进行插值,您需要了解当前播放的动画片段。具体来说,您需要知道腿处于什么阶段;它是着地的,被抬起的,悬停的,还是被放置的?编码这些信息的常见方法是使用标量曲线。
想法是创建两条标量曲线,一条用于左腿,一条用于右腿。这些曲线对应于当前步伐的幅度。例如,当左脚离开地面时,左曲线的值需要为 0。如果左脚着地,左曲线的值需要为 1。曲线看起来像这样:
图 13.7:步行循环幅度表示为标量曲线
根据当前的归一化播放时间对这些曲线进行采样。结果值将在 0 和 1 之间。使用这个 0 到 1 的值作为混合权重,将非 IK 调整的动画和 IK 调整的动画混合在一起。这条曲线通常是通过使用曲线编辑器进行手动编写的。该曲线是特定于当前播放的动画的。
在下一节中,您将探讨如何调整 IK 角色的垂直位置,以避免过度伸展肢体。
垂直角色定位
接下来,角色需要垂直定位,以便看起来好看。如果角色放得太高,它会以过度伸展的状态结束。太低,IK 系统会过度弯曲腿:
图 13.8:IK 过度伸展与采样动画比较
角色的定位是相对于建模时的情况。如果角色是在假定(0, 0, 0)是地面上的中心点进行建模的,您可以将其放在下方的表面上,并将其稍微陷入表面。
角色需要稍微陷入表面,以便 IK 系统能够进行一些工作并避免过度伸展。这带来了一个问题:角色的脚需要与哪个表面对齐?对齐位置可以来自碰撞/物理系统,或者在一个更简单的例子中,只是从角色正下方向下进行射线投射。
碰撞表面和视觉表面并不相同。考虑一个楼梯:碰撞几何通常是一个坡道。显示几何是看起来像实际楼梯的样子。在这种情况下,角色的位置应该是相对于碰撞几何的,但 IK 目标应该是相对于视觉几何定位的。
如果只有一个几何用于碰撞和视觉,该怎么办?在这种情况下,将角色放置在夹紧的 IK 目标之一,无论哪一个更低。这将确保地面始终可以到达,而不会过度伸展。
IK 传递
现在是解决腿部 IK 链的时候了。在这之前,将动画姿势中的关节复制到 IK 求解器中。对于每条腿,将髋关节的全局变换复制到 IK 求解器的根部。将膝盖的局部变换复制到关节 1,将脚踝的局部变换复制到关节 2。然后,运行 IK 求解器。求解器将把角色的脚放在目标点上,并将其夹紧在地面上。
脚部对齐
在这一点上,夹紧的脚部动画是平滑的,脚部将不再在地面内部剪切。但是只有角色的腿看起来正确,而脚没有。看看角色在非平坦表面上的脚部-仍然有相当多的剪切发生:
图 13.9:腿被夹紧到地面,但脚的方向错误
为了解决这个问题,创建一个脚尖射线。脚尖射线将位于角色的踝关节处,并沿着角色的前向轴一定距离。这将确保脚尖目标始终朝前,即使在动画中脚尖指向下。调整脚尖射线的垂直位置,使其从膝盖上方射到脚尖以下一点的位置:
图 13.10:即使脚尖朝下,也要向前投射偏移
将脚尖定位类似于腿的定位。找到一个目标,即当前脚尖的位置,被夹紧到地面上。通过动画的当前归一化时间在夹紧到地面的目标和活动动画目标之间插值。
这个脚尖目标将用于旋转脚。找到从踝到当前脚尖位置的向量。找到从踝到目标脚尖位置的向量。创建一个在这两个向量之间旋转的四元数。用这个四元数旋转踝部。
在本节中,您学习了如何找到脚目标,在它们之间插值,并使用这些目标和 IK 系统将角色的脚对齐到地面。地面对齐只是 IK 求解器的用例之一。类似的系统可以用于手臂抓取物体或整个身体创建一个布娃娃系统。
摘要
在本章中,您实现了 CCD 和 FABRIK IK 求解器。这两个求解器都可以解决 IK 链,但它们的收敛方式不同。哪种算法更好很大程度上取决于上下文。
您还学习了如何使用约束来限制特定关节的运动范围。通过正确的约束,IK 系统修改当前动画,使其与环境互动。您探讨了如何在本章的脚着地部分实现这一点。
本书的可下载内容中,本章有 4 个样本。Sample00
包含到目前为止的代码。Sample01
演示了如何使用 CCD 求解器,Sample02
演示了如何使用 FABRIK 求解器。Sample03
演示了角色沿着路径行走时的脚夹和地面对齐。
在下一章中,您将学习如何使用双四元数进行蒙皮。当网格弯曲或旋转时,双四元数蒙皮比线性混合蒙皮更好地保持了网格的体积。
进一步阅读
除了 FABRIK 和 CCD,IK 链有时会用解析方法或雅可比矩阵来求解:
-
有关分析 IK 求解器的更多信息,请访问此处。
-
完整的雅可比求解器实现在游戏编程宝石 4中有介绍。
第十四章:使用双四元数进行蒙皮
当前的蒙皮实现在皮肤权重之间线性混合,这称为线性混合蒙皮(LBS)或有时称为线性皮肤混合。线性混合皮肤不保持模型的体积,这会引入蒙皮伪影。可视化这种伪影的简单方法是将矩形的一端扭曲 180 度,如下面的屏幕截图所示:
图 14.1:比较线性混合和双四元数蒙皮
线性皮肤混合的替代方法是双四元数皮肤混合。使用双四元数时,模型的体积得以保持。在本章中,您将实现双四元数网格蒙皮。在本章结束时,您应该能够使用双四元数对动画角色进行蒙皮。本章涵盖以下主题:
-
引入双四元数
-
实现双四元数
-
使用双四元数进行蒙皮
-
了解如何使用双四元数蒙皮
引入双四元数
双四元数将线性和旋转变换结合到一个变量中。这个单一变量可以进行插值、变换和连接。双四元数可以用两个四元数或八个浮点数表示。
双数就像复数一样。复数有实部和虚部,双数有实部和虚部。假设是双重运算符,双数可以表示为,其中和。
双数的运算是作为虚数进行的,其中虚部和实部必须分别操作。例如,双四元数的加法可以表示为:
注意实部和虚部是独立添加的。
重要说明
如果您对双四元数背后更正式的数学感兴趣,请查看 Ben Kenwright 的A Beginner's Guide to Dual-Quaternions,网址 https://cs.gmu.edu/~jmlien/teaching/cs451/uploads/Main/dual-quaternion.pdf。
双四元数只是双数的扩展。实部和虚部由四元数代替标量值表示,大多数数学运算都是有效的。在下一节中,您将开始在代码中实现双四元数。
实现双四元数
在本节中,您将在代码中实现双四元数。在本节结束时,您将已经实现了一个双四元数结构,以及使用双四元数进行网格蒙皮所需的所有数学函数。
双四元数需要被实现为结构,类似于变换或矩阵。创建两个新文件,DualQuaternion.h
和DualQuaternion.cpp
。您将在这些文件中实现与双四元数相关的数学。
首先声明一个DualQuaternion
结构。这个结构将允许您以两个四元数或八个数字的浮点数组的形式访问双四元数结构中的数据。构造函数应该将双四元数设置为单位。单位双四元数的实部是单位四元数,虚部是零四元数,如下面的代码块所示:
struct DualQuaternion {
union {
struct {
quat real;
quat dual;
};
float v[8];
};
inline DualQuaternion() : real(0, 0, 0, 1), dual(0, 0, 0, 0) { }
inline DualQuaternion(const quat& r, const quat& d) :
real(r), dual(d) { }
};
双四元数的实部保存旋转数据,虚部保存位置数据。双四元数不处理缩放。在下一节中,您将声明并实现常见的双四元数操作,如加法和乘法。
在实现双四元数操作子节中,您将实现诸如加法、缩放、乘法和比较运算符之类的平凡双四元数运算符。在测量、归一化和求逆双四元数部分,您将学习如何为双四元数实现点积,如何测量双四元数以及如何求逆。在转换变换和双四元数部分,您将学习如何在DualQuaternion
和Transform
结构之间进行转换。最后,在变换向量和点部分,您将学习如何使用双四元数来变换向量和点,就像变换或矩阵一样。
实现双四元数操作
您需要定义一些数学运算符来处理双四元数。这些函数是加法、标量乘法、双四元数乘法和相等比较运算符。
通过乘法将两个双四元数组合在一起。与矩阵和四元数不同,双四元数从左到右相乘。按照以下步骤实现双四元数操作:
- 在
DualQuaternion.h
中声明加法、标量乘法、双四元数乘法和相等比较运算符,就像这样:
DualQuaternion operator+(const DualQuaternion &l,
const DualQuaternion &r);
DualQuaternion operator*(const DualQuaternion &dq,
float f);
// Multiplication order is left to right
// This is the OPPOSITE of matrices and quaternions
DualQuaternion operator*(const DualQuaternion &l,
const DualQuaternion &r);
bool operator==(const DualQuaternion &l,
const DualQuaternion &r);
bool operator!=(const DualQuaternion &l,
const DualQuaternion &r);
- 实现加法、标量乘法和比较函数。它们都是逐分量操作。分别在双四元数的实部和双部上执行逐分量操作,如下所示:
DualQuaternion operator+(const DualQuaternion &l,
const DualQuaternion &r) {
return DualQuaternion(l.real+r.real,l.dual+r.dual);
}
DualQuaternion operator*(const DualQuaternion &dq,
float f) {
return DualQuaternion(dq.real * f, dq.dual * f);
}
bool operator==(const DualQuaternion &l,
const DualQuaternion &r) {
return l.real == r.real && l.dual == r.dual;
}
bool operator!=(const DualQuaternion &l,
const DualQuaternion &r) {
return l.real != r.real || l.dual != r.dual;
}
- 首先确保两个双四元数都归一化,然后开始实现双四元数乘法:
// Remember, multiplication order is left to right.
// This is the opposite of matrix and quaternion
// multiplication order
DualQuaternion operator*(const DualQuaternion &l, const DualQuaternion &r) {
DualQuaternion lhs = normalized(l);
DualQuaternion rhs = normalized(r);
- 将两个归一化四元数的实部合并在一起。双部更复杂,因为必须等于
0
。通过将两个四元数的双部和实部相乘并将结果相加来满足此要求,就像这样:
return DualQuaternion(lhs.real * rhs.real,
lhs.real * rhs.dual +
lhs.dual * rhs.real);
}
大多数情况下,常见的双四元数运算符是直观的,但是双四元数的乘法顺序与惯例相反,这使它们有点难以处理。在下一节中,您将了解双四元数的点积和正常实现。
测量、归一化和求逆双四元数
点积测量两个双四元数的相似程度。双四元数点积的规则与向量和四元数点积相同。点积的结果是一个标量值,具有以下属性:
-
如果双四元数指向相同方向,则为正。
-
如果双四元数指向相反方向,则为负。
-
如果双四元数垂直,则为零。
非单位双四元数可能会引入不需要的扭曲,这是由双四元数表示的变换引起的。要归一化双四元数,实部和双部都需要除以实部的长度。
归一化双四元数就像归一化常规四元数一样,主要操作在实部上。首先,找到双四元数的实部的长度,然后将实部和双部都除以长度。这将实部和双部都归一化为实部的长度。
由于点积只考虑方向,双四元数的虚部不会被使用。找到两个双四元数的实部的点积。双四元数共轭
操作是四元数共轭的扩展,分别找到实部和双部的共轭。
按照以下步骤实现点积
、求逆
和归一化
函数:
- 在
DualQuaternion.h
中声明双四元数点积、共轭和归一化函数,如下所示:
float dot(const DualQuaternion& l,
const DualQuaternion& r);
DualQuaternion conjugate(const DualQuaternion& dq);
DualQuaternion normalized(const DualQuaternion& dq);
void normalize(DualQuaternion& dq);
- 通过找到两个双四元数的实部的四元数点积来实现点积,并返回它们的结果,就像这样:
float dot(const DualQuaternion& l,
const DualQuaternion& r) {
return dot(l.real, r.real);
}
- 通过分别对实部和双部取四元数共轭来实现
conjugate
函数,如下所示:
DualQuaternion conjugate(const DualQuaternion& dq) {
return DualQuaternion(conjugate(dq.real),
conjugate(dq.dual));
}
- 通过找到实部的长度并将双部和实部都缩放为长度的倒数来实现
normalized
函数,如下所示:
DualQuaternion normalized(const DualQuaternion& dq) {
float magSq = dot(dq.real, dq.real);
if (magSq < 0.000001f) {
return DualQuaternion();
}
float invMag = 1.0f / sqrtf(magSq);
return DualQuaternion(dq.real * invMag,
dq.dual * invMag);
}
- 实现
normalize
函数。与normalized
不同,normalize
函数接受双四元数引用并就地对其进行规范化,如下所示:
void normalize(DualQuaternion& dq) {
float magSq = dot(dq.real, dq.real);
if (magSq < 0.000001f) {
return;
}
float invMag = 1.0f / sqrtf(magSq);
dq.real = dq.real * invMag;
dq.dual = dq.dual * invMag;
}
如果双四元数随时间变化,由于浮点误差可能变得非规范化。如果双四元数的实部长度不是1
,则需要对双四元数进行规范化。而不是检查长度是否等于一,这将涉及平方根运算,您应该检查平方长度是否为1
,这样操作速度更快。在接下来的部分,您将学习如何在变换和双四元数之间转换。
转换变换和双四元数
双四元数包含与变换类似的数据,但没有缩放分量。可以在两者之间进行转换,但会丢失缩放。
将变换转换为双四元数时,双四元数的实部映射到变换的旋转。要计算双部分,从变换的平移向量创建一个纯四元数。然后,将这个纯四元数乘以变换的旋转。结果需要减半——除以二或乘以 0.5。
将双四元数转换为变换时,变换的旋转仍然映射到双四元数的实部。要找到位置,将双部乘以二并将结果与变换的旋转的倒数组合。这将产生一个纯四元数。这个纯四元数的向量部分就是新的位置。
按照以下步骤实现在Transform
和DualQuaternion
对象之间转换的代码:
- 在
DualQuaternion.h
中声明函数,将双四元数转换为变换和将变换转换为双四元数,如下所示:
DualQuaternion transformToDualQuat(const Transform& t);
Transform dualQuatToTransform(const DualQuaternion& dq);
- 实现
transformToDualQuat
函数。生成的双四元数不需要被规范化。以下代码中可以看到这个过程:
DualQuaternion transformToDualQuat(const Transform& t) {
quat d(t.position.x, t.position.y, t.position.z, 0);
quat qr = t.rotation;
quat qd = qr * d * 0.5f;
return DualQuaternion(qr, qd);
}
- 实现
dualQuatToTransform
函数。假定输入的双四元数已经被规范化。以下代码中可以看到这个过程:
Transform dualQuatToTransform(const DualQuaternion& dq){
Transform result;
result.rotation = dq.real;
quat d = conjugate(dq.real) * (dq.dual * 2.0f);
result.position = vec3(d.x, d.y, d.z);
return result;
}
双四元数也可以转换为矩阵,反之亦然;然而,通常不使用该操作。双四元数用于替换蒙皮流程中的矩阵,因此矩阵转换并不是必要的。在接下来的部分,您将探讨双四元数如何转换向量或点。
变换向量和点
双四元数包含刚性变换数据。这意味着双四元数可以用于变换向量和点。要通过双四元数变换点,将双四元数分解为旋转和位置分量,然后以变换的方式变换向量,但不包括缩放。
按照以下步骤声明和实现使用双四元数对向量和点进行变换的transform
函数:
- 在
DualQuaternion.h
中声明transformVector
和transformPoint
函数,如下所示:
vec3 transformVector(const DualQuaternion& dq,
const vec3& v);
vec3 transformPoint(const DualQuaternion& dq,
const vec3& v);
- 通过双四元数旋转向量是微不足道的。由于双四元数的实部包含旋转,将向量乘以双四元数的实部,如下所示:
vec3 transformVector(const DualQuaternion& dq,
const vec3& v) {
return dq.real * v;
}
- 要通过双四元数变换点,将双四元数转换为旋转和平移分量。然后,将这些平移和旋转分量应用于向量:
旋转 * 向量 + 平移
。这个公式的工作方式与变换移动点的方式相同,但没有缩放分量。以下代码中可以看到这个过程:
vec3 transformPoint(const DualQuaternion& dq,
const vec3& v) {
quat d = conjugate(dq.real) * (dq.dual * 2.0f);
vec3 t = vec3(d.x, d.y, d.z);
return dq.real * v + t;
}
现在可以使用双四元数类代替Transform
类。双四元数可以按层次结构排列,并使用乘法进行组合,通过这些新函数,双四元数可以直接转换点或矢量。
在本节中,您在代码中实现了双四元数。您还实现了所有需要使用双四元数的函数。在下一节中,您将学习如何使用双四元数进行网格蒙皮。
使用双四元数进行蒙皮
在本节中,您将学习如何修改蒙皮算法,使其使用双四元数而不是矩阵。具体来说,您将用双四元数替换蒙皮矩阵,这将同时转换顶点位置和法线位置。
双四元数解决的问题是矩阵的线性混合,目前在顶点着色器中实现。具体来说,这是引入蒙皮伪影的代码段:
mat4 skin;
skin = (pose[joints.x] * invBindPose[joints.x]) * weights.x;
skin += (pose[joints.y] * invBindPose[joints.y]) * weights.y;
skin += (pose[joints.z] * invBindPose[joints.z]) * weights.z;
skin += (pose[joints.w] * invBindPose[joints.w]) * weights.w;
在动画流水线中有三个阶段,可以用双四元数替换矩阵。每个阶段都会产生相同的结果。应该实现双四元数的三个地方如下所示:
-
在顶点着色器中将矩阵转换为双四元数。
-
将当前姿势的矩阵转换为双四元数,然后将双四元数传递给顶点着色器。
-
将当前姿势的每个变换转换为双四元数,然后累积世界变换为双四元数。
在本章中,您将实现第三个选项,并向Pose
类添加GetDualQuaternionPalette
函数。您还将为Skeleton
类的GetInvBindPose
函数添加一个重载。在接下来的部分中,您将开始修改Skeleton
类以支持双四元数蒙皮动画。
修改姿势类
Pose
类需要两个新函数——一个用于检索指定关节的世界双四元数(即GetGlobalDualQuaternion
),另一个用于将姿势转换为双四元数调色板。按照以下步骤声明和实现这些函数:
- 在
Pose.h
中的Pose
类中添加GetDualQuaternionPalette
和GetGlobalDualQuaternion
函数的声明,如下所示:
class Pose {
// Existing functions and interface
public: // NEW
void GetDualQuaternionPalette(vector<DualQuaternion>& o);
DualQuaternion GetGlobalDualQuaternion(unsigned int i);
};
- 实现
GetGlobalDualQuaternion
函数以返回关节的世界空间双四元数,如下所示:
DualQuaternion Pose::GetGlobalDualQuaternion(
unsigned int index) {
DualQuaternion result = transformToDualQuat(
mJoints[index]);
for (int p = mParents[index]; p >= 0;
p = mParents[p]) {
DualQuaternion parent = transformToDualQuat(
mJoints[p]);
// Remember, multiplication is in reverse!
result = result * parent;
}
return result;
}
- 实现
GetDualQuaternionPalette
函数,该函数应该循环遍历当前姿势中存储的所有关节,并将它们的世界空间双四元数存储在输出向量中,如下所示:
void Pose::GetDualQuaternionPalette(
vector<DualQuaternion>& out) {
unsigned int size = Size();
if (out.size() != size) {
out.resize(size);
}
for (unsigned int i = 0; i < size; ++i) {
out[i] = GetGlobalDualQuaternion(i);
}
}
双四元数转换发生在关节本地空间中,因此您不需要向Pose
类添加任何额外的数据,而是能够添加两个新函数。在下一节中,您将修改Skeleton
类以提供双四元数的逆绑定姿势。
修改骨骼类
为了使用双四元数对网格进行蒙皮,网格的逆绑定姿势也需要用双四元数表示。在本节中,您将为GetInvBindPose
函数添加一个重载,该函数将填充一个双四元数对象的引用。按照以下步骤实现新的GetInvBindPose
函数:
- 在
Skeleton
类中声明一个额外的GetInvBindPose
函数,该函数将以双四元数向量的引用作为参数。当函数完成时,它将填充向量与逆绑定姿势双四元数。可以在以下片段中看到此代码:
class Skeleton {
// Existing functions and interface
public: // GetInvBindPose is new
void GetInvBindPose(vector<DualQuaternion>& pose);
};
- 在
Skeleton.cpp
中重写GetInvBindPose
函数。调整输入向量的大小与绑定姿势一样大。对于每个关节,获取关节的全局双四元数表示。最后,将每个世界空间双四元数的共轭存储在输出向量中。可以在以下片段中看到此代码:
void Skeleton::GetInvBindPose(std::vector<DualQuaternion>&
outInvBndPose) {
unsigned int size = mBindPose.Size();
outInvBndPose.resize(size);
for (unsigned int i = 0; i < size; ++i) {
DualQuaternion world =
mBindPose.GetGlobalDualQuaternion(i);
outInvBndPose[i] = conjugate(world);
}
}
现在可以将骨骼的动画姿势和逆绑定姿势转换为双四元数数组。 但是,为了在着色器中使用这些双四元数,它们需要以某种方式传递到该着色器。 在下一节中,您将实现一个新的双四元数统一类型来执行此操作。
创建新的统一类型
为了将双四元数作为矩阵的替代品,需要一种方法将它们用作着色器统一变量。 双四元数可以被视为 2x4 矩阵,并且可以使用glUniformMatrix2x4fv
函数进行设置。
使用DualQuaternion
为Uniform
类声明模板特化。 需要实现Set
函数。 它应该使用glUniformMatrix2x4fv
函数将双四元数数组上传为 2x4 矩阵。 实现新的Set
函数,如下面的代码片段所示:
template Uniform<DualQuaternion>;
template<>
void Uniform<DualQuaternion>::Set(unsigned int slot,
DualQuaternion* inputArray,
unsigned int arrayLength) {
glUniformMatrix2x4fv(slot, arrayLength,
false, inputArray[0].v);
}
由于Set
函数是模板化的,因此不需要在头文件中声明; 它只是函数的专门实例。 在下一节中,您将探讨如何实现使用双四元数进行蒙皮的顶点着色器。
创建双四元数着色器
为了支持双四元数蒙皮,唯一剩下的事情就是实现顶点着色器。 新的顶点着色器将类似于其线性混合蒙皮对应物。 此着色器将不再具有用于矩阵调色板的两个mat4
统一数组,而是具有用于双四元数的两个mat2x4
统一数组。
着色器将不得不混合双四元数。 每当两个四元数(双四元数的实部)混合时,都有可能混合发生在错误的邻域,并且四元数以长方式插值。 在混合时需要牢记邻域。
按照以下步骤实现新的顶点着色器:
- 开始声明着色器与
model
,view
和projection
统一变量,如下所示:
#version 330 core
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
- 声明顶点结构。 顶点的输入值如下:
position
,normal
,纹理坐标,权重和关节影响。 每个顶点应该有最多四个权重和影响。 可以在以下代码片段中看到此代码:
in vec3 position;
in vec3 normal;
in vec2 texCoord;
in vec4 weights;
in ivec4 joints;
- 声明传递给片段着色器的输出值。 这些是顶点法线,世界空间中的片段位置和
uv
坐标,如下面的代码片段所示:
out vec3 norm;
out vec3 fragPos;
out vec2 uv;
- 声明蒙皮统一变量。 这些不再是
mat4
数组; 它们现在是mat2x4
数组。mat2x4
有两列四行。 对mat2x4
进行下标,索引0
是双四元数的实部,索引1
是双部。 代码可以在以下代码片段中看到:
uniform mat2x4 pose[120];
uniform mat2x4 invBindPose[120];
- 实现四元数乘法函数。 这个函数的代码与第四章中创建的代码相同,可以在以下代码片段中看到:
vec4 mulQ(vec4 Q1, vec4 Q2) {
return vec4(
Q2.x*Q1.w + Q2.y*Q1.z - Q2.z*Q1.y + Q2.w*Q1.x,
-Q2.x*Q1.z + Q2.y*Q1.w + Q2.z*Q1.x + Q2.w*Q1.y,
Q2.x*Q1.y - Q2.y*Q1.x + Q2.z*Q1.w + Q2.w*Q1.z,
-Q2.x*Q1.x - Q2.y*Q1.y - Q2.z*Q1.z + Q2.w*Q1.w
);
}
- 实现
normalize
双四元数函数。 通过将其实部和双部分都除以实部的大小来规范化双四元数。 代码可以在以下代码片段中看到:
mat2x4 normalizeDq(mat2x4 dq) {
float invMag = 1.0 / length(dq[0]);
dq[0] *= invMag;
dq[1] *= invMag;
return dq;
}
- 实现双四元数乘法函数以组合双四元数,如下所示:
mat2x4 combineDq(mat2x4 l, mat2x4 r) {
l = normalizeDq(l);
r = normalizeDq(r);
vec4 real = mulQ(l[0], r[0]);
vec4 dual = mulQ(l[0], r[1]) + mulQ(l[1], r[0]);
return mat2x4(real, dual);
}
- 实现一个通过双四元数变换向量的函数,如下所示:
vec4 transformVector(mat2x4 dq, vec3 v) {
vec4 real = dq[0];
vec3 r_vector = real.xyz;
float r_scalar = real.w;
vec3 rotated = r_vector * 2.0f * dot(r_vector, v) +
v * (r_scalar * r_scalar - dot(r_vector, r_vector))+
cross(r_vector, v) * 2.0f * r_scalar;
return vec4(rotated, 0);
}
- 实现一个通过双四元数变换点的函数,如下所示:
vec4 transformPoint(mat2x4 dq, vec3 v) {
vec4 real = dq[0];
vec4 dual = dq[1];
vec3 rotated = transformVector(dq, v).xyz;
vec4 conjugate = vec4(-real.xyz, real.w);
vec3 t = mulQ(conjugate, dual * 2.0).xyz;
return vec4(rotated + t, 1);
}
- 实现顶点着色器的主要方法。 通过将关节 1、2 和 3(
joints.y
,joints.z
,joints.w
)邻近到关节 0(joints.x
)来开始实现:
void main() {
vec4 w = weights;
// Neighborhood all of the quaternions correctly
if (dot(pose[joints.x][0], pose[joints.y][0]) < 0.0)
{ w.y *= -1.0; }
if (dot(pose[joints.x][0], pose[joints.z][0]) < 0.0)
{ w.z *= -1.0; }
if (dot(pose[joints.x][0], pose[joints.w][0]) < 0.0)
{ w.w *= -1.0; }
- 将每个关节的世界空间双四元数与相同关节的逆绑定姿势双四元数相结合。 记住:双四元数乘法是从左到右的。 将每次乘法的结果存储在一个新变量中。 代码可以在以下代码片段中看到:
// Combine
mat2x4 dq0 = combineDq(invBindPose[joints.x],
pose[joints.x]);
mat2x4 dq1 = combineDq(invBindPose[joints.y],
pose[joints.y]);
mat2x4 dq2 = combineDq(invBindPose[joints.z],
pose[joints.z]);
mat2x4 dq3 = combineDq(invBindPose[joints.w],
pose[joints.w]);
- 将四个蒙皮双四元数混合在一起。使用双四元数标量乘法和双四元数加法实现混合。不要忘记对皮肤双四元数进行归一化。代码可以在以下片段中看到:
mat2x4 skinDq = w.x * dq0 + w.y * dq1 +
w.z * dq2 + w.w * dq3;
skinDq = normalizeDq(skinDq);
- 使用
transformPoint
函数和皮肤双四元数对顶点进行蒙皮。将结果的vec4
通过正常的模型视图投影管线,如下所示:
vec4 v = transformPoint(skinDq, position);
gl_Position = projection * view * model * v;
fragPos = vec3(model * v);
- 类似地转换法线。不要忘记将
uv
坐标传递给片段着色器。代码可以在以下片段中看到:
vec4 n = transformVector(skinDq, normal);
norm = vec3(model * n);
uv = texCoord;
}
任何涉及缩放的动画都无法使用这种方法。这种双四元数实现不支持缩放。可以在双四元数之上实现缩放支持,但涉及的工作量超过了其性能上的好处。
在本节中,您学习了如何使用双四元数实现蒙皮。这包括修改姿势数据和Skeleton
类,创建新的统一变量,并构建新的着色器。在接下来的部分中,您将探讨如何使用迄今为止编写的双四元数代码。
了解如何使用双四元数蒙皮
本节将探讨如何将迄今为止编写的双四元数蒙皮代码应用于现有应用程序。此代码仅供参考;您无需跟随它。
使用双四元数蒙皮着色器非常简单;在运行时轻松切换蒙皮方法。以下步骤演示了如何使用双四元数着色器或线性蒙皮着色器来对同一模型进行动画化。
跟踪双四元数姿势调色板和反向绑定姿势调色板,以及线性混合姿势调色板和反向绑定姿势调色板。看一下以下代码:
// For dual quaternion skinning
std::vector<DualQuaternion> mDqPosePalette;
std::vector<DualQuaternion> mDqInvBindPalette;
// For linear blend skinning
std::vector<mat4> mLbPosePalette;
std::vector<mat4> mLbInvBindPalette;
应用程序初始化时,将反向绑定姿势缓存为矩阵向量和双四元数向量,如下所示:
mCurrentPose = mSkeleton.GetRestPose();
mCurrentPose.GetDualQuaternionPalette(mDqPosePalette);
mSkeleton.GetInvBindPose(mDqInvBindPalette);
mCurrentPose.GetMatrixPalette(mLbPosePalette);
mLbInvBindPalette = mSkeleton.GetInvBindPose();
在对动画进行采样时,将生成的姿势调色板转换为双四元数和线性混合版本,如下所示:
mPlayTime = mClips[mClip].Sample(mCurrentPose,
mPlayTime + dt);
mCurrentPose.GetDualQuaternionPalette(mDqPosePalette);
mCurrentPose.GetMatrixPalette(mLbPosePalette);
在渲染动画时,请确保使用正确的统一变量,如下所示:
if (mSkinningMethod == SkinningMethod::DualQuaternion) {
Uniform<DualQuaternion>::Set(
shader->GetUniform("pose"), mDqPosePalette);
Uniform<DualQuaternion>::Set(
shader->GetUniform("invBindPose"), mDqInvBindPalette);
}
else {
Uniform<mat4>::Set(shader->GetUniform("pose"),
mLbPosePalette);
Uniform<mat4>::Set(shader->GetUniform("invBindPose"),
mLbInvBindPalette);
}
在此示例中,轻松切换线性混合蒙皮和双四元数蒙皮着色器只需更改mSkinningMethod
变量的值。这是因为两种着色器之间唯一的区别是姿势调色板统一变量。
总结
在本章中,您学习了双四元数背后的数学知识,并实现了双四元数类。您发现了线性混合蒙皮可能产生的一些问题,并了解了如何使用双四元数来避免这些问题。本章中实现的双四元数蒙皮着色器可以用来替换线性混合蒙皮着色器。
如果您在本书的可下载材料中查看Chapter14
,会发现有两个示例。Sample00
包含到目前为止的所有代码。Sample01
将相同的扭曲立方体模型渲染两次。第一个立方体使用线性混合蒙皮着色器进行渲染。第二个使用双四元数着色器进行渲染。
在下一章中,您将探讨如何使用索引绘制来对大型人群进行动画化。这很有趣,因为它涉及将姿势生成移动到图形处理单元(GPU)并在顶点着色器中执行整个蒙皮动画管线。
第十五章:渲染实例化人群
这最后一章探讨了如何使用实例化来渲染大型人群。人群渲染是一个有趣的话题,因为它将姿势生成(采样)和混合移动到了 GPU 上,使整个动画流水线在顶点着色器中运行。
将姿势生成移动到顶点着色器中,需要将动画信息编码到纹理中。本章的重点将是将动画数据编码到纹理中,并使用该纹理创建动画姿势。
没有实例化,绘制大量人群意味着需要进行大量的绘制调用,这将影响帧率。使用实例化,一个网格可以被多次绘制。如果只有一个绘制调用,人群中每个角色的动画姿势将需要不同的生成。
在本章中,您将探讨将动画采样移动到顶点着色器中以绘制大型人群。本章将涵盖以下主题:
-
在纹理中存储任意数据
-
从纹理中检索任意数据
-
将动画烘焙到纹理中
-
在顶点着色器中对动画纹理进行采样
-
优化人群系统
在纹理中存储数据
在 GPU 上进行动画采样并不是一件简单的事情。有很多循环和函数,这使得在 GPU 上进行动画采样成为一个困难的问题。解决这个问题的一种方法是简化它。
与实时采样动画不同,可以在设定的时间间隔内进行采样。在设定的时间间隔内对动画进行采样并将结果数据写入文件的过程称为烘焙。
动画数据烘焙后,着色器就不再需要采样实际的动画片段。相反,它可以根据时间查找最近的采样姿势。那么,这些动画数据烘焙到哪里呢?动画可以烘焙到纹理中。纹理可以用作数据缓冲区,并且已经有一种简单的方法在着色器中读取纹理数据。
通常,纹理中的存储类型和信息都是由着色器中的采样函数抽象出来的。例如,GLSL 中的texture2D
函数以归一化的uv
坐标作为参数,并返回一个四分量向量,其值范围从0
到1
。
但是纹理中的信息并不是这样的。当使用glTexImage2D
创建纹理时,它需要一个内部纹理格式(GL_RGBA
),一个源格式(通常再次是GL_RGBA
)和一个数据类型(通常是GL_UNSIGNED_BYTE
)。这些参数用于将底层数据类型转换为texture2D
返回的归一化值。
在将任意数据存储在纹理中时,存在两个问题。第一个是数据的粒度。在GL_RGBA
的情况下,每个采样的浮点分量只有 256 个唯一值。第二,如果需要存储的值不是归一化到0
到1
范围内的呢?
这就是浮点纹理的用武之地。您可以创建一个具有GL_RGBA32F
格式的四分量浮点纹理。这个纹理会比其他纹理大得多,因为每个像素将存储四个完整的 32 位浮点数。
浮点纹理可以存储任意数据。在接下来的部分,您将学习如何从浮点纹理中检索任意数据。之后,您将探讨着色器如何从浮点纹理中读取数据。
从纹理中读取数据
本节探讨了如何在着色器中检索存储在纹理中的动画数据。在本节中,您将学习如何对纹理进行采样以及在采样纹理时应该使用哪些采样器状态。
一旦数据格式正确,对其进行采样就成为下一个挑战。glTexImage2D
函数期望归一化的uv
坐标并返回一个归一化值。另一方面,texelFetch
函数可以用于使用像素坐标对纹理进行采样并返回这些坐标处的原始数据。
texelFetch
glsl 接受三个参数:一个采样器,一个ivec2
和一个整数。ivec2
是被采样的像素的x和y坐标,以像素空间为单位。最后一个整数是要使用的 mip 级别,对于本章来说,将始终为0
。
mipmap 是同一图像的逐渐降低分辨率版本的链。当 mip 级别缩小时,数据会丢失。这种数据丢失会改变动画的内容。避免为动画纹理生成 mip。
因为需要以与写出时完全相同的方式读取数据,任何插值也会破坏动画数据。确保使用最近邻采样来对动画纹理进行采样。
使用texelFetch
而不是glTexImage2D
来对纹理进行采样应该返回正确的数据。纹理可以在顶点着色器或片段着色器中进行采样。在下一节中,您将探索这些浮点纹理中应该存储什么动画数据。
编码动画数据
现在你知道如何读取和写入数据到纹理了,下一个问题是,纹理中需要写入什么数据?你将把动画数据编码到纹理中。每个动画片段将在设定的间隔内进行采样。所有这些样本的结果姿势将存储在纹理中。
为了编码这些数据,纹理的x轴将表示时间。纹理的y轴将表示正在进行动画的骨骼。每个骨骼将占用三行:一个用于位置,一个用于旋转,一个用于缩放。
动画片段将在设定的间隔内进行采样,以确保纹理的宽度有多少个样本。例如,对于一个256x256的动画纹理,动画片段将需要被采样 256 次。
在对动画片段进行采样以将其编码到纹理中时,对于每个样本,您将找到每个骨骼的世界空间变换并将其写入纹理。y坐标将是joint_index * 3 + component
,其中有效的组件是position = 0
,rotation = 1
和scale = 3
。
一旦这些值被写入纹理,就将纹理上传到 GPU 并使用它。在下一节中,您将探索着色器如何评估这个动画纹理。
探索每个实例数据
在渲染大量人群时,人群中的每个演员都有特定的属性。在本节中,您将探索每个实例数据是什么,以及如何将其传递给着色器。这将大大减少每帧上传到 GPU 的统一数组的数据量。
将蒙皮管道移动到顶点着色器并不能完全消除需要将与人群相关的统一数据传递给着色器。人群中的每个演员都需要一些数据上传到 GPU。每个实例数据比使用姿势调色板矩阵上传的数据要小得多。
人群中的每个演员都需要位置、旋转和缩放来构建模型矩阵。演员需要知道当前帧进行采样以及当前帧和下一帧之间的时间来进行混合。
每个演员实例数据的总大小是 11 个浮点数和 2 个整数。每个实例只有 52 个字节。每个实例数据将始终使用统一数组传递。数组的大小是人群包含的演员数量。数组的每个元素代表一个独特的演员。
着色器将负责从每个实例数据和动画纹理构建适当的矩阵。当前帧和下一帧之间的混合是可选的;混合可能不会 100%正确,但它应该看起来还不错。
在下一节中,您将实现一个AnimationTexture
类,它将让您在代码中使用动画纹理。
创建动画纹理
在这一节中,您将实现所有需要在AnimTexture
类中使用浮点纹理的代码。每个AnimTexture
对象将包含一个 32 位浮点 RGBA 纹理。这些数据将有两份:一份在 CPU 上,一份上传到 GPU 上。
CPU 缓冲区保留下来,以便在保存到磁盘之前或上传到 OpenGL 之前轻松修改纹理的内容。这样做可以简化 API,但会增加一些额外的内存。
没有标准的 32 位纹理格式,因此保存和写入磁盘将简单地将AnimTexture
类的二进制内容转储到磁盘上。在下一节中,您将开始实现AnimTexture
类。这个类将提供一个易于使用的接口,用于实现 32 位浮点纹理。
声明 AnimTexture 类
动画纹理被假定总是正方形的;宽度和高度不需要分别跟踪。使用单个大小变量应该足够了。AnimTexture
类将始终在内存中同时拥有两份纹理,一份在 CPU 上,一份在 GPU 上。
创建一个名为AnimTexture.h
的新文件,并在这个文件中声明AnimTexture
类。按照以下步骤声明AnimTexture
类:
- 声明
AnimTexture
类。它有三个成员变量:一个浮点数组,一个纹理大小的整数,以及一个指向 OpenGL 纹理对象的句柄:
class AnimTexture {
protected:
float* mData;
unsigned int mSize;
unsigned int mHandle;
- 声明
AnimTexture
具有默认构造函数、复制构造函数、赋值运算符和析构函数:
public:
AnimTexture();
AnimTexture(const AnimTexture&);
AnimTexture& operator=(const AnimTexture&);
~AnimTexture();
- 声明函数,以便将
AnimTexture
保存到磁盘并再次加载:
void Load(const char* path);
void Save(const char* path);
- 声明一个函数,将数据从
mData
变量上传到 OpenGL 纹理:
void UploadTextureDataToGPU();
- 声明
AnimTexture
包含的 CPU 端数据的 getter 和 setter 函数:
unsigned int Size();
void Resize(unsigned int newSize);
float* GetData();
- 声明
GetTexel
,它接受x和y坐标并返回一个vec4
,以及一个SetTexel
函数来设置vec3
或quat
对象。这些函数将写入纹理的数据:
void SetTexel(unsigned int x, unsigned int y,
const vec3& v);
void SetTexel(unsigned int x, unsigned int y,
const quat& q);
vec4 GetTexel(unsigned int x, unsigned int y);
- 声明绑定和解绑纹理以进行渲染的函数。这将与
Texture
类的Set
和Unset
函数的方式相同:
void Set(unsigned int uniform, unsigned int texture);
void UnSet(unsigned int textureIndex);
unsigned int GetHandle();
};
AnimTexture
类是一种方便的处理浮点纹理的方式。get
和SetTexel
方法可以使用直观的 API 读取和写入纹理。在下一节中,您将开始实现AnimTexture
类。
实现AnimTexture
类
在这一节中,您将实现AnimTexture
类,其中包含用于处理浮点纹理的 OpenGL 代码,并提供一个易于使用的 API。如果您想使用除了 OpenGL 之外的图形 API,那么这个类将需要使用该 API 进行重写。
当AnimTexture
保存到磁盘时,整个mData
数组将作为一个大的二进制块写入文件。这个大的纹理数据占用了相当多的内存;例如,一个512x512的纹理大约占用 4MB。纹理压缩不适用,因为动画数据需要精确。
SetTexel
函数是我们将要写入动画纹理数据的主要方式。这些函数接受x和y坐标,以及vec3
或四元数值。函数需要根据给定的x和y坐标找出mData
数组中的正确索引,然后相应地设置像素值。
创建一个名为AnimTexture.cpp
的新文件。在这个新文件中实现AnimTexture
类。现在,按照以下步骤实现AnimTexture
类:
- 实现默认构造函数。它应该将数据和大小设置为零,并生成一个新的 OpenGL 着色器句柄:
AnimTexture::AnimTexture() {
mData = 0;
mSize = 0;
glGenTextures(1, &mHandle);
}
- 实现复制构造函数。它应该做与默认构造函数相同的事情,并使用赋值运算符来复制实际的纹理数据:
AnimTexture::AnimTexture(const AnimTexture& other) {
mData = 0;
mSize = 0;
glGenTextures(1, &mHandle);
*this = other;
}
- 实现赋值运算符。它只需要复制 CPU 端的数据;OpenGL 句柄可以不变:
AnimTexture& AnimTexture::operator=(
const AnimTexture& other) {
if (this == &other) {
return *this;
}
mSize = other.mSize;
if (mData != 0) {
delete[] mData;
}
mData = 0;
if (mSize != 0) {
mData = new float[mSize * mSize * 4];
memcpy(mData, other.mData,
sizeof(float) * (mSize * mSize * 4));
}
return *this;
}
- 实现
AnimTexture
类的析构函数。它应该删除内部浮点数组,并释放类所持有的 OpenGL 句柄:
AnimTexture::~AnimTexture() {
if (mData != 0) {
delete[] mData;
}
glDeleteTextures(1, &mHandle);
}
- 实现
Save
函数。它应该将AnimTexture
的大小写入文件,并将mData
的内容作为一个大的二进制块写入:
void AnimTexture::Save(const char* path) {
std::ofstream file;
file.open(path, std::ios::out | std::ios::binary);
if (!file.is_open()) {
cout << "Couldn't open " << path << "\n";
}
file << mSize;
if (mSize != 0) {
file.write((char*)mData,
sizeof(float) * (mSize * mSize * 4));
}
file.close();
}
- 实现
Load
函数,将序列化的动画数据加载回内存:
void AnimTexture::Load(const char* path) {
std::ifstream file;
file.open(path, std::ios::in | std::ios::binary);
if (!file.is_open()) {
cout << "Couldn't open " << path << "\n";
}
file >> mSize;
mData = new float[mSize * mSize * 4];
file.read((char*)mData,
sizeof(float) * (mSize * mSize * 4));
file.close();
UploadTextureDataToGPU();
}
- 实现
UploadDataToGPU
函数。它的实现方式与Texture::Load
非常相似,但使用的是GL_RGBA32F
而不是GL_FLOAT
:
void AnimTexture::UploadTextureDataToGPU() {
glBindTexture(GL_TEXTURE_2D, mHandle);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, mSize,
mSize, 0, GL_RGBA, GL_FLOAT, mData);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S,
GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T,
GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D,
GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D,
GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindTexture(GL_TEXTURE_2D, 0);
}
- 实现大小、OpenGL 句柄和浮点数据获取函数:
unsigned int AnimTexture::Size() {
return mSize;
}
unsigned int AnimTexture::GetHandle() {
return mHandle;
}
float* AnimTexture::GetData() {
return mData;
}
- 实现
resize
函数,它应该设置mData
数组的大小。这个函数的参数是动画纹理的宽度或高度:
void AnimTexture::Resize(unsigned int newSize) {
if (mData != 0) {
delete[] mData;
}
mSize = newSize;
mData = new float[mSize * mSize * 4];
}
- 实现
Set
函数。它的工作方式类似于Texture::Set
:
void AnimTexture::Set(unsigned int uniformIndex, unsigned int textureIndex) {
glActiveTexture(GL_TEXTURE0 + textureIndex);
glBindTexture(GL_TEXTURE_2D, mHandle);
glUniform1i(uniformIndex, textureIndex);
}
- 实现
UnSet
函数。它的工作方式类似于Texture::UnSet
:
void AnimTexture::UnSet(unsigned int textureIndex) {
glActiveTexture(GL_TEXTURE0 + textureIndex);
glBindTexture(GL_TEXTURE_2D, 0);
glActiveTexture(GL_TEXTURE0);
}
- 实现
SetTexel
函数,它以矢量3
作为参数。这个函数应该将像素的未使用的 A 分量设置为0
:
void AnimTexture::SetTexel(unsigned int x,
unsigned int y, const vec3& v) {
unsigned int index = (y * mSize * 4) + (x * 4);
mData[index + 0] = v.x;
mData[index + 1] = v.y;
mData[index + 2] = v.z;
mData[index + 3] = 0.0f;
}
- 实现
SetTexel
函数,它以四元数作为参数:
void AnimTexture::SetTexel(unsigned int x,
unsigned int y, const quat& q) {
unsigned int index = (y * mSize * 4) + (x * 4);
mData[index + 0] = q.x;
mData[index + 1] = q.y;
mData[index + 2] = q.z;
mData[index + 3] = q.w;
}
- 实现
GetTexel
函数。这个函数将始终返回一个vec4
,其中包含像素的每个分量:
vec4 AnimTexture::GetTexel(unsigned int x,
unsigned int y) {
unsigned int index = (y * mSize * 4) + (x * 4);
return vec4(
mData[index + 0],
mData[index + 1],
mData[index + 2],
mData[index + 3]
);
}
在本节中,您学会了如何创建一个 32 位浮点纹理并管理其中的数据。AnimTexture
类应该让您使用直观的 API 来处理浮点纹理,而不必担心任何 OpenGL 函数。在下一节中,您将创建一个函数,该函数将对动画剪辑进行采样,并将结果的动画数据写入纹理。
动画烘焙器
在本节中,您将学习如何将动画剪辑编码到动画纹理中。这个过程称为烘焙。
使用一个辅助函数实现纹理烘焙。这个Bake
函数将在设定的间隔内对动画进行采样,并将每个采样的骨骼层次结构写入浮点纹理中。
对于参数,Bake
函数需要一个骨架、一个动画剪辑,以及一个要写入的AnimTexture
的引用。骨架很重要,因为它提供了静止姿势,这将用于动画剪辑中不存在的任何关节。骨架的每个关节都将被烘焙到纹理中。让我们开始吧:
- 创建一个名为
AnimBaker.h
的新文件,并在其中添加BakeAnimationToTexture
函数的声明:
void BakeAnimationToTexture(Skeleton& skel, Clip& clip,
AnimTexture& outTex);
- 创建一个名为
AnimBaker.cpp
的新文件。开始在这个文件中实现BakeAnimationToTexture
函数:
void BakeAnimationToTexture(Skeleton& skel, Clip& clip,
AnimTexture& tex) {
Pose& bindPose = skel.GetBindPose();
- 要将动画烘焙到纹理中,首先创建一个动画将被采样到的姿势。然后,循环遍历纹理的x维度,即时间:
Pose pose = bindPose;
unsigned int texWidth = tex.Size();
for (unsigned int x = 0; x < texWidth; ++x) {
- 对于每次迭代,找到迭代器的归一化值(迭代器索引/(大小-1))。将归一化时间乘以剪辑的持续时间,然后加上剪辑的开始时间。在当前像素的这个时间点对剪辑进行采样:
float t = (float)x / (float)(texWidth - 1);
float start = clip.GetStartTime();
float time = start + clip.GetDuration() * t;
clip.Sample(pose, time);
- 一旦剪辑被采样,就循环遍历绑定姿势中的所有关节。找到当前关节的全局变换,并使用
SetTexel
将数据写入纹理:
for (unsigned int y = 0;y<pose.Size()*3;y+=3) {
Transform node=pose.GetGlobalTransform(y/3);
tex.SetTexel(x, y + 0, node.position);
tex.SetTexel(x, y + 1, node.rotation);
tex.SetTexel(x, y + 2, node.scale);
}
- 在
Bake
函数返回之前,调用提供的动画纹理上的UploadTextureDataToGPU
函数。这将使纹理在被烘焙后立即可用:
} // End of x loop
tex.UploadTextureDataToGPU();
}
在高层次上,动画纹理被用作时间轴,其中x轴是时间,y轴是该时间点上动画关节的变换。在下一节中,您将创建人群着色器。人群着色器使用BakeAnimationToTexture
烘焙到纹理中的数据来采样动画的当前姿势。
创建人群着色器
要呈现一个群众,您需要创建一个新的着色器。群众着色器将具有投影和视图统一,但没有模型统一。这是因为所有演员都是用相同的投影和视图矩阵绘制的,但需要一个独特的模型矩阵。着色器将有三个统一数组:一个用于位置,一个用于旋转,一个用于比例,而不是模型矩阵。
将放入这些数组的值是一个实例索引-当前正在呈现的网格的索引。每个顶点都通过内置的glsl
变量gl_InstanceID
获得其网格实例的副本。每个顶点将使用位置、旋转和比例统一数组构造一个模型矩阵。
反向绑定姿势就像一个矩阵统一数组,具有常规的蒙皮,但动画姿势不是。要找到动画姿势,着色器将不得不对动画纹理进行采样。由于每个顶点被绑定到四个顶点,所以必须为每个顶点找到四次动画姿势。
创建一个名为crowd.vert
的新文件。群众着色器将在此文件中实现。按照以下步骤实现群众着色器:
- 通过定义两个常量来开始实现着色器:一个用于骨骼的最大数量,一个用于支持的实例的最大数量:
#version 330 core
#define MAX_BONES 60
#define MAX_INSTANCES 80
- 声明所有群众演员共享的制服。这包括视图和投影矩阵,反向绑定姿势调色板和动画纹理:
uniform mat4 view;
uniform mat4 projection;
uniform mat4 invBindPose[MAX_BONES];
uniform sampler2D animTex;
- 声明每个群众演员独有的统一。这包括演员的变换,当前和下一帧,以及混合时间:
uniform vec3 model_pos[MAX_INSTANCES];
uniform vec4 model_rot[MAX_INSTANCES];
uniform vec3 model_scl[MAX_INSTANCES];
uniform ivec2 frames[MAX_INSTANCES];
uniform float time[MAX_INSTANCES];
- 声明顶点结构。每个顶点的数据与任何蒙皮网格的数据相同:
in vec3 position;
in vec3 normal;
in vec2 texCoord;
in vec4 weights;
in ivec4 joints;
- 声明群众着色器的输出值:
out vec3 norm;
out vec3 fragPos;
out vec2 uv;
- 实现一个函数,该函数将一个向量和一个四元数相乘。这个函数将与您在[第四章](B16191_04_Final_JC_ePub.xhtml#_idTextAnchor069)实现四元数中构建的
transformVector
函数具有相同的实现,只是它在着色器中运行:
vec3 QMulV(vec4 q, vec3 v) {
return q.xyz * 2.0f * dot(q.xyz, v) +
v * (q.w * q.w - dot(q.xyz, q.xyz)) +
cross(q.xyz, v) * 2.0f * q.w;
}
- 实现
GetModel
函数。给定一个实例索引,该函数应该从动画纹理中采样并返回一个4x4变换矩阵:
mat4 GetModel(int instance) {
vec3 position = model_pos[instance];
vec4 rotation = model_rot[instance];
vec3 scale = model_scl[instance];
vec3 xBasis = QMulV(rotation, vec3(scale.x, 0, 0));
vec3 yBasis = QMulV(rotation, vec3(0, scale.y, 0));
vec3 zBasis = QMulV(rotation, vec3(0, 0, scale.z));
return mat4(
xBasis.x, xBasis.y, xBasis.z, 0.0,
yBasis.x, yBasis.y, yBasis.z, 0.0,
zBasis.x, zBasis.y, zBasis.z, 0.0,
position.x, position.y, position.z, 1.0
);
}
- 使用关节和实例实现
GetPose
函数,该函数应返回关节的动画世界矩阵。通过找到 x 和 y 位置来采样动画纹理开始实现:
mat4 GetPose(int joint, int instance) {
int x_now = frames[instance].x;
int x_next = frames[instance].y;
int y_pos = joint * 3;
- 从动画纹理中采样当前帧的位置、旋转和比例:
vec4 pos0 = texelFetch(animTex, ivec2(x_now,
(y_pos + 0)), 0);
vec4 rot0 = texelFetch(animTex, ivec2(x_now,
(y_pos + 1)), 0);
vec4 scl0 = texelFetch(animTex, ivec2(x_now,
(y_pos + 2)), 0);
- 从动画纹理中采样下一帧的位置、旋转和比例:
vec4 pos1 = texelFetch(animTex, ivec2(x_next,
(y_pos + 0)), 0);
vec4 rot1 = texelFetch(animTex, ivec2(x_next,
(y_pos + 1)), 0);
vec4 scl1 = texelFetch(animTex, ivec2(x_next,
(y_pos + 2)), 0);
- 在两个帧之间进行插值:
if (dot(rot0, rot1) < 0.0) { rot1 *= -1.0; }
vec4 position = mix(pos0, pos1, time[instance]);
vec4 rotation = normalize(mix(rot0,
rot1, time[instance]));
vec4 scale = mix(scl0, scl1, time[instance]);
- 使用插值的位置、旋转和比例返回一个 4x4 矩阵:
vec3 xBasis = QMulV(rotation, vec3(scale.x, 0, 0));
vec3 yBasis = QMulV(rotation, vec3(0, scale.y, 0));
vec3 zBasis = QMulV(rotation, vec3(0, 0, scale.z));
return mat4(
xBasis.x, xBasis.y, xBasis.z, 0.0,
yBasis.x, yBasis.y, yBasis.z, 0.0,
zBasis.x, zBasis.y, zBasis.z, 0.0,
position.x, position.y, position.z, 1.0
);
}
- 通过找到着色器的主函数来实现着色器的主要功能,找到所有四个动画姿势矩阵,以及群众中当前演员的模型矩阵。使用
gl_InstanceID
来获取当前绘制的演员的 ID:
void main() {
mat4 pose0 = GetPose(joints.x, gl_InstanceID);
mat4 pose1 = GetPose(joints.y, gl_InstanceID);
mat4 pose2 = GetPose(joints.z, gl_InstanceID);
mat4 pose3 = GetPose(joints.w, gl_InstanceID);
mat4 model = GetModel(gl_InstanceID);
- 通过找到顶点的
skin
矩阵来继续实现主函数:
mat4 skin = (pose0*invBindPose[joints.x])*weights.x;
skin += (pose1 * invBindPose[joints.y]) * weights.y;
skin += (pose2 * invBindPose[joints.z]) * weights.z;
skin += (pose3 * invBindPose[joints.w]) * weights.w;
- 通过将位置和法线通过蒙皮顶点的变换管道来完成实现主函数:
gl_Position = projection * view * model *
skin * vec4(position, 1.0);
fragPos = vec3(model * skin * vec4(position, 1.0));
norm = vec3(model * skin * vec4(normal, 0.0f));
uv = texCoord;
}
在本节中,您实现了群众着色器。这个顶点着色器使用动画纹理来构建正在呈现的每个顶点的动画姿势。它将蒙皮管道的姿势生成部分移动到了 GPU 上。该着色器旨在呈现实例化的网格;它使用gl_InstanceID
来确定当前正在呈现的实例。
这个着色器是一个很好的起点,但总有改进的空间。该着色器目前使用了大量的统一索引。一些低端机器可能提供不了足够的统一。本章末尾将介绍几种优化策略。在下一节中,您将实现一个Crowd
类来帮助管理 Crowd 着色器需要的所有数据。
创建 Crowd 实用程序类
在这一部分,您将构建Crowd
类。这是一个实用类,可以使用易于使用的 API 渲染大量人群。Crowd
类封装了人群的状态。
Crowd
类必须维护类中每个演员的实例数据。为了适应这一点,您需要声明一个最大演员数量。然后,所有特定于演员的信息可以存储在结构数组中,其中索引是演员 ID。
特定于演员的数据包括演员的世界变换,以及与其动画播放相关的数据。动画数据是哪些帧正在插值,插值值,以及当前和下一帧的关键时间。
创建一个名为Crowd.h
的新文件。Crowd
类将在此文件中声明。按照以下步骤声明Crowd
类:
- 将人群演员的最大数量定义为
80
:
#define CROWD_MAX_ACTORS 80
- 通过为所有实例数据创建向量来声明
Crowd
类。这包括每个演员的变换、动画帧和时间的数据,以及帧插值信息:
struct Crowd {
protected:
std::vector<vec3> mPositions;
std::vector<quat> mRotations;
std::vector<vec3> mScales;
std::vector<ivec2> mFrames;
std::vector<float> mTimes;
std::vector<float> mCurrentPlayTimes;
std::vector<float> mNextPlayTimes;
- 声明
AdjustTime
、UpdatePlaybackTimes
、UpdateFrameIndices
和UpdateInterpolationTimes
函数。AdjustTime
函数类似于Clip::AdjustTimeToFitRange
;它确保给定时间是有效的:
protected:
float AdjustTime(float t, float start,
float end, bool looping);
void UpdatePlaybackTimes(float dt, bool looping,
float start, float end);
void UpdateFrameIndices(float start,
float duration, unsigned int texWidth);
void UpdateInterpolationTimes(float start,
float duration, unsigned int texWidth);
- 为人群的大小和每个演员的
Transform
属性声明 getter 和 setter 函数:
public:
unsigned int Size();
void Resize(unsigned int size);
Transform GetActor(unsigned int index);
void SetActor(unsigned int index,
const Transform& t);
- 最后,声明
Update
和SetUniforms
函数。这些函数将推进当前动画并更新每个实例的着色器 uniforms:
void Update(float deltaTime, Clip& mClip,
unsigned int texWidth);
void SetUniforms(Shader* shader);
};
Crowd
类为管理人群中每个演员的每个实例信息提供了直观的接口。在下一节中,您将开始实现Crowd
类。
实现 Crowd 类
Crowd
类为您提供了一种方便的方式来管理人群中的所有演员。这个类的大部分复杂性在于计算正确的播放信息。这项工作在Update
函数中完成。Update
函数使用三个辅助函数,即UpdatePlaybackTimes
、UpdateFrameIndices
和UpdateInterpolateionTimes
来工作。
人群中每个演员的当前动画播放时间将存储在mCurrentPlayTimes
向量中。mNextPlayTimes
向量是动画的预计下一个时间,这允许两个采样帧进行插值。UpdatePlaybackTimes
函数将更新这两个向量。
猜测下一帧的播放时间很重要,因为动画纹理的采样率是未知的。例如,如果动画以 240 FPS 编码,并以 60 FPS 播放,那么下一帧将相隔四个采样。
mFrames
向量包含两个组件整数向量。第一个组件是当前动画帧的u
纹理坐标。第二个组件是下一帧中将显示的动画帧的v
纹理坐标。v
纹理坐标是关节索引。
UpdateFrameIndex
函数负责更新这个向量。要找到当前帧的x坐标,需要对帧时间进行归一化,然后将归一化的帧时间乘以纹理的大小。可以通过从开始时间减去帧时间并将结果除以剪辑的持续时间来归一化帧的时间。
着色器需要在当前动画姿势和下一个动画姿势之间进行插值。为此,它需要知道两个姿势帧之间的当前归一化时间。这存储在mTimes
变量中。
mTimes
变量由UpdateInterpolationTimes
函数更新。该函数找到当前帧的持续时间,然后将播放时间相对于当前帧归一化到该持续时间。
要更新Crowd
类,您必须按顺序调用UpdatePlaybackTimes
、UpdateFrameIndices
和UpdateInterpolateionTimes
函数。完成后,Crowd
类可以使用SetUniforms
函数设置其 uniform 值。
创建一个名为Crowd.cpp
的新文件。Crowd
类将在此文件中实现。按照以下步骤实现Crowd
类:
- 实现大小的获取器和设置器函数。设置器函数需要设置
Crowd
类中包含的所有向量的size
:
unsigned int Crowd::Size() {
return mCurrentPlayTimes.size();
}
void Crowd::Resize(unsigned int size) {
if (size > CROWD_MAX_ACTORS) {
size = CROWD_MAX_ACTORS;
}
mPositions.resize(size);
mRotations.resize(size);
mScales.resize(size, vec3(1, 1, 1));
mFrames.resize(size);
mTimes.resize(size);
mCurrentPlayTimes.resize(size);
mNextPlayTimes.resize(size);
}
- 实现演员变换的获取器和设置器函数。位置、旋转和缩放保存在单独的向量中;演员的获取器和设置器函数隐藏了该实现,而是使用
Transform
对象:
Transform Crowd::GetActor(unsigned int index) {
return Transform(
mPositions[index],
mRotations[index],
mScales[index] );
}
void Crowd::SetActor(unsigned int index,
const Transform& t) {
mPositions[index] = t.position;
mRotations[index] = t.rotation;
mScales[index] = t.scale;
}
- 实现
AdjustTime
函数;它类似于Clip::AdjustTimeToFitRange
函数:
float Crowd::AdjustTime(float time, float start,
float end, bool looping) {
if (looping) {
time = fmodf(time - start, end - start);
if (time < 0.0f) {
time += end - start;
}
time = time + start;
}
else {
if (time < start) { time = start; }
if (time > end) { time = end; }
}
return time;
}
- 实现
UpdatePlaybackTimes
辅助函数。该函数将按照增量时间推进所有演员的播放时间:
void Crowd::UpdatePlaybackTimes(float deltaTime,
bool looping, float start, float end) {
unsigned int size = mCurrentPlayTimes.size();
for (unsigned int i = 0; i < size; ++i) {
float time = mCurrentPlayTimes[i] + deltaTime;
mCurrentPlayTimes[i] = AdjustTime(time, start,
end, looping);
time = mCurrentPlayTimes[i] + deltaTime;
mNextPlayTimes[i] = AdjustTime(time, start,
end, looping);
}
}
- 实现
UpdateFrameIndices
函数。该函数将当前播放时间转换为沿动画纹理x轴的像素坐标:
void Crowd::UpdateFrameIndices(float start, float duration, unsigned int texWidth) {
unsigned int size = mCurrentPlayTimes.size();
for (unsigned int i = 0; i < size; ++i) {
float thisNormalizedTime =
(mCurrentPlayTimes[i] - start) / duration;
unsigned int thisFrame =
thisNormalizedTime * (texWidth - 1);
float nextNormalizedTime =
(mNextPlayTimes[i] - start) / duration;
unsigned int nextFrame =
nextNormalizedTime * (texWidth - 1);
mFrames[i].x = thisFrame;
mFrames[i].y = nextFrame;
}
}
- 实现
UpdateInterpolationTimes
函数。该函数应该找到当前和下一个动画帧之间的插值时间:
void Crowd::UpdateInterpolationTimes(float start,
float duration, unsigned int texWidth) {
unsigned int size = mCurrentPlayTimes.size();
for (unsigned int i = 0; i < size; ++i) {
if (mFrames[i].x == mFrames[i].y) {
mTimes[i] = 1.0f;
continue;
}
float thisT = (float)mFrames[i].x /
(float)(texWidth - 1);
float thisTime = start + duration * thisT;
float nextT = (float)mFrames[i].y /
(float)(texWidth - 1);
float nextTime = start + duration * nextT;
if (nextTime < thisTime) {
nextTime += duration;
}
float frameDuration = nextTime - thisTime;
mTimes[i] = (mCurrentPlayTimes[i] - thisTime) /
frameDuration;
}
}
- 实现
Update
方法。该方法依赖于UpdatePlaybackTimes
、UpdateFrameIndices
和UpdateInterpolationTimes
辅助函数:
void Crowd::Update(float deltaTime, Clip& mClip,
unsigned int texWidth) {
bool looping = mClip.GetLooping();
float start = mClip.GetStartTime();
float end = mClip.GetEndTime();
float duration = mClip.GetDuration();
UpdatePlaybackTimes(deltaTime, looping, start, end);
UpdateFrameIndices(start, duration, texWidth);
UpdateInterpolationTimes(start, duration, texWidth);
}
- 实现
SetUniforms
函数,将Crowd
类中包含的向量传递给人群着色器作为 uniform 数组:
void Crowd::SetUniforms(Shader* shader) {
Uniform<vec3>::Set(shader->GetUniform("model_pos"),
mPositions);
Uniform<quat>::Set(shader->GetUniform("model_rot"),
mRotations);
Uniform<vec3>::Set(shader->GetUniform("model_scl"),
mScales);
Uniform<ivec2>::Set(shader->GetUniform("frames"),
mFrames);
Uniform<float>::Set(shader->GetUniform("time"),
mTimes);
}
使用Crowd
类应该是直观的:创建一个人群,设置其演员的播放时间和模型变换,然后绘制人群。在下一节中,您将探讨如何使用Crowd
类来绘制大型人群的示例。
使用 Crowd 类
使用Crowd
类应该是直观的,但渲染代码可能不会立即显而易见。人群着色器的非实例 uniform,如视图或投影矩阵,仍然需要手动设置。Crowd
类的Set
函数设置的唯一 uniform 是每个演员的 uniform。
不要使用Mesh
类的Draw
方法进行渲染,而是使用DrawInstanced
方法。对于实例数量参数,传递人群的大小。以下代码片段显示了如何绘制人群的最小示例:
void Render(float aspect) {
mat4 projection = perspective(60.0f, aspect, 0.01f, 100);
mat4 view=lookAt(vec3(0,15,40), vec3(0,3,0), vec3(0,1,0));
mCrowdShader->Bind();
int viewUniform = mCrowdShader->GetUniform("view")
Uniform<mat4>::Set(viewUniform, view);
int projUniform = mCrowdShader->GetUniform("projection")
Uniform<mat4>::Set(projUniform, projection);
int lightUniform = mCrowdShader->GetUniform("light");
Uniform<vec3>::Set(lightUniform, vec3(1, 1, 1));
int invBind = mCrowdShader->GetUniform("invBindPose");
Uniform<mat4>::Set(invBind, mSkeleton.GetInvBindPose());
int texUniform = mCrowdShader->GetUniform("tex0");
mDiffuseTexture->Set(texUniform, 0);
int animTexUniform = mCrowdShader->GetUniform("animTex");
mCrowdTexture->Set(animTexUniform, 1);
mCrowd.SetUniforms(mCrowdShader);
int pAttrib = mCrowdShader->GetAttribute("position");
int nAttrib = mCrowdShader->GetAttribute("normal");
int tAttrib = mCrowdShader->GetAttribute("texCoord");
int wAttrib = mCrowdShader->GetAttribute("weights");
int jAttrib = mCrowdShader->GetAttribute("joints");
mMesh.Bind(pAttrib, nAttrib, uAttrib, wAttrib, jAttrib);
mMesh.DrawInstanced(mCrowd.Size());
mMesh.UnBind(pAttrib, nAttrib, uAttrib, wAttrib, jAttrib);
mCrowdTexture->UnSet(1);
mDiffuseTexture->UnSet(0);
mCrowdShader->UnBind();
}
在大多数情况下,代码看起来与常规蒙皮网格相似。这是因为Crowd
类的SetUniforms
函数设置了特定实例的 uniform 值。其他 uniform 的设置方式与以前相同。在下一节中,您将探讨如何在顶点着色器中混合两个动画。
在本节中,您创建了一个Crowd
类,它提供了一个易于使用的接口,以便您可以设置Crowd
着色器所需的 uniform。还介绍了如何使用Crowd
类来渲染大型人群的演示。
混合动画
在顶点着色器中可以在两个动画之间进行混合。有两个原因可能会导致你不希望在顶点着色器中进行动画混合。首先,这样做会使着色器的 texel 获取量翻倍,使着色器更加昂贵。
这种 texel 获取的激增发生是因为您必须检索姿势矩阵的两个副本 - 每个动画一个 - 然后在它们之间进行混合。执行此操作的着色器代码可能如下代码片段所示:
mat4 pose0a = GetPose(animTexA, joints.x, instance);
mat4 pose1a = GetPose(animTexA, joints.y, instance);
mat4 pose2a = GetPose(animTexA, joints.z, instance);
mat4 pose3a = GetPose(animTexA, joints.w, instance);
mat4 pose0b = GetPose(animTexB, joints.x, instance);
mat4 pose1b = GetPose(animTexB, joints.y, instance);
mat4 pose2b = GetPose(animTexB, joints.z, instance);
mat4 pose3b = GetPose(animTexB, joints.w, instance);
mat4 pose0 = pose0a * (1.0 - fade) + pose0b * fade;
mat4 pose1 = pose1a * (1.0 - fade) + pose1b * fade;
mat4 pose2 = pose2a * (1.0 - fade) + pose2b * fade;
mat4 pose3 = pose3a * (1.0 - fade) + pose3b * fade;
另一个原因是混合在技术上不正确。着色器在世界空间中进行线性混合。结果混合的骨架看起来不错,但与在本地空间中进行插值的关节不同。
如果你在两个姿势之间进行淡入淡出,混合是短暂的,只是为了隐藏过渡。在大多数情况下,过渡是否在技术上正确并不像过渡看起来平滑那样重要。在下一节中,您将探索使用替代纹理格式。
探索纹理格式
动画纹理目前以 32 位浮点纹理格式存储。这是一种容易存储动画纹理的格式,因为它与源数据的格式相同。这种方法在移动硬件上效果不佳。从主内存到图块内存的内存带宽是一种稀缺资源。
为了针对移动平台,考虑从GL_RGBA32F
更改为带有GL_UNSIGNED_BYTE
存储类型的GL_RGBA
。切换到标准纹理格式确实意味着丢失一些数据。使用GL_UNSIGNED_BYTE
存储类型,颜色的每个分量都限制在 256 个唯一值。这些值在采样时被标准化,并将返回在 0 到 1 的范围内。
如果任何动画信息存储值不在 0 到 1 的范围内,数据将需要被标准化。标准化比例因子将需要作为统一传递给着色器。如果你的目标是移动硬件,你可能只想存储旋转信息,这些信息已经在 0 到 1 的范围内。
在下一节中,您将探索如何将多个动画纹理合并成单个纹理。这减少了需要绑定的纹理数量,以便人群播放多个动画。
合并动画纹理
将许多较小的纹理合并成一个较大的纹理的行为称为纹理合并。包含多个较小纹理的大纹理通常称为纹理图集。纹理合并的好处是需要使用较少的纹理采样器。
本章介绍的人群渲染系统有一个主要缺点:虽然人群可以以不同的时间偏移播放动画,但他们只能播放相同的动画。有一个简单的方法可以解决这个问题:将多个动画纹理合并到一个大纹理上。
例如,一个1024x1024的纹理可以包含 16 个较小的256x256纹理。这意味着人群中的任何成员都可以播放 16 种动画中的一种。着色器的每个实例数据都需要添加一个额外的“偏移”统一。这个偏移统一将是一个MAX_INSTANCES
大小的数组。
对于每个被渲染的角色,GetPose
函数在检索动画纹素之前必须应用偏移。在下一节中,您将探索不同的技术,可以使用这些技术来通过最小化纹素获取来优化人群着色器。
优化纹素获取
即使在游戏 PC 上,渲染超过 200 个人群角色将花费超过 4 毫秒的时间,这是一个相当长的时间,假设您有 16.6 毫秒的帧时间。那么,为什么人群渲染如此昂贵呢?
每次调用GetPose
辅助函数时,着色器执行 6 个纹素获取。由于每个顶点都被蒙皮到四个影响,每个顶点需要 24 个纹素获取!即使是低多边形模型,这也是大量的纹素获取。优化这个着色器将归结为最小化纹素获取的数量。
以下部分介绍了您可以使用的不同策略,以最小化每个顶点的纹素获取数量。
限制影响
优化纹素获取的一种天真的方法是在着色器代码中添加一个分支。毕竟,如果矩阵的权重为 0,为什么要获取姿势呢?这种优化可以实现如下:
mat4 pose0 = (weights.x < 0.0001)?
mat4(1.0) : GetPose(joints.x, instance);
mat4 pose1 = (weights.y < 0.0001)?
mat4(1.0) : GetPose(joints.y, instance);
mat4 pose2 = (weights.z < 0.0001)?
mat4(1.0) : GetPose(joints.z, instance);
mat4 pose3 = (weights.w < 0.0001)?
mat4(1.0) : GetPose(joints.w, instance);
在最理想的情况下,这可能会节省一点时间。在最坏的情况下(每个骨骼恰好有四个影响),这实际上会给着色器增加额外的成本,因为现在每个影响都带有一个条件分支。
限制纹理获取的更好方法是限制骨骼影响。Blender、3DS Max 或 Maya 等 3DCC 工具具有导出选项,可以限制每个顶点的最大骨骼影响数量。您应该将最大骨骼影响数量限制为 1 或 2。
通常,在人群中,很难看清个别演员的细节。因此,将骨骼影响降低到 1,有效地使人群的皮肤刚性化,通常是可行的。在接下来的部分,您将探讨如何通过限制动画组件的数量来帮助减少每个顶点的纹理获取次数。
限制动画组件
考虑一个动画的人类角色。人类关节只旋转;它们永远不会平移或缩放。如果您知道一个动画只对每个关节进行一到两个组件的动画,GetPose
函数可以被编辑以采样更少的数据。
这里还有一个额外的好处:可以将编码到动画纹理中的骨骼数量增加。如果您编码位置、旋转和缩放,最大关节数为纹理大小/3
。如果您只编码一个组件,可以编码的关节数就是纹理的大小。
这种优化将使256x256纹理能够编码 256 个旋转,而不是 85 个变换。在下一节中,您将探讨是否需要在帧之间进行插值。
不进行插值
考虑动画纹理。它以设定的增量对动画进行采样,以填充纹理的每一列。在 256 个样本中,您可以在 60 FPS 下编码 3.6 秒的动画。
是否需要插值将取决于动画纹理的大小和被编码的动画长度。对于大多数游戏角色动画,如奔跑、行走、攻击或死亡,不需要帧插值。
通过这种优化,发送到 GPU 的数据量大大减少。帧统一可以从ivec2
变为int
,将数据大小减半。这意味着时间统一可以完全消失。
在下一节中,您将探讨您刚刚学到的三种优化的综合效果。
结合这些优化
让我们探讨这些优化可能产生的影响,假设以下三种优化都已实施:
-
将骨骼影响的数量限制为 2。
-
只对变换的旋转组件进行动画。
-
不要在帧之间进行插值。
这将把每个顶点的纹理获取次数从 24 减少到 2。可以编码到动画纹理中的关节数量将增加,并且每帧传输到 GPU 的数据量将大大减少。
总结
在本章中,您学会了如何将动画数据编码到纹理中,以及如何在顶点着色器中解释数据。还介绍了通过改变动画数据编码方式来改善性能的几种策略。将数据写入纹理的这种技术可以用于烘焙任何类型的采样数据。
要烘焙动画,您需要将其剪辑到纹理中。这个剪辑是在设定的间隔内采样的。每个骨骼的全局位置在每个间隔都被记录并写入纹理。在这个动画纹理中,每个关节占据三行:一个用于位置,一个用于旋转,一个用于缩放。
您使用实例化渲染了人群网格,并创建了一个可以从统一数组中读取每个实例数据的着色器。人群演员的每个实例数据,如位置、旋转和缩放,都作为统一数组传递给着色器,并使用实例 ID 作为这些数组的索引进行解释。
最后,您创建了Crowd
类。这个实用类提供了一个易于使用的界面,用于管理人群中的演员。这个类将自动填充人群着色器的每个实例统一。使用这个类,您可以轻松地创建大型、有趣的人群。
本书的可下载内容中有本章的两个示例。Sample00
是本章中我们编写的所有代码。另一方面,Sample01
演示了如何在实践中使用这些代码来渲染大规模人群。