@zakkster/lite-gl
v1.4.0
Published
Signal-native instanced primitive renderer: project ~1M points/quads/lines from reactive inputs, upload only what changed, draw in one instanced call. Deep-zoom precision via a GPU camera with double-emulated world coordinates. Zero-GC core + a browser We
Maintainers
Readme
@zakkster/lite-gl
A signal-native instanced primitive renderer — built for reactive charts at ~1M points, not as a Pixi competitor.
The moat here isn't the gl.draw call; Pixi and Three have that. It's the
zero-GC, dirty-batched instance engine that feeds the GPU from your reactive
graph: a packed buffer that re-projects a million points without allocating,
uploads only what changed, and redraws in one draw call — driven by a
camera or data signal.
npm install @zakkster/lite-glPeers:
@zakkster/lite-signal(the reactive driver) and@zakkster/lite-raf(the loop). ESM only. MIT.
Tested core + swappable GPU backend
lite-gl is split so the valuable, novel half is verifiable:
@zakkster/lite-gl(the core) is renderer-agnostic and fully tested, including the 1M-point zero-GC claim. It manages the packed buffer, the dirty range, and the reactive projection.@zakkster/lite-gl/backendis the WebGL2 sink — browser-only. Both thePOINTandQUADsinks have their GL call sequence unit-tested headlessly against a mock WebGL2 context (compile/link, the one-time VBO sizing, the instance attributes +vertexAttribDivisor, the dirty-windowbufferSubData, and thePOINTS/ instancedTRIANGLE_STRIPdraw); only the real-GPU rendering needs a browser, since CI has no GPU. Swap it for a WebGPU or canvas sink without touching the core.
The honest line: the core and the backend's GL wiring are both tested; the pixels on a real GPU are yours to confirm in a browser.
Shape of it
import { signal } from "@zakkster/lite-signal";
import { startFrames } from "@zakkster/lite-raf";
import { createField, reactiveField, LAYOUT } from "@zakkster/lite-gl";
import { createPointSink } from "@zakkster/lite-gl/backend"; // browser
const gl = canvas.getContext("webgl2");
const sink = createPointSink(gl, { capacity: 1_000_000 });
sink.resize(canvas.width, canvas.height);
// 1M data points (your columns)
const N = 1_000_000;
const xs = new Float64Array(N), ys = new Float64Array(N); // ...filled from your data
const camera = signal({ x: 0, y: 0, scale: 1 }); // pan/zoom as a signal
const field = createField({ capacity: N, stride: LAYOUT.POINT });
field.setCount(N);
// project world -> screen pixels into the packed buffer; re-runs when `camera` changes
const project = (f) => {
const cam = camera(); // tracked
const data = f.data, s = cam.scale;
for (let i = 0; i < N; i++) {
const b = i * LAYOUT.POINT;
data[b] = (xs[i] - cam.x) * s; // x px
data[b + 1] = (ys[i] - cam.y) * s; // y px
data[b + 2] = 2; // size
data[b + 3] = 0.3; data[b + 4] = 0.8; data[b + 5] = 1; data[b + 6] = 1; // rgba
}
f.touchAll();
};
reactiveField(field, { project, sink }); // auto-drives on lite-raf
camera.set({ x: 120, y: 0, scale: 2 }); // pan/zoom -> re-project -> one upload + one drawMove the camera signal and the whole field re-projects and redraws — one
bufferSubData, one drawArrays. Nothing allocates.
How it flows
flowchart LR
D["data / camera<br/>(signals)"] -->|tracked| P["project(field)"]
P --> F["instance field<br/>(packed Float32Array,<br/>dirty range)"]
R["lite-raf"] -->|each frame| FL{"dirty?"}
F --> FL
FL -->|yes| U["sink.upload(dirty range)<br/>sink.draw(count)"]
FL -->|no| SKIP["skip"]
U --> GPU["WebGL2: bufferSubData<br/>+ drawArrays (1 call)"]project re-runs when a tracked signal changes (marking the field dirty); the raf
tick flushes the dirty range once per frame and only redraws when something
changed.
Why instanced + dirty-range matters
- One draw call for the whole set (
drawArrays(POINTS, 0, count)for points;drawArraysInstancedfor quads/lines), instead of per-object draws. - Upload only the dirty window. Edit 200 of a million points and only those
floats hit
bufferSubData— the other ~8M are untouched. - The backing buffer is never reallocated while you stay within capacity, so a per-frame re-projection allocates nothing. Verified: 60 frames of full 1M-point re-projection, buffer reference unchanged, engine counters flat.
What kills WebGL apps is per-frame buffer/GC churn; the discipline that avoids it is the whole point of this package.
Composition
- Camera: project from a
lite-camera-maxview, so pan/zoom is a signal in the reactive graph and the shader only maps pixels to clip space. - Charts: this is the renderer
lite-chartsreaches for past the Canvas2D ceiling (tens of thousands) into the hundreds of thousands / millions.
Honest scope (the non-claims)
- The core (buffer, dirty batching, reactive projection, 1M zero-GC) is tested. The rendering (shader compile, instanced draw, 60fps at 1M) is browser- validated by you — it cannot run headlessly, so the package makes no automated claim about it.
- v1.2 ships all three pipelines — POINT, QUAD, and LINE. Scatter / particles;
bars, sized/rotated markers, and heatmap cells; and now thick polylines for line
charts. All three drive the same zero-GC core via a static instanced base geometry
vertexAttribDivisor.LINEuses butt caps only in 1.2 (ends cut perpendicular; a polyline is N−1 independent segments); round joins are a 1.3 follow-up if seams show at chart widths.
- v1.3 adds multi-field scenes and picking. Sinks of the same primitive type share
one linked program per GL context (refcounted, so disposing one sink can't pull the
program out from under another);
setScissorclips a field to a pane;pick(x, y)runs one offscreen ID pass and reads back a single pixel. Honest limits: picking costs one extra draw per call, so throttle it topointermoverather than the frame loop; overlapping instances resolve as last-drawn-wins (painter's order); andpickis per-sink — for cross-field picking, call it on each field in z-order. - Re-projection is O(N): a camera/data change re-projects all instances. That's cheap on CPU (a million simple writes is sub-millisecond); the pipeline stays allocation-free, which is what actually protects the frame budget.
- Not a scene graph, sprite batcher, text renderer, or filter stack. If you want Pixi, use Pixi.
API
// core (@zakkster/lite-gl)
createField({ capacity?, stride }): Field
reactiveField(field, { project, sink, manual? }): { frame, flush, stop, dispose }
createDriver(registry): { reactiveField }
LAYOUT = { POINT: 8, QUAD: 9, LINE: 9 }
interface Field {
data: Float32Array; count; capacity
push(write): number; set(i, write); swapRemove(i); setCount(n)
touch(i); touchAll(); clearDirty(); dirtyLo(); dirtyHi()
flush(sink); reset()
}
interface Sink {
upload(data, floatOffset, floatCount, instanceOffset, stride): void
draw(count): void
}
// backend (@zakkster/lite-gl/backend, browser-only)
createPointSink(gl: WebGL2RenderingContext, { capacity }): PointSink // LAYOUT.POINT -> GL_POINTS
createQuadSink(gl: WebGL2RenderingContext, { capacity }): QuadSink // LAYOUT.QUAD -> instanced TRIANGLE_STRIP
createLineSink(gl: WebGL2RenderingContext, { capacity }): LineSink // LAYOUT.LINE -> instanced TRIANGLE_STRIP (thick segments)
PICK_MAX_ID // 0xFFFFFE
// all three sinks expose the same surface:
// upload(data, floatOffset, floatCount, instanceOffset, stride)
// draw(count); resize(w, h); onContextRestored(cb); isContextLost(); capacity; dispose; gl
// setScissor(x, y, w, h); clearScissor() // v1.3 -- pane clipping, top-left origin
// pick(x, y, count?) -> instance index | -1 // v1.3 -- GPU ID pass, no CPU hit-testingQUAD instances are x, y, w, h, rot, r, g, b, a (LAYOUT.QUAD, stride 9); LINE
instances are x0, y0, x1, y1, width, r, g, b, a (LAYOUT.LINE, stride 9) — the
endpoints and width are in screen pixels, expanded to a thick quad in the vertex
shader. Everything is screen-space — do world→screen in project, same as points.
capacity for a line sink is the number of segments (a polyline of N points is
N−1 segments).
Multi-field scenes and picking (v1.3)
Many fields, one context, one program per primitive type:
const points = createPointSink(gl, { capacity: 1_000_000 }); // links the point program
const glow = createPointSink(gl, { capacity: 50_000 }); // reuses it (cached per context)
const lines = createLineSink(gl, { capacity: 300_000 });
// Two panes in one canvas, no extra framebuffers:
lines.setScissor(0, 0, 600, 800); // left
points.setScissor(620, 0, 600, 800); // right
// Hover at 1M points, without touching the CPU:
canvas.addEventListener("pointermove", (e) => {
const i = points.pick(e.offsetX * dpr, e.offsetY * dpr); // device px, top-left origin
if (i !== -1) showTooltip(myData[i]);
});pick renders one offscreen ID pass and reads back a single pixel, so the CPU never
hit-tests. It respects the sink's scissor (a hover can't hit a neighbouring pane) and
restores the framebuffer, clear colour and blend state it found. Call it on a throttled
pointermove — not every frame. IDs are 24-bit, so 0 … PICK_MAX_ID are pickable.
Deep zoom: world coordinates on the GPU (v1.4)
LAYOUT.POINT, QUAD and LINE hold screen pixels. That is the contract: project() runs in JS (float64) and writes post-camera coordinates, so the float32 field only ever sees small numbers. Relative-to-eye, by construction — which is why precision has never bitten you.
The cost shows up when the camera moves. A one-pixel pan re-projects every instance and dirties the whole field: ~5 ms of JS at 1M points, plus a 31 MB re-upload, every frame. Panning a large field is CPU-bound, and the GPU never even sees a camera.
LAYOUT.POINT_HI (v1.4) holds world coordinates, uploads once, and puts the camera in the vertex shader:
import { createField, reactiveField, LAYOUT, writePointHi, needsHiPrecision } from '@zakkster/lite-gl';
import { createPointHiSink } from '@zakkster/lite-gl/GLBackend.js';
const sink = createPointHiSink(gl, { capacity: 1_000_000 });
const field = createField({ capacity: 1_000_000, stride: LAYOUT.POINT_HI });
// World coordinates. Raw timestamps — no projection, no camera.
for (let i = 0; i < n; i++) {
field.push((d, base) => writePointHi(d, base, ts[i], value[i], 2, 0.3, 1, 0.6, 1));
}
field.flush(sink); // the one and only upload
// A pan is four floats. No CPU work, no re-upload, no dirty range.
onPan((t, v, pxPerMs, pxPerUnit) => sink.setCamera(t, v, pxPerMs, pxPerUnit));Why the coordinates are split. float32 carries a 24-bit significand — integers are exact only to 2²⁴ = 16,777,216. A Unix epoch millisecond is ~1.78 × 10¹², where the gap between representable values is 131,072 ms ≈ 2.2 minutes. Store a raw timestamp in a float32 and a full minute of one-second ticks collapses onto a single value. Epoch-seconds is no escape (ULP ≈ 128 s).
So POINT_HI splits each coordinate into a hi/lo float32 pair, splits the camera the same way, and the shader computes:
vec2 rel = (a_posHi - u_cameraHi) + (a_posLo - u_cameraLo);The high parts are float32s of near-identical magnitude, so that subtraction cancels exactly (Sterbenz), and the residuals carry the detail. Order matters: subtract first. Scale first and the precision is already gone.
The guarantee, stated honestly. Error scales with distance from the eye, not with absolute magnitude. Anything within a day of the camera round-trips bit-exactly from an epoch-ms timestamp. A point a year away is good to ~800 ms — which sounds like a caveat and is the entire point: you only see distant points when you are zoomed out, and the pixel grows at exactly the rate the error does. Worst-case error across a 2000 px viewport is ~10⁻⁴ px — at any zoom, at any magnitude. The naive path degrades with absolute magnitude, and nothing saves you from that.
Not sure whether you need it?
needsHiPrecision(Date.now(), 1000); // true — 1s ticks in epoch-ms
needsHiPrecision(1e6, 0.5); // false — ordinary chart coords, use LAYOUT.POINTOn cameras. setCamera() takes six plain numbers, and that is deliberate — lite-gl does not depend on a camera package, and should not. @zakkster/lite-camera is a game-follow camera for Canvas2D: deadzone, lookahead, trauma shake, clamped to a world rect, and it emits a ctx transform rather than numbers. It has no zoom at all, it pins x ≥ 0, and its apply() does ctx.translate(-(pos[0] | 0), …) — a ToInt32 truncation that wraps at epoch-ms magnitudes. Every one of those is correct for a platformer and wrong for a chart. Drive setCamera() from whatever owns your pan/zoom state; the numeric seam is the integration.
Use POINT for almost everything. Reach for POINT_HI when the world coordinates are large — timestamps, geodetic coords, deep-zoom fractals — or when you pan a large field often enough that CPU re-projection is the bottleneck. PointHiSink carries the full v1.3 surface: scissor, pick(), shared program cache, context-loss recovery. The ID pass projects through the same camera as the visible pass, so hover stays honest at any zoom.
License
MIT (c) 2026 Zahary Shinikchiev <[email protected]>
