WebGL 最佳实践

WebGL 是一个复杂的 API,其推荐用法往往不明显。本页面针对不同专业水平的用户提供了建议,不仅强调了应该做和不应该做的事情,还详细阐述了原因。无论您的用户运行何种浏览器或硬件,您都可以依靠本文档来指导您选择方法,并确保您走在正确的道路上。

解决并消除 WebGL 错误

您的应用程序应在不产生任何 WebGL 错误(由 getError 返回)的情况下运行。每个 WebGL 错误都会在 Web 控制台中作为 JavaScript 警告报告,并附有描述性消息。如果错误过多(Firefox 中为 32 个),WebGL 将停止生成描述性消息,这会严重阻碍调试。

一个格式良好的页面唯一会生成的错误是 OUT_OF_MEMORYCONTEXT_LOST

了解扩展可用性

大多数 WebGL 扩展的可用性取决于客户端系统。在使用 WebGL 扩展时,如果可能,请尝试通过优雅地适应不支持的情况来使其成为可选。

这些 WebGL 1 扩展普遍受支持,可以依赖它们的存在

  • ANGLE_instanced_arrays
  • EXT_blend_minmax
  • OES_element_index_uint
  • OES_standard_derivatives
  • OES_vertex_array_object
  • WEBGL_debug_renderer_info
  • WEBGL_lose_context

(另请参阅:WebGL 特性级别和支持百分比

考虑将这些扩展填充到 WebGLRenderingContext 中,例如:https://github.com/kdashg/misc/blob/tip/webgl/webgl-v1.1.js

了解系统限制

与扩展类似,您的系统限制会与客户端系统不同!不要仅仅因为您的机器上可以使用每个着色器三十个纹理采样器就假设您可以这样做!

WebGL 的最低要求相当低。实际上,几乎所有系统都至少支持以下内容

MAX_CUBE_MAP_TEXTURE_SIZE: 4096
MAX_RENDERBUFFER_SIZE: 4096
MAX_TEXTURE_SIZE: 4096
MAX_VIEWPORT_DIMS: [4096,4096]
MAX_VERTEX_TEXTURE_IMAGE_UNITS: 4
MAX_TEXTURE_IMAGE_UNITS: 8
MAX_COMBINED_TEXTURE_IMAGE_UNITS: 8
MAX_VERTEX_ATTRIBS: 16
MAX_VARYING_VECTORS: 8
MAX_VERTEX_UNIFORM_VECTORS: 128
MAX_FRAGMENT_UNIFORM_VECTORS: 64
ALIASED_POINT_SIZE_RANGE: [1,100]

您的桌面可能支持 16k 纹理,或者在顶点着色器中支持 16 个纹理单元,但大多数其他系统不支持,因此在您机器上能运行的内容在它们上面可能无法运行!

避免使 FBO 附件绑定失效

几乎任何对 FBO 附件绑定的更改都会使其帧缓冲区完整性失效。请提前设置好您的热帧缓冲区。

在 Firefox 中,在 about:config 中将首选项 webgl.perf.max-warnings 设置为 -1 将启用性能警告,其中包括关于 FBO 完整性失效的警告。

避免更改 VAO 附件(vertexAttribPointer、disable/enableVertexAttribArray)

从静态、不变的 VAO 绘制比每次绘制调用都修改同一个 VAO 要快。对于未更改的 VAO,浏览器可以缓存获取限制,而当 VAO 更改时,浏览器必须重新验证和重新计算限制。这样做的开销相对较低,但重用 VAO 也意味着更少的 vertexAttribPointer 调用,因此只要容易,就值得这样做。

尽快删除对象

不要等待垃圾收集器/循环收集器意识到对象是孤立的并销毁它们。实现会跟踪对象的活跃性,因此在 API 级别“删除”它们只会释放引用实际对象的句柄。(概念上释放句柄对对象的引用指针)只有当对象在实现中不再使用时,它才会被实际释放。例如,如果您永远不想再次直接访问着色器对象,只需在将它们附加到程序对象后删除它们的句柄即可。

尽快丢失上下文

当您确定不再需要 WebGL 上下文,并且不再需要目标画布的渲染结果时,也可以考虑通过 WEBGL_lose_context 扩展主动丢失 WebGL 上下文。请注意,在离开页面时无需这样做——不要仅仅为此目的添加卸载事件处理程序。

当期望结果时调用 flush

当预期结果(例如查询)或渲染帧完成时,调用 flush()

Flush 告诉实现将所有待处理的命令推出执行,将它们从队列中刷新,而不是等待更多命令入队后再发送执行。

例如,以下情况可能在不丢失上下文的情况下永远不会完成

js
sync = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
glClientWaitSync(sync, 0, GL_TIMEOUT_IGNORED);

WebGL 默认没有 SwapBuffers 调用,因此 flush 也可以帮助填补空白。

不使用 requestAnimationFrame 时使用 webgl.flush()

当不使用 RAF 时,使用 webgl.flush() 以鼓励急切执行排队命令。

由于 RAF 直接跟随帧边界,因此使用 RAF 时实际上不需要显式的 webgl.flush()

在生产环境中避免阻塞 API 调用

某些 WebGL 入口点(包括 getErrorgetParameter)会导致调用线程同步阻塞。即使是基本请求也可能需要长达 1ms 的时间,如果它们需要等待所有图形工作完成(其效果类似于原生 OpenGL 中的 glFinish()),则可能需要更长时间。

在生产代码中,避免使用此类入口点,尤其是在浏览器主线程上,它们可能导致整个页面卡顿(通常包括滚动甚至整个浏览器)。

  • getError():导致刷新 + 往返以从 GPU 进程获取错误)。

    例如,在 Firefox 中,仅在分配(bufferData*texImage*texStorage*)之后才检查 glGetError,以捕获任何 GL_OUT_OF_MEMORY 错误。

  • getShader/ProgramParameter()getShader/ProgramInfoLog()、其他着色器/程序的 get 调用:如果未在着色器编译完成后执行,则会导致刷新 + 着色器编译 + 往返。(另请参阅下面的并行着色器编译。)

  • 一般的 get*Parameter():可能导致刷新 + 往返。在某些情况下,这些会被缓存以避免往返,但尽量避免依赖此行为。

  • checkFramebufferStatus():可能导致刷新 + 往返。

  • getBufferSubData():通常会导致完成 + 往返。(与围栏结合使用时,对于 READ 缓冲区来说这没问题——请参阅下面的异步数据回读。)

  • readPixels() 到 CPU(即,未绑定 UNPACK 缓冲区):完成 + 往返。相反,结合异步数据回读使用 GPU-GPU readPixels

始终将顶点属性 0 启用为数组

如果您在未将顶点属性 0 启用为数组的情况下进行绘制,则在桌面 OpenGL(例如 macOS)上运行时,您将强制浏览器执行复杂的仿真。这是因为在桌面 OpenGL 中,如果顶点属性 0 未启用为数组,则不会绘制任何内容。您可以使用 bindAttribLocation 强制顶点属性使用位置 0,并使用 enableVertexAttribArray(0) 使其启用为数组。

估算每像素 VRAM 预算

WebGL 不提供查询系统上最大显存量的 API,因为此类查询不可移植。尽管如此,应用程序仍必须注意 VRAM 使用情况,而不仅仅是尽可能多地分配。

Google 地图团队开创的一项技术是每像素 VRAM 预算的概念

1) 对于一个系统(例如,特定的台式机/笔记本电脑),决定您的应用程序应该使用的最大 VRAM 量。2) 计算最大化浏览器窗口覆盖的像素数。例如 (window.innerWidth * devicePixelRatio) * (window.innerHeight * window.devicePixelRatio) 3) 每像素 VRAM 预算是 (1) 除以 (2),并且是一个常数。

这个常数通常在系统之间是可移植的。移动设备的屏幕通常比带有大显示器的强大桌面机器小。在几个目标系统上重新计算这个常数以获得可靠的估计。

现在调整应用程序中的所有内部缓存(WebGLBuffers、WebGLTextures 等),使其遵守最大大小,该大小由该常数乘以当前浏览器窗口覆盖的像素数计算得出。这需要估计每个纹理消耗的字节数。该上限也必须通常随着浏览器窗口大小的调整而更新,并且超过限制的旧资源必须被清除。

将应用程序的 VRAM 使用量保持在该上限之下将有助于避免内存不足错误和相关的系统不稳定。

考虑渲染到较小的后台缓冲区

一种常见的(且简单)以质量换取速度的方法是渲染到较小的后台缓冲区,然后将结果放大。考虑减小 canvas.width 和 height,并保持 canvas.style.width 和 height 为固定大小。

批量绘制调用

将绘制调用“批量处理”成更少、更大的绘制调用通常会提高性能。如果您有 1000 个精灵要绘制,请尝试通过一次 drawArrays() 或 drawElements() 调用来完成。

如果您需要将不连续的对象绘制为单个 drawArrays(TRIANGLE_STRIP) 调用,通常会使用“退化三角形”。退化三角形是没有面积的三角形,因此任何一个点或多个点位于完全相同位置的三角形都是退化三角形。这些三角形实际上会被跳过,这让您可以开始一个新的三角形带,而不必将其与前一个三角形带连接,也无需拆分为多个绘制调用。

另一种重要的批处理方法是纹理图集,其中多个图像被放置到一个纹理中,通常像棋盘一样。由于您需要分割绘制调用批次以更改纹理,因此纹理图集允许您将更多绘制调用组合成更少、更大的批次。请参阅此示例,演示如何将甚至引用多个纹理图集的精灵组合成单个绘制调用。

避免 "#ifdef GL_ES"

您绝不应该在 WebGL 着色器中使用 #ifdef GL_ES;在 WebGL 中,此条件始终为真。尽管一些早期示例使用了它,但它并非必需。

优先在顶点着色器中完成工作

在顶点着色器中完成尽可能多的工作,而不是在片段着色器中。这是因为对于每次绘制调用,片段着色器通常比顶点着色器运行更多次。任何可以在顶点上完成的计算,然后通过 varyings 在片段之间插值,都会带来性能提升。(varying 的插值非常廉价,并通过图形管道的固定功能光栅化阶段自动为您完成。)

例如,可以通过纹理坐标随时间变化的变换来实现纹理表面的简单动画。(最简单的情况是向纹理坐标属性向量添加一个均匀向量)如果视觉上可接受,可以在顶点着色器而不是片段着色器中变换纹理坐标,以获得更好的性能。

一个常见的折衷方案是按顶点而不是按片段(像素)进行一些光照计算。在某些情况下,特别是对于简单模型或密集顶点,这看起来已经足够好。

相反的情况是,如果一个模型的顶点数量多于渲染输出中的像素数量。然而,LOD 网格通常是解决此问题的方法,很少将工作从顶点转移到片段着色器。

按顺序编译着色器和链接程序很有诱惑力,但许多浏览器可以在后台线程上并行编译和链接。

而不是

js
function compileOnce(gl, shader) {
  if (shader.compiled) return;
  gl.compileShader(shader);
  shader.compiled = true;
}
for (const [vs, fs, prog] of programs) {
  compileOnce(gl, vs);
  compileOnce(gl, fs);
  gl.linkProgram(prog);
  if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
    console.error(`Link failed: ${gl.getProgramInfoLog(prog)}`);
    console.error(`vs info-log: ${gl.getShaderInfoLog(vs)}`);
    console.error(`fs info-log: ${gl.getShaderInfoLog(fs)}`);
  }
}

考虑

js
function compileOnce(gl, shader) {
  if (shader.compiled) return;
  gl.compileShader(shader);
  shader.compiled = true;
}
for (const [vs, fs, prog] of programs) {
  compileOnce(gl, vs);
  compileOnce(gl, fs);
}
for (const [vs, fs, prog] of programs) {
  gl.linkProgram(prog);
}
for (const [vs, fs, prog] of programs) {
  if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
    console.error(`Link failed: ${gl.getProgramInfoLog(prog)}`);
    console.error(`vs info-log: ${gl.getShaderInfoLog(vs)}`);
    console.error(`fs info-log: ${gl.getShaderInfoLog(fs)}`);
  }
}

优先使用 KHR_parallel_shader_compile

虽然我们已经描述了一种允许浏览器并行编译和链接的模式,但通常检查 COMPILE_STATUSLINK_STATUS 会阻塞直到编译或链接完成。在可用此扩展的浏览器中,KHR_parallel_shader_compile 扩展提供了一个非阻塞COMPLETION_STATUS 查询。优先启用和使用此扩展。

使用示例

js
ext = gl.getExtension("KHR_parallel_shader_compile");
gl.compileProgram(vs);
gl.compileProgram(fs);
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);

// Store program in your data structure.
// Later, for example the next frame:

if (ext) {
  if (gl.getProgramParameter(prog, ext.COMPLETION_STATUS_KHR)) {
    // Check program link status; if OK, use and draw with it.
  }
} else {
  // Program linking is synchronous.
  // Check program link status; if OK, use and draw with it.
}

此技术可能不适用于所有应用程序,例如那些需要程序立即可用进行渲染的应用程序。尽管如此,请考虑不同变体可能如何工作。

除非链接失败,否则不要检查着色器编译状态

导致着色器编译失败,但无法推迟到链接时处理的错误很少。ESSL3 规范在“错误处理”部分对此进行了说明

实现应尽可能早地报告错误,但在任何情况下都必须满足以下条件

  • 在调用 glLinkProgram 后,必须检测到所有词法、语法和语义错误
  • 在调用 glLinkProgram 后,必须检测到由于顶点着色器和片段着色器不匹配(链接错误)而导致的错误
  • 在任何绘制调用或调用 glValidateProgram 后,必须检测到由于超出资源限制而导致的错误
  • 调用 glValidateProgram 必须报告给定当前 GL 状态下与程序对象相关的所有错误。

编译器和链接器之间的任务分配是实现相关的。因此,许多错误可能在编译时或链接时检测到,具体取决于实现。

此外,查询编译状态是同步调用,这会中断管道。

而不是

js
gl.compileShader(vs);
if (!gl.getShaderParameter(vs, gl.COMPILE_STATUS)) {
  console.error(`vs compile failed: ${gl.getShaderInfoLog(vs)}`);
}

gl.compileShader(fs);
if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS)) {
  console.error(`fs compile failed: ${gl.getShaderInfoLog(fs)}`);
}

gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
  console.error(`Link failed: ${gl.getProgramInfoLog(prog)}`);
}

考虑

js
gl.compileShader(vs);
gl.compileShader(fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
  console.error(`Link failed: ${gl.getProgramInfoLog(prog)}`);
  console.error(`vs info-log: ${gl.getShaderInfoLog(vs)}`);
  console.error(`fs info-log: ${gl.getShaderInfoLog(fs)}`);
}

GLSL 精度注解要精确

如果您期望在着色器之间传递一个 essl300 int,并且您需要它是 32 位的,那么您必须使用 highp,否则您将遇到可移植性问题。(在桌面端有效,但在 Android 上无效)

如果您有浮点纹理,iOS 要求您使用 highp sampler2D foo;,否则它会非常痛苦地给您 lowp 纹理采样!(+/-2.0 最大值可能对您来说不够好)

隐式默认值

顶点语言具有以下预声明的全局作用域默认精度语句

glsl
precision highp float;
precision highp int;
precision lowp sampler2D;
precision lowp samplerCube;

片段语言具有以下预声明的全局作用域默认精度语句

glsl
precision mediump int;
precision lowp sampler2D;
precision lowp samplerCube;

在 WebGL 1 中,片段着色器中的“highp float”支持是可选的

在片段着色器中无条件使用 highp 精度将导致您的内容在某些较旧的移动硬件上无法正常工作。

虽然您可以使用 mediump float 代替,但请注意,这通常会导致由于精度不足而损坏渲染(尤其是在移动系统上),尽管在典型的台式计算机上这种损坏可能不可见。

如果您了解您的精度要求,getShaderPrecisionFormat() 会告诉您系统支持什么。

如果 highp float 可用,则 GL_FRAGMENT_PRECISION_HIGH 将定义为 1

“始终给我最高精度”的一个好模式

glsl
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif

ESSL100 最低要求 (WebGL 1)

float 思考 range 最小非零 精度
highp float24* (-2^62, 2^62) 2^-62 2^-16 相对
mediump IEEE float16 (-2^14, 2^14) 2^-14 2^-10 相对
lowp 10 位有符号定点 (-2, 2) 2^-8 2^-8 绝对
int 思考 range
highp int17 (-2^16, 2^16)
mediump int11 (-2^10, 2^10)
lowp int9 (-2^8, 2^8)

*float24:符号位,7 位指数,16 位尾数。

ESSL300 最低要求 (WebGL 2)

float 思考 range 最小非零 精度
highp IEEE float32 (-2^126, 2^127) 2^-126 2^-24 相对
mediump IEEE float16 (-2^14, 2^14) 2^-14 2^-10 相对
lowp 10 位有符号定点 (-2, 2) 2^-8 2^-8 绝对
(u)int 思考 int 范围 unsigned int 范围
highp (u)int32 [-2^31, 2^31] [0, 2^32]
mediump (u)int16 [-2^15, 2^15] [0, 2^16]
lowp (u)int9 [-2^8, 2^8] [0, 2^9]

优先使用内置函数而不是自己实现

优先使用 dotmixnormalize 等内置函数。在最好的情况下,自定义实现可能与它们所替代的内置函数一样快,但不要期望它们都能做到。硬件通常对内置函数有超优化甚至专门的指令,编译器无法可靠地用特殊的内置代码路径替换您自定义的内置函数替代品。

对于所有将在 3D 中看到的纹理,请使用 Mipmap

如有疑问,在纹理上传后调用 generateMipmaps()。Mipmap 对内存的开销很小(仅 30%),但在 3D 场景中纹理“缩放缩小”或整体缩小距离时,甚至对于立方体贴图,它们都能带来显著的性能优势!

由于更好的固有纹理获取缓存局部性,从较小的纹理图像采样更快:对非 mipmap 纹理进行缩放缩小会破坏纹理获取缓存局部性,因为相邻像素不再从相邻纹素采样!

但是,对于从不“缩放缩小”的 2D 资源,不要为 mipmap 支付 30% 的内存附加费

js
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); // Defaults to NEAREST_MIPMAP_LINEAR, for mipmapping!

(在 WebGL 2 中,您应该只使用 texStorage 并设置 levels=1

一个注意事项:generateMipmaps 仅在您可以将纹理渲染到帧缓冲区时才有效。(规范称之为“颜色可渲染格式”)例如,如果系统支持浮点纹理但不支持渲染到浮点纹理,则 generateMipmaps 将对浮点格式失败。

不要假设可以渲染到浮点纹理

有许多许多系统支持 RGBA32F 纹理,但如果您将其附加到帧缓冲区,您将从 checkFramebufferStatus() 获得 FRAMEBUFFER_INCOMPLETE_ATTACHMENT。它可能在您的系统上工作,但大多数移动系统不支持它!

在 WebGL 1 上,使用 EXT_color_buffer_half_floatWEBGL_color_buffer_float 扩展分别检查对 float16 和 float32 的渲染到浮点纹理支持。

在 WebGL 2 上,EXT_color_buffer_float 检查对 float32 和 float16 的渲染到浮点纹理支持。EXT_color_buffer_half_float 存在于仅支持渲染到 float16 纹理的系统上。

渲染到 float32 并不意味着 float32 混合!

它可能在您的系统上工作,但在许多其他系统上不行。如果可以,请避免使用它。检查 EXT_float_blend 扩展以检查支持情况。

Float16 混合始终受支持。

某些格式(例如,RGB)可能会被模拟

许多格式(特别是三通道格式)是模拟的。例如,RGB32F 通常实际上是 RGBA32F,而 Luminance8 实际上可能是 RGBA8。特别是 RGB8 通常出奇地慢,因为屏蔽 alpha 通道和/或修补混合函数具有相当高的开销。为了获得更好的性能,优先使用 RGBA8 并自行忽略 alpha。

避免使用 alpha:false,这可能会很昂贵

在上下文创建期间指定 alpha:false 会导致浏览器将 WebGL 渲染的画布合成成不透明的,忽略应用程序在其片段着色器中写入的任何 alpha 值。不幸的是,在某些平台上,此功能会带来显著的性能成本。RGB 后台缓冲区可能必须在 RGBA 表面之上进行模拟,并且 OpenGL API 中可用的技术相对较少,无法使应用程序看起来 RGBA 表面没有 alpha 通道。已发现所有这些技术对受影响平台的影响性能大致相同。

大多数应用程序,即使是那些需要 alpha 混合的应用程序,也可以构造为生成 1.0 作为 alpha 通道。主要的例外是任何在混合函数中需要目标 alpha 的应用程序。如果可行,建议这样做而不是使用 alpha:false

考虑压缩纹理格式

虽然 JPG 和 PNG 在网络传输中通常更小,但 GPU 压缩纹理格式在 GPU 内存中更小,并且采样速度更快。(这减少了纹理内存带宽,这在移动设备上非常宝贵)然而,压缩纹理格式的质量不如 JPG,并且通常只适用于颜色(例如,不适用于法线或坐标)。

不幸的是,没有一个普遍支持的单一格式。但是每个系统都至少有一个以下格式

  • WEBGL_compressed_texture_s3tc (桌面)
  • WEBGL_compressed_texture_etc1 (Android)
  • WEBGL_compressed_texture_pvrtc (iOS)

WebGL 2 通过结合以下方式实现普遍支持

  • WEBGL_compressed_texture_s3tc (桌面)
  • WEBGL_compressed_texture_etc (移动)

WEBGL_compressed_texture_astc 具有更高的质量和/或更高的压缩率,但仅在较新的硬件上受支持。

Basis Universal 纹理压缩格式/库

Basis Universal 解决了上面提到的几个问题。它提供了一种通过单个压缩纹理文件支持所有常见压缩纹理格式的方法,通过一个在加载时高效转换格式的 JavaScript 库。它还添加了额外的压缩,使 Basis Universal 压缩纹理文件在网络传输中比普通压缩纹理小得多,更接近 JPEG。

https://github.com/BinomialLLC/basis_universal/blob/master/webgl/README.md

深度和模板格式的内存使用情况

在许多设备上,深度和模板附件及其格式实际上是不可分离的。您可能会要求 DEPTH_COMPONENT24 或 STENCIL_INDEX8,但幕后通常会得到 D24X8 和 X24S8 32bpp 格式。假设深度和模板格式的内存使用量向上取整到最接近的四个字节。

texImage/texSubImage 上传(尤其是视频)可能导致管道刷新

大多数从 DOM 元素上传的纹理都会导致一个处理过程,该过程会在内部临时切换 GL 程序,从而导致管道刷新。(管道在 Vulkan 等中明确形式化,但在 OpenGL 和 WebGL 中隐式地在幕后。管道或多或少是着色器程序、深度/模板/多重采样/混合/光栅化状态的元组)

在 WebGL 中

glsl
    …
    useProgram(prog1)
<pipeline flush>
    bindFramebuffer(target)
    drawArrays()
    bindTexture(webgl_texture)
    texImage2D(HTMLVideoElement)
    drawArrays()
    …

浏览器内部

glsl
    …
    useProgram(prog1)
<pipeline flush>
    bindFramebuffer(target)
    drawArrays()
    bindTexture(webgl_texture)
    -texImage2D(HTMLVideoElement):
        +useProgram(_internal_tex_transform_prog)
<pipeline flush>
        +bindFramebuffer(webgl_texture._internal_framebuffer)
        +bindTexture(HTMLVideoElement._internal_video_tex)
        +drawArrays() // y-flip/colorspace-transform/alpha-(un)premultiply
        +bindTexture(webgl_texture)
        +bindFramebuffer(target)
        +useProgram(prog1)
<pipeline flush>
    drawArrays()
    …

优先在开始绘制之前或至少在管道之间进行上传

在 WebGL 中

glsl
    …
    bindTexture(webgl_texture)
    texImage2D(HTMLVideoElement)
    useProgram(prog1)
<pipeline flush>
    bindFramebuffer(target)
    drawArrays()
    bindTexture(webgl_texture)
    drawArrays()
    …

浏览器内部

glsl
    …
    bindTexture(webgl_texture)
    -texImage2D(HTMLVideoElement):
        +useProgram(_internal_tex_transform_prog)
<pipeline flush>
        +bindFramebuffer(webgl_texture._internal_framebuffer)
        +bindTexture(HTMLVideoElement._internal_video_tex)
        +drawArrays() // y-flip/colorspace-transform/alpha-(un)premultiply
        +bindTexture(webgl_texture)
        +bindFramebuffer(target)
    useProgram(prog1)
<pipeline flush>
    bindFramebuffer(target)
    drawArrays()
    bindTexture(webgl_texture)
    drawArrays()
    …

使用 texStorage 创建纹理

WebGL 2.0 texImage* API 允许您独立定义每个 mip 级别并以任何大小,即使是错误的 mip 大小也不是错误,直到绘制时才出现错误,这意味着驱动程序实际上无法在首次绘制纹理之前在 GPU 内存中准备纹理。

此外,一些驱动程序可能会无条件地分配整个 mip 链(+30% 内存!),即使您只想要一个级别。

因此,在 WebGL 2 中,对于纹理,优先使用 texStorage + texSubImage

使用 invalidateFramebuffer

存储您不再使用的数据可能会导致高昂的成本,尤其是在移动设备上常见的瓦片式渲染 GPU 上。当您处理完帧缓冲区附件的内容后,使用 WebGL 2.0 的 invalidateFramebuffer 来丢弃数据,而不是让驱动程序浪费时间存储数据以供以后使用。特别是 DEPTH/STENCIL 和/或多重采样附件是 invalidateFramebuffer 的绝佳选择。

使用非阻塞异步数据回读

readPixelsgetBufferSubData 这样的操作通常是同步的,但是使用相同的 API,可以实现非阻塞、异步数据回读。WebGL 2 中的方法类似于 OpenGL 中的方法:阻塞 API 中的异步下载

js
function clientWaitAsync(gl, sync, flags, interval_ms) {
  return new Promise((resolve, reject) => {
    function test() {
      const res = gl.clientWaitSync(sync, flags, 0);
      if (res === gl.WAIT_FAILED) {
        reject(new Error("clientWaitSync failed"));
        return;
      }
      if (res === gl.TIMEOUT_EXPIRED) {
        setTimeout(test, interval_ms);
        return;
      }
      resolve();
    }
    test();
  });
}

async function getBufferSubDataAsync(
  gl,
  target,
  buffer,
  srcByteOffset,
  dstBuffer,
  /* optional */ dstOffset,
  /* optional */ length,
) {
  const sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
  gl.flush();

  await clientWaitAsync(gl, sync, 0, 10);
  gl.deleteSync(sync);

  gl.bindBuffer(target, buffer);
  gl.getBufferSubData(target, srcByteOffset, dstBuffer, dstOffset, length);
  gl.bindBuffer(target, null);

  return dstBuffer;
}

async function readPixelsAsync(gl, x, y, w, h, format, type, dest) {
  const buf = gl.createBuffer();
  gl.bindBuffer(gl.PIXEL_PACK_BUFFER, buf);
  gl.bufferData(gl.PIXEL_PACK_BUFFER, dest.byteLength, gl.STREAM_READ);
  gl.readPixels(x, y, w, h, format, type, 0);
  gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);

  await getBufferSubDataAsync(gl, gl.PIXEL_PACK_BUFFER, buf, 0, dest);

  gl.deleteBuffer(buf);
  return dest;
}

devicePixelRatio 和高 DPI 渲染

处理 devicePixelRatio !== 1.0 很棘手。虽然常见的方法是设置 canvas.width = width * devicePixelRatio,但这会由于 devicePixelRatio 的非整数值(这在 Windows 上的 UI 缩放以及所有平台上的缩放中很常见)而导致摩尔纹伪影。

相反,我们可以对 CSS 的 top/bottom/left/right 使用非整数值,以相当可靠地将画布“预对齐”到整数设备坐标。

演示:设备像素预对齐

ResizeObserver 和 'device-pixel-content-box'

支持的浏览器上,ResizeObserver 可以与 'device-pixel-content-box' 一起使用,以请求一个包含元素真实设备像素大小的回调。这可以用于构建一个异步但精确的函数

js
function getDevicePixelSize(elem) {
  return new Promise((resolve) => {
    const observer = new ResizeObserver(([cur]) => {
      if (!cur) {
        throw new Error(
          `device-pixel-content-box not observed for elem ${elem}`,
        );
      }
      const devSize = cur.devicePixelContentBoxSize;
      const ret = {
        width: devSize[0].inlineSize,
        height: devSize[0].blockSize,
      };
      resolve(ret);
      observer.disconnect();
    });
    observer.observe(elem, { box: "device-pixel-content-box" });
  });
}

在可用时使用 WEBGL_provoking_vertex

在将顶点组装成三角形和线等图元时,按照 OpenGL 的约定,图元的最后一个顶点被认为是“引发顶点”。这在使用 ESSL300 (WebGL 2) 中的 flat 顶点属性插值时相关;引发顶点的属性值用于图元的所有顶点。

如今,许多浏览器的 WebGL 实现都托管在不同于 OpenGL 的图形 API 之上,其中一些 API 将第一个顶点用作绘制命令的引发顶点。在其中一些 API 上模拟 OpenGL 的引发顶点约定可能会产生计算开销。

因此,引入了 WEBGL_provoking_vertex 扩展。如果 WebGL 实现公开此扩展,则这是向应用程序发出的提示,表明将约定更改为 FIRST_VERTEX_CONVENTION_WEBGL 将提高性能。强烈建议使用平面着色(flat shading)的应用程序检查此扩展是否存在,并在可用时使用它。请注意,这可能需要更改应用程序的顶点缓冲区或着色器。