顶点数据管理

对于任何图形应用,无论该应用由 2D 界面组成,还是属于大型 3D 开放世界游戏,如要取得良好的性能,都必须具有出色的顶点数据布局和压缩技术。使用 Android GPU 检查器的 Frame Profiler 对数十款热门 Android 游戏进行的内部测试表明,顶点数据管理还有很大的改进空间。我们发现,顶点数据通常会针对所有顶点属性使用全精度 32 位浮点值,并且使用顶点缓冲区布局,这种布局使用一组属性完全交错的格式的结构。

本文介绍了如何通过使用以下技术来优化 Android 应用的图形性能:

  • 顶点压缩
  • 顶点流拆分

实现这些技术可以使顶点内存带宽用量减少高达 50%,减少与 CPU 的内存总线争用,减少系统内存中的暂停,以及延长电池续航时间;所有这些对开发者和最终用户来说都是双赢!

显示的所有数据均来自 Pixel 4 上运行的一个示例静态场景,其中包含大约 1900 万个顶点:

包含 6 个环和 1900 万个顶点的示例场景

图 1:包含 6 个环和 1900 万个顶点的示例场景

顶点压缩

顶点压缩是有损压缩技术的涵盖性术语,这类技术通过高效打包来缩减运行时和存储空间内的顶点数据大小。缩减顶点数据的大小具有多项优势,其中包括减少 GPU 上的内存带宽(通过用计算换取带宽),提高缓存利用率,并且有可能降低溢出寄存器的风险。

常见的顶点压缩方法包括:

  • 降低顶点数据属性的数值精度(例如从 32 位浮点数降低到 16 位浮点数)
  • 以不同的格式表示属性

例如,如果某个顶点针对位置 (vec3)、法线 (vec3) 和纹理坐标 (vec2) 使用全精度 32 位浮点数,则将所有这些浮点数替换为 16 位浮点数会使顶点大小减少 50%(在平均 32 个字节的顶点上有 16 个字节)。

顶点位置

在绝大多数网格中,顶点位置数据可以从全精度 32 位浮点值压缩到半精度 16 位浮点值,并且几乎所有移动设备的硬件都支持半精度浮点数。从 float32 到 float16 的转换函数如下所示(改写自本指南):

uint16_t f32_to_f16(float f) {
  uint32_t x = (uint32_t)f;
  uint32_t sign = (unsigned short)(x >> 31);
  uint32_t mantissa;
  uint32_t exp;
  uint16_t hf;

  mantissa = x & ((1 << 23) - 1);
  exp = x & (0xFF << 23);
  if (exp >= 0x47800000) {
    // check if the original number is a NaN
    if (mantissa && (exp == (0xFF << 23))) {
      // single precision NaN
      mantissa = (1 << 23) - 1;
    } else {
      // half-float will be Inf
      mantissa = 0;
    }
    hf = (((uint16_t)sign) << 15) | (uint16_t)((0x1F << 10)) |
         (uint16_t)(mantissa >> 13);
  }
  // check if exponent is <= -15
  else if (exp <= 0x38000000) {
    hf = 0;  // too small to be represented
  } else {
    hf = (((uint16_t)sign) << 15) | (uint16_t)((exp - 0x38000000) >> 13) |
         (uint16_t)(mantissa >> 13);
  }

  return hf;
}

此方法存在限制;随着顶点距离原点越来越远,精度也会降低,这使得此方法不太适合从空间上来说非常大的网格(顶点的元素数量超过 1024 个)。如要解决此问题,您可以将网格拆分成较小的区块,使每个区块以模型原点为中心,并进行缩放,以便每个区块的所有顶点都处在 [-1, 1] 范围之内,该范围包含浮点值的最高精度。用于压缩的伪代码如下所示:

for each position p in Mesh:
   p -= center_of_bounding_box // Moves Mesh back to the center of model space
   p /= half_size_bounding_box // Fits the mesh into a [-1, 1] cube
   vec3<float16> result = vec3(f32_to_f16(p.x), f32_to_f16(p.y), f32_to_f16(p.z));

将缩放比例和平移融入到模型矩阵中,以便在渲染时解压缩顶点数据。注意,请勿使用同一模型矩阵对法线进行转换,因为法线并未应用相同的压缩。您需要一个不对法线进行此类解压缩转换的矩阵,或者您可以使用基本的模型矩阵(可用于法线),然后在着色器中对该模型矩阵应用其他解压缩转换。示例如下:

vec3 in in_pos;

void main() {
   ...
   // bounding box data packed into uniform buffer
   vec3 decompress_pos = in_pos * half_size_bounding_box + center_of_bounding_box;
   gl_Position = proj * view * model * decompress_pos;
}

另一种方法涉及使用有符号归一化整数 (SNORM)。SNORM 数据类型使用整数而不是浮点来表示 [-1, 1] 之间的值。针对位置使用 16 位 SNORM,可让您实现与 float16 相同的内存节省,同时又没有非均匀分布的缺点。我们建议使用 SNORM 的实现如下所示:

const int BITS = 16

for each position p in Mesh:
   p -= center_of_bounding_box // Moves Mesh back to the center of model space
   p /= half_size_bounding_box // Fits the mesh into a [-1, 1] cube
   // float to integer value conversion
   p = clamp(p * (2^(BITS - 1) - 1), -2^(BITS - 1), 2^(BITS - 1) - 1) 
格式 大小
之前 vec4<float32> 16 个字节
之后 vec3<float16/SNORM16> 6 个字节

顶点法线和切线空间

照明需要顶点法线,而法线映射等更复杂的技术需要切线空间。

切线空间

切线空间是一个坐标系,其中每个顶点都由法线、切线和双切线向量组成。由于这三个向量通常彼此正交,我们只需存储其中两个向量,便可以通过在顶点着色器中取其他两个向量的叉积来计算第三个向量。

这些向量通常可以使用 16 位浮点数表示,且在视觉保真度方面不会有任何可感知的损失,因此是一个不错的入手点!

我们可以通过一种称为“QTangents”的技术进一步压缩,该技术可将整个切线空间存储在一个四元数中。由于四元数可用于表示旋转,因此,通过将切线空间向量视为表示旋转的 3x3 矩阵的列向量(在本例中,从模型空间到切线空间),我们可以在两者之间进行转换!四元数可被视为 vec4 数据,从切线空间向量到 QTangent 的转换(基于上面链接的文章并改写自此处的实现)如下所示:

const int BITS = 16

quaternion tangent_space_to_quat(vec3 normal, vec3 tangent, vec3 bitangent) {
   mat3 tbn = {normal, tangent, bitangent};
   quaternion qTangent(tbn);
   qTangent.normalize();

   //Make sure QTangent is always positive
   if (qTangent.w < 0)
       qTangent = -qTangent;

   const float bias = 1.0 / (2^(BITS - 1) - 1);

   //Because '-0' sign information is lost when using integers,
   //we need to apply a "bias"; while making sure the Quaternion
   //stays normalized.
   // ** Also our shaders assume qTangent.w is never 0. **
   if (qTangent.w < bias) {
       Real normFactor = Math::Sqrt( 1 - bias * bias );
       qTangent.w = bias;
       qTangent.x *= normFactor;
       qTangent.y *= normFactor;
       qTangent.z *= normFactor;
   }

   //If it's reflected, then make sure .w is negative.
   vec3 naturalBinormal = cross_product(tangent, normal);
   if (dot_product(naturalBinormal, binormal) <= 0)
       qTangent = -qTangent;
   return qTangent;
}

四元数会进行归一化处理,您将能够使用 SNORM 对其进行压缩。16 位 SNORM 可提供良好的精度,并节省内存。8 位 SNORM 能够节省更多内存,但可能会导致高反射性材料出现伪影。这两者您都可以试用一下,看看哪种最适合您的资源!对四元数进行的编码如下所示:

for each vertex v in mesh:
   quaternion res = tangent_space_to_quat(v.normal, v.tangent, v.bitangent);
   // Once we have the quaternion we can compress it
   res = clamp(res * (2^(BITS - 1) - 1), -2^(BITS - 1), 2^(BITS - 1) - 1);

如需在顶点着色器中对四元数进行解码(改写自此处),请参阅以下代码:

vec3 xAxis( vec4 qQuat )
{
  float fTy  = 2.0 * qQuat.y;
  float fTz  = 2.0 * qQuat.z;
  float fTwy = fTy * qQuat.w;
  float fTwz = fTz * qQuat.w;
  float fTxy = fTy * qQuat.x;
  float fTxz = fTz * qQuat.x;
  float fTyy = fTy * qQuat.y;
  float fTzz = fTz * qQuat.z;

  return vec3( 1.0-(fTyy+fTzz), fTxy+fTwz, fTxz-fTwy );
}

vec3 yAxis( vec4 qQuat )
{
  float fTx  = 2.0 * qQuat.x;
  float fTy  = 2.0 * qQuat.y;
  float fTz  = 2.0 * qQuat.z;
  float fTwx = fTx * qQuat.w;
  float fTwz = fTz * qQuat.w;
  float fTxx = fTx * qQuat.x;
  float fTxy = fTy * qQuat.x;
  float fTyz = fTz * qQuat.y;
  float fTzz = fTz * qQuat.z;

  return vec3( fTxy-fTwz, 1.0-(fTxx+fTzz), fTyz+fTwx );
}

void main() {
  vec4 qtangent = normalize(in_qtangent); //Needed because 16-bit quantization
  vec3 normal = xAxis(qtangent);
  vec3 tangent = yAxis(qtangent);
  float biNormalReflection = sign(in_qtangent.w); //ensured qtangent.w != 0
  vec3 binormal = cross(normal, tangent) * biNormalReflection;
  ...
}
格式 大小
之前 vec3<float32>+ vec3<float32>+ vec3<float32> 36 个字节
之后 vec4<SNORM16> 8 个字节

仅法线

如果您只需存储法线向量,还有一种方法可节省更多内存 - 使用单位向量的八面体映射而不是笛卡尔坐标来压缩法线向量。八面体映射的工作原理是:将单位球面投影到八面体上,然后将八面体向下投影到 2D 平面。这样一来,您只需使用两个数字即可表示任何法线向量。这两个数字可以视为我们用于对将球面投影到的 2D 平面进行“采样”的纹理坐标,让我们能够恢复原始向量。这两个数字随后可以使用 SNORM8 存储。

将单位球面投影到八面体上,然后将八面体投影到 2D 平面

图 2:直观的八面体映射(来源

const int BITS = 8

// Assumes the vector is unit length
// sign() function should return positive for 0
for each normal n in mesh:
  float invL1Norm = 1.0 / (abs(n.x) + abs(n.y) + abs(n.z));
  vec2 res;
  if (n.z < 0.0) {
    res.x = (1.0 - abs(n.y * invL1Norm)) * sign(n.x);
    res.y = (1.0 - abs(n.x * invL1Norm)) * sign(n.y);
  } else {
    res.x = n.x * invL1Norm;
    res.y = n.y * invL1Norm;
  }
  res = clamp(res * (2^(BITS - 1) - 1), -2^(BITS - 1), 2^(BITS - 1) - 1)

在顶点着色器中进行解压缩(以转换回笛卡尔坐标)的成本很低;大多数现代移动设备在实现此技术时均未出现性能大幅下降的情况。在顶点着色器中进行解压缩:

//Additional Optimization: twitter.com/Stubbesaurus/status/937994790553227264
vec3 oct_to_vec(vec2 e):
  vec3 v = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y));
  float t = max(-v.z, 0.0);
  v.xy += t * -sign(v.xy);
  return v;

此方法还可用于存储整个切线空间,您可以利用这一技术通过 vec2<SNORM8> 存储法线向量和切线向量,但您需要找到一种方法来存储双切线的方向(这是在模型上具有镜像 UV 坐标的常见情形所需的)。实现此技术的一种方式是,映射切线矢量编码的组成部分,以使其始终为正数;然后,如果您需要翻转双切线方向,请翻转其符号,并在顶点着色器中进行检查,如下所示:

const int BITS = 8
const float bias = 1.0 / (2^(BITS - 1) - 1)

// Compressing
for each normal n in mesh:
  //encode to octahedron, result in range [-1, 1]
  vec2 res = vec_to_oct(n);

  // map y to always be positive
  res.y = res.y * 0.5 + 0.5;

  // add a bias so that y is never 0 (sign in the vertex shader)
  if (res.y < bias)
    res.y = bias;

  // Apply the sign of the binormal to y, which was computed elsewhere
  if (binormal_sign < 0)
    res.y *= -1;

  res = clamp(res * (2^(BITS - 1) - 1), -2^(BITS - 1), 2^(BITS - 1) - 1)
// Vertex shader decompression
vec2 encode = vec2(tangent_encoded.x, abs(tangent_encoded.y) * 2.0 - 1.0));
vec3 tangent_real = oct_to_vec3(encode);
float binormal_sign = sign(tangent_encode.y);
格式 大小
之前 vec3<float32> 12 个字节
之后 vec2<SNORM8> 2 个字节

顶点 UV 坐标

除了其他作用之外,UV 坐标还可用于纹理映射,且通常使用 32 位浮点数进行存储。对于精度大于 1024x1024 的纹理,使用 16 位浮点数进行压缩会导致出现精度问题;浮点精度介于 [0.5, 1.0] 之间意味着值的增量将会大于 1 像素!

更好的方法是使用无符号归一化整数 (UNORM),具体而言,就是 UNORM16;它可在整个纹理坐标范围内实现均匀分布,从而支持压缩高达 65536x65536 的纹理!此方法假设纹理坐标处于各元素的 [0.0, 1.0] 范围内,然而事实可能并非如此,具体取决于网格(例如,墙可以使用超过 1.0 的环绕纹理坐标),因此在评估此技术时请注意这一点。转换函数如下所示:

const int BITS = 16

for each vertex_uv V in mesh:
  V *= clamp(2^BITS - 1, 0, 2^BITS - 1);  // float to integer value conversion
格式 大小
之前 vec2<float32> 8 个字节
之后 vec2<UNORM16> 4 个字节

顶点压缩结果

这些顶点压缩技术使顶点内存存储空间占用减少了 66%,即从 48 个字节缩减到 16 个字节。主要表现为:

  • 顶点内存读取带宽:
    • 分箱:27GB/s 到 9GB/s
    • 渲染:4.5GB/s 到 1.5GB/s
  • 顶点提取暂停:
    • 分箱:50% 到 0%
    • 渲染:90% 到 90%
  • 平均字节/顶点数:
    • 分箱:48 字节到 16 字节
    • 渲染:52 字节到 18 字节

未压缩顶点的 Android GPU 检查器视图

图 3:未压缩顶点的 Android GPU 检查器视图

已压缩顶点的 Android GPU 检查器视图

图 4:已压缩顶点的 Android GPU 检查器视图

顶点流拆分

顶点流拆分可优化顶点缓冲区中数据的组织方式。它是一项缓存性能优化,会对 Android 设备中通常具有的基于图块的 GPU 产生影响 - 尤其是在渲染流程的分箱步骤中。

基于图块的 GPU 会创建一个着色器,该着色器会根据提供的顶点着色器计算归一化的设备坐标,以便进行分箱。它首先会在场景中的每个顶点上执行,无论是否可见。因此,在内存中保持顶点位置数据的连续性会是一大优势。此顶点流布局对于阴影通道也颇有助益,因为通常您只需要位置数据(用于阴影计算)以及深度预通道(一种通常用于控制台/桌面设备渲染的技术);对于渲染引擎的多个类来说,这种顶点流布局都是理想之选!

流拆分涉及设置顶点缓冲区,使其具有一个包含连续顶点位置数据的部分,和一个包含交错顶点属性的部分。大多数应用通常都会将缓冲区设置为所有属性完全交错。以下代码直观地说明了其中的不同之处:

Before:
|Position1/Normal1/Tangent1/UV1/Position2/Normal2/Tangent2/UV2......|

After:
|Position1/Position2...|Normal1/Tangent1/UV1/Normal2/Tangent2/UV2...|

了解 GPU 如何提取顶点数据,可帮助我们了解流拆分的优势。为了便于讨论,我们做出以下假设:

  • 32 字节的缓存行(相当常见的大小)
  • 顶点格式由以下内容组成:
    • 位置 vec3<float32> = 12 字节
    • 法线 vec3<float32> = 12 字节
    • UV 坐标 vec2<float32> = 8 字节
    • 总大小 = 32 字节

当 GPU 从内存提取数据以进行分箱时,它会拉取 32 字节的缓存行来处理。如果不使用顶点流拆分,它实际上只会使用该缓存行的前 12 个字节进行分箱,并在提取下一个顶点时丢弃另外 20 个字节。如果采用顶点流拆分,内存中的顶点位置将具有连续性,因此当该 32 字节的区块被提取到缓存中时,在必须返回主内存以提取更多数据之前,它实际上包含 2 个可处理的完整顶点位置,这是 2 倍的提升!

现在,如果将顶点流拆分与顶点压缩搭配使用,单个顶点位置的大小将会缩减至 6 字节,因此从系统内存中提取的单个 32 字节的缓存行将具有 5 个可处理的完整顶点位置,这是 5 倍的提升!

顶点流拆分结果

  • 顶点内存读取带宽:
    • 分箱:27GB/s 到 6.5GB/s
    • 渲染:4.5GB/s 到 4.5GB/s
  • 顶点提取暂停:
    • 分箱:40% 到 0%
    • 渲染:90% 到 90%
  • 平均字节/顶点数:
    • 分箱:48 字节到 12 字节
    • 渲染:52 字节到 52 字节

未拆分顶点流的 Android GPU 检查器视图

图 5:未拆分顶点流的 Android GPU 检查器视图

拆分顶点流的 Android GPU 检查器视图

图 6:拆分顶点流的 Android GPU 检查器视图

合用结果

  • 顶点内存读取带宽:
    • 分箱:25GB/s 到4.5 GB/s
    • 渲染:4.5GB/s 到 1.7GB/s
  • 顶点提取暂停:
    • 分箱:41% 到 0%
    • 渲染:90% 到 90%
  • 平均字节/顶点数:
    • 分箱:48 字节到 8 字节
    • 渲染:52 字节到 19 字节

未拆分顶点流的 Android GPU 检查器视图

图 7:未拆分、未压缩顶点流的 Android GPU 检查器视图

未拆分顶点流的 Android GPU 检查器视图

图 8:已压缩的拆分顶点流的 Android GPU 检查器视图

其他注意事项

16 位与 32 位索引缓冲区数据

  • 始终拆分网格/对网格进行分块,以便它们适配 16 位索引缓冲区(最多 65536 个唯一顶点)。这将有助于在移动设备上进行具有索引的渲染,因为提取顶点数据的成本更低,且功耗更少。

不受支持的顶点缓冲区属性格式

  • SSCALED 顶点格式在移动设备上并未得到广泛支持,并且如果这些格式没有硬件支持,那么使用它们可能会严重影响尝试模拟它们的驱动程序的性能。始终选择使用 SNORM,并使用几乎可以忽略不计的 ALU 来进行解压缩。