☰
WebGL 绘制多个物体:从状态机原理到渲染循环与排序优化(webgl-fundamentals 实战指南)
2026/10/15 15:34:41 网站建设 项目流程
  • 图形学

【免费下载链接】webgl-fundamentals

WebGL lessons that start with the basics

项目地址:https://gitcode.com/gh_mirrors/we/webgl-fundamentals
点击查看免费下载

本篇是 webgl-fundamentals 教程体系中的「绘制多个物体(Drawing Multiple Things)」实战指南。文章先讲透 WebGL 的"状态机"本质,再以球体、立方体、圆锥三个物体为例,逐步演示手动绘制、待绘制对象列表(objectsToDraw)、状态复用优化与按排序分批渲染四种写法,并深入对应示例源码与webgl-utils.js工具实现。读完你将掌握大多数 3D 引擎主渲染循环的核心骨架,并能据此组织自己的多物体渲染代码。

本文是系列文章的延续,建议先阅读 WebGL 基础 系列,特别是其中关于 bufferInfo 对象、透视投影 与 WebGL 与 3D 库的定位差异 的章节。

一、先建立正确的认知:WebGL 是一个"状态机"

很多人在 WebGL 里画出第一个物体后,问得最多的问题就是:怎么才能画多个物体?要回答它,首先必须认清 WebGL 的工作方式。

除极少数例外,WebGL 本质上就像别人写好的一个函数:它把常规函数里"传入大量参数"的做法,拆成了一个负责绘制的函数 + 70 多个负责设置状态的函数。例如绘制一个圆,你可以这样写:

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等函数负责把缓冲区(顶点)数据与纹理数据上传到 GPU;
  • gl.createProgram、gl.createShader、gl.compileShader、gl.linkProgram等函数负责创建 GLSL 着色器;
  • 其余几乎全部函数,都是在设置这些"全局变量 / 状态",供最终调用gl.drawArrays或gl.drawElements时使用。

理解了这一点,典型的 WebGL 程序结构就非常清晰了。

初始化阶段(Init time)

  • 创建所有着色器与程序,并查询 attribute / uniform 位置;
  • 创建缓冲区并上传顶点数据;
  • 创建纹理并上传纹理数据。

渲染阶段(Render time)

  • 清屏、设置视口(viewport)与其它全局状态(如开启深度测试、开启背面剔除等);
  • 对每一个要绘制的物体:
    • 调用gl.useProgram切换到绘制该物体所需的程序;
    • 设置该物体的 attribute:对每个 attribute 依次调用gl.bindBuffer、gl.vertexAttribPointer、gl.enableVertexAttribArray;
    • 设置该物体的 uniform:对每个 uniform 调用gl.uniformXXX,并通过gl.activeTexture与gl.bindTexture把纹理分配到纹理单元;
    • 调用gl.drawArrays或gl.drawElements完成一次绘制。

整体框架就是如此,具体代码怎么组织完全取决于你自己。另外要注意,纹理数据(有时也包括顶点数据)的上传可能是异步的,因为需要等待资源从网络下载完成。

二、实战一:手动绘制三个物体(球体、立方体、圆锥)

下面我们做一个简单应用,同时绘制三个物体:一个球体(sphere)、一个立方体(cube)和一个圆锥(cone)。这里不展开球体、立方体、圆锥的顶点数据如何计算,直接假设有现成函数可以生成它们,并返回上一篇文章介绍的 bufferInfo 对象 结构。

在仓库中,这些几何数据由 primitives.js 提供,例如:

  • primitives.createSphereWithVertexColorsBufferInfo(gl, 10, 12, 6)
  • primitives.createCubeWithVertexColorsBufferInfo(gl, 20)
  • primitives.createTruncatedConeWithVertexColorsBufferInfo(gl, 10, 0, 20, 12, 1, true, false)

它们内部会完成gl.createBuffer、gl.bindBuffer、gl.bufferData等上传工作(见 primitives.js 的createFlattenedFunc,最终通过webglUtils.createBufferInfoFromArrays生成带attribs、numElements的 BufferInfo),并给每个面填充随机顶点颜色。

着色器沿用 透视投影示例 中那个简单的着色器,只增加了一个u_colorMult用来乘到顶点颜色上:

// Passed in from the vertex shader. varying vec4 v_color; uniform vec4 u_colorMult; void main() { gl_FragColor = v_color * u_colorMult; }

对应的顶点着色器把u_matrix与a_position相乘得到裁剪坐标,并把a_color传递给片元着色器(完整代码见 webgl-multiple-objects-manual.html)。

初始化:为每个物体准备独立的 uniforms 与位移

每个物体有自己的颜色倍率(u_colorMult)与矩阵(u_matrix):

// Our uniforms for each thing we want to draw 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(), }; // The translation for each object. var sphereTranslation = [ 0, 0, 0]; var cubeTranslation = [-40, 0, 0]; var coneTranslation = [ 40, 0, 0];

渲染:逐物体重复"换程序 → 设属性 → 设 uniform → 绘制"

每帧先根据时间算出各物体的旋转角,然后逐个绘制:

var sphereXRotation = time; var sphereYRotation = time; var cubeXRotation = -time; var cubeYRotation = time; var coneXRotation = time; var coneYRotation = -time; // ------ Draw the sphere -------- gl.useProgram(programInfo.program); // Setup all the needed attributes. webglUtils.setBuffersAndAttributes(gl, programInfo, sphereBufferInfo); sphereUniforms.u_matrix = computeMatrix( viewProjectionMatrix, sphereTranslation, sphereXRotation, sphereYRotation); // Set the uniforms we just computed webglUtils.setUniforms(programInfo, sphereUniforms); gl.drawArrays(gl.TRIANGLES, 0, sphereBufferInfo.numElements); // ------ Draw the cube -------- // Setup all the needed attributes. webglUtils.setBuffersAndAttributes(gl, programInfo, cubeBufferInfo); cubeUniforms.u_matrix = computeMatrix( viewProjectionMatrix, cubeTranslation, cubeXRotation, cubeYRotation); // Set the uniforms we just computed webglUtils.setUniforms(programInfo, cubeUniforms); gl.drawArrays(gl.TRIANGLES, 0, cubeBufferInfo.numElements); // ------ Draw the cone -------- // Setup all the needed attributes. webglUtils.setBuffersAndAttributes(gl, programInfo, coneBufferInfo); coneUniforms.u_matrix = computeMatrix( viewProjectionMatrix, coneTranslation, coneXRotation, coneYRotation); // Set the uniforms we just computed webglUtils.setUniforms(programInfo, coneUniforms); gl.drawArrays(gl.TRIANGLES, 0, coneBufferInfo.numElements);

可运行示例见 webgl-multiple-objects-manual.html。

这里有一个值得注意的点:因为三个物体共用同一个着色器程序,所以我们只调用了一次gl.useProgram。如果换成不同的着色器程序,就必须在每次使用某个程序之前调用gl.useProgram。

webglUtils.setBuffersAndAttributes与webglUtils.setUniforms是 webgl-utils.js 提供的两个关键工具:

  • setBuffersAndAttributes(gl, setters, buffers)(webgl-utils.js):遍历buffers.attribs,对每个 attribute 绑定ARRAY_BUFFER、enableVertexAttribArray并设置vertexAttribPointer;若buffers.indices存在,还会绑定ELEMENT_ARRAY_BUFFER。
  • setUniforms(setters, ...values)(webgl-utils.js):根据 uniform 名字找到对应的 setter 函数并调用。这些 setter 由createUniformSetters(webgl-utils.js)为每个激活的 uniform 生成,内部封装了gl.uniform1f、gl.uniform4fv、gl.uniformMatrix4fv等调用,对于 sampler 类型的 uniform 还会自动完成gl.activeTexture与gl.bindTexture。
  • 而programInfo由createProgramInfo(gl, ["vertex-shader-3d", "fragment-shader-3d"])生成(webgl-utils.js),它把program、uniformSetters、attribSetters打包在一起,正是后续所有写法的核心数据载体。

三、实战二:用"待绘制列表"简化渲染循环

上面的手动写法在物体数量少时还行,一旦物体变多就会写出大量重复代码。这里又是一个适合做简化的点。归纳起来,一次绘制本质上只有 3 样东西需要组合:

  1. 一个着色器程序(连同它的 uniform / attribute 信息与 setter);
  2. 要绘制物体的缓冲区与属性(bufferInfo);
  3. 用给定着色器绘制该物体所需的 uniforms。

最简单的简化方式,就是把这三样东西打包进一个"待绘制对象"数组:

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; // Compute the matrices for each object. sphereUniforms.u_matrix = computeMatrix( viewProjectionMatrix, sphereTranslation, sphereXRotation, sphereYRotation); cubeUniforms.u_matrix = computeMatrix( viewProjectionMatrix, cubeTranslation, cubeXRotation, cubeYRotation); coneUniforms.u_matrix = computeMatrix( viewProjectionMatrix, coneTranslation, coneXRotation, coneYRotation);

但绘制代码本身现在只是一个简单的循环:

// ------ Draw the objects -------- objectsToDraw.forEach(function(object) { var programInfo = object.programInfo; var bufferInfo = object.bufferInfo; gl.useProgram(programInfo.program); // Setup all the needed attributes. webglUtils.setBuffersAndAttributes(gl, programInfo, bufferInfo); // Set the uniforms. webglUtils.setUniforms(programInfo, object.uniforms); // Draw gl.drawArrays(gl.TRIANGLES, 0, bufferInfo.numElements); });

可以毫不夸张地说,这个循环就是当今绝大多数 3D 引擎的主渲染循环——总有一段代码决定哪些对象进入objectsToDraw列表,而绘制本身就是这样一段循环。可运行示例见 webgl-multiple-objects-list.html。

四、实战三:基础优化——避免重复设置状态

上面这个循环里还有几个明显可以优化的点:

  • 如果下一个要绘制的物体与当前使用的程序是同一个,就没有必要再次调用gl.useProgram;
  • 如果下一个物体与当前使用的几何体(形状 / 顶点数据)相同,就没有必要重新绑定与设置那些 attribute。

一个非常简单的优化版本长这样:

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); // We have to rebind buffers when changing programs because we // only bind buffers the program uses. So if 2 programs use the same // bufferInfo but the 1st one uses only positions then when // we switch to the 2nd one some of the attributes will not be on. bindBuffers = true; } // Setup all the needed attributes. if (bindBuffers || bufferInfo != lastUsedBufferInfo) { lastUsedBufferInfo = bufferInfo; webglUtils.setBuffersAndAttributes(gl, programInfo, bufferInfo); } // Set the uniforms. webglUtils.setUniforms(programInfo, object.uniforms); // Draw gl.drawArrays(gl.TRIANGLES, 0, bufferInfo.numElements); });

注意代码里的bindBuffers标志:切换程序时必须重新绑定缓冲区。原因是每个程序只绑定它自己用到的 attribute——如果两个程序共享同一个 bufferInfo,但第一个程序只用到 position,切换后有些 attribute 会处于未启用状态,必须重新设置。

这次我们不再只画 3 个物体,而是把待绘制列表做大。先把三种形状放进数组便于随机挑选:

// put the shapes in an array so it's easy to pick them at random var shapes = [ sphereBufferInfo, cubeBufferInfo, coneBufferInfo, ]; // make 2 lists of objects, one of stuff to draw, one to manipulate. var objectsToDraw = []; var objects = []; // Uniforms for each object. var numObjects = 200; for (var ii = 0; ii < numObjects; ++ii) { // pick a shape var bufferInfo = shapes[rand(0, shapes.length) | 0]; // make an object. 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); // Add it to the list of things to draw. objectsToDraw.push({ programInfo: programInfo, bufferInfo: bufferInfo, uniforms: object.uniforms, }); }

渲染阶段先逐物体计算矩阵,再用上面的循环绘制:

// Compute the matrices for each object. objects.forEach(function(object) { object.uniforms.u_matrix = computeMatrix( viewMatrix, projectionMatrix, object.translation, object.xRotationSpeed * time, object.yRotationSpeed * time); });

可运行示例见 webgl-multiple-objects-list-optimized.html。

值得一提的是,这套"换程序才重绑、换几何才重设属性"的逻辑在 webgl-utils.js 中被封装成了现成的drawObjectList(gl, objectsToDraw)函数(webgl-utils.js),其内部实现与上面的优化循环完全一致,并借助drawBufferInfo(webgl-utils.js)根据是否带索引自动选择gl.drawElements或gl.drawArrays。如果你不想手写这段循环,直接调用它即可。

五、实战四:按程序与几何排序,让优化更频繁地生效

还可以进一步按programInfo和 / 或bufferInfo对待绘制列表排序,让上面的优化在更多相邻对象之间生效。绝大多数游戏引擎都会这么做。

不过事情并没有那么简单:只有当所有待绘制物体都是不透明的,才能直接排序。一旦需要绘制半透明物体,就必须按特定的顺序绘制(通常是按深度从远到近),否则混合结果会出错。大多数 3D 引擎的解决办法是维护两个或更多待绘制列表:

  • 一个列表放不透明物体,按程序与几何排序;
  • 另一个列表放半透明物体,按深度排序;
  • 可能还会有覆盖层(overlay)、后处理效果等其它独立列表。

仓库提供了一个带复选框的排序对比示例 webgl-multiple-objects-list-optimized-sorted.html,它绘制 2000 个物体,并可通过 "Use Sorted List" 开关在排序 / 不排序之间切换。其排序逻辑如下:先给每种形状与每个 programInfo 分配一个id,再按bufferInfo.id优先、programInfo.id次之的比较器排序:

// give an id to each shape so we can sort them shapes.forEach(function(shape, ndx) { shape.id = ndx; }); // give an id to each programInfo so we can sort them programInfo.id = 1; var sortedObjectsToDraw = objectsToDraw.slice().sort(sortByBufferInfoThenProgramInfo); function sortByBufferInfoThenProgramInfo(a, b) { var diff = a.bufferInfo.id - b.bufferInfo.id; if (diff) { return diff; } return a.programInfo.id - b.programInfo.id; }

绘制时把排序后的列表交给同一套优化循环:

drawObjects(useSorted ? sortedObjectsToDraw : objectsToDraw);

原文档作者在编写该示例的机器上实测:不排序约 31fps,排序后约 37fps,提升接近 20%。但需要说明的是,这是作者在特定机器与最坏 / 最好情况对比下测得的数据;绝大多数真实程序要做的事情远比这个多,因此除了一些非常特殊的场景,这种排序优化是否值得投入精力是可以商榷的。

六、重要约束:并不是任何几何都能配任何着色器

一个很容易被忽略的事实是:你不能拿任意几何去配任意着色器。例如:

  • 一个需要法线(normal)的着色器,无法在没有法线数据的几何体上正常工作;
  • 一个需要纹理(texture)的着色器,在没有纹理的情况下无法正常工作。

这正是选择 Three.js 这类 3D 库的一大好处:你只需要创建几何体、告诉它你希望如何渲染,它会在运行时为所需的功能生成着色器。从 Unity3D 到 Unreal、Source、Crytek,几乎所有 3D 引擎都是这么做的——区别只是有些引擎在离线时生成着色器。关键认知是:这些引擎都在"生成"着色器,而不是让用户手写每一份。

当然,读这套教程的读者多半是想深入了解底层发生了什么,亲手写全部代码也确实是件有趣的事。只是要意识到 WebGL 是超低层的,想完全自己实现就有一大堆工作要做,其中往往就包括写一个"着色器生成器"——因为不同的功能组合往往需要不同的着色器。

七、组织建议:把矩阵计算与渲染分离

细心的读者会注意到,上面所有示例里computeMatrix都没有被放进绘制循环内部。这是因为渲染逻辑应当与矩阵计算分离:矩阵计算通常来源于场景图(scene graph)的遍历,把两者分开可以让代码职责更清晰、更易维护。场景图的具体组织方式,会在 场景图文章 中详细讲解。

八、小结与下一步

回顾一下本文的核心脉络:

  1. WebGL 是状态机:一次drawArrays/drawElements的绘制结果,取决于此前几十个状态函数的设置;
  2. 程序结构分为"初始化阶段"(上传数据、编译程序)与"渲染阶段"(设置全局状态 + 逐物体设置局部状态并绘制);
  3. 手动绘制适合少量物体;把programInfo、bufferInfo、uniforms打包成objectsToDraw列表后,绘制就收敛成一个循环——这也是 3D 引擎主渲染循环的雏形;
  4. 通过记录lastUsedProgramInfo/lastUsedBufferInfo可以跳过重复的状态设置;按程序与几何排序能让这种跳过更频繁地命中;
  5. 半透明物体必须单独维护按深度排序的列表,与不透明列表分开渲染;
  6. 着色器与几何数据必须互相匹配,这也是引擎选择运行时生成着色器的根本原因。

现在我们已经有了绘制多个物体的框架,下一步自然是 在场景中绘制文字。也可以直接查看本文配套的可运行示例:手动版、列表版、优化版 与 排序对比版。

  • 图形学

【免费下载链接】webgl-fundamentals

WebGL lessons that start with the basics

项目地址:https://gitcode.com/gh_mirrors/we/webgl-fundamentals
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询