A GPU-accelerated WebGL renderer for arbitrarily large n-dimensional scientific datasets stored in Zarr format. Delivers maximum visualization performance limited only by your graphics hardware, display resolution, and network bandwidthโnot by software constraints. Features advanced HDR rendering, real-time effects, and intuitive navigation controls.
โถ Try it in your browser โ 90 live demos as interactive scenes, no install. To open your own compiled archive, the viewer is deployed on its own at luxarviewer.dev?src=<url-to-your-scene>.
src/config/ with TypeScript types@luxar/viewer ships as a side-effect-free ES module. Importing the
package does not patch your console, inject CSS into your body, or
mutate :root โ the viewer only touches DOM you give it via the canvas
option, plus the UI overlays it mounts into the container you provide
(defaulting to document.body).
npm install @luxar/viewer three # three is a peer dep
import { LuxarApp } from '@luxar/viewer';
import '@luxar/viewer/styles.css'; // component styles, prefixed under .luxar-*
const canvas = document.querySelector<HTMLCanvasElement>('#viewer-canvas')!;
const app = new LuxarApp();
await app.init({
canvas,
src: 'https://example.com/data.zarr',
updateBrowserUrl: false, // default: don't rewrite host URL on dataset change
});
// Later (e.g. when the host route unmounts):
app.dispose(); // removes all listeners, GPU resources, UI
The styles.css bundle also ships a Tailwind-like set of atomic utility
classes for embedders to reuse โ flexbox, justify/gap, padding/margin,
text color/size/weight/align, surface, border, radius, shadow, backdrop
blur, transition, display, position, overflow, cursor, opacity, and
z-index groups. Every selector is namespaced under .luxar- (so it never
collides with host-page styles) and resolves to the viewer's --luxar-*
theme custom properties, so utilities pick up the active theme with no
hardcoded colors or spacing. See
src/styles/base/README.md for the full
group table.
ShaderSource and buildMaterial are exported from @luxar/viewer for
embedders that add their own materials to a Three.js scene. Supply GLSL3
strings for WebGLRenderer and a synchronous TSL NodeMaterial factory for
WebGPURenderer:
import { buildMaterial, type ShaderSource } from '@luxar/viewer';
import { NodeMaterial } from 'three/webgpu';
import { vec4 } from 'three/tsl';
const source: ShaderSource = {
name: 'my-overlay',
webgl: {
vertex:
'void main() { gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
fragment: 'out vec4 fragColor; void main() { fragColor = vec4(0.2, 0.8, 1.0, 1.0); }',
},
webgpu: () => {
const material = new NodeMaterial();
material.colorNode = vec4(0.2, 0.8, 1.0, 1.0);
return material;
},
};
const material = buildMaterial(
source,
{ depthWrite: false },
{
apiSurface: 'isWebGPURenderer' in renderer ? 'webgpu' : 'webgl2',
}
);
Use 'webgpu' for WebGPURenderer even when it runs on its internal WebGL2
backend (webgpuForceWebGL or no adapter): it still requires a NodeMaterial.
Passing 'webgl2' there creates a ShaderMaterial that it cannot draw.
buildMaterial passes the same config.uniforms table to either path and
applies the requested blending, depth, transparency, tone-mapping, and side
state to both. The TSL factory must use those uniform references when its
values need to change after construction. GLSL defines and
onBeforeCompile patches have no TSL equivalent; express those changes in
the webgpu factory. A factory that needs custom blend factors should set
them after buildMaterial returns, since its basic render-state config does
not include blend factors.
If the active renderer uses the WebGPU API surface and webgpu is absent,
buildMaterial throws an error naming the source. It does not change the
renderer for a single material: WebGPURenderer cannot draw a GLSL
ShaderMaterial, even on its internal WebGL2 backend. Unlike the
adapter-missing fallback in #3147,
which selects a renderer before materials are built, changing renderers for one
material would leave it in a scene using the other renderer. For a known
GLSL-only plugin, set LuxarAppOptions.renderer: 'webgl' in app mode, or
construct a WebGLRenderer as the host renderer in layer mode, before building
materials.
The viewer's own WebGPU code remains lazily loaded; a host that imports
three/webgpu for its factory controls when that dependency loads.
To change a built-in visual Points, Lines, GSplats, or Mesh TSL material (not a
material="physical" mesh), use setTSLNodeOverride on that material after it
is created. In layer mode, find the rendered leaf object in the host scene and
read its current object.material each time you set or clear an override.
Luxar creates a separate material per node, and a Layers appearance edit may
replace it with a clone. The callback receives the material's original output
nodes and returns only the outputs to replace:
import { setTSLNodeOverride } from '@luxar/viewer';
import { mul, vec4 } from 'three/tsl';
setTSLNodeOverride(material, ({ colorNode }) => ({
colorNode: mul(colorNode, vec4(1, 0.5, 1, 1)),
}));
The callback runs again when Luxar rebuilds the TSL graph (for example, after a
colormap or gamma variant change), and follows the material when it is cloned.
If it throws during a rebuild, that variant uses its base graph; the callback
stays registered and runs again on the next rebuild. Overrides affect only the
visual pass: vertexNode and depthNode changes are not mirrored into picking,
so displaced geometry is still picked at its built-in position.
It must return new nodes without mutating the original nodes, which are shared
by materials of the same configuration. Pass null to restore the built-in
graph. This hook applies to Luxar's built-in visual TSL materials; a
custom NodeMaterial uses its own node properties. On WebGL, use the material's
GLSL onBeforeCompile hook. Keep the two backend implementations equivalent
when an extension is intended to work in both renderers.
LuxarAppOptions| Option | Type | Default | Notes |
|---|---|---|---|
canvas |
HTMLCanvasElement |
โ | The canvas the viewer renders into. Required. |
container |
HTMLElement |
document.body |
Host element the viewer mounts all overlays/panels/toasts/dialogs into. A non-body container is promoted to a containing block (contain: layout) so fixed overlays scope to it; restored on dispose(). |
src |
string |
config | Initial Zarr URL. Empty/missing shows the dataset browser. |
debug |
boolean |
false |
Exposes window.__luxarDebug for Playwright / dev console. |
loaderConfig |
LoaderConfig |
โ | Cache and prefetch flags (noCache, cacheDebug, clearCache, noPrefetch, prefetchDebug). |
gpuPoolMaxBytes |
number | null |
config | Session-wide GPU geometry budget in bytes. null auto-sizes from device memory, measured heap, and device class; 0 disables byte-budget eviction; a positive value pins it. |
updateBrowserUrl |
boolean |
false |
Opt in to mirroring picked datasets into the browser URL. bootstrapStandalone() sets this to true. |
wasmPath |
string |
โ | Override for bundlers that don't resolve import.meta.url for WASM (webpack 4, Parcel 1, etc.). Serving the published package unbundled usually costs one benign 404 before the next candidate wins. |
workerPath |
string |
โ | Same, for the data worker. |
renderer |
'webgl' | 'webgpu' |
'webgl' |
Force the rendering backend. 'webgpu' uses WebGPURenderer + TSL NodeMaterial, falling back to WebGL2 when no adapter. |
webgpuForceWebGL |
boolean |
false |
Diagnostic: with renderer: 'webgpu', route through Three.js's internal WebGL2 backend while keeping the WebGPU/TSL API surface. |
perfTimestamp |
boolean |
false |
Opt in to WebGPU timestamp-query GPU profiling. Tiny runtime cost; ignored under WebGL. |
hdr |
boolean |
true |
PROTOTYPE, WebGPU only: extended-range HDR canvas output, used when the WebGPU renderer runs natively on a display reporting (dynamic-range: high); otherwise (WebGL, forceWebGL, SDR display, no extended tone-mapping support) an SDR canvas. false = always SDR. |
hdrHeadroom |
number |
per display | Pin the HDR headroom (multiple of SDR white, clamped to 1-16) for the session. Omitted, it is resolved per display. Only read when hdr is on. |
renderAlways |
boolean |
false |
Render every animation-loop tick instead of only when something changed (config.animation.renderOnChange). Kill switch; the pixels are the same either way. |
renderAudit |
boolean |
false |
Debug only (needs debug): render every tick and count ticks the render-on-change scheduler would have skipped whose pixels changed, in the perf counter render.missedDirty. |
openCacheStats |
boolean |
false |
Open the data-loading monitor (Cache tab, expanded) once the scene is wired up โ useful for profiling cache behaviour. |
pinnedDPR |
number |
โ | Pin DPR to [0.25, native] and disable adaptive DPR; intended for deterministic tests, captures, and bug reproduction. |
lodFade |
boolean |
true |
Cross-fade adjacent replacement LOD levels instead of swapping abruptly. |
lodEnergyComp |
boolean |
true |
Compensate incomplete stream ladders by their committed energy fraction to reduce brightness popping. |
lodFinest |
boolean |
false |
Force the finest replacement LOD regardless of projected coverage; useful for high-quality still or video capture. |
lodBias |
number |
1 |
Bias replacement LOD selection in screen-area units; 2 selects one occupancy-halved level finer and 4 selects two. Below 1, a partition-anchored ladder cannot reach its finest level; below 0.5, neither can a whole-object ladder. |
depthSort |
boolean |
true |
Enable worker-based back-to-front sorting for order-dependent geometry; disable for deterministic comparisons. |
blendWarmup |
boolean |
device | Blend-program warm-up, WebGL programs and WebGPU pipelines (one variant compile per macrotask). On for laptops/desktops, off on phones/tablets; false opts out (?noBlendWarmup). |
densityGuard |
boolean |
true |
Projected-density guard: thin blendable nodes packing more elements per pixel than config.densityGuard allows (brightness compensated) and cap refinement rungs (?noDensityGuard). |
densityCap |
number |
config | Session-only override of the density guard's blendable cap, in elements per drawing-buffer pixel (?densityCap=8). |
bakeEnvironment |
object |
โ | { probe?, resolution? }: bake the scene-derived environment once the load settles, for luxar env bake (?bakeEnv&probe=โฆ&envResolution=โฆ). Omit for normal viewing. |
control |
string | null |
โ | Attach to a remote-control hub at this WebSocket URL (?control). The embedder is responsible for the URL; see docs/guides/specs/REMOTE_CONTROL_SPEC.md. |
controlToken |
string | null |
โ | Shared secret presented to the hub (luxar serve --control-token). |
kiosk |
boolean |
false |
Lock the display down for unattended use (?kiosk); a hard override over the scene's authored ui.kiosk block โ it can lock, never unlock. |
allowLinks |
boolean |
true |
Allow element-authored links to navigate. Set false to keep element-click / element-contextmenu events and copy actions while suppressing navigation and link menu items. |
factories |
AppFactories |
โ | Construction overrides for the heavy components built by init() (scene manager, recording panel, โฆ). For tests and advanced embedders; omit for the production path. |
Beyond init()/dispose(), LuxarApp exposes flat methods so a host page can
drive the viewer without the built-in UI. All throw if called before init(),
except on(), onDispose(), awaitDimensionUpdate(), shortcutForAction() and
getDatasetFault(): the first three only register or wait, shortcutForAction()
returns undefined until input is available, and getDatasetFault() returns
null until a dataset is loaded.
// Dataset
await app.switchDataset('https://example.com/other.zarr'); // reload in place
const fault = app.getDatasetFault(); // terminal post-load fault, or null
// nD dimensions
const dims = app.getDimensions(); // { ndim, displayed, currentStep, metadata, ranges } (cloned)
app.setDimensionValue(/* index */ 3, /* value */ 12);
await app.awaitDimensionUpdate(); // resolve once the slice data has loaded
// Camera
app.recenterCamera(); // fit/recenter on the scene (the 'F' key)
const pose = app.getCameraPose();
app.setCameraPose(pose); // e.g. restore a saved view
// Viewport โ auto-resizes to the canvas via a ResizeObserver; call manually
// after a synchronous layout change you know the observer won't catch in time.
app.resize();
// Screenshot (async โ WebGPU readback is async)
const blob = await app.screenshot({ format: 'png' }); // 'png' | 'webp' | 'jpeg'
// Share-a-view snapshot (camera placement + slice position, JSON-serializable)
const view = app.captureSnapshot();
app.restoreSnapshot(view); // { cameraApplied, dimsApplied }
// Remote control: flight, rendering, layers, sound, one-call state
const { completed } = await app.flyTo(pose, { durationMs: 2000 });
app.setRenderingSettings({ exposure: 0.5 }); // getRenderingSettings() reads them
app.setLayer(app.getLayers()[0].path, { opacity: 0.3 });
const state = app.getViewerState(); // { src, title, camera, dimensions, rendering, layers, audio, controlPanel }
app.setAudio({ muted: true }); // getAudioState(), playSound(name), stopSound(name)
// Lifetime-scoped cleanup, run by dispose()
app.onDispose(() => myOverlay.remove());
// Keyboard input
app.registerContext('annotation', {
priority: 100,
passthrough: true,
fallbackContexts: ['navigation'],
allowRegisteredBindings: true,
});
app.registerBinding('annotation', {
actionId: 'annotation.accept',
key: 'x',
handler: acceptAnnotation,
description: 'Accept annotation',
help: { section: 'panels', group: 'Annotation', order: 200 },
});
app.pushContext('annotation');
app.popContext();
app.unregisterBinding('annotation', 'x');
app.unregisterContext('annotation');
app.setInputEnabled(false);
const helpKey = app.shortcutForAction('help.toggle');
See src/core/app/embedder/README.md for each
method's contract (the remote-control table) and the full event table.
Events โ subscribe with on(event, listener), which returns an unsubscribe:
const off = app.on('dataset-loaded', ({ src }) => console.log('loaded', src));
app.on('dataset-error', ({ src, error }) => console.error(src, error));
app.on('dataset-fault', ({ src, error }) => console.error(src, error));
app.on('dimensions-changed', (dims) => updateMyUI(dims));
app.on('selection', (sel) => console.log(sel)); // { nodeName, elementIndex, hitNodeName } | null
app.on('element-click', (event) => console.log(event));
app.on('element-contextmenu', (event) => console.log(event));
app.on('camera-changed', (pose) => mirror(pose)); // frame rate while moving; throttle if relaying
app.on('sound-started', ({ name }) => console.log('playing', name)); // and 'sound-ended'
app.on('waypoint-arrived', ({ index, completed }) => console.log(index, completed)); // and 'waypoint-departed'
app.on('webgpu-device-unrecoverable', () => location.reload()); // after a WebGPU device loss the viewer recovers on its own when it can
// off();
element-click and element-contextmenu fire after a no-drag left- or
right-click and include the resolved element, gesture, and link. Subscribe
before init() / switchDataset() so label-less scenes provision picking;
pass allowLinks: false to observe or replace navigation without allowing it.
Note on
selection: fires with the element under the cursor (ornullwhen the hover clears) on any dataset. WhatelementIndexcounts is per-node โ seeSelectionPayloadfor the exact contract: a labelled node reports an on-disk index (for a labelled lines node, the picked segment's start-vertex row), while an unlabelled one reports the visible-buffer slot. Subscribe before the dataset loads (i.e. beforeinit()/switchDataset()) โ the GPU picking pipeline is provisioned at load time only when a listener exists, so picking stays zero-cost for pages that never consume it. Hover-driven; useelement-clickfor click gestures.nodeNameis the user-facing layer (the outermostkind=partitionwrapper when there is one), whileelementIndexis local to the leaf actually hit โ index it againsthitNodeName, which equalsnodeNamewhen the node is not partitioned.
LuxarApp embeds the viewer. LuxarLayer is for the other case: the host
already has a Three.js scene and wants Luxar's data as one more thing in it,
sharing a single WebGL context, camera, and set of controls.
import { LuxarLayer } from '@luxar/viewer';
const layer = new LuxarLayer({
renderer, // host-owned
getCamera: () => camera, // live getter
getViewportSize: () => renderer.getSize(new THREE.Vector2()),
scene, // host-owned
});
await layer.load('https://example.com/imaging.luxar.zarr');
function animate() {
requestAnimationFrame(animate);
layer.update(); // BEFORE the host renders
renderer.render(scene, camera);
}
await layer.dispose();
The layer owns no renderer, camera, controls, post-processing, or UI โ it
contributes a THREE.Group plus the per-frame LOD and depth-sort bookkeeping.
The host must call update() each frame before rendering, resize() after a
viewport or camera-projection change, and pass requestRender if it renders
on demand rather than continuously. renderOrder defaults to 10 and is stamped
onto every nested Luxar Group; host transparent groups should use explicit
lower/higher values. gpuPoolMaxBytes controls the session-wide geometry budget:
null auto-sizes from device memory, measured heap, and device class, 0
disables byte-budget eviction, and a positive value pins bytes. On WebGL context
loss, call handleContextLost() so Luxar backs off that budget; after rebuilding
the host renderer and post-processing, call handleContextRestored().
nD navigation coalesces, so a host can drive it from a slider at frame rate:
const t = layer.findDimension('time');
if (t !== null) {
layer.prefetchDimensionValue(t, frame + 1); // warm the next slice
void layer.setDimensionValue(t, frame); // don't await during playback
}
alignTo(matrix) places the data in the host's world space (for a host that
normalizes its own coordinates); it may be called before or after load().
Read getDimensionNames() rather than assuming a centre-column order โ producers
disagree, and guessing renders a silently transposed scene. setVisible() hides
without discarding caches or in-flight fetches; lazy LOD loads resume on the next
update after re-showing, while hidden resident levels are preferred for eviction
under GPU-budget pressure. setExposure() scales exposure relative to the scene's
authored value, which a host needs because that value was tuned against a different
post chain than its own. On a Mesh in the default opaque mode, this scales cutout
coverage rather than brightness: values below alphaCutoff discard the surface,
while normal blending provides smooth transparency.
Note that a scene's tone_mapping does not apply in layer mode: Luxar
tone-maps in a post-processing pass the layer does not own, so a host wanting a
filmic rolloff over additive geometry must set renderer.toneMapping itself.
One LuxarApp and any number of LuxarLayers may share a page (each layer owns
its loaders, dimension, material and depth-sort state; the worker pools are
shared and lease-counted); two LuxarApps may not. See
docs/specs/LUXAR_LAYER_SPEC.md for the normative public
contract and src/core/layer/README.md for implementation rationale.
LuxarApps on the same page. The app owns page-global UI (ThemeManager, document.title, __luxarDebug, DOM panels, the keyboard), so a second instance would share it. One app plus any number of LuxarLayers is supported (see Layer mode above)..luxar-* classnames, but a host page that already styles .luxar-foo will collide.LuxarApp.init() throws a friendly error if window/document are unavailable.A runnable example with a non-trivial host page lives in
examples/embed/. The host-owned renderer counterpart lives in
examples/layer/.
^20.19.0 || >=22.12.0 (engines.node)Browser support: any browser with WebGL 2.0. See Browser Compatibility for what has actually been tested โ Chromium, Firefox and WebKit all pass the E2E smoke subset, and WebKit runs without the on-disk chunk cache.
# Clone the repository
git clone <repository-url>
cd luxar/packages/luxar-viewer
# Install dependencies
pnpm install
# Start development server
pnpm dev
The viewer will be available at http://localhost:5173
# View a specific Zarr dataset
http://localhost:5173/?src=/path/to/your/dataset.zarr
# View demo dataset (if available)
http://localhost:5173
Luxar Viewer supports three navigation modes:
| Key | Action |
|---|---|
| V | Cycle control modes: Orbit -> Fly -> Ortho -> Orbit |
| I | Toggle inertial mode (Fly mode only) |
| F | Recenter camera on scene |
| C | Toggle cinematic mode |
| Input | Action |
|---|---|
| Left Mouse Drag | Pan camera |
| Right Click + Drag | Rotate camera around scene |
| Mouse Wheel | Zoom in/out |
| Shift + Mouse Wheel | Roll (view-axis rotation) |
| Input | Action |
|---|---|
| W/S | Move forward/backward |
| A/D | Strafe left/right |
| Alt+W / Alt+S | Move up/down |
| Arrow Keys | Look up/down/left/right |
| Left Mouse Drag | Strafe (screen-space translation) |
| Right Mouse Drag | Free look (rotate camera) |
| Mouse Wheel | Forward/backward velocity impulse |
| Shift + Mouse Wheel | Roll (view-axis rotation) |
| I | Toggle inertial physics (drift/momentum) |
| Input | Action |
|---|---|
| Left Mouse Drag | Pan (Napari/Google Maps convention) |
| Mouse Wheel | Zoom in/out |
| Shift + Mouse Wheel | Roll (view-axis rotation) |
| Input | Action |
|---|---|
| Space | Toggle fullscreen mode |
| H | Show/hide help overlay |
| R | Toggle advanced rendering controls panel |
| P | Toggle performance statistics |
| N | Toggle nD dimension panel |
| O | Toggle dataset browser |
| Ctrl+L | Toggle debug console |
| Esc | Exit fullscreen / Close panels |
| Input | Action |
|---|---|
| Number keys (1-9) | Select which non-displayed dimension to navigate |
[ and ] |
Step backward/forward in the selected dimension |
| Dimension Sliders | Click and drag to navigate through dimensions |
Luxar Viewer expects Zarr datasets with the following structure:
dataset.zarr/
โโโ .zmetadata # Consolidated metadata (optional)
โโโ .zattrs # Scene attributes including dimensions
โโโ positions/ # nD coordinates (Float32, shape: [N, D])
โ โโโ .zarray
โ โโโ [chunks...]
โโโ colors/ # RGB colors (Uint8, shape: [N, 3]) - optional
โ โโโ .zarray
โ โโโ [chunks...]
โโโ radii/ # Point radii (Float32, shape: [N]) - optional
โ โโโ .zarray
โ โโโ [chunks...]
โโโ sharpness/ # Edge falloff (Float32, shape: [N]) - optional
โโโ .zarray
โโโ [chunks...]
{
"type": "points",
"transform": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], // 4x4 transform matrix (optional)
"sceneDimensions": {
// Required for nD data
"dimensions": [
{ "name": "x", "unit": "ฮผm", "range": [-100, 100], "display": true },
{ "name": "y", "unit": "ฮผm", "range": [-100, 100], "display": true },
{ "name": "z", "unit": "ฮผm", "range": [-50, 50], "display": true },
{ "name": "time", "unit": "s", "range": [0, 10], "display": false, "step": 0.1 }
]
}
}
[N, D] where D matches dimension count[N, 3] (RGB values 0-255)[N] (per-point radius)[N] (edge falloff, normalized 0-1; 0.5 = Gaussian)Luxar Viewer supports visualization of n-dimensional data beyond traditional 3D:
Every Zarr dataset defines its dimensions at the scene level:
Points in nD space are treated as hyperspheres. When viewing a 3D slice:
spatial=True (non-displayed dimensions default to non-spatial)r_eff = sqrt(rยฒ - dยฒ) where d is distance from slice// Dataset with x, y, z (displayed) and time, channel (non-displayed)
// Press '1' to select time dimension
// Use '[' and ']' to step through time
// Press '2' to select channel dimension
// Sliders update automatically
The advanced rendering controls panel (located on the left side) provides real-time adjustment of:
The viewer source tree is organized into 17 subpackages, each with its own
README.md documenting its files, public surface, invariants, and
dependencies in detail. The top-level shape:
src/
โโโ index.ts # Public-API barrel (side-effect-free)
โโโ lib-styles-entry.ts # CSS-only build entry
โ
โโโ core/ # Application bootstrap, lifecycle, debug interface
โโโ config/ # Unified configuration system (sections/ + zarr-bridge/)
โโโ cache/ # 3-tier cache: L0 decompressed, L1 memory, L2 OPFS
โโโ data/ # Zarr loading, nD slicing, per-geometry loaders
โโโ rendering/ # GLSL/TSL materials, post-processing, picking, GPU buffer pool
โโโ scene/ # SceneManager + animation + scene-manager helpers
โโโ controls/ # Orbit / Fly / Ortho camera controls
โโโ input/ # Keyboard / mouse handlers, context routing
โโโ ui/ # GUI library, panels, monitors, recording panel
โโโ styles/ # CSS (base, components, themes, embed vs standalone)
โโโ themes/ # Theme manager + dark/light/glass theme definitions
โโโ types/ # Type definitions and ambient declarations
โโโ utils/ # Cross-cutting utilities (log, event bus, HDR, platform)
โโโ wasm/ # Rust kernels + TypeScript fallback (parity-tested)
โโโ workers/ # Worker pool, data worker, validation
โโโ profiling/ # UpdateProfiler for hierarchical timing
โโโ tests/ # Unit (vitest), e2e harnesses, mocks, builders, benchmarks
For per-subpackage details โ file tables, public exports, invariants โ read
the README.md inside the subfolder. The major subpackages also document
their own subpackages (e.g. rendering/README.md links to materials/,
picking/, post-processing/, material-manager/, node-factory/,
gpu-buffer-pool/).
See also: src/README.md for the navigational hub and
the enforced layer order, CONVENTIONS.md for project-
wide conventions, and ARCHITECTURE-DIAGRAMS.md
for high-level diagrams.
# Development
pnpm dev # Start development server with hot reload
pnpm build # Build for production (includes WASM build)
pnpm preview # Preview production build
# Code Quality
pnpm lint # Run ESLint
pnpm typecheck # Run TypeScript type checking
pnpm format # Format code with Prettier
pnpm check # Run all quality checks (typecheck + lint + test)
# Unit Testing
pnpm test # Run unit tests with Vitest
pnpm test:coverage # Run tests with coverage report
pnpm test:ui # Run tests with interactive UI
pnpm test:watch # Run tests in watch mode
pnpm test:with-fixtures # Generate test fixtures, then run tests
# E2E Testing (Playwright)
# Prerequisite: examples + fixtures must exist. Run once locally:
# make run-examples
# pnpm test:generate-fixtures
# The Make targets refresh their required examples and generated fixtures.
# GitHub CI runs the Chromium mobile/touch suite; the full, smoke, cross-browser,
# and visual suites remain local entry points. Visual snapshots are Linux-only
# developer aids and are not validated by green CI.
pnpm test:e2e # Run all E2E tests
pnpm test:e2e:smoke # Run the non-GPU smoke subset
pnpm test:e2e:smoke:strict # Run smoke with strict console handling
pnpm test:e2e:mobile # Run the Chromium mobile/touch suite used by CI
pnpm test:e2e:browsers # Run the Firefox/WebKit cross-browser subset
pnpm test:e2e:visual # Run visual tests (snapshot checks on Linux)
pnpm test:e2e:visual:update # Refresh Linux visual baselines
pnpm test:e2e:ui # Run E2E tests with interactive UI
pnpm test:e2e:debug # Run E2E tests in debug mode
pnpm test:e2e:report # Show E2E test report
# WASM
pnpm build:wasm # Build Rust WASM module
pnpm build:wasm:dev # Build WASM in development mode
pnpm test:wasm # Run Rust unit tests (cargo test)
pnpm bench:wasm # Run WASM vs TypeScript benchmarks
# Fixtures & Media
pnpm test:generate-fixtures # Generate test fixtures from Python
pnpm readme-images # Generate README screenshot images
# AI Debugging
pnpm agent:debug # Run Playwright agent driver (headless)
pnpm agent:debug:visible # Run agent driver with visible browser
The native launcher binaries (produced by make build-launchers in the
repo root and used by luxar export --native ...) honor:
LUXAR_LAUNCHER_NO_WEBVIEW=1 โ Skip the embedded WebView and open the
exported scene in the system default browser instead. Useful for
smoke-testing the launcher without a graphical session. It does not let
the prebuilt Linux binary run without libwebkit2gtk โ WebKit is linked
at build time, so the webkit2gtk-4.1 runtime must be present to start.LUXAR_CACHE_BUDGET_MB=<N> โ Total in-memory cache pool (L0 + L1 +
S-cache) the launcher passes to the viewer via ?cacheBudgetMB=
(default 2048). WebKit WebViews don't implement performance.memory,
so the viewer can't auto-size its caches from the JS heap. The same value
supplies the auto GPU-geometry/LOD residency signal through the implied
non-cache remainder; the default therefore raises that budget from 512 to
1432 MB when deviceMemory is unavailable. Lower it on a constrained machine
(e.g. =512).Luxar Viewer uses a unified configuration system in src/config/. Edit src/config/index.ts to customize:
export const config: AppConfig = {
camera: {
initialPosition: { x: 0, y: 0, z: 8 },
fovMin: 10,
fovMax: 170,
},
scene: {
backgroundColor: 0x000000, // Pitch black โ zero radiance under the HDR exposure chain
},
animation: {
idleTimeoutMs: 2000, // Auto-pause after 2 seconds
},
renderingControls: {
defaults: {
fov: 47, // Field of view in degrees (50mm Normal)
bloomEnabled: false, // Bloom effect (opt-in via zarr viewer_config)
bloomStrength: 0.25, // Bloom intensity multiplier
bloomRadius: 1.0, // Blur radius for bloom spread
bloomThreshold: 0.01, // Luminance threshold for bloom
fxaaEnabled: false, // FXAA anti-aliasing
msaaEnabled: false, // MSAA (hardware-accelerated, fast and sharp)
ssaaEnabled: false, // SSAA (supersampling, highest quality, heavy cost)
// ... more rendering options
},
},
// ... more options
};
The material manager in src/rendering/material-manager.ts provides optimized material handling. Per-geometry getters return cached materials keyed by property bucketing:
// Supported blending modes (canonical set: BLENDING_MODES in src/types/blending.ts)
type BlendingMode = 'additive' | 'volumetric' | 'normal' | 'max' | 'opaque' | 'luminous';
// Per-geometry getters โ see src/rendering/material-manager.ts for
// PointMaterialProperties / LineMaterialProperties / GSplatMaterialProperties
const pointMat = materialManager.getPointMaterial({
blendingMode: 'additive',
opacity: 1.0,
gamma: 2.2,
});
const lineMat = materialManager.getLineMaterial({/* ... */});
const gsplatMat = materialManager.getGSplatMaterial({/* ... */});
Point rendering supports per-point attributes:
Customize bloom and tone mapping in src/config/index.ts under renderingControls.defaults:
renderingControls: {
defaults: {
bloomEnabled: false, // Enable/disable bloom effect
bloomThreshold: 0.01, // Luminance threshold (0.0 = everything glows)
bloomStrength: 0.25, // Bloom intensity multiplier
bloomRadius: 1.0, // Bloom spread
bloomLevels: 8, // Mipmap levels (1-12, lower = faster)
toneMapping: 'ACES', // Default. Options: None, Linear, Reinhard, Cineon, ACES, AgX, Neutral
// (use 'None' for exact colormap-LUT fidelity inside [0, 1])
exposure: 0.0, // Log2 stops (0 = neutral, +1 = 2x brighter)
},
},
Luxar Viewer supports multiple anti-aliasing techniques with important compatibility notes:
renderingControls: {
defaults: {
fxaaEnabled: false, // FXAA: Fast post-process AA (disabled by default)
msaaEnabled: false, // MSAA: Hardware-accelerated, fast and sharp
msaaSamples: 4, // MSAA sample count (2, 4, 8)
ssaaEnabled: false, // SSAA: Supersampling, highest quality, heavy cost
ssaaMultiplier: 2.0, // SSAA resolution multiplier (1.5x, 2x, 4x)
},
},
Anti-Aliasing Notes:
TARGET_CHUNK_BYTES in luxar.typing_utils.constantsBuilt-in performance monitoring in src/ui/performance-monitor.ts is a compact,
theme-matched readout (no third-party dependency) that docks into the control
rail. It shows one metric at a time and cycles on click/Enter:
// A vendored square readout; the control rail docks its `.element`.
const monitor = new PerformanceMonitor();
// Visibility control โ measurement is driven by the animation loop's
// `frame-start` / `frame-end` events on the event bus. The monitor only
// subscribes while visible, so it incurs no cost when hidden.
monitor.show(); // Show (subscribes to frame timing)
monitor.hide(); // Hide (unsubscribes)
monitor.toggle(); // Toggle visibility (bound to the P key / rail gauge)
monitor.cycleMode(); // Cycle the metric: FPS -> frame time (ms) -> graph
monitor.visible; // boolean getter for current visibility
monitor.element; // the widget element (mounted by the control rail)
cycleMode() rotates FPS -> ms -> graphWhite screen on load
Poor performance
Zarr loading errors
Anti-aliasing selection
nD navigation not working
sceneDimensions are defined in dataset .zattrsrange and step valuesRequires WebGL 2.0, which is the default backend. WebGPU is opt-in via
?renderer=webgpu; with no WebGPU adapter that explicit choice runs on three's
internal WebGL2 backend, while VITE_LUXAR_USE_WEBGPU=1 falls back to the
WebGL renderer. The build targets esnext with no browserslist, so there is no
toolchain-derived version floor.
Verified 2026-09-04 on macOS arm64 by running the E2E smoke subset (13 tests) against Playwright's bundled engines:
| Engine | Smoke subset | Notes |
|---|---|---|
| Chromium | 13/13 pass | L2 (OPFS) disk cache initialises |
| Firefox | 13/13 pass | L2 (OPFS) disk cache initialises |
| WebKit | 13/13 pass | runs without the L2 disk cache โ the OPFS store's init / write probe fails, so chunk data is not persisted between sessions |
Reproduce after running pnpm test:generate-fixtures and pnpm exec playwright install firefox webkit, then run pnpm test:e2e:browsers. The checked-in visual
snapshot corpus is Chromium-only, so this command ignores snapshot assertions
and compares functional behavior rather than pixels.
Not verified: the full E2E suite on any engine but Chromium; Safari and Edge
themselves โ Playwright's WebKit is a WebKit build, not Safari, and Edge is
Chromium-based but untested; and any performance comparison between engines.
WebKit lacks main-thread FileSystemFileHandle.createWritable(), so Safari and
the native WKWebView launcher fall back to L1-only caching; see the
opfs-unavailable cache badge.
?src=<path> โ Path to a Zarr dataset (trailing slashes are normalized away)?theme=<id> โ Select dark, light, frosted-glass, or liquid-glass?debug โ Expose window.__luxarDebug for Playwright / dev console?verboseLog โ Also print per-query / per-part detail log lines (off by default; they reach tens of thousands per slice step on large partitions)#view=<JSON> โ Restores a shared view bookmark after the dataset loads (camera, slices, rendering and layers)?title=<text> โ Browser tab title; luxar serve --open derives it from the dataset file name, a scene's authored viewer_config.title overrides it, and it is dropped when you switch datasets?kiosk โ Force kiosk mode on as a hard operator override (locks a scene; cannot unlock authored kiosk mode)?control / ?control=<ws-url> โ Attach to the serving app's remote-control hub; an explicit URL must be same-origin unless ?controlAllowCrossOrigin is also present?controlToken=<secret> โ Shared control-hub token, matching luxar serve --control-token?controlAllowCrossOrigin โ Permit an explicit ?control= URL to cross the page origin (LAN kiosks with an authenticated hub only)?panel=<module-url> โ On control.html, load an alternative same-origin control-panel module?bakeEnv โ Capture the scene environment and expose the encoded result through the debug API?probe=<auto|node:<path>|x,y,z> โ Select the environment-capture probe used by ?bakeEnv?envResolution=<16-1024> โ Set the cube-face resolution used by ?bakeEnv?noCache โ Disable all cache tiers (S-cache + L0 + L1 + L2) for this session?noSliceCache โ Disable only S-cache; L0/L1/L2 remain active?noOpfs โ Disable only the L2 persistent (OPFS) tier; L0/L1/S-cache remain active. For environments whose OPFS stalls; the automatic circuit breaker covers the un-flagged case?opfsReadConcurrency=<N> โ Override the page-wide concurrent OPFS read cap (default 64) for diagnosis?cacheDebug โ Verbose cache logging?clearCache โ Clear stored cache tiers before loading; the per-load S-cache starts empty?cacheStats โ Open the data-loading monitor on its Cache tab after initialization?noPrefetch โ Disable adjacent-chunk prefetching (caches still active)?prefetchDebug โ Verbose prefetch logging?mainThreadCodecs โ Decompress zarr blosc chunks on the main thread instead of the data workers (safety valve / A/B switch; decoded bytes are identical either way)?noLodFade โ Disable replacement-LOD cross-fading (enabled by default)?noLodEnergy โ Disable stream-ladder energy compensation (enabled by default)?lodFinest โ Force the finest replacement LOD regardless of projected coverage?lodBias=<N> โ Bias replacement LOD selection in screen-area units (2 = one level finer on occupancy-halved ladders; positive values only). Because finite screen-area coverage tops out at 1, values below 1 make a partition-anchored finest threshold of 1 unreachable, and values below 0.5 make a whole-object finest threshold of 0.5 unreachable; near-plane saturation can still select finest?noBlendWarmup โ Disable the blend-variant warm-up (enabled by default; WebGL programs and WebGPU pipelines): each reachable blend-mode program is otherwise pre-linked off the interaction path after a dataset load, so the first Layers-panel blend switch does not pay the link cost on the click?noLinks โ Disable element-authored navigation and link menu items while preserving copy actions and element-click / element-contextmenu events?depthSort=0 โ Disable worker depth sorting (false and off are also accepted)?noDensityGuard โ Disable the projected-density guard (per-node keep-fraction thinning + refinement rung cap on over-drawn nodes; enabled by default) for the session?densityCap=<N> โ Session-only override of the density guard's blendable cap, in elements per drawing-buffer pixel (configured default 4)?renderer=webgpu โ Use WebGPURenderer (TSL NodeMaterial) instead of the default WebGLRenderer?hdr[=off|<headroom>] โ PROTOTYPE, WebGPU only (?renderer=webgpu): HDR output (an extended-range rgba16float canvas) is on by default on a native WebGPU session with an HDR display and falls back to SDR otherwise. ?hdr=off (or 0 / false) forces SDR for the page load; a positive number pins the headroom (multiple of SDR white, clamped to 1-16; 1 means SDR); any other value or a bare ?hdr forces auto over a Settings "Off". Unpinned, the headroom is resolved per display and adjustable in the Rendering panel's HDR folder (Headroom slider, Detect Display)?renderer=webgpu&webgpuForceWebgl โ Keep the WebGPU/TSL API surface while Three.js routes through its internal WebGL2 backend (diagnostic)?perfTimestamp โ Enable WebGPU timestamp-query profiling for performance tests?renderAlways โ Render every animation-loop tick, as before render-on-change (kill switch; pixels are identical, only the redundant re-renders of an unchanged frame come back)?renderAudit โ Debug only (needs ?debug): render every tick and count, in the perf counter render.missedDirty, ticks the render-on-change scheduler would have skipped whose pixels nonetheless changed?dpr=<value> โ Pin a fixed device pixel ratio for the session (clamped to [0.25, native]) and lock adaptive resolution off?input=<touch|mouse> โ Force the session's JS input profile: pointer flags, hover capability, touch points, and device tier. This changes device-class fallback budgets (touch only โ mouse keeps the detected tier), primary-tip pen routing, the Safari gesture-canceller gate, and whether the help overlay lists its Touch section; touch additionally applies the mobile rendering budgets (adaptive-DPR floor and refresh ceiling, high-DPR cap, GPU-byte and element-texture ceilings, data-worker count) and skips the blend-variant program warm-up. Stylesheets and non-pen gesture routing still follow the real media features and PointerEvent.pointerType, so a faithful check needs device emulation or a real device. Detected by default, including iPadOS masquerading as macOS?lineJoin=<none|miter> โ Force the line join style for the session; applies only to linePrimitive=screen-space (the capsule partitions joints unconditionally)?linePrimitive=<capsule|screen-space> โ Select the line rendering primitive for the session (#1352), overriding the Settings โ Advanced โ Line primitive policy; default policy auto builds the capsule (gaussian-like 2D point-to-segment profile: stable end-on discs, seamless partitioned joints) except for very large line nodes, which build the leaner screen-space quad. The third primitive, volumetric, was deleted after the capsule flip?gpuBudgetMB=<N> โ Override the shared GPU-geometry/LOD retention budget; 0 means unbounded?cacheBudgetMB=<N> โ Override the total in-memory cache pool (L0 + L1 + S-cache) in megabytes; used where performance.memory is unavailable (WKWebView, Safari), and also supplies the implied non-cache remainder as a GPU-geometry/LOD residency signal (replacing the 512 MB fallback in either direction when deviceMemory is unavailable, capped at 2 GB)?workers=<N> โ Worker pool size for this session (0 = auto), beating the stored Settings value without rewriting it; clamped to the Settings range (0-16)?prefetch=<N> โ Concurrent chunk fetches for this session (the Settings "Prefetch Limit"), same precedence and clamping (1-12)?textScale=<X> โ Scale every overlay's type for this session (e.g. 0.8), beating the scene's viewer_config.text_scale; positions, widths and anchors stay as authored. Clamped to [0.25, 4]?viewAxis=<xy|xz|yz> โ Axis-aligned opening view for this session (face the plane of the dimensions with those names, horizontal axis right), beating the scene's viewer_config.camera.view_axis; the scene's up still picks whether the vertical axis points up or down. Not ?view=, which is the bookmark parameterimport { LuxarApp } from './src/core/app.js';
import { config } from './src/config/index.js';
const app = new LuxarApp();
await app.init({
canvas: document.getElementById('app'),
src: '/path/to/dataset.zarr',
});
// Access components (available after init)
const { sceneManager, animationController, renderingControls } = app.components;
// Modify bloom defaults before initialization (or for next scene load)
config.renderingControls.defaults.bloomEnabled = true;
config.renderingControls.defaults.bloomStrength = 0.2;
config.renderingControls.defaults.bloomRadius = 0.8;
config.renderingControls.defaults.bloomThreshold = 0.1;
// Available components:
// sceneManager - 3D scene, renderer, camera, controls
// animationController - Render loop, per-frame callbacks
// renderingControls - UI panel for rendering settings
// adaptiveDPRManager - Dynamic resolution scaling
git checkout -b feature/amazing-feature)git commit -m 'Add amazing feature')git push origin feature/amazing-feature)Copyright (c) 2025-2026 The Luxar Authors
This project is licensed under the BSD-3-Clause License. See the LICENSE file for details.
Built with โค๏ธ for the scientific visualization community