three-ilda
v0.1.0
Published
ILDA loader and laser shows for Three.js, in a WebGPU / TSL version and a WebGL 2 version
Maintainers
Readme
three-ilda
ILDA laser shows for Three.js — a galvanometer simulation, additive projection with soft projector beams and a post-processing pipeline, shipped as two full versions: WebGPU / TSL and WebGL 2

Load .ild files, replay them through a scanner model that behaves like real mirrors, and draw the result as light: a persistent trail, bloom, beam sheets that fade in haze and are cut by scene geometry. The same API is available for THREE.WebGPURenderer with node materials (TSL) and for THREE.WebGLRenderer with GLSL, and the two versions are measured against each other for parity.
Examples: npm run dev serves the gallery showcase plus a minimal setup and a render validation page per version, each in its own file (see Examples).
Why it looks like a laser
Drawing an ILDA point list as a polyline looks wrong: hard corners, visible clusters of repeated points, no persistence, no light in the air. three-ilda treats the file as commands for a physical scanner:
| What you see | What's happening under the hood |
| --- | --- |
| Rounded corners and bright knots where strokes pause | A second-order mirror model per axis (v = (v + (target − x) · gain) · dampening) replays the points at a fixed sample rate; dwell points pile samples up in one place |
| A fading trail instead of a static drawing | Samples live in a ring buffer with birth times; brightness decays as lightDecay ^ (age · 60), independent of the display frame rate |
| Clean blanked jumps, no tails | The blank flag is read a few points behind the command (blankingOffset), where the lagging mirror actually is |
| Soft beams from the projector to the drawing | One additive triangle per drawn segment, apex at the projector aperture, faded at both ends and modulated by 3D noise |
| Beams that stop at walls and people | The beam pass samples the scene depth and fades sheets that lie behind opaque geometry |
| Glow without washing out the lines | Bloom on the drawing, then haze compressed with 1 − exp(−x) and suppressed where the image is already bright |
All of it runs identically in the WebGPU / TSL version (three-ilda: WebGPURenderer, node materials, RenderPipeline) and in the WebGL 2 version (three-ilda/webgl: WebGLRenderer, ShaderMaterial, render targets).
Two versions, one API
| | WebGPU / TSL version | WebGL 2 version |
| --- | --- | --- |
| Import | three-ilda | three-ilda/webgl |
| Renderer | THREE.WebGPURenderer (WebGPU) | THREE.WebGLRenderer (WebGL 2) |
| Materials | LineBasicNodeMaterial, MeshBasicNodeMaterial with TSL nodes | ShaderMaterial with GLSL, same math |
| Post-processing | RenderPipeline + PassNode, bloom(), gaussianBlur() | WebGLRenderTarget + DepthTexture, UnrealBloomPass, custom blur and composite quads |
| Bloom control | pipeline.glow.strength.value, radius.value, threshold.value (uniform nodes) | pipeline.glow.strength, radius, threshold (numbers) |
| Extras | inspect: true labels the passes for the r185 Inspector | — |
| Shared code | ILDALoader, LaserScanner, LaserShowBase (playback, buffers, public API) | the same files |
Parity is measured, not assumed: examples/validation-webgpu.html and examples/validation-webgl.html run the same checks per version and read the composite back from a render target. Mean brightness is 30.43 vs 30.52 at bloom strength 15 and 0.98 vs 0.98 without bloom; beam coverage, occlusion and background handling match. Each pipeline refuses a show built for the other version, so mixing them fails loudly instead of rendering nothing.
Stack
- Three.js r185 —
three,three/webgpu,three/tsl,three/addons(peer dependency^0.185.0) - Plain JavaScript ES modules, no build step required: package exports point at
src/ - Vite for the example site
- node:test for the parser, scanner and ownership tests (no GPU needed); GPU behaviour is validated in the browser
- Type declarations generated from the JSDoc with
tsc(npm run types, runs automatically onnpm pack)
WebGPU / TSL version: a browser with WebGPU. WebGL 2 version: any WebGL 2 browser with float colour buffers (EXT_color_buffer_float / EXT_color_buffer_half_float, available on current GPUs).
Advanced techniques
1. Galvanometer simulation
Per sample and per axis the scanner integrates
v = (v + (target − x) · gain) · dampening
x = x + vgainis the spring;dampeningis the fraction of velocity kept per sample, so larger means less damping (the name follows the reference notebook).- On the error
e = x − targetone step is the linear map[[1 − g·d, d], [−g·d, d]]with trace1 + d − g·dand determinantd: the mirror converges iff0 < d < 1andg < 2(1 + d) / d. The setter ranges (gain≤ 2,dampening≤ 1) can never diverge;dampening = 1rings forever and0freezes the mirror. - With the defaults
gain 0.51,dampening 0.39the eigenvalues are complex with magnitude √0.39 ≈ 0.62 per sample: a step settles within 1 % in 7 samples with 0.8 % overshoot and rings with a period of ≈ 20 samples (≈ 390 Hz at 8,000 points per second). Corners round over the next 5–7 points and dwell points collapse into bright knots, as on hardware. - Two independent axes, no torque limit, no separate position and velocity loops: deliberately minimal, so the notebook's tuned defaults carry over unchanged.
2. Frame-rate independent sample clock
requested = fraction + dt · rate; steps = ⌊requested⌋; fraction = requested − steps- Every sample receives a
borntime exactly1/rateapart; 30, 60 and 120 Hz produce identical trails (covered by a test). - IDTF stores no timing, so the animation rate is a consequence of the scan rate:
rate / pointsPerFrame(an 800-point frame at 8,000 points per second plays at 10 fps). dt ≤ 0is ignored; clamping large gaps such as a hidden tab is the application's decision, the library never rewrites time.
3. Trail persistence
- Samples go into a fixed ring of
capacityentries (default 2048) stored as flat typed arrays; nothing is allocated per frame. snapshot()unrolls the ring oldest → newest and fades each sample withblank ? 0 : lightDecay ^ (age · 60). The exponent counts 60ths of a second because the reference multiplied colours by 0.95 once per 60 Hz frame;lightDecay = 0shows only the last 1/60 s.capacity / ratebounds visible history (256 ms at the defaults). AtlightDecay 0.95a sample still has 45 % brightness when it leaves the ring, at 0.9 about 20 %, at 0.8 about 3 % — raisecapacitywhen a long afterglow has to fade out rather than end.seekFrame(i)resets and scans exactly one frame, so a paused show is drawn immediately and parameter changes while paused re-scan the current frame instead of showing a stale trail.
4. Blanking offset
- The sample heading for point
itakes the blank flag of pointi + blankingOffset, wrapping across frame boundaries in both directions. - The mirror trails the command by a few samples; the default −3 switches the laser where the mirror physically is, which removes the tails that appear when the beam stays on into a blanked jump or lights before the mirror has arrived.
- Blanked points keep their colour in the parsed data for exactly this reason: the standard suggests zeroing them at read time, here blanking is a scanner concern.
5. Projection and beam geometry
- Scanner coordinates stay normalised in [−1, 1); the vertex shader multiplies them by the uniform
(width · zoom / 2, height · zoom / 2, 0), so size and zoom never touch buffers. - The drawing is
LineSegments, not a strip: each update compacts the snapshot intoDynamicDrawUsageattributes, keeps only segments whose two samples are unblanked and brighter than 0.001, and limits draw and update ranges to the written prefix. Additive blending without depth write: strokes and dwell knots add like light. - Each drawn segment yields one triangle apex → a → b. The apex is uploaded as
(0, 0, 0); abeamAlongattribute (0 at the apex, 1 at the ends) lets the shader computemix(origin, position · scale, beamAlong)withorigina uniform copied fromprojector.position— moving the projector or zooming re-uploads nothing. - Sheet colour is
mean(colour a, colour b) · envelope · haze · beamIntensitywithenvelope = smoothstep(0, 0.025, t) · (1 − smoothstep(0.78, 1, t))(no hot spot at the aperture, no doubling where sheets land on the drawing) andhaze = 0.7 + 0.3 · noise(1.4 · worldPosition + drift(time))(MaterialX noise in TSL, a 3D gradient noise in GLSL). DoubleSidewithforceSinglePass, additive, no depth write, no frustum culling.beamMode = 'Disabled'hides the mesh and skips beam uploads entirely.
6. Depth-faded beam pass and haze compositing
- The scene renders without
beamLayer(default 31) into a half-float target with MSAA and depth. Beams render alone at half resolution, cleared to transparent black so no background is doubled, then get a separable Gaussian blur (σ 3 texels). - Registered beam materials read the scene depth and apply
opacity = 1 − smoothstep(0.002, 0.015, sceneViewZ − viewZ). View z is negative, so the difference is positive exactly where a sheet lies behind an opaque surface; the band gives a soft edge instead of aliasing. Transparent objects do not occlude. - Composite:
protect = 1 − smoothstep(0.025, 0.3, max(rgb))andoutput = projectionOutput + (1 − exp(−blurredBeams)) · 0.14 · protect.1 − exp(−x)saturates stacked sheets sobeamIntensitybehaves like fog density;protectkeeps haze off the lines and their bloom. - Bloom (
strength 15,radius 1,threshold 0) applies to the whole scene image because the glow of thin lines has to come from the final image; raisethresholdin scenes with other bright content. - While no registered show has visible beams, the WebGPU / TSL version drops the beam pass, blur and haze from the node graph (one rebuild per toggle) and the WebGL 2 version skips those passes.
7. Bloom parity between the versions
- TSL
bloom()andUnrealBloomPassshare their lineage but not their scale:UnrealBloomPassmultiplies its composite by3.0 · bloomStrength"for backwards compatibility". The WebGL 2 pipeline strips that factor from the composite shader (and leaves the shader alone if a future release removes it), soglow.strengthmeans the same thing in both versions. - Measured with the validation pages: mean brightness 30.43 (WebGPU / TSL) vs 30.52 (WebGL 2) at strength 15, 11.06 vs 11.09 at strength 5, 26.24 vs 26.24 at radius 0.
8. Performance knobs that actually matter
capacity(constructor) — trail length in samples and the size of every CPU and GPU buffer; 2048 by default, up to 65,536.pointRate— samples per second, i.e. CPU work per frame; also the animation speed.beamMode = 'Disabled'— removes the beam pass, blur and haze, not just the geometry.samples(pipeline option) — MSAA of the scene pass; the beam pass is always half resolution without MSAA.zoom,intensity,beamIntensityandprojector.positionare uniforms and free to animate;gain,dampening,blankingOffset,lightDecayandbeamModerebuild the buffers on the nextupdate().
Quick start
Install the package next to Three.js r185:
npm install three-ilda three@^0.185.0Then import from three-ilda (WebGPU / TSL version) or three-ilda/webgl (WebGL 2 version), see Using the library. Nothing needs to be built: the exports resolve to plain ES modules in src/, and type declarations are generated from the JSDoc.
To run the examples from a clone (Node.js 22+):
npm ci
npm run dev # gallery, minimal examples and validation pages on http://127.0.0.1:5181
npm test # parser, scanner, ownership, blanking, pipeline registration — both versions
npm run build # static example site in dist-examples/Using the library
WebGPU / TSL version
import * as THREE from 'three/webgpu'
import { ILDALoader, LaserShow, LaserShowPipeline } from 'three-ilda'
const renderer = new THREE.WebGPURenderer({ antialias: false })
await renderer.init()
const asset = await new ILDALoader().loadAsync('/shows/example.ild')
const show = new LaserShow(asset, { track: 0, width: 4.8, height: 3.2 })
show.beamMode = 'Front'
scene.add(show)
const pipeline = new LaserShowPipeline(renderer, scene, camera) // optional: bloom, beams, haze
pipeline.add(show)
pipeline.glow.strength.value = 15
const timer = new THREE.Timer(); timer.connect(document)
renderer.setAnimationLoop(() => {
timer.update()
show.update(timer.getDelta()) // seconds; update(0) applies parameter changes while paused
pipeline.render() // or renderer.render(scene, camera) without the pipeline
})WebGL 2 version
import * as THREE from 'three'
import { ILDALoader, LaserShow, LaserShowPipeline } from 'three-ilda/webgl'
const renderer = new THREE.WebGLRenderer({ antialias: false })
const asset = await new ILDALoader().loadAsync('/shows/example.ild')
const show = new LaserShow(asset, { track: 0, width: 4.8, height: 3.2 })
show.beamMode = 'Front'
scene.add(show)
const pipeline = new LaserShowPipeline(renderer, scene, camera)
pipeline.add(show)
pipeline.glow.strength = 15
const timer = new THREE.Timer(); timer.connect(document)
renderer.setAnimationLoop(() => {
timer.update()
show.update(timer.getDelta())
pipeline.render()
})What happens in both:
ILDALoaderparses the file into frames grouped by projector track (asset.tracks), with positions normalised to [−1, 1), colours in [0, 1] and blank flags.LaserShowbuilds aLaserScannerfor one track and owns its GPU buffers; several shows may share one asset.LaserShowPipeline.add(show)moves the beam mesh to the reserved layer and installs the depth fade;remove(show)restores it.show.update(dt)advances the scanner and uploads the compacted segments;pipeline.render()draws the scene, bloom, beams and haze.
Teardown: pipeline.dispose(); scene.remove(show); show.dispose(). The asset stays valid for other shows.
ILDAAsset {
frames: ILDAFrame[] // file order; palette records excluded
tracks: Map<number, ILDAFrame[]> // the same objects grouped by projector, keys in order of first appearance
totalPoints, blanked, paletteFallbacks: number
eof: boolean // explicit end-of-file record seen
bytes, parseTimeMs: number
}
ILDAFrame { number, name, projector, count, position: Float32Array, color: Float32Array, blank: Uint8Array }Parameters
Defaults come from the reference notebook; ranges are enforced by the setters (RangeError outside them).
Playback and scanner (LaserShow)
| Parameter | Type | Default | Range | Description |
| --- | --- | --- | --- | --- |
| playing | boolean | true | — | Advance the scanner and the fade clock in update() |
| pointRate | number | 8000 | 1 – 100000 | Scanner samples per second; the animation rate is pointRate / pointsPerFrame |
| gain | number | 0.51 | 0.001 – 2 | Mirror spring constant |
| dampening | number | 0.39 | 0 – 1 | Velocity kept per sample; larger = less damping, 0 freezes, 1 rings forever |
| blankingOffset | number (int) | -3 | -100 – 100 | Which point's blank flag applies to the current sample, in points |
| lightDecay | number | 0.95 | 0 – 1 | Afterglow factor per 1/60 s; 0 shows only the last 1/60 s |
Projection and beams (LaserShow)
| Parameter | Type | Default | Range | Description |
| --- | --- | --- | --- | --- |
| zoom | number | 1 | 0 – 4 | Multiplies the projection size (uniform, free to animate) |
| intensity | number | 1 | 0 – 100 | Line colour multiplier (uniform) |
| beamMode | 'Disabled' \| 'Front' \| 'Back' | 'Disabled' | — | Beam sheets on the local +Z or −Z side of the image plane; Disabled also skips the beam passes |
| beamIntensity | number | 0.5 | 0 – 2 | Sheet colour multiplier, acts like fog density through the haze compression |
| projector.position | Vector3 | (-2.2, -1.4, 3) | — | Beam origin in the show's local units; Front / Back flip the sign of z, keeping it ≥ 0.1 from the plane |
| setProjectionSize(width, height) | method | 4.8 × 3.2 | 0.001 – 10000 | Full image size in local units at zoom = 1 |
Constructor and lifecycle (LaserShow)
| Member | Type | Default | Description |
| --- | --- | --- | --- |
| new LaserShow(asset, { track, capacity, width, height }) | constructor | first track, 2048, 4.8, 3.2 | capacity is the trail length in samples (2 – 65536) and fixes every buffer size |
| update(deltaSeconds) | method | — | Call once per frame; update(0) applies pending changes while paused |
| setData(asset, track?) / setTrack(id) | methods | — | Replace the show or switch projector track; a fresh scanner, immediate upload |
| seekFrame(index) / reset() | methods | — | Seek (re-scans one frame) or clear the trail; playing is untouched |
| frame, frameCount, trackIds, time, projectionSize, asset, track, scanner | read-only | — | Playback information |
| clone() | method | — | Shares the asset, copies scanner state including the ring, allocates new GPU resources |
| dispose() | method | — | Frees this instance's geometry and materials and emits dispose; the asset stays usable |
Pipeline (LaserShowPipeline)
| Option / member | Type | Default | Range | Description |
| --- | --- | --- | --- | --- |
| beamLayer | number (int) | 31 | 1 – 31 | Layer reserved for registered beam meshes |
| strength | number | 15 | — | Bloom strength; later via glow.strength.value (WebGPU / TSL) or glow.strength (WebGL 2) |
| radius | number | 1 | 0 – 1 | Bloom radius, weights the larger mips |
| threshold | number | 0 | — | Bloom luminance threshold; raise it in scenes with other bright content |
| samples | number (int) | 4 | — | MSAA sample count of the scene pass |
| inspect | boolean | false | — | WebGPU / TSL version only: toInspector() labels for the scene, bloom and beam textures |
| add(show) / remove(show) | methods | — | — | Register a show (one pipeline per show at a time); remove restores layers and material state |
| render() | method | — | — | Render the host scene and camera through the pipeline into the current render target |
| dispose() | method | — | — | Free passes, targets and nodes; nothing of the application's |
Loader (ILDALoader)
| Member | Type | Default | Description |
| --- | --- | --- | --- |
| load(url, onLoad, onProgress?, onError?) / loadAsync(url) | methods | — | Standard THREE.Loader API through FileLoader; parse errors reach onError and the LoadingManager |
| parse(arrayBuffer) | method | — | Synchronous; formats 0, 1, 2, 4, 5; limits 32 MiB and 2,000,000 points |
| setFallbackPalette(triplets \| null) | method | ILDA_DEFAULT_PALETTE | 1–256 [r, g, b] byte triplets for indexed frames whose projector has no palette record; null restores the 64-entry Appendix A palette |
| paletteFallbacks (result) | number | — | Indices outside the palette became white and were counted instead of throwing |
Architecture (source map)
src/
index.js # WebGPU / TSL version entry
webgl.js # WebGL 2 version entry
loaders/ILDALoader.js # IDTF formats 0/1/2/4/5 → typed arrays (shared)
core/LaserScanner.js # mirror model, sample clock, ring buffer (shared)
core/LaserShowBase.js # scene graph, compaction, uploads, public API (shared)
objects/LaserShow.js # TSL materials (WebGPU / TSL version)
postprocessing/LaserShowPipeline.js # RenderPipeline: scene pass, bloom(), beam pass, blur, haze
webgl/LaserShow.js # GLSL ShaderMaterials (WebGL 2 version)
webgl/LaserShowPipeline.js # render targets, DepthTexture, UnrealBloomPass, blur, composite
examples/ # gallery showcase, *-webgpu.html and *-webgl.html pages per version, ILDA collection
test/ # node:test suites, run for both versions
types/ # .d.ts generated from the JSDoc by npm run types, not committedKey exports for reuse
| Export | From | Role |
| --- | --- | --- |
| ILDALoader, ILDA_DEFAULT_PALETTE | three-ilda, three-ilda/webgl | Parse .ild files; no renderer dependency |
| LaserScanner, SCANNER_DEFAULTS, TRAIL_POINTS | three-ilda, three-ilda/webgl | The scanner alone, for workers or other renderers |
| LaserShow | three-ilda / three-ilda/webgl | The playable object for the respective renderer |
| LaserShowPipeline | three-ilda / three-ilda/webgl | Optional bloom, beams and haze for the respective renderer |
| LaserShowBase | three-ilda/core/LaserShowBase.js | Bring your own materials: implement _createMaterials() for another renderer |
Examples
| Page | Version | What it shows |
| --- | --- | --- |
| index.html | WebGPU / TSL | The gallery showcase: the ILDA collection with live previews, one LaserShow and one pipeline, every parameter in the Inspector |
| minimal-webgpu.html | WebGPU / TSL | The smallest complete setup: renderer, one show, the pipeline and the loop, about 40 lines |
| minimal-webgl.html | WebGL 2 | The same setup on WebGLRenderer through three-ilda/webgl |
| validation-webgpu.html | WebGPU / TSL | Render checks by render-target readback: beams on both sides, complete occlusion by an opaque wall, no doubled background, exact restore on Disabled, mean brightness with and without bloom |
| validation-webgl.html | WebGL 2 | The same checks for the WebGL 2 version, so the two reports can be compared line by line |
Each page imports exactly one version. Details in examples/README.md.
Browser / renderer notes
- WebGPU / TSL version: create
THREE.WebGPURendererandawait renderer.init()before the first frame. It needs a browser with WebGPU; for WebGL 2 browsers use the WebGL 2 version. - WebGL 2 version: the pipeline allocates half-float targets and a
DepthTexture, sizes them from the drawing buffer on eachrender()and composites into whatever render target is current, so it can feed a further pass. - One pipeline per camera view. It renders the host's scene and camera and reserves
beamLayer; XR and multi-view rendering need their own integration. - Both pipelines assume a dark scene:
protectand bloom read the whole image, so a bright background suppresses haze and blooms itself. - The library owns no clock.
THREE.Timer.connect(document)avoids catch-up after a hidden tab; the examples also clampdtto 0.1 s. - The examples render with
LinearSRGBColorSpaceoutput and no tone mapping so the defaultintensityand bloom values look the same in both versions; with sRGB output or tone mapping expect brighter, softer lines. - Toggling beams rebuilds the node graph once in the WebGPU / TSL version (a short hitch on first use); the WebGL 2 version just skips passes.
Credits
- The scanner algorithm follows Tom Larkworthy's ILDA laser show player.
- File parsing follows ILDA IDTF revision 011.
- The ILDA collection and further notices are listed in CREDITS.md.
License
MIT © Artem Korenevych. See LICENSE and CREDITS.md.
