Documentation
    Preparing search index...

    Interface Renderer

    A backend that turns a scene into pixels (§61).

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

    §62's promise is that changing backend is an import and a constructor. That holds, with one measured exception a reader meets immediately: this interface has no surface member. Each backend takes its own surface in its own constructor, so the swap costs a third line whenever the two backends' surface types differ.

    • Two lines (cycle 3c): WebGL 2 → WebGPU. Both take an HTMLCanvasElement, so only the import and the constructor change.
    • Three lines (cycle 9A): Canvas 2D → SVG. Canvas2dRenderer takes an HTMLCanvasElement and SvgRenderer an SVGSVGElement, so the surface handle changes too.

    The seam held in both cases — nothing above the surface moved, and the per-frame calls are identical. The cost is one line, not a redesign, and it is by design rather than omission: a surface member on this interface would have to name a union of every backend's surface type, which is exactly the backend coupling §62 keeps out. docs/guides/custom-renderer-backends.md shows the diff.

    §61: "Device and context loss (WebGL context loss, WebGPU device loss) is a first-class event, not an error case." Every implementation of this interface owes the following, and the §92 integration test ("renderer context loss and restore") checks it:

    1. Emit contextlost on Renderer.events when the context is lost, and contextrestored when it comes back. Loss is reported, never thrown — the CONTEXT_LOST / DEVICE_LOST codes of §89 exist for the case where a caller asks for something that cannot be satisfied while lost, not to turn the loss itself into an exception.
    2. Re-create engine-owned GPU resources on restore — pipelines, internal buffers, render targets — before emitting contextrestored, and re-upload user resources that retain CPU-side sources. Resources with no retained source expose a documented re-upload hook (that hook arrives with the texture API; see the deferred members below).
    3. Never throw from Renderer.render while the context is lost. Skip the frame and return. A lost context can occur between any two frames, on a driver's schedule and not the application's; a backend that throws turns a routine, recoverable event into an unwinding application loop, and an application cannot pre-empt it because the loss is asynchronous. The same applies to Renderer.resize: record the new size, apply it on restore.
    4. Stay usable across the whole cycle. dispose() during a lost context must succeed, and the renderer's own state (size, resolution, capabilities) survives loss — only GPU-side objects are rebuilt.

    These are part of §61 and are not part of this interface. The list is shorter again — readPixels joined the interface when Rectangle2 landed in @fourjs/math (2026-08-29; RFC 0005's recorded prerequisite, cleared) — but the two factories stay deferred by decision, not by absence (R-4, 2026-08-07):

    createTexture(source: TextureSource): Texture;
    createRenderTarget(options: RenderTargetOptions): RenderTarget;
    // Both types exist. A renderer-*owned* resource, though, cannot be built
    // before a renderer, has to be built once per renderer, and has to be
    // re-created by hand after a §61 context loss. `GeometryCache` and
    // `TextureCache` in @fourjs/render-webgl are the standing proof that the
    // alternative works: the resource is a CPU-side descriptor with an id and a
    // version, GPU residency is a backend cache, and a context loss is handled
    // by dropping that cache. `texture.ts` and `render-target.ts` each carry the
    // full argument on the class it concerns. These land with the tier that
    // genuinely needs renderer-owned resources — compressed or GPU-only
    // formats, which have no CPU-side description at all.
    interface Renderer {
        capabilities: RendererCapabilities;
        events: EventEmitter<RendererEventMap>;
        lastGpuFrameTimeSeconds?: number;
        statistics?: RenderStatistics | null;
        compute?(pass: ComputePassDescriptor): void;
        createPickingService?(): PickingService;
        dispose(): void;
        initialize(options?: RendererOptions): Promise<void>;
        prepareTexture?(texture: MaterialTexture): Promise<boolean>;
        readPixels?(
            target: RenderTarget,
            region?: Rectangle2,
        ): Promise<ArrayBuffer>;
        render(
            root: Node,
            views: readonly Viewport[],
            interpolation?: RenderInterpolation,
            target?: RenderTarget | null,
        ): void;
        renderEffect?(pass: EffectRenderPass): void;
        resize(width: number, height: number, resolution?: number): void;
    }

    Hierarchy (View Summary)

    Implemented by

    Index
    capabilities: RendererCapabilities

    What this backend can do (§62). Valid before Renderer.initialize only as far as the backend can know statically; a backend that queries device limits publishes the real values during initialization.

    This renderer's event channel (§6b) — contextlost and contextrestored.

    Composition rather than inheritance: see the module header. Use it exactly like any other fourJS emitter, including the returned unsubscriber:

    const off = renderer.events.on("contextlost", (event) => {
    pauseWhileLost(event.renderer);
    });
    lastGpuFrameTimeSeconds?: number

    Seconds the GPU spent on the most recently completed frame (§84 gpuFrameTime, A-1), or undefined when this backend does not measure GPU time.

    Optional, and its presence is the capability — the same stance Renderer.statistics takes. A backend that can time GPU work declares the member (the WebGL 2 and WebGPU backends do); one that cannot omits it, and Application then leaves stats.gpuFrameTime as NaN rather than inventing a zero.

    The value is seconds (§7a). NaN means "measuring, but no completed sample yet" — timestamp queries are asynchronous on both GPU APIs, so the first armed frame (and any frame whose query was disjoint or still in flight) honestly reports unmeasured. A finite number is the last query that landed, never the frame still on the GPU.

    Reading the getter is what arms measurement. A renderer whose lastGpuFrameTimeSeconds is never read issues not one extra GPU command (R-30b): landed transcripts stay byte-identical. That is why this is not gated on Renderer.statistics — A-1's draw counters must not add, remove, or reorder a GPU call, and a timer query would.

    statistics?: RenderStatistics | null

    Where this backend accumulates §84's drawCalls, triangles, and instances, or null to count nothing (A-1, 2026-08-07).

    Optional, and its presence is the capability. §61's interface says nothing about statistics, and §84 asks the application for the counters — but only a backend knows what it actually submitted, so the numbers have to originate here. A backend that counts declares the member (the WebGL 2 backend does); one that cannot omits it, and supportsRenderStatistics tells them apart so an application reports "not measured" rather than a confident zero.

    A backend that declares it owes three things, and nothing else:

    1. Accumulate, never clear — a frame may be several render calls (an off-screen pass, then the on-screen one) and §84's counters are the frame's totals. The owner of the record clears it per frame.
    2. Count what was submitted, not what was in the render list: a draw skipped for a missing geometry, a disposed texture, or a feedback loop did not happen.
    3. Change nothing else. Switching statistics on must not add, remove, or reorder a single GPU call — the counters are integer increments beside the draw, never around it (see statistics.ts).
    renderer.statistics = createRenderStatistics();
    
    • Records and submits one §82 compute dispatch — the kernel in pass.shader, pass.workgroups workgroups, pass.bindings bound in array order at @group(0) (WP-R1.8; promoted here by the R-1 plan's Q3, 2026-08-29).

      Optional, and its presence is the capability — the fourth instance of the Renderer.statistics / Renderer.renderEffect / Renderer.createPickingService stance, for the same two reasons: a required member would break every implementor, and a backend with no compute stage (WebGL 2, Canvas 2D, SVG, the null tier) must say so by omission rather than by emulating on the CPU — §82's own closing sentence makes compute an advanced optional capability, and §62's computeShaders capability is how an application asks before reaching for it. supportsCompute is the type-level test.

      A backend that declares it owes:

      • Nothing in the frame path — §82's "basic graphics and physics functionality must not require compute support", held structurally: render never calls this and this never draws;
      • refusal of a buffer another backend created, a disposed buffer, and a malformed grid — loudly (INVALID_APPLICATION_STATE), never as a silent no-op (§85);
      • UNSUPPORTED_GPU_FEATURE when the live device turns out to lack the compute entry points the backend expected (readPixels' contract).

      Buffer allocation stays a backend API — see compute.ts's module header for what deliberately did not move.

      Parameters

      Returns void

    • Builds a PickingService over this backend — §71's "gpu"/"pixel" picking tier (RFC 0005, 2026-08-28).

      Optional, and its presence is the capability — the Renderer.statistics / Renderer.renderEffect stance, for the same two reasons: a required member would break every implementor, and a backend with no readable pixels should say so by omission rather than by emulating. supportsPicking is the runtime test. Absent on NullRenderer (no pixels at all), and deliberately absent on the future Canvas 2D / SVG tiers even though both could answer "pixel" natively (isPointInPath, SVG hit testing): emulation would make §71's result quality vary by backend, and the owner's decision on RFC 0005 Q6 is that the tier is declared absent there instead.

      The WebGL 2 and WebGPU backends declare it, gated on each package's registerPickingPipeline() (the skinning seam's shape): the member says what the backend can do, registration is the application opting in to paying for it, and calling this without registering is refused with INVALID_APPLICATION_STATE (§85) naming the fix. Each call builds an independent service with its own id buffer; the caller owns it and disposes it (§83). WebGL draws particle systems (one id per emitter) and skinned meshes (deformed silhouette via SkinnedIdProgram). WebGPU draws particle systems the same way (CPU 8-float billboard) and skinned meshes through a private skinned id pipeline (never bind pose).

      Returns PickingService

    • Releases every GPU resource this renderer owns (§83).

      Declared by Disposable, which this interface extends, and restated here to carry the renderer-specific contract. Implementations must make it idempotent (a second call is a no-op) and terminal, must succeed while the context is lost, and must leave no listeners attached to Renderer.events (§83: teardown may not retain listeners). Resources the renderer did not create — geometries, materials, textures handed in by the application — are not disposed here; whoever created them owns them.

      Returns void

    • Acquires the backend's context or device (§61, §45).

      Asynchronous because WebGPU adapter/device acquisition is (§45's await app.initialize()), and because a backend may compile pipelines up front. Rejects with a FourError carrying RENDERER_INITIALIZATION_FAILED (§62, §89) when the backend cannot start — explicitly, rather than silently downgrading; backend selection and its "auto" fallback are the application's job (§62), not an individual backend's.

      Calling it twice is an implementation-defined error, not a second acquisition.

      Parameters

      Returns Promise<void>

    • Pre-upload CPU texture bytes and wait for GPU completion. False means disposed, lost, or superseded while waiting; unavailable completion primitives reject.

      Parameters

      Returns Promise<boolean>

    • Reads back target's colour attachment — or the region rectangle of it — as tightly packed RGBA8 bytes (§61, §92; the member the module header's typed TODO carried until Rectangle2 landed, 2026-08-29).

      Optional, and its presence is the capability — the Renderer.statistics / Renderer.renderEffect stance, for the same two reasons: a required member would break every implementor, and a backend with no readable pixels (NullRenderer, §62's SVG tier) says so by omission rather than by fabricating bytes.

      Asynchronous, honestly and permanently (RFC 0005's commitment, and §61's own signature). WebGPU has no synchronous readback at all — copyTextureToBuffer + mapAsync is the only path — so the Promise is the floor of what every backend can promise, and a backend whose native read happens to be synchronous (GL's readPixels) still resolves rather than returning bytes directly: the shape never varies by backend (§62). Callers must not assume resolution within any particular frame.

      The contract every backend that declares it owes:

      • The result is width × height × 4 bytes (the region's, when given; the target's otherwise) of tightly packed, straight-alpha RGBA8 — no row padding, regardless of the backend's own alignment rules.
      • Rows run bottom-to-top: row 0 of the result is the bottom row of the image, matching §7a's Y-up world (recorded decision, WP-R1.6; GL's native order, flipped on the way out by WebGPU).
      • region is measured in target texels from the bottom-left corner, +Y up — the same space the result's rows are defined in. It must be non-empty, integer-valued, and lie inside the target (validateReadbackRegion is the shared §85 check; a violation rejects with its RangeError). Omitted means the whole target.
      • Rejects rather than skips. Unlike the frame methods this one is not called inside a frame and its caller is awaiting a value, so a lost context, a disposed renderer or target, and a device without the readback entry points all reject with a FourError (DEVICE_LOST / CONTEXT_LOST, INVALID_APPLICATION_STATE, UNSUPPORTED_GPU_FEATURE; §89) — a silently empty buffer would be undefined content by another name.
      • A target never rendered into reads back its zero-filled allocation — transparent black, the same defined answer sampling one gives.

      §92's visual regression tier is the first consumer. The pixel-picking service (RFC 0005) does not read through this member: it owns its own one-texel fence / mapAsync path.

      Parameters

      Returns Promise<ArrayBuffer>

    • Draws root's subtree once for every viewport in views, in order (§61, §48).

      root is usually the Scene. Nothing in the scene is mutated: rendering reads world transforms (or §43 interpolated render poses) and writes pixels — §42's transform authority and §43's "render poses never feed back" rule both depend on that, and it is what lets a renderer be swapped, run twice, or omitted entirely without changing simulation results.

      • Viewports are drawn in array order, each into its own rectangle. An empty views array draws nothing — and, importantly, clears nothing.
      • A viewport's rectangle is x/y/width/height in pixels, or fractions of the current drawing-buffer size when normalized is true (§48). Fractions are resolved against the size last given to Renderer.resize, multiplied by the resolution — so a normalized full-surface view stays correct across resizes with no per-frame work by the application.
      • The rectangle's origin is the bottom-left corner with +Y up, matching the world convention (§7a) and the clip space of the D8 projections. A backend whose native scissor rectangle is top-left based flips on the way in; the flip never leaks into Viewport.
      • clearColor present means clear that rectangle to that colour (RGBA in [0, 1], straight alpha, linear-light per §60a) before drawing it; absent means do not clear colour, which is how a minimap or picture-in-picture view composites over what an earlier view drew.
      • Depth is cleared per view whenever the backend has a depth buffer, so a later view's geometry cannot be occluded by an earlier view's. §48's configurable clearDepth is deferred; the cleared value is the far plane.
      • Clears are confined to the viewport rectangle, never the whole surface.

      With interpolation present the backend draws each node at its §43 render pose — the previous and current fixed-step poses blended at interpolation.alpha, positions lerped and rotations slerped — instead of at its resolved world transform. In @fourjs/render terms that is buildInterpolatedRenderList rather than buildRenderList, and it is what makes motion smooth when the display rate and the fixed simulation rate disagree (§10). Nodes the buffer does not track are drawn from their live transforms at every alpha, so a mixed scene needs no bookkeeping.

      With interpolation omitted the backend draws the resolved world transforms, which requires the caller to have run resolveWorldTransforms for the frame (§7, §64) — the interpolated path derives its matrices itself and does not.

      With target present the frame is drawn into that off-screen surface instead of the backend's default drawing buffer, and target.colorTexture can then be sampled by any material — which is the whole of render-to-texture, and what §48's minimaps, mirrors, portals and previews, §63's transient resources, and §70's effect chain are all built out of. Everything else is unchanged: the same views, the same clears, the same interpolation.

      Three rules bind every backend:

      • Normalized viewport rectangles resolve against the target's size, not the surface's, so a full-target view is the same { x: 0, y: 0, width: 1, height: 1, normalized: true } it is on screen. Pixel rectangles are target pixels, and are not scaled by the Renderer.resize resolution — the target has a size of its own.
      • The target is bound for the call and unbound before it returns, even if the frame throws. A backend leaves nothing bound behind: the next thing to touch the device may be another renderer, or the same one drawing to screen.
      • A material sampling the target currently being drawn into is skipped rather than drawn. That is a read-write feedback loop on one surface, which is undefined behaviour on every backend; the draw is dropped the same way a disposed texture's is (§83). Ping-pong between two targets.

      A disposed target draws nothing at all, and a target the backend cannot allocate (an incomplete framebuffer, a device out of memory) skips the frame rather than throwing — same reasoning as the lost-context rule.

      Returns immediately if the context is lost (see the context-loss contract above): never throws for that reason.

      Parameters

      Returns void

    • Draws one §70 full-screen effect: pass.source's texels over the whole of pass.target (or of the drawing buffer) through pass.effect (R-6, 2026-08-07).

      Optional, and its presence is the capability — the same stance Renderer.statistics takes, for the same two reasons: adding a required member to a published interface breaks every implementor, and a backend with no fragment stage to run an effect in (§62's SVG tier draws DOM nodes) should say so by omission rather than by silently copying. supportsScreenEffects is the runtime test; RenderGraph.execute uses it before forwarding an EffectRenderPass.

      A backend that declares it owes exactly what Renderer.render owes, restated because the two are separate entry points into the same device:

      • the destination is bound for the call and unbound before it returns, even if the call throws, and nothing else is left bound either;
      • a lost context, a disposed source or destination, and an allocation the device refused all skip the effect and return, rather than throwing (§61, §83);
      • a pass whose destination is the surface it samples is refused, not drawn — R-4's feedback rule, which RenderGraph.validate also reports statically;
      • the effect covers the whole destination surface, replaces rather than composites (no blend, no depth test, no clear), and leaves the §57 state mirror where the next frame expects it.

      See effect-pass.ts for which of §70's effects this tier ships and what each staged one is waiting on.

      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