Skip to content

WebGL2-only: drop WebGL1 path, adopt VAOs (per-flush attribute setup → bindVertexArray) #1509

Closed

Description

@obiot

Summary

Drop the WebGL 1 rendering path and make the WebGL renderer WebGL 2-only (with the existing Canvas2D fallback unchanged), then adopt the WebGL2-only features that path unlocks — Vertex Array Objects (VAOs) first, as the concrete perf win.

Motivation

Two parts, kept distinct because they matter differently:

1. Dropping WebGL 1 by itself is ~0 runtime gain — it's an enabler + cleanup, not a speedup.
The ~9 WebGLVersion > 1 branches are per-flush / per-init, constant for a session, and perfectly branch-predicted — never in the per-vertex hot loop. Deleting them makes no existing WebGL 2 draw faster. The value of removing WebGL 1 is: smaller bundle, fewer code paths to test, removal of degraded fallbacks (Uint16 index coercion, NPOT-repeat guards, extension probing), and — most importantly — it lets us assume WebGL2-only features without guards.

2. The actual perf comes from what WebGL2-only unlocks — VAOs above all.
melonJS currently uses no VAOs (no createVertexArray / bindVertexArray). The batcher calls setVertexAttributes — i.e. N× enableVertexAttribArray + vertexAttribPointer — on every flush (src/video/webgl/batchers/batcher.js). A dense scene flushes ~10–20+ times/frame, so that attribute re-specification is paid repeatedly. With a VAO the layout is set up once and each flush becomes a single bindVertexArray — a real per-draw CPU saving. VAOs are core in WebGL 2 but only an unreliable extension on WebGL 1, so committing to WebGL2-only makes adopting them clean.

Scope

A. Make the WebGL renderer WebGL2-only

  • Request a webgl2 context; if unavailable, fall back to Canvas2D (drop the WebGL1 middle tier). Update WebGLVersion handling / the misconfiguration-throws path (Camera3d should fail loudly when used with the Canvas renderer #1479) accordingly.
  • Remove the WebGLVersion > 1 branches and their WebGL1 else-paths (renderer, quad_batcher, batcher, material_batcher): the offset/length bufferData form, NPOT-repeat guards, Uint16 index coercion fallback, OES_element_index_uint assumptions, etc.
  • Keep Canvas2D fallback fully working (it already exists).

B. Adopt VAOs (the perf item)

  • Create one VAO per batcher (or per shader/attribute-layout), set the vertex attribute layout once, and bindVertexArray per flush instead of re-calling setVertexAttributes.
  • Handle context loss/restore (VAOs are GPU objects — recreate on ONCONTEXT_RESTORED).
  • Verify the shared-vertex-buffer rebinding in setBatcher still holds with VAOs.

C. (Follow-on, optional in this ticket or separate) other WebGL2-only wins now unblocked

Cost / risk

  • Drops the WebGL-1-only device tier. WebGL 2 is ~96–98% globally; the Canvas2D fallback still covers the truly ancient, so the net is "WebGL 2 or Canvas2D." The 3D mesh path is already best-on-WebGL-2 (WebGL 1 3D is degraded today), so impact is mostly 2D on very old hardware that already falls back to Canvas2D.
  • Breaking-ish: anything relying on a WebGL 1 context specifically. Call it out in the changelog; gate behind a major/minor as appropriate.

Acceptance criteria

  • WebGL renderer creates only a webgl2 context; missing → Canvas2D (with the existing loud signal when renderer: video.WEBGL was explicitly requested, per Camera3d should fail loudly when used with the Canvas renderer #1479).
  • All WebGLVersion > 1 branches removed; single code path remains.
  • Batchers use a VAO: setVertexAttributes is called once per layout, not per flush (verifiable via a GL spy — vertexAttribPointer count per frame drops from O(flushes×attribs) to O(layouts)).
  • VAOs recreated correctly after context loss/restore.
  • No visual regression across the full suite; FPS/draw-time on the night-city stress scene improves measurably (per-flush attribute setup eliminated).

References

Activity

  1. obiot commented on Jun 28, 2026

    @obiot
    MemberAuthor

    Folded in from #1391 (closed as duplicate)

    #1391 ("Drop WebGL1 support") tracked the same WebGL2-only baseline. Merging its additional analysis here so nothing is lost.

    Extra cleanup item (extends scope A)

    • Remove the preferWebGL1 application setting (and any docs/types referencing it).
    • Simplify IndexBuffer to always use Uint32Array (WebGL2 guarantees OES_element_index_uint), and drop the WebGL1-specific bufferData overloads (WebGL2 accepts the offset+length form).

    Shader modernization — GLSL #version 300 es (not yet in this ticket's scope)

    Going WebGL2-only also unlocks modern shaders:

    • attribute/varying → in/out; texture2D() → texture(); gl_FragColor → an explicit out variable.
    • Sampler array indexing (texture(uSamplers[idx], uv)) — eliminates the if/else sampler chain in the multi-texture batching fragment shader (a real per-fragment win + simpler codegen).
    • Per-variable precision qualifiers instead of the single global one (src/video/webgl/utils/precision.js).

    Other WebGL2-only wins now unblocked (extends scope C)

    Beyond the texStorage2D / MSAA / instancing / transform-feedback already listed:

    • UBOs (Uniform Buffer Objects) — share projection matrix / global tint / time uniforms across shaders without per-draw re-upload.
    • MRT (Multiple Render Targets) — multi-texture output in one pass (G-buffers / deferred effects).
    • 3D textures / texture arrays — e.g. a single texture array for tilemap rendering instead of multiple atlases.
    • NPOT textures with no isPowerOfTwo / nextPowerOfTwo guards (the repeat-guard removal is already in scope A; this also drops the POT checks in src/video/texture/cache.js).

    Additional references (from #1391)

    • Context creation also lives in src/video/rendertarget/canvasrendertarget.js (besides webgl_renderer.js).
    • POT checks: src/video/webgl/batchers/material_batcher.js, src/video/texture/cache.js.
    • Shader precision: src/video/webgl/utils/precision.js.
  2. obiot commented on Jul 29, 2026

    @obiot
    MemberAuthor

    Acceptance criteria status (PR #1553)

    Four of five are met. Recording the fifth honestly rather than closing quietly over it.

    • ✅ WebGL renderer creates only a webgl2 context; missing → Canvas2D, with the Camera3d should fail loudly when used with the Canvas renderer #1479 loud signal preserved for an explicit renderer: video.WEBGL
    • ✅ All WebGLVersion > 1 branches removed; single code path
    • ✅ Attributes specified once per layout, not per flush — verified by GL spy in tests/webgl_vao_call_counts.spec.js: 19 vertexAttribPointer + 19 enableVertexAttribArray at startup (4+5+3+3+4 across the five batchers), 0 per frame thereafter, and 0 across 100 alternating batcher switches
    • ✅ VAOs recreated after context loss/restore — tests/webgl_vao_recreation.spec.js, driving a real WEBGL_lose_context cycle
    • ⚠️ "No visual regression … FPS/draw-time on the night-city stress scene improves measurably" — visual regression: verified (7 examples captured before/after; four pixel-identical, the three animated ones below their measured same-code capture noise). Frame-time: not measurable.

    Measured on night-city

    Batcher switches / frame 2.88
    Indexed flushes / frame 43.1
    GL calls eliminated / frame ~310
    Measured cost per eliminated call 0.018 µs (desktop Chrome command buffer)
    Time saved / frame ~5.6 µs

    For comparison, a 2D scene (platformer) saves ~42 calls/frame, so the stress scene does show the ~7× larger saving this ticket predicted — the mechanism is confirmed. But ~5.6 µs against a 16.6 ms budget is roughly 0.03%, which is not observable as FPS. Measured headless (SwiftShader), so absolute timings on real hardware will differ; the call counts are exact and hardware-independent.

    One correction to the ticket's premise

    The motivation says the batcher re-specifies attributes "on every flush". That was true only for the mesh family: base Batcher.flush() bound its own glVertexBuffer and re-applied the pointers, because vertexAttribPointer captures whichever ARRAY_BUFFER is bound when it is called — so switching from the shared buffer to its own invalidated the pointers set at useShader() time. QuadBatcher.flush() overrode the base and did not re-specify, since it uploads into the already-bound shared renderer.vertexBuffer.

    That asymmetry was correct behaviour for a no-VAO design, not redundancy — and it is exactly what a VAO removes, since the VAO records buffer and pointer together, so restoring it needs no re-pointing after a buffer switch. It does mean the per-flush cost this ticket describes was a 3D-mesh cost, not a 2D-sprite one, which is why sprite scenes save far less than the framing implies.

    Suggestion

    Amend criterion 5 to "GL call count per frame drops from O(flushes × attribs) to O(layouts), verified by GL spy" — which is met and machine-checkable — and drop the FPS clause, or re-run night-city on real hardware before closing if a frame-time figure is wanted.

  3. added a commit that references this issue on Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions