- 图形学
【免费下载链接】webgl-fundamentals
WebGL lessons that start with the basics
本篇是 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 样东西需要组合:
- 一个着色器程序(连同它的 uniform / attribute 信息与 setter);
- 要绘制物体的缓冲区与属性(bufferInfo);
- 用给定着色器绘制该物体所需的 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)的遍历,把两者分开可以让代码职责更清晰、更易维护。场景图的具体组织方式,会在 场景图文章 中详细讲解。
八、小结与下一步
回顾一下本文的核心脉络:
- WebGL 是状态机:一次
drawArrays/drawElements的绘制结果,取决于此前几十个状态函数的设置; - 程序结构分为"初始化阶段"(上传数据、编译程序)与"渲染阶段"(设置全局状态 + 逐物体设置局部状态并绘制);
- 手动绘制适合少量物体;把
programInfo、bufferInfo、uniforms打包成objectsToDraw列表后,绘制就收敛成一个循环——这也是 3D 引擎主渲染循环的雏形; - 通过记录
lastUsedProgramInfo/lastUsedBufferInfo可以跳过重复的状态设置;按程序与几何排序能让这种跳过更频繁地命中; - 半透明物体必须单独维护按深度排序的列表,与不透明列表分开渲染;
- 着色器与几何数据必须互相匹配,这也是引擎选择运行时生成着色器的根本原因。
现在我们已经有了绘制多个物体的框架,下一步自然是 在场景中绘制文字。也可以直接查看本文配套的可运行示例:手动版、列表版、优化版 与 排序对比版。
- 图形学
【免费下载链接】webgl-fundamentals
WebGL lessons that start with the basics
相关推荐
WebGL 绘制多个物体:从状态机本质到批量渲染循环的实战指南(webgl-fundamentals)
WebGL 绘制多个物体:从状态机本质到批量渲染循环的实战指南(webgl fundamentals) 本篇技术指南聚焦 webgl fundamentals
图形学WebGL 多物体渲染实战:从状态机到 objectsToDraw 渲染循环(webgl-fundamentals 系列)
WebGL 多物体渲染实战:从状态机到 objectsToDraw 渲染循环(webgl fundamentals 系列) 本篇技术指南围绕 webgl fun
图形学WebGL 图像处理完全指南:从纹理渲染到 3×3 卷积实战(webgl-fundamentals)
WebGL 图像处理完全指南:从纹理渲染到 3×3 卷积实战(webgl fundamentals) 本文是 webgl fundamentals 开源教程「W
图形学
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考