Documentation
    Preparing search index...

    Class WebglRenderer

    Draws fourJS scenes with WebGL 2 (§61, §62, §120).

    const renderer = new WebglRenderer();
    await renderer.initialize({ canvas });
    renderer.resize(800, 600, devicePixelRatio);
    renderer.render(scene, [createFullscreenViewport(camera)]);
    // …
    renderer.dispose();

    Set once at initialization and re-applied on context restore:

    • Depth test on, LEQUAL, cleared to 1 per view. §61's shared contract makes the per-view depth clear mandatory; LEQUAL rather than LESS so 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 default Camera.updateProjectionMatrix already 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.
    • Counter-clockwise front faces, matching the right-handed, Y-up world of §7a and the winding the §53 primitive builders emit.
    • Back-face culling OFF. The MVP tier draws planar 2D shapes as much as 3D meshes, and a plane seen from behind is a legitimate view of it, not a back face to discard. §57's base landed with 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.
    • Scissor test on for the renderer's lifetime. §61 requires clears to be confined to the viewport rectangle, and a scissor that 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.
    • Blend function 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, and Viewport.clearColor alike. Set once at initialization, and the state a frame is always handed back in; since 2026-08-06 a draw whose material names a different blendMode (§57) changes the function for its run and render restores it before returning.

    Every draw applies the six fields §57 puts on Material:

    field GL
    transparent enable/disable(BLEND)
    blendMode blendFunc (see BLEND_FUNCTIONS)
    depthTest enable/disable(DEPTH_TEST)
    depthWrite depthMask
    colorWrite colorMask
    opacity multiplied into the uploaded alpha

    This is the fix for the recorded defect that UnlitMaterial.color[3] and LitMaterial.color[3] were dead fields: blending was disabled for both pipelines unconditionally, so animating alpha was a silent no-op. It is now transparent: true away from working, and opacity drives it without touching the shared colour.

    The state is mirrored on the CPU (one GlState per 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 the finally that restores what it borrowed, so the mirror cannot outlive its agreement with the context (see WebglRenderer.render).

    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):

    • unlit — flat colour, the §120 MVP pipeline, depth-tested, and opaque unless its material declares transparent or its colour alpha is not 1;
    • lit — Lambert diffuse under §68's directional light plus the scene ambient term, depth-tested and opaque-by-default like unlit. The frame's lights are collected once per render call (collectSceneLights, @fourjs/render) — and only for frames whose list actually contains a lit or standard item, so unlit scenes never pay the walk;
    • standard — §59's metallic-roughness BRDF (GGX, Smith, Schlick) under the same one light and the same ambient term, plus the eye position the specular lobe needs (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;
    • sprite — one texture sample times a tint, with GL_BLEND enabled whatever the material says (the pipeline blends by construction);
    • particles — one drawArraysInstanced per §36 particle system, screen-aligned quads coloured per instance, with GL_BLEND enabled (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 renderOrder and usually sets depthWrite: 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's createTexture stays deferred — the argument is written out in that module's header and in @fourjs/render's texture.ts.

    render's fourth argument draws the frame into an off-screen framebuffer instead of the canvas, and RenderTarget.colorTexture is 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 on Renderer.render:

    • Framebuffers are a cache, keyed and invalidated exactly as geometry and textures are (gl-render-target.ts), including through a context loss.
    • The bind lives inside the frame's try/finally, so a draw that throws mid-pass cannot leave this renderer's framebuffer bound.
    • A frame with no target issues no framebuffer call at all — not a bind, not an unbind. The on-screen GL sequence is byte-for-byte the one this backend issued before render targets existed, which is the property the pixel goldens and the recorded-sequence tests both pin.

    WebglRenderer.statistics is null by default and counts nothing. Assign a RenderStatistics record and this backend adds one entry per draw call it actually submits — drawCalls, triangles (per-instance count times instances, lines excluded), instances — accumulating across every render call 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.

    webglcontextlost is captured, defaulted-prevented (without which the browser never restores), and turned into a contextlost event; GPU handles are dropped without being deleted, because they are already invalid. render then returns silently until webglcontextrestored arrives, 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, and contextrestored is 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).

    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 throws INVALID_APPLICATION_STATE afterwards, as NullRenderer does — disposal is the application's own doing, unlike a lost context, and a silent no-op would hide the bug.

    Implements

    Index
    batching: RenderBatching | null

    §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.

    The §6b channel required by Renderer — contextlost/contextrestored.

    renderTime: number

    §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.

    statistics: RenderStatistics | null

    §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.

    • get capabilities(): RendererCapabilities

      §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.

      Returns RendererCapabilities

    • get contextLost(): boolean

      Whether the backing context is currently lost (§61).

      Returns boolean

    • get disposed(): boolean

      Whether WebglRenderer.dispose has run. Disposal is terminal.

      Returns boolean

    • get initialized(): boolean

      Whether WebglRenderer.initialize has completed.

      Returns boolean

    • get lastGpuFrameTimeSeconds(): number

      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).

      Returns number

    • 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.

      Returns PickingService

      FourError INVALID_APPLICATION_STATE on a disposed renderer, or when no picking pipeline is registered.

    • 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).

      Returns void

    • 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.

      Parameters

      Returns Promise<void>

    • Upload a CPU texture and wait until its queued GPU upload has completed.

      Parameters

      Returns Promise<boolean>

    • 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.

      Parameters

      Returns Promise<ArrayBuffer>

    • Draws 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.

      Parameters

      Returns void

    • Draws 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.

      A separate entry point, and why render was not touched

      An 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.

      Parameters

      Returns void

    • 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).

      Parameters

      • width: number
      • height: number
      • Optionalresolution: number

      Returns void