Custom renderer backends

Nothing in fourJS draws pixels directly: an application hands a Renderer to Application, and §61's interface is the only thing the rest of the engine knows about a backend. render-webgl and render-webgpu ship one each. render-canvas and render-svg are reserved stubs — one source line apiece, exporting only PACKAGE_NAME — so AUTO_RENDERER_ORDER's canvas2d and svg rungs cannot be filled by any fourJS package today.

That is what makes this guide worth having rather than academic. It is the renderer seam's counterpart to custom solver adapters, and everything below was written and measured from a consumer seat during dogfood cycle 9 (2026-09-19): a Canvas 2D backend and an SVG backend, about 120 lines each, against published exports only — Renderer, RendererCapabilities, buildRenderList, createRenderStatistics, registerRenderer — with no access to package internals.

What the interface actually requires

Renderer (in @fourjs/render) has six required members and a long tail of optional ones. The required set is small on purpose:

MemberWhat a backend owes
capabilitiesa RendererCapabilities record; backend and maxTextureSize are its only required fields
eventsan EventEmitter<RendererEventMap> — contextlost / contextrestored
initialize(options?)acquire the context or device; rejecting is how §62's "auto" falls through
render(root, views, …)draw root's subtree once per viewport, mutating nothing in the scene
resize(w, h, resolution?)resize the drawing surface; cameras are the application's to update
dispose()idempotent, terminal, leaves no listeners on events

Everything else — statistics, lastGpuFrameTimeSeconds, renderEffect, createPickingService, compute, prepareTexture, readPixels — is optional, and its presence is the capability. A backend that cannot do one of those omits the member, and the matching supports* predicate is how the engine tells "cannot" from "did not say". Omission is the honest answer; an emulated member is not.

One trap the declarations invite, found from the consumer seat in cycle 9: Renderer extends Disposable, and Disposable is fourJS's own interface, not TC39's. The member is the plain dispose(). Implementing [Symbol.dispose]() instead satisfies the TypeScript global and nothing in this repository ever calls it.

A backend, end to end

buildRenderList flattens a subtree into pooled, compact items (§64), so a backend never walks the scene graph itself. The items are rewritten in place by the next build — read them inside the call, retain nothing.

import { EventEmitter } from "fourJS/core";
import {
  buildRenderList,
  createRenderStatistics,
  type RenderItem,
  type Renderer,
  type RendererCapabilities,
  type RendererEventMap,
  type RenderStatistics,
} from "fourJS/render";
import type { Node, Viewport } from "fourJS/scene";

class Canvas2dRenderer implements Renderer {
  readonly capabilities: RendererCapabilities = {
    backend: "canvas2d",
    maxTextureSize: 4096,
    // Everything unanswered stays absent: `undefined` means "not taught to
    // answer", which §62 refuses to read as a yes.
  };
  readonly events = new EventEmitter<RendererEventMap>();
  statistics: RenderStatistics | null = createRenderStatistics();

  readonly #surface: HTMLCanvasElement;
  #context: CanvasRenderingContext2D | null = null;
  readonly #items: RenderItem[] = [];

  constructor(surface: HTMLCanvasElement) {
    this.#surface = surface;
  }

  async initialize(): Promise<void> {
    const context = this.#surface.getContext("2d");
    if (context === null) throw new Error("no 2D context on this surface");
    this.#context = context;
  }

  render(root: Node, views: readonly Viewport[]): void {
    const context = this.#context;
    if (context === null) return;
    const items = buildRenderList(root, this.#items);
    for (const view of views) {
      // Viewport rectangles are bottom-left-origin, +Y up (§7a, §48); a
      // top-left-based API flips on the way in and never leaks the flip.
      for (const item of items) {
        drawOneItem(context, view, item); // your projection + fill
        if (this.statistics !== null) this.statistics.drawCalls += 1;
      }
    }
  }

  resize(width: number, height: number, resolution = 1): void {
    this.#surface.width = Math.round(width * resolution);
    this.#surface.height = Math.round(height * resolution);
  }

  dispose(): void {
    this.#context = null;
    this.events.removeAllListeners();
  }
}

statistics is the one optional member worth declaring first: it costs an integer increment beside each draw, and §84's overlay reports "not measured" rather than a confident zero when it is absent. Two rules bind it — accumulate, never clear (the frame's owner resets it), and count what was submitted, not what the list contained.

Registering it, and the ladder it joins

import { registerRenderer, resolveRenderer } from "fourJS/render";

const surface = document.createElement("canvas");

registerRenderer({
  backend: "canvas2d",
  // Cheap and side-effect-free. Never acquire a context here: a canvas hands
  // out one context per type, so a probe would fix the attributes of the
  // context the backend later acquires.
  isSupported: () => typeof document !== "undefined",
  create: () => new Canvas2dRenderer(surface),
});

const renderer = await resolveRenderer("auto");

resolveRenderer("auto") walks AUTO_RENDERER_ORDER — webgpu, webgl2, canvas2d, svg — and cycle 9 measured that it selects a consumer-registered backend exactly as it would a first-party one. §62's ladder accepts a third-party rung. The two lower rungs are simply empty until an application fills them or the stub packages ship.

create must not initialize: the registry calls initialize() itself, because §62's fallback is defined in terms of initialization failing.

The swap costs three consumer lines, not two

Cycle 3c measured a WebGL → WebGPU swap at two lines — the import and the constructor. Cycle 9 measured Canvas 2D → SVG at three, and the third is the one worth knowing about:

-import { Canvas2dRenderer } from "./canvas2d-renderer.js";
-const surface: HTMLCanvasElement = document.createElement("canvas");
-const renderer = new Canvas2dRenderer(surface);
+import { SvgRenderer } from "./svg-renderer.js";
+const surface: SVGSVGElement =
+  document.createElementNS("http://www.w3.org/2000/svg", "svg");
+const renderer = new SvgRenderer(surface);

Renderer has no surface member — each backend takes its own surface in its own constructor — so Canvas 2D and SVG do not share HTMLCanvasElement. The seam holds above the surface; the surface handle is the one thing that leaks into consumer code, and it does so by design rather than by omission. WebGL and WebGPU both take a canvas, which is why cycle 3c saw two lines and not three.

What cycle 9 measured

All from a consumer seat, on the same scene, in headless Chromium:

Rules a new backend must keep

Honest state

Cross-references