Skip to main content

Performance

Rendering performance comes from three levers: the right backend, aggressive culling, and moving work off the main thread. The editor auto-tunes all three via capability detection, but you can drive each one directly.

Pick a backendโ€‹

detectCapabilities chooses between "webgl2", "offscreen", and "canvas2d" by probing the runtime (WebGPU currently maps to webgl2; OffscreenCanvas + Worker enables offscreen). Let it pick, or force a backend via the capabilities prop for benchmarking. Low-level probes (isWebGL2Available, pickAvailableBackend) live in @oh-just-another/renderer-canvas.

import { Editor, detectCapabilities } from "@oh-just-another/editor";

const profile = await detectCapabilities();
console.log(profile.renderer); // "webgl2" | "offscreen" | "canvas2d"

// Force a backend:
<Editor capabilities={{ renderer: "canvas2d" }} />;

Cull and cacheโ€‹

Pass a viewport, a shared boundsCache, and (for big scenes) a spatialIndex to renderScene so frame cost tracks on-screen content rather than scene size. See the culling page for the full set of options.

import { renderScene } from "@oh-just-another/renderer-core";

renderScene(scene, target, { viewport, boundsCache, spatialIndex });

Off-thread renderingโ€‹

Hosts that wire up WorkerPool + transferCanvasToWorker can render layers in OffscreenCanvas workers, and renderViaTiles pre-rasterizes tiles for very large scenes. WORKER_AUTO_THRESHOLD (5,000 elements) is where off-thread work starts to beat the postMessage overhead; LARGE_SCENE_WORKER_THRESHOLD (50,000) marks where a full main-thread render starts dropping frames even with culling.

import { WORKER_AUTO_THRESHOLD } from "@oh-just-another/renderer-core";
import { WorkerPool, LARGE_SCENE_WORKER_THRESHOLD } from "@oh-just-another/renderer-canvas";

if (scene.elements.size > WORKER_AUTO_THRESHOLD) {
// worker-backed rendering becomes worthwhile
}

Level of detailโ€‹

LodOptions picks cheaper paths per element, from its size on screen โ€” not from the zoom level โ€” so a huge shape or a giant heading stays fully drawn at 1 % while small ones degrade first. A shape whose longer side is below placeholderMaxScreenPx on screen becomes a flat AABB fill; text (standalone and embedded labels) whose font size on screen is below minTextScreenPx is skipped. Pass it via RenderSceneOptions.lod; the defaults are exported as DEFAULT_LOD (placeholderMaxScreenPx: 8, minTextScreenPx: 6).

import { renderScene, type LodOptions } from "@oh-just-another/renderer-core";

const lod: LodOptions = { placeholderMaxScreenPx: 12, minTextScreenPx: 5 };
renderScene(scene, target, { viewport, lod });

Inside an Editor the main pass reads editor.renderLod (defaults to DEFAULT_LOD); editor.setRenderLod(lod) repaints everything with the new thresholds โ€” a full repaint, so do not toggle it per frame.

Frame costโ€‹

editor.frameStats reports the last painted frame: lastMs, an EMA emaMs (main + overlay pass), the achieved frame gap gapMs (wall-clock between paints โ€” 1000 / gapMs is the fps the user sees, including everything outside the pass), the display interval intervalMs (the panel's refresh rate, measured by a probe of empty requestAnimationFrame callbacks while the editor is idle), and the frame counter. The frame event fires after every paint with the same object โ€” subscribe from diagnostics, not from React state that re-renders chrome.

editor.on("frame", ({ emaMs, intervalMs }) => {
if (emaMs > intervalMs) console.warn("dropping frames");
});