§65 batching, or null (the default) to batch nothing — the opt-in
capability (R-9, 2026-08-09; see gl-batch.ts).
import { createGlBatching } from "@fourjs/render-webgl";
renderer.batching = createGlBatching();
With one assigned, consecutive render items that share a pipeline and a
material instance are merged into one drawElements — §65's sprite
batching and compatible shape batching, over the unlit and sprite
pipelines. With none, this backend issues exactly the GL sequence it issued
before batching existed, to the byte: the field is read once per frame,
and the whole of the no-batching cost is one null comparison per render
item — no allocation, no GL call, no reordering (asserted as a full
transcript in tests/integration/render-batching.test.ts).
Assigned rather than constructed here on purpose. Nothing reachable
from a class method tree-shakes, so naming GlBatching in this file would
put the batcher in every bundle that carries this renderer — the measured
law R-6, R-13 and R-18 each paid. The type is imported import type, so an
application that never calls createGlBatching does not link the module.
Assign it between frames, as WebglRenderer.statistics asks: render
reads it once per call.
ReadonlyeventsThe §6b channel required by Renderer — contextlost/contextrestored.
§9 render time in seconds, read by §60 node graphs containing a
time node (RFC 0001) — and by nothing else in this backend. 0 by
default, which is GL's own initial value for the uniform, so a scene
whose graphs never read time pays nothing whether or not this moves.
Assign it between frames, exactly as WebglRenderer.statistics
asks — typically renderer.renderTime = timeState.renderTime in the
application's frame callback. Deliberately render time and never
simulation time: a shader is a rendering artefact, and §42/§43 forbid
anything downstream of one becoming simulation input. Must be finite; a
plain field (the statistics/batching precedent) because it is
assigned per frame on the application's hot path.
§84's render counters, or null (the default) to count nothing — the
optional Renderer capability (A-1, 2026-08-07; see
@fourjs/render's statistics.ts).
This backend accumulates one entry per submitted draw call: a draw skipped for a geometry it could not allocate, a texture the application disposed, a zero-particle system, or a feedback loop on the current render target never reaches a counter, because it never reached the GPU.
Assign it between frames. WebglRenderer.render reads the field once per call, so a record assigned from inside a frame's own listeners applies from the next frame.
§62 capability report. maxTextureSize is 0 until
WebglRenderer.initialize has queried the context, and is re-read on
context restore. maxAnisotropy is omitted until that same moment, then
resolved on first read (not during initialize) so landed GL transcripts
stay byte-identical.
Whether the backing context is currently lost (§61).
Whether WebglRenderer.dispose has run. Disposal is terminal.
Whether WebglRenderer.initialize has completed.
Last completed GPU-frame duration in seconds (A-1). Reading this
getter is what arms EXT_disjoint_timer_query_webgl2; a renderer
that never reads it issues not one extra GL call (R-30b).
Builds a PickingService over this renderer — §71's "gpu" tier
(RFC 0005), gated on registerPickingPipeline() exactly as skinned
draws are gated on registerSkinningPipeline(): this method resolves the
registry slot and refuses (§85) when nothing registered, so the id
program, the service, and its read-back live only in bundles that opted
in (the pipeline-cost law; gl-picking-registry.ts).
What the service receives is a live window onto exactly the renderer
state an id pass needs — context, the three shared caches (geometry,
render targets, particle batches), the surface size, and the two
lifecycle flags — as accessors, so a §61 restore's new caches are seen
rather than captured stale (PickingRendererHost). Each call builds an
independent service; the caller owns and disposes it (§83). No GL call is
issued here — the id program compiles on the service's first pass.
Releases every GPU resource this renderer owns and detaches its listeners (§83). Idempotent, terminal, and safe while the context is lost — in which case the GL objects are dropped rather than deleted, since the handles are already invalid.
Geometries and materials are not disposed: the renderer did not create them (§83).
Acquires the WebGL 2 context from options.canvas, compiles the unlit
program, sets the fixed GL state, and wires the context-loss events (§61,
§45).
Rejects with a FourError carrying RENDERER_INITIALIZATION_FAILED
when there is no canvas, when the canvas will not give up a "webgl2"
context (an older browser, a blocked GPU, a context already taken by
another API), or when what it gives back is not a WebGL 2 context; and with
SHADER_COMPILATION_FAILED when the unlit program will not build. §62
requires an explicit backend to fail fast rather than downgrade silently —
"auto" selection is the application's job, not this class's.
The work is synchronous — unlike WebGPU, getContext returns immediately —
but the signature is Promise<void> because §61's is, so an application
awaits every backend the same way. The failure is delivered as a
rejection rather than a synchronous throw, which is what await app.initialize() expects.
Calling it twice rejects with INVALID_APPLICATION_STATE.
Optionaloptions: RendererOptionsUpload a CPU texture and wait until its queued GPU upload has completed.
Reads back target's colour attachment — or the region rectangle of it
— as tightly packed RGBA8 bytes: §61's readPixels (landed 2026-08-29,
with Rectangle2 in @fourjs/math — RFC 0005's named prerequisite — after
sitting on gl-render-target.ts's staged list since R-4).
A Promise over a synchronous read, and that is the §62 contract, not
a fib. §61 types the member Promise<ArrayBuffer> because WebGPU has
no synchronous readback at all, so the promise is the floor every
backend meets; GL's readPixels is synchronous and this method simply
resolves once it returns — the caller's shape never varies by backend,
exactly as pick() promises through both of its paths (RFC 0005 §4).
The stalling form, deliberately. The picking packet built a
non-stalling fence path (PIXEL_PACK_BUFFER + fenceSync + a polled
clientWaitSync, gl-picking.ts) because picks happen every frame
inside an interactive loop, where a pipeline stall costs the frame
budget. A target readback is the opposite shape of operation: §92's
visual tier and RFC 0005's fallback path call it between frames, usually
once, and its caller is already awaiting the full GPU round trip — a
fence would add poll-granularity latency to save a stall nobody is
racing, and reusing the picking path would couple this method to that
service's pass state (or duplicate its ~80-line poll loop) for that
non-benefit. The upgrade is invisible to callers if a profile ever
disagrees: same member, same promise.
The result is width * height * 4 bytes (the region's, when given; the
target's otherwise), rows bottom-to-top — GL's native order, and the
§7a order the WebGPU backend flips into (wgpu-readback.ts records the
decision), so the two backends agree byte for byte. region is measured
in target texels from the bottom-left corner — GL's own readback
space, passed straight through. Rows are tightly packed with no
PACK_ALIGNMENT help needed: RGBA8 rows are always a multiple of 4
bytes, so the default pack alignment never pads.
A target that was never rendered into reads back its zero-filled
allocation — transparent black, the same defined answer sampling one
gives. Unlike the frame methods this one rejects rather than skips
(the caller is awaiting a value; a silently empty buffer would be
undefined content by another name): INVALID_APPLICATION_STATE for a
disposed renderer, one never initialized, or a target that is disposed
or that GL would not allocate; CONTEXT_LOST while the context is lost
(§89 — this backend's loss code, as in gl-picking.ts);
UNSUPPORTED_GPU_FEATURE on a context double without the readPixels
entry point (presence is the capability, gl-program.ts). A malformed
region rejects with validateReadbackRegion's RangeError (§85,
@fourjs/render's shared check, so both backends refuse with the same
words).
The framebuffer binding is borrowed and restored in a finally, exactly
as the stalling pick read borrows it — nothing is left bound behind.
Optionalregion: Rectangle2Draws root's subtree once per viewport, in array order (§61, §48, §64).
The per-view sequence is scissor rectangle, viewport rectangle, clears,
then the draws — the rectangles first so that the clears are already
confined when they run. Colour is cleared only when the view carries a
clearColor (absent means "composite over what an earlier view drew");
depth is cleared for every view to the far plane, so a later view cannot be
occluded by an earlier one. Both clears are issued as one clear call with
the combined mask.
viewProjection is camera.projectionMatrix * camera.viewMatrix, uploaded
once per view after camera.updateViewMatrix() — the camera's world
transform is resolved by that call, so a camera moved after the frame's
resolve pass is still correct. The camera's projection is never rewritten
here: this backend's clip depth is "negative-one-to-one", which is what
Camera.updateProjectionMatrix already defaults to (plan D8).
The render list is built once per call, not once per view: it does not depend on the camera, because §64 stage 3 (culling) is not implemented yet. When culling lands, the build moves inside the view loop.
With interpolation present the list is built by
buildInterpolatedRenderList instead of buildRenderList, so each item's
model matrix is the node's §43 render pose at interpolation.alpha —
position lerped, rotation slerped, composed through the hierarchy — rather
than its resolved world matrix. That is the whole of this backend's part in
§43: nothing else in the draw path changes, because the interpolated pose
arrives as an ordinary RenderItem.worldMatrix.
Which of the two lists is built is decided per call, from the argument: this class keeps no interpolation state, so the same renderer can draw an interpolated frame for the application and a raw one for a picking or screenshot pass without being reconfigured.
Nothing in the scene is mutated — the interpolated path composes into
pooled matrices and never writes a render pose back (§42, §43). World
matrices are not resolved here: §7 and §64 make that a separate,
earlier stage, and Application runs it before its render listeners.
The interpolated path does not even need that pass, since it derives every
matrix itself.
With target present the frame is drawn into that target's framebuffer:
bound as the first act of the frame's try, unbound in its finally, and
never left bound for the next caller. Normalized viewport rectangles
resolve against the target's size rather than the drawing buffer's, so a
full-target view is the same { 0, 0, 1, 1, normalized } it is on screen;
unnormalized rectangles are target pixels, unscaled by the resize
resolution, which the target does not have.
The pass is skipped entirely — no bind, no clear, no draw — when the
target is disposed, when GL will not allocate its framebuffer, or when the
assembled framebuffer is not FRAMEBUFFER_COMPLETE. §61 forbids throwing
from render for the asynchronous, driver-scheduled failures this is one
of, and half-drawing into an incomplete framebuffer would turn every
subsequent draw into an unread GL_INVALID_FRAMEBUFFER_OPERATION.
A material whose texture is this target's own colour attachment is
skipped for the duration of the pass (resolveTexture): sampling the
surface being written is undefined on every backend.
Without target — the on-screen path — not one framebuffer call is
issued, so the GL sequence is byte-for-byte what it was before render
targets existed.
Returns immediately and silently while the context is lost, and when
views is empty (which therefore also clears nothing) — §61 both times.
Throws only for programmer error: rendering before initialize or after
dispose.
render itself raises nothing else, but the frame runs application code
— a geometry or material accessor, a texture the application disposed
mid-frame — and that can. The draw work is therefore wrapped in a
try/finally: whatever escapes, the fixed GL state this frame borrowed
is put back, the texture unit and the vertex array are unbound, and the
§57 mirror ends where the next frame expects to find it. The exception is
re-thrown unchanged; the next frame draws correctly. Before this, one
transient exception desynced the mirror from the context permanently and
silently, because every later frame asserted the defaults it had just
stopped guaranteeing.
Optionalinterpolation: RenderInterpolationOptionaltarget: RenderTarget | nullDraws one §70 full-screen effect (R-6, 2026-08-07) — pass.source's
colour attachment over the whole of pass.target, or of the drawing
buffer, through pass.effect.
The normative contract is on @fourjs/render's Renderer.renderEffect;
this is what the WebGL 2 backend does with it, and the two readings that
sentence left open.
render was not touchedAn effect is not a scene: it has no root, no views, no camera, no render
list, and no interpolation, and it draws a triangle that exists only in
the vertex stage. Routing it through render would have meant a fifth
RenderItemKind, a synthetic node, and a branch inside the draw loop —
and the loop is exactly where R-4, R-5 and F13 each had to re-prove that
an application which uses none of those features still emits the identical
GL call sequence. So §70 got its own method, and the property is preserved
by construction rather than by proof: render is byte-for-byte the
function it was before this method existed. The one thing R-6 adds to a
frame that runs no effect is a fifth useProgram-able object compiled at
initialization, which issues no call after that.
Everything, on the same terms render uses — a lost context, a source or
destination the application disposed, a framebuffer GL would not allocate,
an effect kind this build does not implement, and a feedback loop: a
pass whose destination is the very surface it samples. That last one is
R-4's rule, applied to the pass instead of to a material's map; reading
and writing one surface in a single draw is undefined behaviour on every
backend, and RenderGraph.validate reports it statically as "feedback"
so the mistake is normally caught at setup rather than here.
The effect borrows four things — the framebuffer binding, the scissor and
viewport rectangles, the depth test, and unit 0's texture binding — inside
a try/finally, so whatever escapes leaves this renderer's §57 mirror
and the context in the state the next frame expects (F13, 2026-08-07).
The mirror is deliberately not reset on entry: applyDepthColorState
moves only what the effect needs and the finally puts back exactly that,
which is strictly more conservative than re-asserting a state this method
did not establish.
The scissor and viewport rectangles are not restored, exactly as render
does not restore them: both are written unconditionally by the next view of
the next frame, and every path that reads them writes them first.
Resizes the drawing surface to width × height logical pixels at
resolution device pixels per logical pixel (§61, §45).
The drawing buffer becomes width * resolution × height * resolution.
resolution defaults to 1 (see the module header); pass
devicePixelRatio in a browser.
Cameras are not updated: a camera's aspect is the application's to
set, because only the application knows which camera belongs to which
viewport and whether a resize should change the field of view, the visible
extent, or neither (§47's explicit-recomputation rule).
Optionalresolution: number
Draws fourJS scenes with WebGL 2 (§61, §62, §120).
Fixed GL state (decisions, WP-3.5)
Set once at initialization and re-applied on context restore:
LEQUAL, cleared to 1 per view. §61's shared contract makes the per-view depth clear mandatory;LEQUALrather thanLESSso that co-planar geometry drawn later wins, which is what 2D content stacked in render order expects."negative-one-to-one"clip depth (plan D8). GL's default depth range, and the defaultCamera.updateProjectionMatrixalready writes — so this backend never rewrites a camera's projection. A"zero-to-one"backend (WebGPU) must, which is why the argument is on the camera method.depthTest/depthWrite/colorWrite(2026-08-06) and this backend honours all three per draw, but §57 declares no face-culling field, so culling stays a global off; turning it on would make half the 2D scenes in §93's examples disappear when the camera crosses their plane.scissorthat is never enabled does not confine anything. Drawing is confined too, which is what stops a minimap's geometry from spilling into the main view.SRC_ALPHA/ONE_MINUS_SRC_ALPHA; blending itself off. §66 requires the engine to state its "premultiplied and straight alpha policies": this tier is straight alpha, not premultiplied, on every colour it touches —UnlitMaterial.color,SpriteMaterial.tint, texel data, andViewport.clearColoralike. Set once at initialization, and the state a frame is always handed back in; since 2026-08-06 a draw whose material names a differentblendMode(§57) changes the function for its run andrenderrestores it before returning.Material render state (§57, 2026-08-06)
Every draw applies the six fields §57 puts on
Material:transparentenable/disable(BLEND)blendModeblendFunc(seeBLEND_FUNCTIONS)depthTestenable/disable(DEPTH_TEST)depthWritedepthMaskcolorWritecolorMaskopacityThis is the fix for the recorded defect that
UnlitMaterial.color[3]andLitMaterial.color[3]were dead fields: blending was disabled for both pipelines unconditionally, so animating alpha was a silent no-op. It is nowtransparent: trueaway from working, andopacitydrives it without touching the shared colour.The state is mirrored on the CPU (one
GlStateper renderer, F13) and a call is issued only where a draw changes something, so §57's defaults — which are exactly this backend's previous fixed state — issue nothing at all: a scene authored before the base existed produces the same GL sequence it always did. A frame always leaves through thefinallythat restores what it borrowed, so the mirror cannot outlive its agreement with the context (see WebglRenderer.render).Five scene pipelines (§55, §36, §59, §68; WP-3a.3, WP-9.3, lighting
2026-08-04, R-13 2026-08-08)
A render item says which pipeline draws it (
RenderItem.kind), and this backend keeps all five live (a sixth program, §70's full-screen effect, is driven by WebglRenderer.renderEffect and never by a render item):transparentor its colour alpha is not 1;rendercall (collectSceneLights,@fourjs/render) — and only for frames whose list actually contains a lit or standard item, so unlit scenes never pay the walk;gl-standard.ts). It shades in the same untagged linear space and blends by the same straight-alpha rule as the lit pipeline, so a scene may mix the two families freely;GL_BLENDenabled whatever the material says (the pipeline blends by construction);drawArraysInstancedper §36 particle system, screen-aligned quads coloured per instance, withGL_BLENDenabled (gl-particles.ts).The unlit pipeline is the frame's starting and resting state: a scene with no sprites and no particles issues exactly the GL sequence it issued before either existed, down to the single
useProgram. Blending is enabled when a draw asks for it and disabled when one stops asking, so an opaque draw never runs through a blend equation it did not ask for.Draws are not depth-sorted: §66's key 2 (opaque before transparent) arrived in the render list on 2026-08-06 and this backend sees its effect for free, but key 4 (depth) is still deferred with the per-view list it needs, so transparent draws are ordered by layer, then explicit render order, then scene order, with depth testing and depth writing left on unless a material turns them off. That is correct for content that does not overlap, and for overlapping content an author orders with
renderOrderand usually setsdepthWrite: false; it is wrong for arbitrary unsorted overlapping transparency, which is the documented limitation §66 asks the engine to state and the depth-sorting packet to fix.Textures are discovered from the render list and cached exactly as geometry is (
gl-texture.ts), which is why §61'screateTexturestays deferred — the argument is written out in that module's header and in@fourjs/render'stexture.ts.Render targets (§61, §48; R-4, 2026-08-07)
render's fourth argument draws the frame into an off-screen framebuffer instead of the canvas, andRenderTarget.colorTextureis then an ordinary material texture — which is the whole of render-to-texture. Three things are this backend's part of the shared contract stated onRenderer.render:gl-render-target.ts), including through a context loss.try/finally, so a draw that throws mid-pass cannot leave this renderer's framebuffer bound.Statistics (§84; A-1, 2026-08-07)
WebglRenderer.statistics is
nullby default and counts nothing. Assign aRenderStatisticsrecord and this backend adds one entry per draw call it actually submits —drawCalls,triangles(per-instance count times instances, lines excluded),instances— accumulating across everyrendercall until the record's owner clears it.The counters are integer increments beside the draw, never around it, and the field is read once per frame. Switching them on therefore adds, removes, and reorders nothing: the recorded-GL-sequence tests assert that a frame with statistics on issues the byte-identical call list of the same frame with them off, which is the property the pixel goldens rest on.
Context loss (§61)
webglcontextlostis captured, defaulted-prevented (without which the browser never restores), and turned into acontextlostevent; GPU handles are dropped without being deleted, because they are already invalid.renderthen returns silently untilwebglcontextrestoredarrives, at which point all six programs and all four caches are rebuilt, the fixed state and the surface size are re-applied, capabilities are re-read, andcontextrestoredis emitted — after the rebuild, so the first frame a listener triggers already draws. Geometry and texture content re-uploads lazily from the CPU-side sources the application still holds, on the next draw that needs it, and so do the framebuffers of §61's "engine-owned GPU resources … render targets": the cache cannot know which targets the next frame will use, so each one is re-allocated by the pass that asks for it (R-4).Lifecycle
dispose()is idempotent and terminal, succeeds while the context is lost, and leaves no listeners on the canvas or on WebglRenderer.events (§83). Every other method throwsINVALID_APPLICATION_STATEafterwards, asNullRendererdoes — disposal is the application's own doing, unlike a lost context, and a silent no-op would hide the bug.