OpenGL ES(WebGL) 渲染许多小物体

OpenGL ES(WebGL) rendering many small objects

我需要渲染许多小对象(大小为 2 - 100 个三角形),它们位于较深的层次结构中,每个对象都有自己的矩阵。为了渲染它们,我预先计算每个对象的实际矩阵,将对象放在一个列表中,我有两个调用来绘制每个对象:set matrix uniform 和 gl.drawElements().

显然这不是最快的方法。然后我有几千个对象,性能变得无法接受。我正在考虑的唯一解决方案是将多个对象批量放入单个缓冲区。但这并不是一件容易的事,因为每个对象都有自己的矩阵,要将对象放入共享缓冲区,我需要在 CPU 上按矩阵转换其顶点。更糟糕的问题是用户可以随时移动任何对象,我需要再次重新计算大型顶点数据(因为用户可以移动具有许多嵌套子对象的对象)

所以我正在寻找替代方法。最近在 onshape.com 项目中发现了奇怪的顶点着色器:

uniform mat4 uMVMatrix;
uniform mat3 uNMatrix;
uniform mat4 uPMatrix;
 
uniform vec3 uSpecular;
uniform float uOpacity;
uniform float uColorAmbientFactor;  //Determines how much of the vertex-specified color to use in the ambient term
uniform float uColorDiffuseFactor;  //Determines how much of the vertex-specified color to use in the diffuse term
 
uniform bool uApplyTranslucentAlphaToAll;
uniform float uTranslucentPassAlpha;
 
attribute vec3 aVertexPosition;
attribute vec3 aVertexNormal;
attribute vec2 aTextureCoordinate;
attribute vec4 aVertexColor;
 
varying vec3 vPosition;
varying lowp vec3 vNormal;
varying mediump vec2 vTextureCoordinate;
varying lowp vec3 vAmbient;
varying lowp vec3 vDiffuse;
varying lowp vec3 vSpecular;
varying lowp float vOpacity;
 
attribute vec4 aOccurrenceId;
 
float unpackOccurrenceId() {
  return aOccurrenceId.g * 65536.0 + aOccurrenceId.b * 256.0 + aOccurrenceId.a;
}
 
float unpackHashedBodyId() {
  return aOccurrenceId.r;
}
 
#define USE_OCCURRENCE_TEXTURE 1
 
#ifdef USE_OCCURRENCE_TEXTURE
 
uniform sampler2D uOccurrenceDataTexture;
uniform float uOccurrenceTexelWidth;
uniform float uOccurrenceTexelHeight;
#define ELEMENTS_PER_OCCURRENCE 2.0
 
void getOccurrenceData(out vec4 occurrenceData[2]) {
  // We will extract the occurrence data from the occurrence texture by converting the occurrence id to texture coordinates
 
  // Convert the packed occurrenceId into a single number
  float occurrenceId = unpackOccurrenceId();
 
  // We first determine the row of the texture by dividing by the overall texture width.  Each occurrence
  // has multiple rgba texture entries, so we need to account for each of those entries when determining the
  // element's offset into the buffer.
  float divided = (ELEMENTS_PER_OCCURRENCE * occurrenceId) * uOccurrenceTexelWidth;
  float row = floor(divided);
  vec2 coordinate;
  // The actual coordinate lies between 0 and 1.  We need to take care that coordinate lies on the texel
  // center by offsetting the coordinate by a half texel.
  coordinate.t = (0.5 + row) * uOccurrenceTexelHeight;
  // Figure out the width of one texel in texture space
  // Since we've already done the texture width division, we can figure out the horizontal coordinate
  // by adding a half-texel width to the remainder
  coordinate.s = (divided - row) + 0.5 * uOccurrenceTexelWidth;
  occurrenceData[0] = texture2D(uOccurrenceDataTexture, coordinate);
  // The second piece of texture data will lie in the adjacent column
  coordinate.s += uOccurrenceTexelWidth;
  occurrenceData[1] = texture2D(uOccurrenceDataTexture, coordinate);
}
 
#else
 
attribute vec4 aOccurrenceData0;
attribute vec4 aOccurrenceData1;
void getOccurrenceData(out vec4 occurrenceData[2]) {
  occurrenceData[0] = aOccurrenceData0;
  occurrenceData[1] = aOccurrenceData1;
}
 
#endif
 
/**
 * Create a model matrix from the given occurrence data.
 *
 * The method for deriving the rotation matrix from the euler angles is based on this publication:
 * http://www.soi.city.ac.uk/~sbbh653/publications/euler.pdf
 */
mat4 createModelTransformationFromOccurrenceData(vec4 occurrenceData[2]) {
  float cx = cos(occurrenceData[0].x);
  float sx = sin(occurrenceData[0].x);
  float cy = cos(occurrenceData[0].y);
  float sy = sin(occurrenceData[0].y);
  float cz = cos(occurrenceData[0].z);
  float sz = sin(occurrenceData[0].z);
 
  mat4 modelMatrix = mat4(1.0);
 
  float scale = occurrenceData[0][3];
 
  modelMatrix[0][0] = (cy * cz) * scale;
  modelMatrix[0][1] = (cy * sz) * scale;
  modelMatrix[0][2] = -sy * scale;
 
  modelMatrix[1][0] = (sx * sy * cz - cx * sz) * scale;
  modelMatrix[1][1] = (sx * sy * sz + cx * cz) * scale;
  modelMatrix[1][2] = (sx * cy) * scale;
 
  modelMatrix[2][0] = (cx * sy * cz + sx * sz) * scale;
  modelMatrix[2][1] = (cx * sy * sz - sx * cz) * scale;
  modelMatrix[2][2] = (cx * cy) * scale;
 
  modelMatrix[3].xyz = occurrenceData[1].xyz;
 
  return modelMatrix;
}
 
 
void main(void) {
  vec4 occurrenceData[2];
  getOccurrenceData(occurrenceData);
  mat4 modelMatrix = createModelTransformationFromOccurrenceData(occurrenceData);
  mat3 normalMatrix = mat3(modelMatrix);
 
  vec4 position = uMVMatrix * modelMatrix * vec4(aVertexPosition, 1.0);
  vPosition = position.xyz;
  vNormal = uNMatrix * normalMatrix * aVertexNormal;
  vTextureCoordinate = aTextureCoordinate;
 
  vAmbient = uColorAmbientFactor * aVertexColor.rgb;
  vDiffuse = uColorDiffuseFactor * aVertexColor.rgb;
  vSpecular = uSpecular;
  vOpacity = uApplyTranslucentAlphaToAll ? (min(uTranslucentPassAlpha, aVertexColor.a)) : aVertexColor.a;
 
  gl_Position = uPMatrix * position;
}

看起来他们将对象位置和旋转角度编码为 4 分量浮动纹理中的 2 个条目,在该纹理中添加存储每个顶点变换位置的属性,然后在顶点着色器中执行矩阵计算。

所以问题是这个着色器实际上是解决我的问题的有效方法,还是我应该更好地使用批处理或其他方法?

PS:可能更好的方法是存储四元数而不是角度并直接通过它转换顶点?

this 可能会给您一些想法。

如果理解 Rem 的评论...

最简单的解决方案是存储某种逐顶点变换数据。这实际上就是上面的视频所做的。该解决方案的问题是,如果您有一个包含 100 个顶点的模型,则必须更新所有 100 个顶点的变换。

解决方案是通过纹理间接变换。对于每个模型存储中的每个顶点,只有一个额外的浮点数,我们可以将这个浮点数称为"modelId",如

attribute float modelId;

所以第一个模型中的所有顶点都获得 id = 0,第二个模型中的所有顶点获得 id = 1,等等。

然后将变换存储在纹理中。例如,您可以存储平移 (x, y, z) + 一个四元数 (x, y, z, w)。如果您的目标平台支持浮点纹理,那么每次变换 2 个 RGBA 像素。

您使用 modelId 来计算纹理中提取变换数据的位置。

float col = mod(modelId, halfTextureWidth) * 2.;
float row = floor(modelId / halfTextureWidth);
float oneHPixel = 1. / textureWidth;
vec2 uv = vec2((col + 0.5) / textureWidth, (row + 0.5) / textureHeight);
vec4 translation = texture2D(transforms, uv);
vec4 rotationQuat = texture2D(transform, uv + vec2(oneHPixel, 0));

现在您可以使用 translation 和 rotationQuat 在顶点着色器中创建矩阵。

为什么halfTextureWidth?因为我们每次变换 2 个像素。

为什么 + 0.5?参见

这意味着您只需为每个模型更新 1 个变换,而不是每个顶点更新 1 个变换,这使得它的工作量最少。

This example generates some matrices from quaternions。这是一个类似的想法,但由于它处理的是粒子,因此不需要纹理间接。

注意:以上假设您只需要平移和旋转。如果需要的话,没有什么可以阻止您将整个矩阵存储在纹理中。或者其他任何与此相关的东西,例如 material 属性、照明属性等。

据我所知,几乎所有当前平台都支持从浮点纹理读取数据。您必须使用

启用该功能
var ext = gl.getExtension("OES_texture_float");
if (!ext) {
   // no floating point textures for you!
}

但请注意,并非每个平台都支持过滤浮点纹理。此解决方案不需要过滤(并且需要单独启用)。请务必将过滤设置为 gl.NEAREST

我对此也很好奇,所以我 运行 使用 4 种不同的绘图技术进行了几次测试。

第一个是通过您在大多数教程和书籍中找到的 uniform 进行实例化。为每个模型设置制服,然后绘制模型。

第二个是存储一个额外的属性,每个顶点的矩阵变换,在GPU上做变换。在每次抽奖中,gl.bufferSubData 然后在每次抽奖中抽取尽可能多的模型。

第三种方法是将多个矩阵变换统一上传到 GPU,并在每个顶点上有一个额外的 matrixID 到 selectGPU 上的正确矩阵。这与第一个类似,只是它允许批量绘制模型。这也是它通常在骨骼动画中的实现方式。在绘制时,对于每个批次,将矩阵从 batch[index] 处的模型上传到 GPU 中的矩阵数组 [index] 并绘制批次。

最后的技术是通过纹理查找。我创建了一个大小为 4096 * 256 * 4 的 Float32Array,其中包含每个模型的世界矩阵(足够 ~256k 模型)。每个模型都有一个 modelIndex 属性,用于从纹理中读取其矩阵。然后在每一帧,gl.texSubImage2D 整个纹理并在每次绘制调用中绘制尽可能多的图像。

不考虑硬件实例化,因为我认为要求是绘制许多独特的模型,即使在我的测试中我只绘制每帧具有不同世界矩阵的立方体。

结果如下:(60FPS能画多少)

  1. 每个模型的不同制服:~2000
  2. 具有 matrixId 的批量制服:~20000
  3. 每个顶点的存储变换:~40000(发现第一个实现的错误)
  4. 纹理查找:~160000
  5. 没有绘制,只是 CPU 计算矩阵的时间:~170000

我认为很明显统一实例化不是可行的方法。技术 1 失败只是因为它进行了太多的绘制调用。批量制服应该可以处理绘图调用问题,但我发现 CPU 太多时间用于从正确的模型获取矩阵数据并将其上传到 GPU。大量的 uniformMatrix4f 调用也无济于事。

与计算动态对象的新世界矩阵所花费的时间相比,执行 gl.texSubImage2D 所花费的时间要少得多。在每个顶点上复制变换数据比大多数人想象的要好,但它浪费了大量内存带宽。在所有上述技术中,纹理查找方法可能对 CPU 最友好。进行 4 纹理查找的速度似乎与进行统一数组查找的速度相似。 (结果来自对我受 GPU 约束的更大的复杂对象进行测试)。

这是使用纹理查找方法进行的一项测试的快照:

因此,总而言之,如果您的模型较小,您可能需要在每个顶点上存储变换数据,或者如果您的模型较大,则使用纹理查找方法。

评论中问题的回答:

  1. 填充率:我完全不受GPU的约束。当我尝试使用大型复杂模型时,均匀实例化实际上变得最快。我想使用统一批处理和纹理查找会产生一些 GPU 开销,这会导致它们变慢。
  2. 存储四元数和翻译:在我的情况下不会有太大影响,因为正如您所见,texSubImage2D 只占用了 CPU 时间的 9%,将其减少到 4.5% 也没什么大不了的。很难说它对 GPU 的影响,因为虽然你做的纹理查找较少,但你必须将四元数转换为矩阵。
  3. 交错:如果您的应用程序是顶点绑定的,假设这种技术可以提供大约 5-10% 的速度。但是,我从未见过交错在我的测试中对我产生影响。所以我完全摆脱了它。
  4. 内存:除了在每个顶点上重复之外,所有技术基本相同。所有其他 3 种技术都应将相同数量的数据传递给 GPU。 (你可以将翻译+四元数作为统一而不是矩阵传递)