Lightshow is a browser-based WebGPU real-time / progressive raytracing playground built for experimenting with scenes, materials, and interactive editing (selection + gizmos) in a desktop-like UI. Users open the app in a WebGPU-capable browser, orbit/pan/zoom the camera, select scene objects via click picking, edit transforms/materials via panels and W/E/R gizmos, and the renderer progressively accumulates samples until invalidated by camera/scene changes.
- Language: TypeScript (
tsconfig.json, strict,noEmit) - UI: React 18 (
src/App.tsx,src/components/*) - State: Zustand (stores in
src/store/*; accessed externally via adapters) - Rendering/graphics: WebGPU + WGSL shaders
- WebGPU init:
src/renderer/webgpu.ts - Raytracing shader:
src/renderer/shaders/raytracer.wgsl - Gizmo shader:
src/gizmos/gizmoShader.wgsl - TS types:
@webgpu/types(seetsconfig.json)
- WebGPU init:
- Build tool: Vite 5 (
vite.config.ts) - Tests: Vitest + JSDOM (
package.json,vite.config.ts, tests insrc/__tests__) - Lint: ESLint 9 flat config (
eslint.config.js)- Enforces no import cycles and module boundary rules (details below)
- Styling: TailwindCSS + PostCSS (
tailwind.config.js,postcss.config.js,src/index.css)
All commands are in package.json:
- Install
npm install
- Dev server
npm run dev- Note: port is not pinned in config; rely on Vite’s console output.
- Build
npm run build(runstsctypecheck +vite build)
- Preview built app
npm run preview- Note: port is not pinned in config; rely on Vite’s console output.
- Tests
npm run test- Common CI-style run (no watch):
npm run test -- --run(also suggested inREADME.md) - UI runner:
npm run test:ui - Coverage:
npm run test:coverage
- Benchmarks
npm run bench(runsbench/run.mjs)
Benchmark prerequisites / env vars (see bench/run.mjs, benchmarks/README.md):
- Requires local Chrome/Chromium (uses Chrome DevTools Protocol over WebSocket; no Playwright).
- Optional env:
CHROME_BIN: path to Chrome/Chromium binary (required on non-macOS; optional on macOS)BENCH_RUNS(default3)BENCH_MAX_ATTEMPTS_PER_RUN(default2)BENCH_PORT(default4173) — bench forces preview to127.0.0.1with--strictPortBENCH_ORBIT_MS(default10000)
src/: authoritative application sourcesrc/main.tsx: app entry; conditionally installs benchmark bridge when?__benchsrc/App.tsx: app shell +KernelProviderwiring + panels/canvas/status barsrc/components/: React UI (layout, panels, UI primitives)src/ports/: cross-module contracts (commands/queries/events) — dependency-freesrc/kernel/: application core/state authority (dispatch, history, events)src/adapters/: bridges ports/kernel to tech (React context, DOM input, Zustand backing store, renderer deps)src/store/: Zustand stores (scene/camera/gizmo)src/renderer/: WebGPU renderer + pipelines (raytracing, blit) + WebGPU initsrc/core/: math, camera math/controller, raycasting, scene buffers, shared typessrc/gizmos/: gizmo renderer, picking, geometrysrc/__tests__/: vitest suite (unit + contract + integration)
docs/: design docsdocs/architecture.md: module map + dependency rules + “where to find X”docs/components.md: contracts + wiring diagrams + replacement guides
bench/: benchmark runner script (bench/run.mjs)benchmarks/: benchmark artifacts/notes (benchmarks/baseline.json,benchmarks/latest.json,benchmarks/README.md)public/: static assets + hosting headers (public/_headers)dist/: generated build output (not authoritative; produced bynpm run build)prp/: staged planning/roadmap docs (useful context, not runtime code)
This repo follows a ports/kernel/adapters style boundary (see docs/architecture.md, docs/components.md):
@ports(src/ports/*): stable, dependency-free contracts:Command(writes),KernelQueries(reads),KernelEvents(notifications)
@kernel(src/kernel/Kernel.ts): single authority for state transitions and history:KernelShell.dispatch(command)applies intent, maintains undo/redo + grouping, emits minimal events- The kernel depends on an internal
KernelBackingStoreinterface (not a port)
- Backing store (
src/adapters/zustand/ZustandSceneBackingStore.ts):- Implements
KernelBackingStoreon top of ZustanduseSceneStore(legacy store) - Also handles ray-pick (
selection.pick) via@coreraycaster
- Implements
- UI layer (
src/components/*):- Uses
KernelProvider/useKernel()(React adapter) and dispatchesCommands - Reads state via coarse snapshots (
kernel.queries.getSceneSnapshot())
- Uses
- Renderer (
src/renderer/Renderer.ts):- WebGPU render loop that is dependency-injected with
RendererDeps:- kernel
queries/events+getCameraState()+getGizmoState()
- kernel
- Subscribes to kernel events to resync scene and reset accumulation when invalidated
- WebGPU render loop that is dependency-injected with
- Composition root / wiring:
- Kernel + keyboard input:
src/adapters/react/KernelContext.tsx - WebGPU init + renderer + camera controller + canvas interaction:
src/components/Canvas.tsx - Bench bridge:
src/main.tsx+src/bench/benchBridge.ts
- Kernel + keyboard input:
State management strategy
- App state is split into:
- Kernel-owned: command application + history semantics + eventing (
src/kernel/Kernel.ts) - Zustand-owned (current backing): scene/camera/gizmo stores (
src/store/*) - The kernel reads/writes the scene through a backing store adapter (Zustand today).
- Kernel-owned: command application + history semantics + eventing (
Rendering pipeline
Rendererruns everyrequestAnimationFrame:- Raytracing compute pass (
src/renderer/RaytracingPipeline.ts) with progressive accumulation - Blit pass (
src/renderer/BlitPipeline.ts) to present - Gizmo overlay (
src/gizmos/GizmoRenderer.ts) when an object is selected
- Raytracing compute pass (
If you’re new, read in this order:
docs/architecture.mdanddocs/components.mdsrc/main.tsx→src/App.tsx→src/adapters/react/KernelContext.tsx→src/components/Canvas.tsxsrc/kernel/Kernel.tsandsrc/ports/*src/renderer/Renderer.tsandsrc/renderer/RaytracingPipeline.ts
Key modules/classes:
Command/parseCommand(src/ports/commands.ts): versioned, serializable write intent surface.SceneSnapshot/KernelQueries(src/ports/queries.ts): coarse-grained read surface used by UI + renderer.KernelEvents(src/ports/events.ts): minimal ordered notification surface (state.changed,render.invalidated).KernelShell(src/kernel/Kernel.ts): dispatch + undo/redo + transform grouping; caches snapshots for low allocations.ZustandSceneBackingStore(src/adapters/zustand/ZustandSceneBackingStore.ts): implements kernel backing store on top ofuseSceneStore; handles picking and scene mutations.KernelProvider/useKernel/useKernelSceneSnapshot(src/adapters/react/KernelContext.tsx): React composition + external-store subscription.Renderer(src/renderer/Renderer.ts): render loop; listens to kernel events; only uploads GPU scene whensnapshot.objectsreference changes.RaytracingPipeline(src/renderer/RaytracingPipeline.ts): compute pipeline, accumulation, scene buffers, uniforms/settings packing.initWebGPU(src/renderer/webgpu.ts): adapter/device/context setup and capability checks.Canvas(src/components/Canvas.tsx): WebGPU/renderer init, camera controller, selection + gizmo input handling, resize, device-lost UX.
- Where tests live:
src/__tests__/and adapter tests undersrc/adapters/**/__tests__/ - Test runner: Vitest (
package.json, config invite.config.ts)- Environment: JSDOM (
vite.config.ts) - Global setup:
src/__tests__/setup.ts(stubsResizeObserver)
- Environment: JSDOM (
Test organization (examples)
- Contract tests (validate stable contracts/semantics):
- Ports:
src/__tests__/portsCommands.contract.test.ts,portsQueries.contract.test.ts,portsEvents.contract.test.ts - Kernel/history:
src/__tests__/kernelHistory.contract.test.ts,kernelHistoryGrouping.contract.test.ts
- Ports:
- Integration-ish:
src/__tests__/integration.test.ts(repo has it; use when touching wiring) - Renderer/pipeline unit tests:
src/__tests__/Renderer.test.ts,RaytracingPipeline.test.ts,BlitPipeline.test.ts
Run a focused subset
- Single file:
npm run test -- --run src/__tests__/Renderer.test.ts
- Name filter:
npm run test -- --run -t "KernelShell"
Benchmark harness
- Command:
npm run bench - Runner:
bench/run.mjs- Builds + runs
vite previewon127.0.0.1:${BENCH_PORT}with--strictPort - Launches Chrome via CDP, loads
/?__bench=1, waits forwindow.__LIGHTSHOW_BENCH__, runs:- TTFF (time-to-first-frame; derived from renderer sample count)
- Orbit median FPS (scripted camera orbit)
- Writes results:
benchmarks/latest.json - Compares against baseline:
benchmarks/baseline.jsonand enforces regression gates
- Builds + runs
In-app bench bridge
- Installed only when
?__benchis present:src/main.tsxdynamically imports@benchsrc/bench/benchBridge.tspublisheswindow.__LIGHTSHOW_BENCH__withrun()andregisterRenderer()
Hotspots / rules of thumb
- Avoid per-frame allocations in the render loop:
Rendereronly callsraytracingPipeline.updateScene()whensnapshot.objectsreference changes (src/renderer/Renderer.ts).- When editing stores/backing store, preserve structural sharing where possible to avoid unnecessary GPU uploads.
- Accumulation resets are expensive:
- Camera moves call
renderer.resetAccumulation()(seesrc/components/Canvas.tsx). - Kernel emits
render.invalidatedfor scene changes that require resetting accumulation (src/kernel/README.md,src/kernel/Kernel.ts).
- Camera moves call
- Continuous transforms are grouped into one undo step:
history.group.begin/history.group.endare used during gizmo drags (src/components/Canvas.tsx).
- Aliased imports must use module entrypoints:
- ESLint forbids
@core/*,@renderer/*, etc. from outside the module. - Prefer:
import { Camera } from '@core'(viasrc/core/index.ts) notimport { Camera } from '@core/Camera'. - Rule:
no-restricted-importspatterns ineslint.config.js.
- ESLint forbids
- No dependency cycles: enforced via
import/no-cycle(eslint.config.js). - Directionality constraints:
src/kernel/**cannot import React/Zustand/UI/renderer/gizmos/hooks (enforced ineslint.config.js).src/ports/**is dependency-free and must not import implementations.
- TypeScript: strict, unused locals/params are errors (
tsconfig.json). - React: Vite + React Refresh; hook linting is on but
exhaustive-depsis off (eslint.config.js).
Add a new command (new user intent)
- Define the contract: extend
Commandunion +parseCommandinsrc/ports/commands.ts - Implement behavior:
- Kernel typically stays generic; behavior is applied via backing store:
- Add handling in
src/adapters/zustand/ZustandSceneBackingStore.ts(or your new backing store)
- Wire UI: dispatch via
kernel.dispatch(...)from the relevant component (often insrc/components/panels/*orsrc/components/Canvas.tsx) - Add tests:
- Contract/parse semantics:
src/__tests__/portsCommands.contract.test.ts - Behavior/history semantics: kernel/history contract tests (
src/__tests__/kernelHistory*.test.ts) or add a targeted unit test
- Contract/parse semantics:
Add a new query field
- Update
src/ports/queries.ts(SceneSnapshot) - Update kernel snapshot construction in
src/kernel/Kernel.ts - Update backing store state shape if needed (
src/adapters/zustand/ZustandSceneBackingStore.ts)
Modify selection/picking
- Command is
selection.pick(src/ports/commands.ts) - Backing store uses raycaster:
src/adapters/zustand/ZustandSceneBackingStore.ts(calls@coreraycaster)
Modify gizmo interactions
- Input + drag grouping:
src/components/Canvas.tsx - Gizmo store:
src/store/gizmoStore.ts - Gizmo rendering/picking:
src/gizmos/*(renderer + raycaster + geometry)
Modify camera behavior
- Camera store:
src/store/cameraStore.ts - DOM controller:
src/core/CameraController.ts - Canvas wiring:
src/components/Canvas.tsx
Modify renderer behavior / shaders
- Main loop + kernel event reactions:
src/renderer/Renderer.ts - Compute shader/pipeline:
src/renderer/RaytracingPipeline.ts+src/renderer/shaders/raytracer.wgsl - Presentation:
src/renderer/BlitPipeline.ts
Add a new UI component
- Place in
src/components/ui/*(reusable primitives) orsrc/components/panels/*(feature panels). - Tailwind tokens/colors are in
tailwind.config.js.
Add/adjust benchmark
- Runner logic/gates:
bench/run.mjs - In-app metrics:
src/bench/benchBridge.ts - Baseline numbers:
benchmarks/baseline.json(committed)
- WebGPU availability:
- Many browsers/machines won’t support WebGPU;
initWebGPU()throws with a compatibility message (src/renderer/webgpu.ts). - Device loss is handled and surfaced to the user in
src/components/Canvas.tsx.
- Many browsers/machines won’t support WebGPU;
- Bench stability:
- Bench assumes the page stays “active” so
requestAnimationFrameisn’t throttled; it will retry if the orbit doesn’t advance (bench/run.mjs,src/bench/benchBridge.ts). - Bench launches a real Chrome instance by default (not Playwright/headless).
- Bench assumes the page stays “active” so
- Module boundary lint rules can surprise you:
- If an import “should work” but ESLint blocks it, import from the module
index.tsentrypoint instead (seeeslint.config.js).
- If an import “should work” but ESLint blocks it, import from the module
- Renderer upload behavior depends on reference equality:
Rendereronly re-uploads the GPU scene ifsnapshot.objectsreference changes. Be deliberate about when arrays are replaced vs reused (src/renderer/Renderer.ts).
- WebGPU: Modern browser GPU API used for compute + rendering.
- WGSL: WebGPU Shading Language (shader code in
*.wgsl). - Kernel: App core that applies commands, manages history, and emits events (
src/kernel/*). - Ports: Dependency-free contracts between subsystems (
src/ports/*). - Backing store: Implementation behind the kernel that reads/writes scene state (Zustand adapter today).
- SceneSnapshot: Coarse, read-only view of scene state used by UI/renderer (
src/ports/queries.ts). - Accumulation: Progressive path tracing accumulation over frames; reset on invalidation.
- TTFF: “Time to first frame” measured by the bench bridge when the renderer first produces samples.