这最后一章探索了如何使用实例渲染大量人群。群组渲染是一个有趣的话题,因为它将姿势生成(采样)和混合移动到 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。每个实例的数据比使用姿势调色板矩阵时上传的数据要小得多。
人群中的每个演员都需要一个位置、旋转和缩放来构建模型矩阵。演员需要知道要采样的当前帧以及要混合的当前帧和下一帧之间的时间。
每个参与者实例数据的总大小为 11 个浮点数和 2 个整数。每个实例只有 52 字节。每实例数据将始终使用统一数组传递。每个数组的大小是人群包含的演员数量。数组的每个元素代表一个唯一的参与者。
着色器将负责根据每个实例的数据和动画纹理构建适当的矩阵。当前帧和下一帧之间的混合是可选的;混合不会 100%正确,但看起来应该还是不错的。
在下一节中,您将实现一个AnimationTexture
类,它将允许您在代码中使用动画纹理。
在本节中,您将在AnimTexture
类中实现处理浮点纹理所需的所有代码。每个AnimTexture
对象将包含一个 32 位浮点 RGBA 纹理。这些数据将有两个副本:一个在中央处理器上,一个上传到图形处理器上。
CPU 缓冲区保留在周围,以便在保存到磁盘或上传到 OpenGL 之前轻松批量修改纹理的内容。它以一些额外的内存为代价保持了 API 的简单性。
没有标准的 32 位纹理格式,所以保存和写入磁盘时只会将AnimTexture
类的二进制内容转储到磁盘。在下一节中,您将开始实现AnimTexture
类。这个类将为实现 32 位浮点纹理提供一个易于使用的接口。
动画纹理假设总是正方形;宽度和高度不需要单独跟踪。使用单个大小变量应该就足够了。AnimTexture
类每次在内存中总是有两个纹理副本,一个在 CPU 上,一个在 GPU 上。
创建一个名为AnimTexture.h
的新文件,并在该文件中声明AnimTexture
类。按照以下步骤申报AnimTexture
类:
-
申报
AnimTexture
班。它有三个成员变量:一个浮点数组,一个表示纹理大小的整数,以及一个 OpenGL 纹理对象的句柄:class AnimTexture { protected: float* mData; unsigned int mSize; unsigned int mHandle;
-
用默认构造函数、复制构造函数、赋值操作符和析构函数声明【T0:
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(); };
类是处理浮点纹理的一种方便的方法。get
和SetTexel
方法可以使用直观的应用编程接口读写纹理。在下一节中,您将开始实现AnimTexture
类。
在本节中,您将实现AnimTexture
类,该类包含用于处理浮点纹理的 OpenGL 代码,并提供了一个易于使用的 API。如果你想使用一个图形应用编程接口而不是 OpenGL,这个类将需要使用那个应用编程接口重写。
当一个AnimTexture
保存到磁盘时,整个mData
数组作为一个大的二进制 blob 写入文件。这个大的纹理数据占用了相当多的内存;例如,一个 512x512 纹理占用大约 4 MB。纹理压缩并不适合,因为动画数据需要精确。
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
的内容写入一个大的二进制 blob: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
:
```cpp
void AnimTexture::Set(unsigned int uniformIndex, unsigned int textureIndex) {
glActiveTexture(GL_TEXTURE0 + textureIndex);
glBindTexture(GL_TEXTURE_2D, mHandle);
glUniform1i(uniformIndex, textureIndex);
}
```
- 实现
UnSet
功能。工作原理类似Texture::UnSet
:
```cpp
void AnimTexture::UnSet(unsigned int textureIndex) {
glActiveTexture(GL_TEXTURE0 + textureIndex);
glBindTexture(GL_TEXTURE_2D, 0);
glActiveTexture(GL_TEXTURE0);
}
```
- 实现
SetTexel
函数,该函数以向量3
为参数。该功能应将像素未使用的 A 分量设置为0
:
```cpp
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
函数,以四元数为参数:
```cpp
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
,它包含像素的每个分量:
```cpp
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) {
-
对于每次迭代,找到迭代器的归一化值(
iterator index / (size - 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;
-
实现一个将向量和四元数相乘的函数。该函数将具有与您在 第 4 章 中构建的
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);
-
从动画纹理中采样下一帧的位置、旋转和缩放:
```cpp
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);
```
- 在两帧的变换之间进行插值:
```cpp
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 矩阵:
```cpp
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:
```cpp
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
矩阵:
```cpp
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;
```
- 通过蒙皮顶点的变换管道
```cpp
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;
}
```
放置位置和法线,完成主功能的实现
在本节中,您实现了群组着色器。这个顶点着色器使用动画纹理来构造每个渲染顶点的动画姿态。它将蒙皮管道的姿势生成部分移动到图形处理器。着色器旨在渲染实例化网格;它使用gl_InstanceID
来确定当前正在渲染哪个实例。
这个着色器是一个很好的起点,但总有改进的空间。着色器目前使用了许多统一的索引。一些低端机器可能无法提供足够的制服。本章末尾将介绍几种优化策略。在下一节中,您将实现一个Crowd
类来帮助管理群组着色器所需的所有数据。
在本节中,您将构建Crowd
类。这是一个实用程序类,将使用一个易于使用的应用编程接口渲染大量人群。Crowd
类封装了人群的状态。
Crowd
类必须维护类中每个参与者的实例数据。为了适应这一点,您需要声明最大数量的参与者。然后,所有特定于行动者的信息可以存储在结构数组中,其中索引是行动者标识。
演员特定数据包括演员的世界变换,以及与其动画回放相关的数据。动画数据是正在插值的帧、插值值以及当前帧和下一帧的关键时间。
创建一个名为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
功能。这些功能将推进当前动画并更新每个实例的着色器制服:void Update(float deltaTime, Clip& mClip, unsigned int texWidth); void SetUniforms(Shader* shader); };
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
功能设置其统一值。
创建一个名为Crowd.cpp
的新文件。Crowd
类将在该文件中实现。按照以下步骤实施Crowd
课程:
-
实现大小获取器和设置器函数。setter 函数需要设置包含在
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); }
-
实现参与者转换的 getter 和 setter 函数。位置、旋转和缩放保持在单独的向量中;actor getter 和 setter 函数隐藏了实现,支持使用
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
类中的向量作为统一数组传递给人群着色器: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
班级的Set
功能设置的唯一制服是演员制服。
使用DrawInstanced
方法渲染,而不是使用Mesh
类的Draw
方法渲染。对于实例数量参数,传递人群的大小。下面的代码片段显示了如何绘制人群的最小示例:
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
功能设置的。每隔一套制服都和以前一样。在下一节中,您将探索如何在顶点着色器中混合两个动画。
在本节中,您创建了一个Crowd
类,它提供了一个易于使用的界面,以便您可以设置Crowd
着色器所需的制服。还演示了如何使用Crowd
类来渲染大量人群。
可以在顶点着色器中混合两个动画。有两个原因可以解释为什么想要避免顶点着色器中动画之间的混合。首先,这样做将使纹理元素提取量翻倍,这将使着色器更加昂贵。
发生这种 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_RGBA
,采用GL_UNSIGNED_BYTE
存储类型。切换到标准纹理格式确实意味着丢失一些数据。使用GL_UNSIGNED_BYTE
存储类型,一种颜色的每个成分限于 256 个唯一值。这些值在采样时被标准化,并将在 0 到 1 的范围内返回。
如果任何动画信息存储值不在 0 到 1 的范围内,则需要对数据进行规范化。规范化比例因子需要作为一个统一的传递给着色器。如果您的目标是移动硬件,您可能只想存储轮换信息,轮换信息已经在 0 到 1 的范围内。
在下一节中,您将探索如何将多个动画纹理组合成一个纹理。这减少了需要为一群人绑定以播放多个动画的纹理数量。
将许多较小的纹理组合成一个较大的纹理的行为称为贴图。包含多个较小纹理的大纹理通常称为纹理图谱。贴图的好处是需要使用更少的贴图采样器。
本章介绍的人群渲染系统有一个主要缺点:虽然人群可以在不同的时间偏移播放动画,但他们只能播放相同的动画。有一个简单的方法可以解决这个问题:将多个动画纹理映射到一个大纹理上。
例如,一个 1024x1024 纹理可以包含 16 个较小的 256x256 纹理。这意味着人群中的任何成员都可以播放 16 个动画中的一个。必须为着色器的每个实例数据添加额外的“偏移”统一。这种偏移一致将是一个MAX_INSTANCES
大小的数组。
对于正在渲染的每个角色,GetPose
函数必须在检索动画纹理元素之前应用偏移。在下一节中,您将探索通过最小化纹理元素提取来优化群组着色器的不同技术。
即使在游戏电脑上,渲染超过 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
功能可以被编辑以采样更少的数据。
这里还有一个额外的好处:可以编码到动画纹理中的骨骼数量会增加。如果您正在编码位置、旋转和缩放,关节的最大数量是texture size / 3
。如果只对一个组件进行编码,可以编码的关节数量就是纹理的大小。
该优化将使 256x256 纹理能够编码 256 次旋转,而不是 85 次变换。在下一节中,您将探讨是否需要帧间插值。
考虑动画纹理。它以设定的增量对动画进行采样,以填充纹理的每一列。在 256 个样本的情况下,您可以以 60 FPS 编码 3.6 秒的动画。
是否需要插值取决于动画纹理的大小和正在编码的动画的长度。对于大多数游戏中的角色动画,如跑步、行走、附着或死亡,插值不需要帧插值。
通过这种优化,发送到图形处理器的数据量大大减少。统一的帧可以从ivec2
变为int
,将数据的大小减半。这意味着时间制服可以完全消失。
在下一节中,您将探索刚刚了解到的三种优化的组合效果是什么。
让我们探索这些优化可能产生的影响,假设实现了以下三个优化:
- 将骨骼影响的数量限制为 2。
- 仅动画显示变换的旋转组件。
- 不要在帧间插值。
这将减少纹理元素的提取次数,从每个顶点 24 次减少到每个顶点 2 次。可以编码到动画纹理中的关节数量将会增加,并且每帧传输到图形处理器的数据量将会大大减少。
在本章中,您学习了如何将动画数据编码为纹理,以及如何在顶点着色器中解释数据。还介绍了通过改变动画数据的编码方式来提高性能的几种策略。这种将数据写入纹理的技术可用于烘焙任何种类的采样数据。
要烘焙动画,您需要裁剪成纹理。该片段以设定的时间间隔进行采样。每块骨头的整体位置在每个间隔被记录下来,并被写入纹理。在这个动画纹理中,每个关节占用三行:一行用于位置,一行用于旋转,一行用于缩放。
您使用实例化渲染了人群网格,并创建了一个可以从统一数组读取每个实例数据的着色器。每个实例-人群中演员的数据,如位置、旋转和缩放,作为统一数组传递给着色器,并使用实例标识作为这些数组的索引进行解释。
最后,你创建了Crowd
类。这个实用程序类为管理人群中的参与者提供了一个易于使用的界面。这个类将自动填充人群着色器的每个实例的统一。使用这个类,你可以轻松地创建大量有趣的人群。
这本书的可下载内容中有两个关于本章的示例。Sample00
是我们在这一章写的全部代码。Sample01
另一方面,演示了如何在实践中使用这段代码来渲染大量人群。