顶点数据管理

良好的顶点数据布局和压缩对于任何图形应用程序的性能都至关重要,无论应用程序是包含 2D 用户界面还是大型 3D 开放世界游戏。对数十款顶级 Android 游戏进行的内部测试(使用 Android GPU 检查器 的帧分析器)表明,在顶点数据管理方面可以做很多改进。我们观察到,顶点数据通常使用全精度 32 位浮点数表示所有顶点属性,并且顶点缓冲区布局使用一个结构数组,该数组以完全交错的属性格式化。

本文讨论了如何通过使用以下技术优化 Android 应用程序的图形性能

  • 顶点压缩
  • 顶点流拆分

实施这些技术可以将顶点内存带宽使用率提高多达 50%,减少与 CPU 的内存总线竞争,减少系统内存上的停顿,并延长电池续航时间;所有这些都对开发人员和最终用户都有利!

所有提供的数据来自 Pixel 4 上运行的包含约 19,000,000 个顶点的示例静态场景

Sample scene with 6 rings and 19m vertices

图 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 字节

仅法线

如果您只需要存储法线向量,则有一种不同的方法可以带来更多节省 - 使用八面体映射单位向量而不是笛卡尔坐标来压缩法线向量。八面体映射通过将单位球体投影到八面体,然后将八面体投影到二维平面上来工作。结果是,您可以仅使用两个数字来表示任何法线向量。这两个数字可以被认为是我们用来“采样”我们将球体投影到的二维平面的纹理坐标,使我们能够恢复原始向量。然后,这两个数字可以存储在 SNORM8 中。

Projecting a unit sphere to an octahedron and projecting the octahedron to a 2D plane

图 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 位浮点数存储。使用 16 位浮点数压缩它们会导致大于 1024x1024 的纹理出现精度问题;[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.5B/s 至 1.5GB/s
  • 顶点获取停顿
    • 分箱:50% 至 0%
    • 渲染:90% 至 90%
  • 平均字节/顶点
    • 分箱:48B 至 16B
    • 渲染:52B 至 18B

Android GPU Inspector view of uncompressed vertices

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

Android GPU Inspector view of compressed vertices

图 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%
  • 平均字节/顶点
    • 分箱:48B 至 12B
    • 渲染:52B 至 52B

Android GPU Inspector view of unsplit vertex streams

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

Android GPU Inspector view of split vertex streams

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

复合结果

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

Android GPU Inspector view of unsplit vertex streams

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

Android GPU Inspector view of unsplit vertex streams

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

其他注意事项

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

  • 始终拆分/分块网格,使其适合 16 位索引缓冲区(最大 65536 个唯一顶点)。这将有助于移动设备上的索引渲染,因为它获取顶点数据的成本更低,并且将消耗更少的电量。

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

  • SSCALED 顶点格式在移动设备上不受广泛支持,并且在使用时,在尝试模拟它们的驱动程序中可能会产生代价高昂的性能权衡,如果它们没有硬件支持。始终选择 SNORM 并支付可忽略不计的 ALU 成本来解压缩。