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");
});