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

了解系统限制

与扩展类似,您系统的限制将与客户端系统的限制不同!不要假设您可以使用 30 个纹理采样器,仅仅因为在您的机器上可以做到!

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 中,将 webgl.perf.max-warnings 设置为 -1(在 about:config 中),将启用性能警告,其中包括有关 FB 完整性失效的警告。

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

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

及时删除对象

不要等待垃圾收集器/循环收集器来识别对象是孤立的并销毁它们。实现会跟踪对象的存活状态,因此在 API 级别“删除”它们只会释放指向实际对象的句柄。(在概念上释放句柄对对象的 ref-指针)只有在实现中不再使用对象后,它才会被真正释放。例如,如果您不再需要直接访问着色器对象,只需在将它们附加到程序对象后删除它们的句柄。

及时丢失上下文

在您完全完成使用 WebGL 上下文并且不再需要目标画布的渲染结果时,还可以考虑使用 WEBGL_lose_context 扩展及时丢失 WebGL 上下文。请注意,在离开页面时,这样做没有必要 - 不要为此目的添加卸载事件处理程序。

预期结果时刷新

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

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

例如,以下情况可能永远不会完成,除非发生上下文丢失。

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

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

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

在不使用 RAF 时,使用 webgl.flush() 来鼓励及时执行已入队的命令。

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

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

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

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

  • getError():会导致刷新 + 往返以从 GPU 进程中获取错误)。例如,在 Firefox 中,只有在分配后才会检查 glGetError(bufferData*texImage*texStorage*),以获取任何 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 和 canvas.height,并保持 canvas.style.width 和 canvas.style.height 为恒定大小。

批处理绘制调用

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

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

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

避免 “#ifdef GL_ES”

您永远不应在 WebGL 着色器中使用 #ifdef GL_ES;此条件在 WebGL 中始终为真。虽然一些早期示例使用了它,但它不是必需的。

优先在顶点着色器中执行操作

尽可能在顶点着色器中完成工作,而不是在片段着色器中。这是因为每个绘制调用,片段着色器通常比顶点着色器运行的次数更多。任何可以在顶点上完成然后通过 varying 进行插值的计算(在片段中)都是性能的优势。(varyings 的插值非常便宜,并且通过图形管线的固定功能光栅化阶段自动为您完成。)

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

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

反过来,如果模型的顶点数多于渲染输出中的像素数。但是,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 think range min above zero precision
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 think 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 think range min above zero precision
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 think 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 混合的应用程序,也可以被构建为对 Alpha 通道产生 1.0。主要例外是任何在混合函数中需要目标 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[1] 等中被明确地形式化,但在 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 丟棄數據,而不是讓驅動程序浪費時間將數據存儲以供日後使用。深度/模板和/或多重採樣附件特別適合使用 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();
        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'

在支持的浏览器(Chromium?)上,可以使用 ResizeObserver 以及 'device-pixel-content-box' 来请求一个回调,该回调包含元素的真实设备像素大小。这可以用于构建一个异步但准确的函数。

js
window.getDevicePixelSize =
  window.getDevicePixelSize ||
  (async (elem) => {
    await new Promise((fn_resolve) => {
      const observer = new ResizeObserver((entries) => {
        for (const cur of entries) {
          const dev_size = cur.devicePixelContentBoxSize;
          const ret = {
            width: dev_size[0].inlineSize,
            height: dev_size[0].blockSize,
          };
          fn_resolve(ret);
          observer.disconnect();
          return;
        }
        throw `device-pixel-content-box not observed for elem ${elem}`;
      });
      observer.observe(elem, { box: "device-pixel-content-box" });
    });
  });

有关更多详细信息,请参阅规范

ImageBitmap 创建

使用ImageBitmapOptions 字典对于正确准备要上传到 WebGL 的纹理至关重要,但不幸的是,没有明显的方法可以查询特定浏览器支持的字典成员。

这个 JSFiddle 说明了如何确定特定浏览器支持的字典成员。

在可用时使用 WEBGL_provoking_vertex

当将顶点组装成三角形和线段之类的基元时,在 OpenGL 的约定中,基元的最后一个顶点被视为“激发顶点”。这在 ESSL300(WebGL 2)中使用 flat 顶点属性插值时是相关的;来自激发顶点的属性值将用于基元的所有顶点。

如今,许多浏览器的 WebGL 实现都托管在与 OpenGL 不同的图形 API 之上,其中一些 API 使用第一个顶点作为绘制命令的激发顶点。模拟 OpenGL 的激发顶点约定在某些 API 上的计算量可能很大。

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