目录

WebGLFundamentals.org

Fix, Fork, Contribute

WebGL 绘制多个物体

此文上接一系列WebGL相关文章, 如果没读请从那里开始。

学到WebGL的一些基础以后,面临的一个问题可能是如何绘制多个物体。

这里有一些特别的地方你需要提前了解,WebGL就像是一个方法, 但不同于一般的方法直接传递参数,它需要调用一些方法去设置状态, 最后用某个方法执行绘制,并使用之前设置的状态。你在写代码时可能会用这种形式的方法

function drawCircle(centerX, centerY, radius, color) { ... }

或者用这种形式的方法

var centerX;
var centerY;
var radius;
var color;

function setCenter(x, y) {
   centerX = x;
   centerY = y;
}

function setRadius(r) {
   radius = r;
}

function setColor(c) {
   color = c;
}

function drawCircle() {
   ...
}

WebGL使用的是后一种形式,例如 gl.createBuffer, gl.bufferData, gl.createTexture, 和 gl.texImage2D 方法让你上传缓冲(顶点)或者纹理(颜色等)给WebGL, gl.createProgram, gl.createShader, gl.compileProgram, 和 gl.linkProgram 让你创建自己的 GLSL 着色器, 剩下的所有方法几乎都是设置全局变量或者最终方法 gl.drawArraysgl.drawElements 需要的状态

清楚这个以后,WebGL应用基本都遵循以下结构

初始化阶段

  • 创建所有着色器和程序并寻找参数位置
  • 创建缓冲并上传顶点数据
  • 创建纹理并上传纹理数据

渲染阶段

  • 清空并设置视图和其他全局状态(开启深度检测,剔除等等)
  • 对于想要绘制的每个物体
    • 调用 gl.useProgram 使用需要的程序
    • 设置物体的属性变量
      • 为每个属性调用 gl.bindBuffer, gl.vertexAttribPointer, gl.enableVertexAttribArray
    • 设置物体的全局变量
      • 为每个全局变量调用 gl.uniformXXX
      • 调用 gl.activeTexturegl.bindTexture 设置纹理到纹理单元
    • 调用 gl.drawArraysgl.drawElements

基本上就是这些,详细情况取决于你的实际目的和代码组织情况。

有的事情例如上传纹理数据(甚至时顶点数据)可能遇到异步, 你就需要等所有资源下载完成后才能开始。

让我们来做一个简单的应用,绘制三个物体,一个立方体,一个球体,一个椎体。

我不会详细介绍如何计算出立方体,球体和椎体数据, 假设有方法能够返回上篇文章中的 bufferInfo 对象

这是代码,着色器是透视示例中的简单的着色器, 新添加了一个 u_colorMult 全局变量和顶点颜色相乘。

// 从顶点着色器中传入的值
varying vec4 v_color;

uniform vec4 u_colorMult;

void main() {
   gl_FragColor = v_color * u_colorMult;
}

初始化阶段

// 每个物体需要的全局变量
var sphereUniforms = {
  u_colorMult: [0.5, 1, 0.5, 1],
  u_matrix: m4.identity(),
};
var cubeUniforms = {
  u_colorMult: [1, 0.5, 0.5, 1],
  u_matrix: m4.identity(),
};
var coneUniforms = {
  u_colorMult: [0.5, 0.5, 1, 1],
  u_matrix: m4.identity(),
};

// 每个物体的平移量
var sphereTranslation = [  0, 0, 0];
var cubeTranslation   = [-40, 0, 0];
var coneTranslation   = [ 40, 0, 0];

绘制阶段

var sphereXRotation =  time;
var sphereYRotation =  time;
var cubeXRotation   = -time;
var cubeYRotation   =  time;
var coneXRotation   =  time;
var coneYRotation   = -time;

// ------ 绘制球体 --------

gl.useProgram(programInfo.program);

// 设置所需的属性变量
webglUtils.setBuffersAndAttributes(gl, programInfo, sphereBufferInfo);

sphereUniforms.u_matrix = computeMatrix(
    viewProjectionMatrix,
    sphereTranslation,
    sphereXRotation,
    sphereYRotation);

// 设置刚才计算出的全局变量
webglUtils.setUniforms(programInfo, sphereUniforms);

gl.drawArrays(gl.TRIANGLES, 0, sphereBufferInfo.numElements);

// ------ 绘制立方体 --------

// 设置所需的属性变量
webglUtils.setBuffersAndAttributes(gl, programInfo, cubeBufferInfo);

cubeUniforms.u_matrix = computeMatrix(
    viewProjectionMatrix,
    cubeTranslation,
    cubeXRotation,
    cubeYRotation);

// 设置刚才计算出的全局变量
webglUtils.setUniforms(programInfo, cubeUniforms);

gl.drawArrays(gl.TRIANGLES, 0, cubeBufferInfo.numElements);

// ------ 绘制椎体 --------

// 设置所需的属性变量
webglUtils.setBuffersAndAttributes(gl, programInfo, coneBufferInfo);

coneUniforms.u_matrix = computeMatrix(
    viewProjectionMatrix,
    coneTranslation,
    coneXRotation,
    coneYRotation);

// 设置刚才计算出的全局变量
webglUtils.setUniforms(programInfo, coneUniforms);

gl.drawArrays(gl.TRIANGLES, 0, coneBufferInfo.numElements);

这是结果

需要注意的是,由于我们只有一个程序,所以只调用了一次 gl.useProgram, 如果我们有不同的着色程序,则需要在使用前调用 gl.useProgram

这还有一个值得简化的地方,将这三个相关的事情组合到一起。

  1. 着色程序(和它的全局变量以及属性 info/setter)
  2. 想要绘制的物体的缓冲和属性变量
  3. 绘制物体所需程序的全局变量

简单的简化后制作一个序列对象,将三个物体放在其中

var objectsToDraw = [
  {
    programInfo: programInfo,
    bufferInfo: sphereBufferInfo,
    uniforms: sphereUniforms,
  },
  {
    programInfo: programInfo,
    bufferInfo: cubeBufferInfo,
    uniforms: cubeUniforms,
  },
  {
    programInfo: programInfo,
    bufferInfo: coneBufferInfo,
    uniforms: coneUniforms,
  },
];

绘制的时候仍然需要更新矩阵

var sphereXRotation =  time;
var sphereYRotation =  time;
var cubeXRotation   = -time;
var cubeYRotation   =  time;
var coneXRotation   =  time;
var coneYRotation   = -time;

// 为每个物体计算矩阵
sphereUniforms.u_matrix = computeMatrix(
    viewProjectionMatrix,
    sphereTranslation,
    sphereXRotation,
    sphereYRotation);

cubeUniforms.u_matrix = computeMatrix(
    viewProjectionMatrix,
    cubeTranslation,
    cubeXRotation,
    cubeYRotation);

coneUniforms.u_matrix = computeMatrix(
    viewProjectionMatrix,
    coneTranslation,
    coneXRotation,
    coneYRotation);

但是绘制代码就会变成一个简单的循环

// ------ 绘制几何体 --------

objectsToDraw.forEach(function(object) {
  var programInfo = object.programInfo;
  var bufferInfo = object.bufferInfo;

  gl.useProgram(programInfo.program);

  // 设置所需的属性
  webglUtils.setBuffersAndAttributes(gl, programInfo, bufferInfo);

  // 设置全局变量
  webglUtils.setUniforms(programInfo, object.uniforms);

  // 绘制
  gl.drawArrays(gl.TRIANGLES, 0, bufferInfo.numElements);
});

理论上这就是大多数现有三维引擎的主要渲染循环。 其他地方的某些代码控制 objectsToDraw 列表中的对象, 基本上就是这样。

这还有一些小优化,如果将要绘制的对象和前一个对象使用相同的程序, 则不需要调用 gl.useProgram。同样的,如果绘制的形状/几何体/顶点 是之前绘制过的,相同的参数就不必再设置一遍。

所以,简单的优化后可能像这样

var lastUsedProgramInfo = null;
var lastUsedBufferInfo = null;

objectsToDraw.forEach(function(object) {
  var programInfo = object.programInfo;
  var bufferInfo = object.bufferInfo;
  var bindBuffers = false;

  if (programInfo !== lastUsedProgramInfo) {
    lastUsedProgramInfo = programInfo;
    gl.useProgram(programInfo.program);

    // 更换程序后要重新绑定缓冲,因为只需要绑定程序要用的缓冲。
    // 如果两个程序使用相同的bufferInfo但是第一个只用位置数据,
    // 当我们从第一个程序切换到第二个时,有些属性就不存在。
    bindBuffers = true;
  }

  // 设置所需的属性
  if (bindBuffers || bufferInfo != lastUsedBufferInfo) {
    lastUsedBufferInfo = bufferInfo;
    webglUtils.setBuffersAndAttributes(gl, programInfo, bufferInfo);
  }

  // 设置全局变量
  webglUtils.setUniforms(programInfo, object.uniforms);

  // 绘制
  gl.drawArrays(gl.TRIANGLES, 0, bufferInfo.numElements);
});

这次我们多绘制一些物体,用包含更多物体的序列代替之前的三个物体。

// 将图形放在数组中以便随机抽取
var shapes = [
  sphereBufferInfo,
  cubeBufferInfo,
  coneBufferInfo,
];

// 创建两个对象数组,一个用于绘制,一个用于使用
var objectsToDraw = [];
var objects = [];

// 每个物体的全局变量
var numObjects = 200;
for (var ii = 0; ii < numObjects; ++ii) {
  // 选择一个形状
  var bufferInfo = shapes[rand(0, shapes.length) | 0];

  // 创建一个物体
  var object = {
    uniforms: {
      u_colorMult: [rand(0, 1), rand(0, 1), rand(0, 1), 1],
      u_matrix: m4.identity(),
    },
    translation: [rand(-100, 100), rand(-100, 100), rand(-150, -50)],
    xRotationSpeed: rand(0.8, 1.2),
    yRotationSpeed: rand(0.8, 1.2),
  };
  objects.push(object);

  // 添加到绘制数组中
  objectsToDraw.push({
    programInfo: programInfo,
    bufferInfo: bufferInfo,
    uniforms: object.uniforms,
  });
}

渲染时

// 计算每个物体的矩阵
objects.forEach(function(object) {
  object.uniforms.u_matrix = computeMatrix(
      viewMatrix,
      projectionMatrix,
      object.translation,
      object.xRotationSpeed * time,
      object.yRotationSpeed * time);
});

然后在上方的循环中绘制所有物体。

你也可以根据 programInfo 和/或 bufferInfo 对物体进行排序, 这样就会更大程度的利用优化代码,大多数游戏引擎都会这么做。 但这并不简单,如果你绘制的都是不透明物体那就可以直接排序, 但是一旦你要绘制半透明物体时,就必须按照一定的顺序绘制。 大多数三维引擎通过使用两个或多个对象数组解决这个问题,一个存储不透明物体, 另一个存储透明物体,不透明数组按照程序和几何体排序,透明数组按照深度排序, 可能还有其他数组存储覆盖物或者后处理效果等。

这是一个使用排序的例子。在我的机器上从 ~31fps 提升到了 ~37fps,几乎是 20% 的性能提升。但是这是最差的情况和最好的情况的对比, 大多数应用考虑的非常全面,理论上除了一些非常特殊的情况以外,其他情况并不需要考虑太多。

需要特别注意的是着色器和图形往往一一对应, 例如一个需要法向量的着色器就不能用在没有法向量的几何体上, 同样的一个需要纹理的着色器在没有纹理时就无法正常运行。

这就是需要选择一个优质的三维引擎(例如Three.js)的原因之一, 因为它可以帮你解决这些问题。你创建几何体时只需要告诉 three.js 你想如何渲染, 它就会在运行时为你创建你需要的着色器。几乎所有的三维引擎,从 Unity3D 到 Unreal 到 Source 到 Crytek,有些在离线时创建着色器,但是重要的是它们都会创建着色器。

当然,你阅读这些文章的目的是想知道底层原理,自己写所有的东西非常好并且也很有趣, 但是需要注意的是WebGL是非常底层的, 所以如果你想自己做所有的东西的话,要做的东西很多,通常包括着色器生成器, 因为不同的特性需要不同的着色器。

你可能注意到我并没有把 computeMatrix 放在循环中, 那是因为渲染理论上应该和矩阵计算分离,通常情况下矩阵计算放在接下来要讲的 场景图中。

现在我们有了绘制多个物体的框架,就可以绘制一些文字了

有疑问? 在stackoverflow上提问.
Issue/Bug? 在GitHub上提issue.
使用 <pre><code> 代码 </code></pre> 的格式编写代码块
comments powered by Disqus