Skip to content

feat(video): backend-neutral vertex formats and draw topologies (#1551) - #1558

Merged
obiot merged 2 commits into
masterfrom
feat/1551-backend-neutral-vertex-vocabulary
Jul 31, 2026
Merged

feat(video): backend-neutral vertex formats and draw topologies (#1551)#1558
obiot merged 2 commits into
masterfrom
feat/1551-backend-neutral-vertex-vocabulary

Conversation

@obiot

@obiot obiot commented Jul 31, 2026

Copy link
Copy Markdown
Member

Description

Adopts a backend-neutral vocabulary for vertex layouts and draw topologies, closing #1551. Groundwork for the WebGPU backend (#1184).

Since #1509 each Batcher has owned an immutable vertex descriptor that is already shaped like a GPU vertex-buffer layout — except type held a raw GLenum and draw modes held gl.TRIANGLES. Those were the last GL-specific values in it, so a second backend would have to translate on every path rather than read the record.

An attribute can now be declared with a single format token, and a draw with a topology name:

{ name: "aColor", format: "unorm8x4", offset: 20 }
batcher.mode = "triangle-list";
batcher.topology                      // -> "triangle-list"

The GL form is accepted indefinitely and behaves exactly as before. addAttribute takes three shapes — a descriptor object, (name, format, offset), and the existing (name, size, glType, normalized, offset) — and records carry both spellings, so every existing reader of size / type / normalized is untouched and vertexstate.js needed no change at all. packages/spine-plugin still declares its layout in GLenum and was deliberately left alone as the live back-compat test.

Why formats rather than inferring from the data

Translation is only lossless downward: "unorm8x4" yields 4 + UNSIGNED_BYTE + normalized, while UNSIGNED_BYTE alone cannot say how many components or whether it is normalized. Inferring the format from the typed array — the other approach in use elsewhere — cannot work here: the interleaved buffer is a Float32Array while aColor is four normalized bytes smuggled through it (hence the Uint8Array-view upload). The data cannot describe itself, so the format has to be declared.

A format-declared layout also needs no live rendering context, which is what lets one layout serve two backends.

Batcher.mode becomes an accessor pair

Not cosmetic. A plain field cannot intercept batcher.mode = "triangle-list", and WebIDL coerces that string to 0 — which is GL_POINTS. The batch would render as points with no error anywhere, and PrimitiveBatcher's topology switch falls through to POINTS as well, so two independent paths would agree on the same wrong answer. Reading mode still returns the GLenum, so every internal comparison and the switch are untouched; topology is the new portable spelling.

Two behaviour changes on the legacy path

Both are fixes, both are in the changelog:

  • an omitted offset now packs after the previous attribute instead of silently defaulting to byte 0 (which overlapped every attribute)
  • a stride that is not a multiple of 4 now throws, instead of building a fractional vertex size that silently discarded every write

Scope

36 vertex formats and 7 topologies. float16* is excluded because no vertex buffer view can currently populate it; packed and BGRA formats because they break the components × size stride formula or have no WebGL equivalent. "line-loop" and "triangle-fan" ship as documented engine extensions — modern GPU APIs have neither, and the primitive batcher uses both.

The five built-in layouts are migrated, which is what runs the format table through the whole rendering suite: the hardcoded stride table and the 19-pointer count catch any byte-size error immediately. mesh's aColor stays float32x4, not unorm8x4 — the Metal NaN-canonicalization rationale for that is unchanged.

Type of change

  • New feature
  • Refactoring (no functional changes)

Checklist

  • I have read the Contributing Guide
  • My code follows the existing code style (pnpm lint passes)
  • I have tested my changes locally (pnpm test passes)
  • I have added tests that cover my changes (if applicable)
  • The build succeeds (pnpm build)

214 new tests, 100% line/branch/function coverage on all four new modules. Every one of the 36 formats and 7 topologies is pinned against a longhand table rather than a derivation of the implementation; all 7 GL types × 4 component counts × both normalization settings are covered; legacy and format spellings are asserted to produce byte-identical records.

Verified visually across ten examples covering all five migrated layouts, the primitive topology paths (dashed lines, polygon fills, arcs, thick-stroke expansion) and the untouched plugin — each compared against its own same-code noise floor.

An adversarial review before merge caught a blocker that would have shipped: the emitted declarations typed the setters as number, making batcher.mode = "line-list" — the headline affordance — a compile error for TypeScript consumers. It also found a surviving mutant in QuadBatcher.flush and two assertions that could not fail. All fixed and mutation-verified.

Related issues

Closes #1551. Largely supersedes #1492, whose declarative-descriptor ask was delivered by #1509 — what remained was the vocabulary. Groundwork for #1184; #1552 and #1555 build the uniform-buffer machinery on top.

🤖 Generated with Claude Code

The batcher's vertex layout has been a portable descriptor since #1509 —
except `type` held a raw GLenum and draw modes held `gl.TRIANGLES`. Those
were the last GL-specific values in it, so a second backend would have to
translate them on every path instead of reading the record.

An attribute can now be declared with a single format token, and a draw
with a topology name:

    { name: "aColor", format: "unorm8x4", offset: 20 }
    batcher.mode = "triangle-list"

The GL form is accepted indefinitely and unchanged. `addAttribute` takes
three shapes — a descriptor object, `(name, format, offset)`, and the
existing `(name, size, glType, normalized, offset)` — and records carry
both spellings, so every existing reader of `size`/`type`/`normalized` is
untouched and `vertexstate.js` needed no change at all. The spine plugin
still declares its layout in GLenum and was deliberately left alone as
the live back-compat test.

The format vocabulary is the canonical one because translation is only
lossless downward: `"unorm8x4"` yields 4 + UNSIGNED_BYTE + normalized,
while UNSIGNED_BYTE alone cannot say how many components or whether it is
normalized. Inferring the format from the typed array instead — the other
approach in use elsewhere — cannot work here: the interleaved buffer is a
Float32Array while `aColor` is four normalized bytes smuggled through it,
so the data cannot describe itself.

`Batcher.mode` becomes an accessor pair over one private canonical
topology. This is not cosmetic: a plain field cannot intercept
`batcher.mode = "triangle-list"`, and WebIDL coerces that string to 0 —
which is GL_POINTS. The batch would render as points with no error, and
`PrimitiveBatcher`'s topology switch falls through to POINTS as well, so
two independent paths would agree on the same wrong answer. Reading
`mode` still returns the GLenum, so every internal comparison and the
switch are untouched; `topology` is the new portable spelling.

Two behaviour changes on the legacy path, both fixes, both in the
changelog: an omitted `offset` now packs after the previous attribute
rather than silently defaulting to byte 0, and a stride that is not a
multiple of 4 now throws instead of building a fractional vertex size
that discards every write.

The five built-in layouts are migrated, which is what runs the format
table through the whole rendering suite — the hardcoded stride table and
the 19-pointer count catch any byte-size error immediately. `mesh`'s
`aColor` stays `float32x4`, not `unorm8x4`; the Metal NaN-canonicalization
rationale for that is unchanged.

Tests: 214 new, 100% line/branch/function coverage on all four new
modules. Every one of the 36 formats and 7 topologies is pinned against a
longhand table rather than a derivation, all 7 GL types x 4 component
counts x both normalization settings are covered, and legacy and format
spellings are asserted to produce byte-identical records. Verified
visually across ten examples covering all five migrated layouts, the
primitive topology paths and the untouched plugin.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JvxaXQRgQn5AoR8DNkv6Wg
Copilot AI review requested due to automatic review settings July 31, 2026 07:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

CI failed with two `beforeAll` timeouts and 12 skipped tests — the shared
WebGL context stopped coming up partway through the run.

The table-driven cases in `webgl_attribute_vocabulary.spec.js` each built
a whole `Batcher`, and a `Batcher` compiles a shader program and
allocates buffers plus a vertex array. At roughly 84 constructions per
run, none of them freed, that is enough live GL state to starve a
software renderer late in a session — and the casualty is whichever spec
happens to ask for a context next, not the one responsible.

`addAttribute` needs a real `Batcher` only because it calls private
methods, so the tables now reuse one and reset its layout between cases.
Measured: 84 shader programs down to 13.

Also fast-paths the topology normalization added in this branch.
`Batcher.flush`, `QuadBatcher.flush` and `PrimitiveBatcher.drawVertices`
were resolving on every call, and resolving a number now costs a WeakMap
plus a Map lookup — per draw, on the hottest path in the renderer. Only
strings need resolving: `this.mode` is normalized by its setter, so the
numeric path is back to what it cost before this branch.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JvxaXQRgQn5AoR8DNkv6Wg
Copilot AI review requested due to automatic review settings July 31, 2026 07:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@obiot
obiot merged commit f853e8d into master Jul 31, 2026
6 checks passed
@obiot
obiot deleted the feat/1551-backend-neutral-vertex-vocabulary branch July 31, 2026 07:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Backend-neutral vertex formats and topologies (GLenum → WebGPU-style strings, with GLenum back-compat)

2 participants