A backend-neutral render stream: scope, 2026-09-22
ASCII TL;DR
Today, one frontend already has the whole topology and the other does not:
D3D WAT frontend ─→ descriptors ─→ ring (3 SABs) ─→ render worker ─→ WAT raster
09a* DFX1/DLT1/ d3d-command- 2nd wasm 09ah ✅
cascade stream.js instance,
held to fence shared memory
GL WAT frontend ─→ call log ─────→ one borrowed ──→ JS replay ────→ WebGL
09a8c/e gl-command- range, gl-compat.js ❌
verts ✅ stream.js reused at once state lives here
state ❌ no software path
Target: GL joins that topology. The stream carries descriptors translated in WAT at record time, not a log of GL calls, so it is backend-neutral by construction and any consumer — WAT software raster, WebGL, WebGPU later — reads the same thing.
This document is scope, not a plan of record. Nothing here is implemented.
The ordering that makes it worth doing
Lifting GL state into WAT is not merely a prerequisite for a software rasterizer. It pays immediately, in round trips, because most GL barriers exist only because the state lives in JavaScript.
lib/gl-command-stream.js:45-62 forces a flush for glGetError (12),
glGetFloatv (13), glIsEnabled (65) and glGetIntegerv (103). Every one of
those is a state query. The comment at :58-64 records why it had to be a
barrier: Warcraft III queries GL_MAX_TEXTURE_UNITS_ARB and copies the answer
on the very next instruction, and batched, "the copy ran first and read zero,
and a renderer that believes it has no texture units never calls
glEnable(GL_TEXTURE_2D) at all".
With state in WAT those are local reads and the flush disappears. Only the
genuinely device-dependent barriers remain real: glReadPixels (17),
gpuPresent (55), glFinish (11). That reduction is what makes asynchronous
consumption worth having; doing the ring first would mostly be draining it.
Measured, 2026-09-22 — Quake II
node -r ./tools/gl-barrier-census.js test/run.js --app=quake2_demo --args='+set vid_ref gl +map demo1' --headless-gl --quiet-api --max-batches=100000000 --max-seconds=120, which attributes each flush to the
barrier record that caused it (the encoder flushes immediately after emitting a
barrier, src/09a8c-gl-encoder.wat:537-539, so that record is always last):
| flushes | share | cause |
|---|---|---|
| 3128 | 40.8% | glGetError — state query, removable |
| 3127 | 40.8% | gpuPresent — real, stays |
| 1414 | 18.4% | capacity/reset — the ring's territory |
| 2 | 0.0% | wglCreateContext, wglMakeCurrent |
7671 flushes, 1,785,803 records, 635 MB submitted over the window.
Three things this settles, and one it does not.
The claim holds, but narrowly. 40.8% of flushes are a state query — and it
is one opcode, glGetError, at almost exactly one per frame (3128
against 3127 presents). glGetFloatv, glIsEnabled and glGetIntegerv caused
zero flushes here, so the Warcraft III hazard the code comment describes
does not arise in this workload at all. "Most barriers are state queries" was
too strong; "two fifths, from one call" is the honest version.
The ring has its own 18.4%. Capacity flushes are not a barrier problem and no state lift touches them; buffer rotation is what removes them. So the two pieces of work are close to independent, and together they address ~59% of flushes.
A present is a present. The other 40.8% is the frame boundary and cannot be batched away by anything in this design.
What it does not settle: whether glGetError is answerable in WAT at all.
GL errors are partly generated by the backend, so a WAT-local error word would
have to account for errors the consumer raises. 40.8% is therefore an upper
bound on this workload, not a promise. One app is also not a corpus — Warcraft
III and SimGolf are the obvious second and third measurements, and the
histogram is cheap to take.
Why the earlier rejection does not block this
docs/wat-gl-encoder.md ("D3D transport boundary") rejected sharing a
transport: "D3DIM also rotates three asynchronous buffers, each held until its
sequence completes, while GL borrows one range only for synchronous replay.
They cannot share a mutable ring without changing that ownership protocol."
That is an argument against merging the two transports as they are. It is
not an argument against giving GL the same rotation. Buffer rotation is exactly
the mechanism that lets a borrowed range outlive the producer's next call, and
both live in shared memory, so FLAG_POINTER_BORROW
(lib/gl-command-stream.js:11) still works across a worker boundary provided
the range is not recycled before its fence. The conflict is resolved by
adopting D3D's protocol, not by merging two incompatible ones.
How the two streams differ today
GL (lib/gl-command-stream.js) |
D3D (lib/d3d-command-stream.js) |
|
|---|---|---|
| a record is | one call — opcode + raw stdcall stack image | one job — DRAW 0x20000, FENCE 0x20001, READY 0x20002, FLIP 0x20003 |
| opcodes | guest-visible gl*/wgl* ordinals 0..110, per-call ARG_WORDS |
four internal |
| state | imperative, in-stream (glEnable is a record) |
out-of-band, STATE_BYTES = 4096 per record |
| buffers | one 2 MB range inside guest linear memory (gl_stream_wa), reused immediately |
three SharedArrayBuffers (DEFAULT_BUFFERS = 3), each held until its seq completes |
| sync | in-band barriers, BARRIERS set |
explicit fence + Int32Array control block, CTRL.{READY,COMPLETED,ERROR,SUBMITTED} |
| consumer | synchronous JS replay | second wasm instance in a worker (lib/d3d-render-worker.js) |
| pointer args | FLAG_POINTER_COPY / FLAG_POINTER_BORROW |
n/a, vertices pre-expanded |
| growth | overflow flushes; never grows | fixed capacity per buffer |
Both headers are 32 bytes with different fields — a coincidence that has already misled one session.
Worth carrying across in the other direction: D3D's READY_OPCODE is asked
before WAT expands an indexed draw, so the expansion is skipped entirely
when no consumer is attached (lib/d3d-command-stream.js:13-14). GL expands
glDrawElements unconditionally (src/09a8e-gl-state.wat:442-472).
The descriptor is already PSO-shaped
src/09af-d3d-shader-ir.wat is an explicitly backend-neutral shader IR
("All public pointers are WASM addresses", ABI 1, own error taxonomy), and
src/09aj-d3d-fixed.wat already lowers fixed-function state into it. DFX1 +
DLT1 + the cascade row are structurally a pipeline-state object plus a bind
group: pull-based, immutable per draw, no global mutable state. That is the
shape modern APIs want, arrived at here for unrelated reasons.
A WebGPU backend would therefore need a WGSL emitter from that IR, parallel
to the GLSL emitter in lib/d3d9-fixed.js, plus explicit render passes and
barriers, which nothing here models. There is no WebGPU anywhere in lib/ or
src/ today, and Node has none either, so headless testing would need Dawn
bindings where GL already has --headless-gl. The IR makes a WebGPU backend
plausible; the emitter and the test story are the real cost.
What the descriptor must grow to carry
These are measured differences between GL as implemented in lib/gl-compat.js
and what src/09aj-d3d-fixed.wat lowers. Each is either an extension to the
shared descriptor or an explicit decline to WebGL.
Note first that DFX1 carries no lighting, material or fog. Those live in a
separate DLT1 block (128-byte header + 64 bytes per light,
src/09aj-d3d-fixed.wat:582-587) and in the cascade5 row's fog tail
(:365-366, offsets +136..+156). And all of them are built in JavaScript
today (lib/d3d9-software-backend.js:284-377) — no WAT code builds
descriptors for anyone yet, so "translate in WAT at record time" is new
capability rather than reuse of an existing WAT builder.
1. No specular in the D3D lowering
GL computes Blinn half-vector specular per light into the vertex colour
(lib/gl-compat.js:260-263). The DLT1 accumulator has no specular term: the
loop does dp3(N,L) → clamp → mad into a diffuse accumulator, then emits
oD0.rgb = md*r5 + r4 (src/09aj-d3d-fixed.wat:686-697). The header carries
no shininess and no material-specular field (:582-586).
2. Directional lights only
GL branches on uLightPosition[i].w and supports positional lights
(lib/gl-compat.js:254-256). $d3d_fixed_bind_lighting rejects any light
whose type is not 3 (src/09aj-d3d-fixed.wat:646); the JS producer refuses
earlier with 'point and spot lighting are not implemented'
(lib/d3d9-software-backend.js:371). Neither side implements attenuation or
spot at all — GL's glLightf is a no-op (lib/gl-compat.js:1304) — so those
stay dropped either way.
3. Fog is a different stage on each side
GL interpolates vFogDistance = abs(eyePosition.z) and computes both the
factor and the colour blend in the fragment shader
(lib/gl-compat.js:279, :324-330). D3D computes the factor in the vertex
shader and the rasterizer owns the RGB blend (src/09aj-d3d-fixed.wat:410,
:366). Encodings disagree: GL maps LINEAR→0 (lib/gl-compat.js:547) where
D3D's linear is mode 3, and D3D's mode 0 means "take fog from the specular
input register" (:377-381), which GL cannot express. D3D rejects non-finite
or equal start/end (:386-387) where GL guards the denominator (:326).
Per-vertex versus per-fragment fog is also a visibly different picture on large
triangles.
4. Normal matrix
GL uses the plain upper-left 3x3 of the modelview, no inverse or transpose
(lib/gl-compat.js:211, used :246). D3D computes a full world-view
inverse and dots against its rows (src/09aj-d3d-fixed.wat:284-286,
:659-660). They agree only without non-uniform scale or shear.
5. Matrix convention and the world/view split
GL stacks are column-major with world and view conflated
(lib/gl-compat.js:352-354, :242-243). DFX1 is row-major with separate
world/view/proj at +96/+160/+224. Transposing is mechanical, but where GL's
modelview is placed is not a free choice, and it is the opposite of what it
first looks like.
Geometry cannot tell world from view — it uses the product — and neither can
the normal matrix, which inverse-transposes world * view
(src/09aj-d3d-fixed.wat:299-300). The only consumer of the view matrix
alone is DLT1's light-direction lowering (:681-683). And GL has already
transformed its light positions into eye space, at glLightfv time
(lib/gl-compat.js:645). So the modelview belongs in world, with view left
identity: the eye-space direction then passes through the lowering untouched,
which is exactly GL's rule that a light is fixed in eye space once specified.
Parking the modelview in view applies it to every light a second time —
geometry still lands correctly, which is what makes it hard to attribute.
What this cannot reproduce is D3D's own rule, where a light is fixed in world space and re-transformed by the view every draw. There is no split to recover: GL has one modelview and never says which part of it is the camera. An app that re-specifies its lights each frame under the camera modelview and then draws with per-object modelviews is the case where the two models genuinely disagree, and no placement fixes it.
6. Batch ceiling — already solved, for D3D
09ah validates 3 <= vertices <= 256, 3 <= indices <= 768, indices % 3 == 0
(src/09ah-d3d-software.wat:266-271), explicitly implementation limits rather
than advertised caps (:25-26). The reason is the allocation model: context is
288 + indexCount*1134 and workspace 3552 + vertexCount*160 + indexCount*2
(:51, :56). That 1134 is a 160-byte vertex snapshot times the worst-case
seven-triangle clip expansion (:26-27), pre-allocated whether or not anything
clips — the price of a bounded, resumable draw. At the ceiling that is ~851 KB
of context to draw 256 triangles.
lib/d3d9-software-backend.js:442-457 already chunks oversized draws via
Geometry.split (lib/d3d-geometry-batches.js) and walks the batches
incrementally (:910-917), with maxTriangles = clip?.mask ? 210 : 256
matching the private clipping limit. GL inherits this if GL descriptors are
built the way D3D9's are.
Chunking is also safe for GL specifically: $gl_finish_immediate
(src/09a8c-gl-encoder.wat:256-429) already expands all topology to
POINTS/LINES/TRIANGLES with flat shading baked per vertex, so strips and fans
never reach this layer and splitting at any multiple of three is correct with
no index remapping and no provoking-vertex hazard.
JavaScript patterns this should remove
Shared wins, not GL-only:
- Per-texel swizzle loops in JS —
lib/d3d9-software-backend.js:410and:894both runfor (let j = 0; j < pixels.length; j += 4)to swap R and B on every texture upload, on the path into a WAT rasterizer. - Whole-surface
.slice()copies —:151readColor,:1020readPixels. Same family aslib/d3dim-gpu.js'sgl.readPixelsplus repack on every fence. - Per-vertex D3DCOLOR byte-swap —
lib/d3dim-gpu.js:340-347. Only on the opt-in WebGL path today, and the reason not to make the JS device the software path. - Reaching through the device abstraction —
t.device.gpu(lib/d3dim-gpu.js:180,:251) grabs raw WebGL for upload and readback. Two named methods on the device contract close it; the software device already has both operations.
Rejected: route everything through the JS device
lib/d3d9-backend.js's Device.draw(snapshot) is backend-neutral with two
implementations selected at lib/d3d9-host.js:62, and lib/d3dim-gpu.js
already builds that snapshot shape — but hardwires new Backend.Device(canvas)
at :116. Making everyone share it looks like the cheap unification.
For D3DIM it is a regression. Its software path today is
$d3dim_draw_tl_triangle (src/09ab-handlers-d3dim-core.wat:5632) — WAT
frontend straight into WAT raster, zero crossings. Routing it through the JS
device inserts the per-vertex colour swap above in front of a rasterizer that
needs none of it. Recorded here so it is not re-proposed.
Shape of the work
GL state into a new per-context WAT block— DONE,cb172c82anda929765d.src/09a8f-gl-matrix.wat, 8992 bytes per context: four 32-deep matrix stacks (modelview, projection, one texture stack per multitexture unit), light model ambient, 8 lights, material, fog. It is a new allocation rather than spare fields in09a8e's 160-byte block, as predicted, and it keys off the encoder's$gl_current_contextso all three per-context tables agree.af15abb4gave it its feed:$gl_wat_encode_callhands every GL call to$gl_mtx_observebefore$gl_state_intercept, which is not an arbitrary placement — that function absorbs opcodes 76 and 77 (glPushAttrib/glPopAttrib) and returns 1, so observing after it would leave the mirror silently stale for exactly the two calls that rewrite the whole lighting and material state. The observer only observes: the call goes on to the stream unchanged, so the WebGL path is untouched and this cannot move a pixel. Verified so on Quake II under--headless-glat a fixed 300000 batches — pixel-identical, identical API counts — which mattered because$gl_mtx_blockallocates from the same guest heap the app uses. Steps 2-4 are still what give the state a consumer.glPushAttrib/glPopAttribwere the last family not mirrored, and are now mirrored too (test-gl-attrib-stack.js). They save and restore lighting and material wholesale, so following them meant owning a copy of GL's attribute stack rather than composing a matrix: the per-context block grew from 9032 to 10832 bytes, holding 16 frames of 112.The saved set is deliberately smaller than real GL's. Real GL's
GL_LIGHTING_BITrestores the light parameters;lib/gl-compat.js:709-720saves only the active texture unit, the light model ambient and the material, and the mirror saves exactly that and no more. Saving more would be a regression, not an improvement: the WAT descriptor and the WebGL picture would then disagree after a pop, only in scenes that push attributes, only as lighting that is subtly wrong in one backend — strictly harder to find than the shared gap. The gap is real and belongs on both sides at once.test-gl-attrib-stack.jsasserts the absence of the extra restore for that reason, so a well-meaning one-sided fix fails a test rather than shipping. Same for the mask: both sides record it and neither consults it on restore.The latch is not retired with the family. A push past the cap still latches, because a dropped push is not a dropped operation — its matching pop still arrives and restores an outer frame, so everything after it is built from state the app never asked for. Underflow does not latch: it corrupts nothing.
It was not a rare family, and that was measured, not assumed.
tools/gl-name-census.js(2026-09-22) finds 35 GL-using binaries in the corpus and 18 of them nameglPushAttrib/glPopAttrib. A string search rather than an import walk, because every GL engine we run resolves GL throughGetProcAddress—pe-imports.js ref_gl.dlllists KERNEL32, USER32, GDI32 and no OpenGL at all, so the import table is blind here by construction.Half of those 18 are not evidence about any app: a GL driver (
3dfxgl.dll,pvrgl.dll) and SDL's loader table name the entire API whatever the program does. The ones that count are engine code — Quake II'sref_gl.dll, whose QGL table is hand-written and lists only what the renderer uses, GoldSrc'shw.dll, both Unrealopengldrv.dlls, Deus Ex,IDDemo.exe. So the attribute stack was a gate on several apps rather than a one-app fold, which is what justified building it.Static reach is not hotness, and the two disagree here already:
ref_gl.dllnamesglPushAttriband a 40,000-batch Quake II menu census counted zero calls to it. Which is the point of having both readings.gluPerspective,gluLookAtandgluOrtho2Dwere in that list and are now mirrored (test-gl-glu-mirror.js). They are library code, not GL entry points, so each is written as the compositionlib/gl-compat.jsperforms — perspective through the existing frustum,gluOrtho2Dthrough ortho with GL's default −1..1 — and the test checks them against the shippingperspective()/lookAt(), now exported for that purpose, rather than a transcription. The one divergence is deliberate and recorded in the source:Math.hypot(…) || 1becomes a plain sqrt of the sum of squares, which agrees across the range a camera basis can occupy and differs only where the operands' squares would overflow f64. The|| 1itself is kept, and is what stops a degenerate camera (eye == centre) filling the stack with NaN that would then poison every later multiply.--gl-censusprints, per run, every GL entry point the app actually issued and whether that latch stayed clear — which turns "we think this app is covered" into a measurement. The counters are load-immune, so it is readable on a box too busy for any timing.The oracle turned out better than
glGetFloatv:lib/gl-compat.jsexportsidentity,multiply,frustumandortho, sotest-gl-matrix-stacks.jsdrives both implementations through one scripted sequence and compares raw f32 bit patterns after every step. To make that meaningful the WAT mirrors the JS rather than improving on it — column-major, f64 accumulation demoted once on store, hostmath_sin/math_cos. One deviation is documented: JS normalizes a rotation axis withMath.hypotand wasm has no equivalent.Three things worth carrying forward.
translation/scale/rotationare not exported, so those references are transcriptions and get a semantic check as well. The lighting half has no exported oracle at all, because it lives onFixedFunctionGL, which needs a live WebGL program to construct. And a zeroed block is not a legal GL state — light 0 is white, material diffuse is 0.8 grey — so the defaults are initialized explicitly.Translate at record time in WAT — GL draw to a DFX1/DLT1-shaped descriptor. The transform half is done:
$gl_dfx1_transformwrites a 288-byte ABI1 DFX1 from the mirror — magic, ABI, viewport, depth range and the three matrices — and is the first thing on this side that produces a backend-neutral descriptor instead of consuming GL calls.It fills only what the mirror owns. Flags, register indices and every stage field stay zero for a caller to complete, because those describe the vertex data and the texture stages and this block knows nothing about either. Guessing them would be worse than leaving them out: a descriptor is read as authoritative, so a wrong flag word draws confidently wrong instead of failing.
Viewport and depth range had to be lifted for it (
glViewport,glDepthRange— neither is transform state, but DFX1 wants both at +68..+88), so the block is now 9032 bytes. The observer also grew an upper bound on the opcode, which is not cosmetic: the packed draw is0x10000, far above everyCALLSindex, and without it the hottest call in a frame walked the entire compare chain to do nothing — 530,916 times a run on Quake II's menu alone.Two conversions carry all the risk, and
test-gl-dfx1-transform.jsexists for them. GL is column-major and DFX1 row-major, so every matrix is transposed; a transposed transform still renders a scene, just the wrong one, so the test asserts against matrices whose transpose differs from themselves (an identity or a pure scale would pass either way). And GL's modelview goes to DFX1's world slot with view left identity, per item 5 above — the swap places geometry correctly and lights it in the wrong space, which is what makes it hard to find. Falsified by making the transpose a straight copy: the test fails.Refuses and writes nothing while the UNTRUSTED latch is set. Verified inert on Quake II at a fixed 40000 batches: 5,340,187 API calls, 530,916 packed draws, 3,185,496 vertices — identical to the run before the change.
The lighting half is done too:
$gl_dlt1_lighting(dst, mask)writes a DLT1 header plus one 64-byte row per enabled light and returns the byte count.maskis a parameter rather than block state becauseglEnablelives in09a8e; this block has no business guessing at it.Three cases are refusals, not omissions, because each is a DLT1 that would render something other than what GL draws: a positional light (DLT1 rows carry a direction and
$d3d_fixed_bind_lightingrejects any row whose type is not 3,09aj:644); a specular term that can reach the picture; and an untrusted mirror. The specular predicate is deliberately narrow — GL's default light 0 specular is white, so refusing on that alone would refuse nearly every app, and what is refused is a light whose specular is nonzero and whose material specular is too. GL's default material specular is black, so an app that never asks for highlights gets an exact build. Widening the predicate to either half alone refuses the default GL state outright, which is howtest-gl-dlt1-lighting.jsfalsifies it.Two silent conversions, both asserted: GL's
GL_POSITIONwithw == 0points toward the light and D3D's direction points away from it, so the row carries the negated vector — get it wrong and the scene is lit from behind, still plausibly lit. And the direction is stored in eye space, which survives the lowering's view multiply only because the DFX1 above leaves view identity; the two functions have to agree or every light moves. Falsified by dropping the negation, which the signed-zero bit pattern alone catches.Not represented and not detectable here:
glColorMaterial(the three source selectors are written 0, GL's state until an app turns colour material on, and09a8edoes not report when it does) and the material shininess, which only a specular term would use.Rotate GL's buffers — adopt D3D's ownership protocol.
Point GL at the existing render worker — same module, shared memory, WAT raster.
Close or decline the semantic gaps — items 1-5 above.
What steps 3-4 actually cost, surveyed 2026-09-22
Steps 1 and 2 above produce descriptors. Nothing in WAT consumes one.
$gl_dfx1_transform and $gl_dlt1_lighting are exported for tests only
(src/09a8f-gl-matrix.wat:1212, :1215); no src/*.wat and no lib/*.js
calls either. Every DFX1, DLT1 and DSP1 that reaches the WAT lowering today is
assembled in JavaScript, in lib/d3d9-software-backend.js — the only DFX1
builder in the tree is fixedPrograms at :242-381, and the only DSP1 builder
is prepareBatch at :735-746. So steps 3-4 are not "hook the GL descriptor
into an existing WAT producer". They are "write in WAT what prepareBatch does
in JS, or reach the same JS the D3D9 path reaches".
Three things do not exist anywhere and each is real work:
- A WAT DFX1/cascade-table/DSP1 assembler.
$gl_dfx1_transformfills the transform half and deliberately leaves flags, register indices and every stage field zero. The consumer needs all of them, plus the separate 6x160 cascade table (src/09aj-d3d-fixed.wat:793-801) and the 128-byte DSP1 (src/09ah-d3d-software.wat:1-17). - A GL render target. DSP1 wants a BGRA pointer and pitch at +16/+20 and an
f32 depth pointer and pitch at +24/+28. GL's output today is a WebGL drawable
owned by
lib/gl-compat.js; there is no GL-side surface allocation to hand over. - A vertex repack. GL vertices are 14 floats / 56 bytes
(
src/09a8c-gl-encoder.wat:134: xyz, rgba, st0, nxyz, st1); the VM reads the float4-per-register layout the DSP1 input nibbles at +116/+124 describe. Chunking to≤256 vertices / ≤768 indices / count % 3 == 0(src/09ah-d3d-software.wat:76-87) is already safe, because$gl_finish_immediateexpands every topology down to points/lines/triangles.
Two things are better than expected. The packed GL record payload is already
a contiguous, interleaved, fully expanded, non-indexed triangle array at a fixed
stride in WASM-addressable memory — structurally what DSP1+32/+36/+40 wants.
And "adopt D3D's ownership protocol" has a precise meaning: Encoder in
lib/d3d-command-stream.js:35 keeps three 2MB SharedArrayBuffer slots, stamps
each with a sequence from Atomics.add(control, CTRL.SUBMITTED, 1), rotates,
and never reuses a slot whose sequence has not retired. GL has one 2MB range
that $gl_wat_stream_flush resets to zero the instant the synchronous host call
returns (src/09a8c-gl-encoder.wat:88-93), which is why FLAG_POINTER_BORROW
is sound today and would stop being sound the moment the consumer is a worker.
Recommendation: take the decline model below first. The survey does not
change what the full path is worth, but it does change what it costs, and the
decline model reaches a working software GL without items 1-3 — the D3DIM
rasterizer ($d3dim_draw_tl_triangle → $d3dim_draw_tri_culled,
src/09ab-handlers-d3dim-core.wat:5641) is the existing proof that a WAT
frontend can drive a WAT raster with zero JS crossings, and it is a different,
simpler pipeline than 09ah. Its coverage cliff has to be measured per app rather
than assumed, which is the honest cost.
Nothing pins this seam: there is no gl-d3d-* test, and no test wires a GL
descriptor to a D3D consumer. test/test-d3d-fixed-native.js, which hand-builds
a DFX1 and drives d3d_fixed_compile*, is the closest model for the first one.
A smaller alternative worth weighing: extend the decline model instead. GL
draws inside the intersection — directional lights, no specular, linear/exp
fog — take the WAT path; everything else falls back to WebGL, exactly as
lib/d3dim-gpu.js:_draw returns 0 and drops to WAT today. Working software GL
without first closing four semantic gaps, at the cost of a coverage cliff that
must be measured per app rather than assumed.
Unverified
- Whether the D3D VM saturates
oD0after lighting; not visible in09aj-d3d-fixed.wat. GL explicitly clamps (lib/gl-compat.js:269). - The rasterizer's exact fog blend expression
(
src/09ah-d3d-software.wat:53), so equivalence with GL'smix(fogColor, color, factor)is not established. - Whether chunking at the 256-vertex ceiling is fast enough to matter for any real GL guest. Nothing here has been measured on a running app.
- Whether
glGetError— the whole of the measured 40.8% — can actually be answered inside WAT, given that the consumer also raises errors. - Whether Quake II's profile generalizes. One app is not a corpus; take the same census on Warcraft III and SimGolf before relying on the shares.