three-ilda 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CREDITS.md ADDED
@@ -0,0 +1,6 @@
1
+ # Credits
2
+
3
+ - The scanner algorithm follows [Tom Larkworthy's ILDA laser show player](https://observablehq.com/@tomlarkworthy/ilda-laser-show-player).
4
+ - The `.ild` files in `examples/public/ilda/` are supplied by the project owner and kept under their original filenames.
5
+ - File parsing follows [ILDA IDTF revision 011](https://www.ilda.com/resources/StandardsDocs/ILDA_IDTF14_rev011.pdf).
6
+ - Three.js is an external dependency under its own license.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Artem Korenevych
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,354 @@
1
+ # three-ilda
2
+
3
+ **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**
4
+
5
+ ![three-ilda demo](./docs/three-ilda.gif)
6
+
7
+ 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.
8
+
9
+ 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](#examples)).
10
+
11
+ ---
12
+
13
+ ## Why it looks like a laser
14
+
15
+ 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**:
16
+
17
+ | What you see | What's happening under the hood |
18
+ | --- | --- |
19
+ | 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 |
20
+ | 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 |
21
+ | Clean blanked jumps, no tails | The blank flag is read a few points behind the command (`blankingOffset`), where the lagging mirror actually is |
22
+ | 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 |
23
+ | Beams that stop at walls and people | The beam pass samples the scene depth and fades sheets that lie behind opaque geometry |
24
+ | 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 |
25
+
26
+ 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).
27
+
28
+ ---
29
+
30
+ ## Two versions, one API
31
+
32
+ | | WebGPU / TSL version | WebGL 2 version |
33
+ | --- | --- | --- |
34
+ | Import | `three-ilda` | `three-ilda/webgl` |
35
+ | Renderer | `THREE.WebGPURenderer` (WebGPU) | `THREE.WebGLRenderer` (WebGL 2) |
36
+ | Materials | `LineBasicNodeMaterial`, `MeshBasicNodeMaterial` with TSL nodes | `ShaderMaterial` with GLSL, same math |
37
+ | Post-processing | `RenderPipeline` + `PassNode`, `bloom()`, `gaussianBlur()` | `WebGLRenderTarget` + `DepthTexture`, `UnrealBloomPass`, custom blur and composite quads |
38
+ | Bloom control | `pipeline.glow.strength.value`, `radius.value`, `threshold.value` (uniform nodes) | `pipeline.glow.strength`, `radius`, `threshold` (numbers) |
39
+ | Extras | `inspect: true` labels the passes for the r185 Inspector | — |
40
+ | Shared code | `ILDALoader`, `LaserScanner`, `LaserShowBase` (playback, buffers, public API) | the same files |
41
+
42
+ 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.
43
+
44
+ ---
45
+
46
+ ## Stack
47
+
48
+ - **Three.js r185** — `three`, `three/webgpu`, `three/tsl`, `three/addons` (peer dependency `^0.185.0`)
49
+ - Plain **JavaScript ES modules**, no build step required: package exports point at `src/`
50
+ - **Vite** for the example site
51
+ - **node:test** for the parser, scanner and ownership tests (no GPU needed); GPU behaviour is validated in the browser
52
+ - Type declarations generated from the JSDoc with `tsc` (`npm run types`, runs automatically on `npm pack`)
53
+
54
+ 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).
55
+
56
+ ---
57
+
58
+ ## Advanced techniques
59
+
60
+ ### 1. Galvanometer simulation
61
+
62
+ Per sample and per axis the scanner integrates
63
+
64
+ ```
65
+ v = (v + (target − x) · gain) · dampening
66
+ x = x + v
67
+ ```
68
+
69
+ - `gain` is the spring; `dampening` is the fraction of velocity kept per sample, so larger means *less* damping (the name follows the reference notebook).
70
+ - On the error `e = x − target` one step is the linear map `[[1 − g·d, d], [−g·d, d]]` with trace `1 + d − g·d` and determinant `d`: the mirror converges iff `0 < d < 1` and `g < 2(1 + d) / d`. The setter ranges (`gain` ≤ 2, `dampening` ≤ 1) can never diverge; `dampening = 1` rings forever and `0` freezes the mirror.
71
+ - With the defaults `gain 0.51`, `dampening 0.39` the 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.
72
+ - Two independent axes, no torque limit, no separate position and velocity loops: deliberately minimal, so the notebook's tuned defaults carry over unchanged.
73
+
74
+ ### 2. Frame-rate independent sample clock
75
+
76
+ ```
77
+ requested = fraction + dt · rate; steps = ⌊requested⌋; fraction = requested − steps
78
+ ```
79
+
80
+ - Every sample receives a `born` time exactly `1/rate` apart; 30, 60 and 120 Hz produce identical trails (covered by a test).
81
+ - 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).
82
+ - `dt ≤ 0` is ignored; clamping large gaps such as a hidden tab is the application's decision, the library never rewrites time.
83
+
84
+ ### 3. Trail persistence
85
+
86
+ - Samples go into a fixed ring of `capacity` entries (default 2048) stored as flat typed arrays; nothing is allocated per frame.
87
+ - `snapshot()` unrolls the ring oldest → newest and fades each sample with `blank ? 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 = 0` shows only the last 1/60 s.
88
+ - `capacity / rate` bounds visible history (256 ms at the defaults). At `lightDecay 0.95` a sample still has 45 % brightness when it leaves the ring, at 0.9 about 20 %, at 0.8 about 3 % — raise `capacity` when a long afterglow has to fade out rather than end.
89
+ - `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.
90
+
91
+ ### 4. Blanking offset
92
+
93
+ - The sample heading for point `i` takes the blank flag of point `i + blankingOffset`, wrapping across frame boundaries in both directions.
94
+ - 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.
95
+ - 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.
96
+
97
+ ### 5. Projection and beam geometry
98
+
99
+ - 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.
100
+ - The drawing is `LineSegments`, not a strip: each update compacts the snapshot into `DynamicDrawUsage` attributes, 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.
101
+ - Each drawn segment yields one triangle apex → a → b. The apex is uploaded as `(0, 0, 0)`; a `beamAlong` attribute (0 at the apex, 1 at the ends) lets the shader compute `mix(origin, position · scale, beamAlong)` with `origin` a uniform copied from `projector.position` — moving the projector or zooming re-uploads nothing.
102
+ - Sheet colour is `mean(colour a, colour b) · envelope · haze · beamIntensity` with `envelope = 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) and `haze = 0.7 + 0.3 · noise(1.4 · worldPosition + drift(time))` (MaterialX noise in TSL, a 3D gradient noise in GLSL).
103
+ - `DoubleSide` with `forceSinglePass`, additive, no depth write, no frustum culling. `beamMode = 'Disabled'` hides the mesh and skips beam uploads entirely.
104
+
105
+ ### 6. Depth-faded beam pass and haze compositing
106
+
107
+ - 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).
108
+ - 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.
109
+ - Composite: `protect = 1 − smoothstep(0.025, 0.3, max(rgb))` and `output = projectionOutput + (1 − exp(−blurredBeams)) · 0.14 · protect`. `1 − exp(−x)` saturates stacked sheets so `beamIntensity` behaves like fog density; `protect` keeps haze off the lines and their bloom.
110
+ - 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; raise `threshold` in scenes with other bright content.
111
+ - 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.
112
+
113
+ ### 7. Bloom parity between the versions
114
+
115
+ - TSL `bloom()` and `UnrealBloomPass` share their lineage but not their scale: `UnrealBloomPass` multiplies its composite by `3.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), so `glow.strength` means the same thing in both versions.
116
+ - 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.
117
+
118
+ ### 8. Performance knobs that actually matter
119
+
120
+ - `capacity` (constructor) — trail length in samples and the size of every CPU and GPU buffer; 2048 by default, up to 65,536.
121
+ - `pointRate` — samples per second, i.e. CPU work per frame; also the animation speed.
122
+ - `beamMode = 'Disabled'` — removes the beam pass, blur and haze, not just the geometry.
123
+ - `samples` (pipeline option) — MSAA of the scene pass; the beam pass is always half resolution without MSAA.
124
+ - `zoom`, `intensity`, `beamIntensity` and `projector.position` are uniforms and free to animate; `gain`, `dampening`, `blankingOffset`, `lightDecay` and `beamMode` rebuild the buffers on the next `update()`.
125
+
126
+ ---
127
+
128
+ ## Quick start
129
+
130
+ Install the package next to Three.js r185:
131
+
132
+ ```bash
133
+ npm install three-ilda three@^0.185.0
134
+ ```
135
+
136
+ Then import from `three-ilda` (WebGPU / TSL version) or `three-ilda/webgl` (WebGL 2 version), see [Using the library](#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.
137
+
138
+ To run the examples from a clone (Node.js 22+):
139
+
140
+ ```bash
141
+ npm ci
142
+ npm run dev # gallery, minimal examples and validation pages on http://127.0.0.1:5181
143
+ npm test # parser, scanner, ownership, blanking, pipeline registration — both versions
144
+ npm run build # static example site in dist-examples/
145
+ ```
146
+
147
+ ---
148
+
149
+ ## Using the library
150
+
151
+ ### WebGPU / TSL version
152
+
153
+ ```js
154
+ import * as THREE from 'three/webgpu'
155
+ import { ILDALoader, LaserShow, LaserShowPipeline } from 'three-ilda'
156
+
157
+ const renderer = new THREE.WebGPURenderer({ antialias: false })
158
+ await renderer.init()
159
+
160
+ const asset = await new ILDALoader().loadAsync('/shows/example.ild')
161
+ const show = new LaserShow(asset, { track: 0, width: 4.8, height: 3.2 })
162
+ show.beamMode = 'Front'
163
+ scene.add(show)
164
+
165
+ const pipeline = new LaserShowPipeline(renderer, scene, camera) // optional: bloom, beams, haze
166
+ pipeline.add(show)
167
+ pipeline.glow.strength.value = 15
168
+
169
+ const timer = new THREE.Timer(); timer.connect(document)
170
+ renderer.setAnimationLoop(() => {
171
+ timer.update()
172
+ show.update(timer.getDelta()) // seconds; update(0) applies parameter changes while paused
173
+ pipeline.render() // or renderer.render(scene, camera) without the pipeline
174
+ })
175
+ ```
176
+
177
+ ### WebGL 2 version
178
+
179
+ ```js
180
+ import * as THREE from 'three'
181
+ import { ILDALoader, LaserShow, LaserShowPipeline } from 'three-ilda/webgl'
182
+
183
+ const renderer = new THREE.WebGLRenderer({ antialias: false })
184
+
185
+ const asset = await new ILDALoader().loadAsync('/shows/example.ild')
186
+ const show = new LaserShow(asset, { track: 0, width: 4.8, height: 3.2 })
187
+ show.beamMode = 'Front'
188
+ scene.add(show)
189
+
190
+ const pipeline = new LaserShowPipeline(renderer, scene, camera)
191
+ pipeline.add(show)
192
+ pipeline.glow.strength = 15
193
+
194
+ const timer = new THREE.Timer(); timer.connect(document)
195
+ renderer.setAnimationLoop(() => {
196
+ timer.update()
197
+ show.update(timer.getDelta())
198
+ pipeline.render()
199
+ })
200
+ ```
201
+
202
+ What happens in both:
203
+
204
+ 1. `ILDALoader` parses the file into frames grouped by projector track (`asset.tracks`), with positions normalised to [−1, 1), colours in [0, 1] and blank flags.
205
+ 2. `LaserShow` builds a `LaserScanner` for one track and owns its GPU buffers; several shows may share one asset.
206
+ 3. `LaserShowPipeline.add(show)` moves the beam mesh to the reserved layer and installs the depth fade; `remove(show)` restores it.
207
+ 4. `show.update(dt)` advances the scanner and uploads the compacted segments; `pipeline.render()` draws the scene, bloom, beams and haze.
208
+
209
+ Teardown: `pipeline.dispose(); scene.remove(show); show.dispose()`. The asset stays valid for other shows.
210
+
211
+ ```ts
212
+ ILDAAsset {
213
+ frames: ILDAFrame[] // file order; palette records excluded
214
+ tracks: Map<number, ILDAFrame[]> // the same objects grouped by projector, keys in order of first appearance
215
+ totalPoints, blanked, paletteFallbacks: number
216
+ eof: boolean // explicit end-of-file record seen
217
+ bytes, parseTimeMs: number
218
+ }
219
+ ILDAFrame { number, name, projector, count, position: Float32Array, color: Float32Array, blank: Uint8Array }
220
+ ```
221
+
222
+ ---
223
+
224
+ ## Parameters
225
+
226
+ Defaults come from the reference notebook; ranges are enforced by the setters (`RangeError` outside them).
227
+
228
+ ### Playback and scanner (`LaserShow`)
229
+
230
+ | Parameter | Type | Default | Range | Description |
231
+ | --- | --- | --- | --- | --- |
232
+ | `playing` | `boolean` | `true` | — | Advance the scanner and the fade clock in `update()` |
233
+ | `pointRate` | `number` | `8000` | `1` – `100000` | Scanner samples per second; the animation rate is `pointRate / pointsPerFrame` |
234
+ | `gain` | `number` | `0.51` | `0.001` – `2` | Mirror spring constant |
235
+ | `dampening` | `number` | `0.39` | `0` – `1` | Velocity kept per sample; larger = less damping, `0` freezes, `1` rings forever |
236
+ | `blankingOffset` | `number` (int) | `-3` | `-100` – `100` | Which point's blank flag applies to the current sample, in points |
237
+ | `lightDecay` | `number` | `0.95` | `0` – `1` | Afterglow factor per 1/60 s; `0` shows only the last 1/60 s |
238
+
239
+ ### Projection and beams (`LaserShow`)
240
+
241
+ | Parameter | Type | Default | Range | Description |
242
+ | --- | --- | --- | --- | --- |
243
+ | `zoom` | `number` | `1` | `0` – `4` | Multiplies the projection size (uniform, free to animate) |
244
+ | `intensity` | `number` | `1` | `0` – `100` | Line colour multiplier (uniform) |
245
+ | `beamMode` | `'Disabled' \| 'Front' \| 'Back'` | `'Disabled'` | — | Beam sheets on the local +Z or −Z side of the image plane; `Disabled` also skips the beam passes |
246
+ | `beamIntensity` | `number` | `0.5` | `0` – `2` | Sheet colour multiplier, acts like fog density through the haze compression |
247
+ | `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 |
248
+ | `setProjectionSize(width, height)` | method | `4.8 × 3.2` | `0.001` – `10000` | Full image size in local units at `zoom = 1` |
249
+
250
+ ### Constructor and lifecycle (`LaserShow`)
251
+
252
+ | Member | Type | Default | Description |
253
+ | --- | --- | --- | --- |
254
+ | `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 |
255
+ | `update(deltaSeconds)` | method | — | Call once per frame; `update(0)` applies pending changes while paused |
256
+ | `setData(asset, track?)` / `setTrack(id)` | methods | — | Replace the show or switch projector track; a fresh scanner, immediate upload |
257
+ | `seekFrame(index)` / `reset()` | methods | — | Seek (re-scans one frame) or clear the trail; `playing` is untouched |
258
+ | `frame`, `frameCount`, `trackIds`, `time`, `projectionSize`, `asset`, `track`, `scanner` | read-only | — | Playback information |
259
+ | `clone()` | method | — | Shares the asset, copies scanner state including the ring, allocates new GPU resources |
260
+ | `dispose()` | method | — | Frees this instance's geometry and materials and emits `dispose`; the asset stays usable |
261
+
262
+ ### Pipeline (`LaserShowPipeline`)
263
+
264
+ | Option / member | Type | Default | Range | Description |
265
+ | --- | --- | --- | --- | --- |
266
+ | `beamLayer` | `number` (int) | `31` | `1` – `31` | Layer reserved for registered beam meshes |
267
+ | `strength` | `number` | `15` | — | Bloom strength; later via `glow.strength.value` (WebGPU / TSL) or `glow.strength` (WebGL 2) |
268
+ | `radius` | `number` | `1` | `0` – `1` | Bloom radius, weights the larger mips |
269
+ | `threshold` | `number` | `0` | — | Bloom luminance threshold; raise it in scenes with other bright content |
270
+ | `samples` | `number` (int) | `4` | — | MSAA sample count of the scene pass |
271
+ | `inspect` | `boolean` | `false` | — | WebGPU / TSL version only: `toInspector()` labels for the scene, bloom and beam textures |
272
+ | `add(show)` / `remove(show)` | methods | — | — | Register a show (one pipeline per show at a time); `remove` restores layers and material state |
273
+ | `render()` | method | — | — | Render the host scene and camera through the pipeline into the current render target |
274
+ | `dispose()` | method | — | — | Free passes, targets and nodes; nothing of the application's |
275
+
276
+ ### Loader (`ILDALoader`)
277
+
278
+ | Member | Type | Default | Description |
279
+ | --- | --- | --- | --- |
280
+ | `load(url, onLoad, onProgress?, onError?)` / `loadAsync(url)` | methods | — | Standard `THREE.Loader` API through `FileLoader`; parse errors reach `onError` and the `LoadingManager` |
281
+ | `parse(arrayBuffer)` | method | — | Synchronous; formats 0, 1, 2, 4, 5; limits 32 MiB and 2,000,000 points |
282
+ | `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 |
283
+ | `paletteFallbacks` (result) | `number` | — | Indices outside the palette became white and were counted instead of throwing |
284
+
285
+ ---
286
+
287
+ ## Architecture (source map)
288
+
289
+ ```
290
+ src/
291
+ index.js # WebGPU / TSL version entry
292
+ webgl.js # WebGL 2 version entry
293
+ loaders/ILDALoader.js # IDTF formats 0/1/2/4/5 → typed arrays (shared)
294
+ core/LaserScanner.js # mirror model, sample clock, ring buffer (shared)
295
+ core/LaserShowBase.js # scene graph, compaction, uploads, public API (shared)
296
+ objects/LaserShow.js # TSL materials (WebGPU / TSL version)
297
+ postprocessing/LaserShowPipeline.js # RenderPipeline: scene pass, bloom(), beam pass, blur, haze
298
+ webgl/LaserShow.js # GLSL ShaderMaterials (WebGL 2 version)
299
+ webgl/LaserShowPipeline.js # render targets, DepthTexture, UnrealBloomPass, blur, composite
300
+
301
+ examples/ # gallery showcase, *-webgpu.html and *-webgl.html pages per version, ILDA collection
302
+ test/ # node:test suites, run for both versions
303
+ types/ # .d.ts generated from the JSDoc by npm run types, not committed
304
+ ```
305
+
306
+ ### Key exports for reuse
307
+
308
+ | Export | From | Role |
309
+ | --- | --- | --- |
310
+ | `ILDALoader`, `ILDA_DEFAULT_PALETTE` | `three-ilda`, `three-ilda/webgl` | Parse `.ild` files; no renderer dependency |
311
+ | `LaserScanner`, `SCANNER_DEFAULTS`, `TRAIL_POINTS` | `three-ilda`, `three-ilda/webgl` | The scanner alone, for workers or other renderers |
312
+ | `LaserShow` | `three-ilda` / `three-ilda/webgl` | The playable object for the respective renderer |
313
+ | `LaserShowPipeline` | `three-ilda` / `three-ilda/webgl` | Optional bloom, beams and haze for the respective renderer |
314
+ | `LaserShowBase` | `three-ilda/core/LaserShowBase.js` | Bring your own materials: implement `_createMaterials()` for another renderer |
315
+
316
+ ---
317
+
318
+ ## Examples
319
+
320
+ | Page | Version | What it shows |
321
+ | --- | --- | --- |
322
+ | `index.html` | WebGPU / TSL | The gallery showcase: the ILDA collection with live previews, one `LaserShow` and one pipeline, every parameter in the Inspector |
323
+ | `minimal-webgpu.html` | WebGPU / TSL | The smallest complete setup: renderer, one show, the pipeline and the loop, about 40 lines |
324
+ | `minimal-webgl.html` | WebGL 2 | The same setup on `WebGLRenderer` through `three-ilda/webgl` |
325
+ | `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 |
326
+ | `validation-webgl.html` | WebGL 2 | The same checks for the WebGL 2 version, so the two reports can be compared line by line |
327
+
328
+ Each page imports exactly one version. Details in [examples/README.md](./examples/README.md).
329
+
330
+ ---
331
+
332
+ ## Browser / renderer notes
333
+
334
+ - WebGPU / TSL version: create `THREE.WebGPURenderer` and `await renderer.init()` before the first frame. It needs a browser with WebGPU; for WebGL 2 browsers use the WebGL 2 version.
335
+ - WebGL 2 version: the pipeline allocates half-float targets and a `DepthTexture`, sizes them from the drawing buffer on each `render()` and composites into whatever render target is current, so it can feed a further pass.
336
+ - 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.
337
+ - Both pipelines assume a dark scene: `protect` and bloom read the whole image, so a bright background suppresses haze and blooms itself.
338
+ - The library owns no clock. `THREE.Timer.connect(document)` avoids catch-up after a hidden tab; the examples also clamp `dt` to 0.1 s.
339
+ - The examples render with `LinearSRGBColorSpace` output and no tone mapping so the default `intensity` and bloom values look the same in both versions; with sRGB output or tone mapping expect brighter, softer lines.
340
+ - 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.
341
+
342
+ ---
343
+
344
+ ## Credits
345
+
346
+ - The scanner algorithm follows [Tom Larkworthy's ILDA laser show player](https://observablehq.com/@tomlarkworthy/ilda-laser-show-player).
347
+ - File parsing follows [ILDA IDTF revision 011](https://www.ilda.com/resources/StandardsDocs/ILDA_IDTF14_rev011.pdf).
348
+ - The ILDA collection and further notices are listed in [CREDITS.md](./CREDITS.md).
349
+
350
+ ---
351
+
352
+ ## License
353
+
354
+ MIT © Artem Korenevych. See [LICENSE](LICENSE) and [CREDITS.md](CREDITS.md).
package/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "name": "three-ilda",
3
+ "version": "0.1.0",
4
+ "description": "ILDA loader and laser shows for Three.js, in a WebGPU / TSL version and a WebGL 2 version",
5
+ "keywords": [
6
+ "three",
7
+ "threejs",
8
+ "three.js",
9
+ "webgpu",
10
+ "tsl",
11
+ "webgl2",
12
+ "ilda",
13
+ "laser",
14
+ "laser-show",
15
+ "galvanometer"
16
+ ],
17
+ "author": "Artem Korenevych <https://artcreativecode.com>",
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/artcodev/three-ilda.git"
22
+ },
23
+ "homepage": "https://github.com/artcodev/three-ilda#readme",
24
+ "bugs": {
25
+ "url": "https://github.com/artcodev/three-ilda/issues"
26
+ },
27
+ "type": "module",
28
+ "sideEffects": false,
29
+ "exports": {
30
+ ".": {
31
+ "types": "./types/index.d.ts",
32
+ "default": "./src/index.js"
33
+ },
34
+ "./webgl": {
35
+ "types": "./types/webgl.d.ts",
36
+ "default": "./src/webgl.js"
37
+ },
38
+ "./loaders/ILDALoader.js": {
39
+ "types": "./types/loaders/ILDALoader.d.ts",
40
+ "default": "./src/loaders/ILDALoader.js"
41
+ },
42
+ "./core/LaserScanner.js": {
43
+ "types": "./types/core/LaserScanner.d.ts",
44
+ "default": "./src/core/LaserScanner.js"
45
+ },
46
+ "./core/LaserShowBase.js": {
47
+ "types": "./types/core/LaserShowBase.d.ts",
48
+ "default": "./src/core/LaserShowBase.js"
49
+ },
50
+ "./objects/LaserShow.js": {
51
+ "types": "./types/objects/LaserShow.d.ts",
52
+ "default": "./src/objects/LaserShow.js"
53
+ },
54
+ "./postprocessing/LaserShowPipeline.js": {
55
+ "types": "./types/postprocessing/LaserShowPipeline.d.ts",
56
+ "default": "./src/postprocessing/LaserShowPipeline.js"
57
+ },
58
+ "./webgl/LaserShow.js": {
59
+ "types": "./types/webgl/LaserShow.d.ts",
60
+ "default": "./src/webgl/LaserShow.js"
61
+ },
62
+ "./webgl/LaserShowPipeline.js": {
63
+ "types": "./types/webgl/LaserShowPipeline.d.ts",
64
+ "default": "./src/webgl/LaserShowPipeline.js"
65
+ },
66
+ "./package.json": "./package.json"
67
+ },
68
+ "files": [
69
+ "src",
70
+ "types",
71
+ "README.md",
72
+ "CREDITS.md",
73
+ "LICENSE"
74
+ ],
75
+ "scripts": {
76
+ "dev": "vite --config vite.examples.config.js",
77
+ "build": "vite build --config vite.examples.config.js",
78
+ "preview": "vite preview --config vite.examples.config.js",
79
+ "test": "node --test test/*.test.js",
80
+ "types": "tsc -p tsconfig.types.json",
81
+ "prepack": "npm run types",
82
+ "prepublishOnly": "npm test"
83
+ },
84
+ "peerDependencies": {
85
+ "three": "^0.185.0"
86
+ },
87
+ "devDependencies": {
88
+ "@types/three": "^0.185.4",
89
+ "three": "0.185.0",
90
+ "typescript": "^5.9.0",
91
+ "vite": "6.4.3"
92
+ }
93
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @typedef {Object} ScannerParams
3
+ * @property {number} rate Samples per second.
4
+ * @property {number} gain Mirror spring constant.
5
+ * @property {number} dampening Fraction of velocity kept per sample (larger = less damping).
6
+ * @property {number} blankingOffset Blank-flag offset in points.
7
+ * @property {number} lightDecay Afterglow factor per 1/60 s.
8
+ */
9
+
10
+ export const TRAIL_POINTS = 2048
11
+ /** @type {Readonly<ScannerParams>} */
12
+ export const SCANNER_DEFAULTS = Object.freeze({ rate: 8000, gain: 0.51, dampening: 0.39, blankingOffset: -3, lightDecay: 0.95 })
13
+
14
+ /** The demo's per-sample galvo recurrence, with a fixed-size ring instead of shifting/allocating arrays each frame. */
15
+ export class LaserScanner {
16
+ /**
17
+ * @param {import('../loaders/ILDALoader.js').ILDAFrame[]} frames Frames of one projector track.
18
+ * @param {number} [capacity] Ring size in samples.
19
+ */
20
+ constructor(frames, capacity = TRAIL_POINTS) {
21
+ if (!frames.length || frames.some(f => !f.count)) throw new Error('No points to scan.')
22
+ this.frames = frames; this.capacity = capacity
23
+ this.starts = []; this.total = 0
24
+ for (const f of frames) { this.starts.push(this.total); this.total += f.count }
25
+ this.position = new Float32Array(capacity * 3); this.color = new Float32Array(capacity * 3)
26
+ this.blank = new Uint8Array(capacity); this.born = new Float64Array(capacity)
27
+ this.output = { position: new Float32Array(capacity * 3), color: new Float32Array(capacity * 3), blank: new Uint8Array(capacity), count: 0 }
28
+ this.reset()
29
+ }
30
+
31
+ /** @param {number} [frame] */
32
+ reset(frame = 0) {
33
+ this.frame = frame; this.index = 0; this.lastFrame = frame; this.x = 0; this.y = 0; this.vx = 0; this.vy = 0
34
+ this.time = 0; this.fraction = 0; this.write = 0; this.count = 0; this.samples = 0; this.output.count = 0
35
+ }
36
+
37
+ get point() { return this.starts[this.frame] + this.index }
38
+
39
+ /** @param {number} offset @returns {number} */
40
+ blankAtOffset(offset) {
41
+ let frame = this.frame, index = this.index + offset
42
+ while (index < 0) { frame = (frame + this.frames.length - 1) % this.frames.length; index += this.frames[frame].count }
43
+ while (index >= this.frames[frame].count) { index -= this.frames[frame].count; frame = (frame + 1) % this.frames.length }
44
+ return this.frames[frame].blank[index]
45
+ }
46
+
47
+ /** @param {ScannerParams} params @param {number} born */
48
+ sample(params, born) {
49
+ const f = this.frames[this.frame], p = this.index * 3
50
+ this.vx = (this.vx + (f.position[p] - this.x) * params.gain) * params.dampening
51
+ this.vy = (this.vy + (f.position[p + 1] - this.y) * params.gain) * params.dampening
52
+ this.x += this.vx; this.y += this.vy
53
+ // Protect rendering from unbounded tuning/input; ordinary slider ranges never approach this guard.
54
+ if (!Number.isFinite(this.x + this.y) || Math.abs(this.x) + Math.abs(this.y) > 1e6) { this.x = f.position[p]; this.y = f.position[p + 1]; this.vx = this.vy = 0 }
55
+ const i = this.write, q = i * 3, blank = this.blankAtOffset(Math.round(params.blankingOffset))
56
+ this.position[q] = this.x; this.position[q + 1] = this.y; this.position[q + 2] = 0 // scanner axes are X/Y, as in the demo
57
+ this.color[q] = f.color[p]; this.color[q + 1] = f.color[p + 1]; this.color[q + 2] = f.color[p + 2]
58
+ this.blank[i] = blank; this.born[i] = born; this.lastFrame = this.frame
59
+ this.write = (i + 1) % this.capacity; this.count = Math.min(this.count + 1, this.capacity); this.samples++
60
+ if (++this.index >= f.count) { this.index = 0; this.frame = (this.frame + 1) % this.frames.length }
61
+ }
62
+
63
+ /** @param {number} dt Seconds. @param {ScannerParams} params @returns {number} Samples produced. */
64
+ advance(dt, params) {
65
+ if (!(dt > 0) || !Number.isFinite(dt)) return 0
66
+ this.time += dt
67
+ const requested = this.fraction + dt * params.rate, steps = Math.floor(requested + 1e-9)
68
+ this.fraction = Math.max(0, requested - steps)
69
+ const first = this.time - (steps - 1 + this.fraction) / params.rate
70
+ for (let i = 0; i < steps; i++) this.sample(params, first + i / params.rate)
71
+ return steps
72
+ }
73
+
74
+ /** @param {number} frame @param {ScannerParams} params */
75
+ seekFrame(frame, params) {
76
+ this.reset(Math.max(0, Math.min(this.frames.length - 1, frame)))
77
+ // Build the selected frame's trail while paused, instead of displaying stale samples from the old location.
78
+ this.advance(this.frames[this.frame].count / params.rate, params)
79
+ }
80
+
81
+ /** @param {number} lightDecay */
82
+ snapshot(lightDecay) {
83
+ const out = this.output, start = (this.write - this.count + this.capacity) % this.capacity
84
+ for (let j = 0; j < this.count; j++) {
85
+ const i = (start + j) % this.capacity, p = i * 3, q = j * 3, age = Math.max(0, this.time - this.born[i])
86
+ // 0.95 is the demo's decay per display frame. Normalize to 60 Hz so persistence is stable at 30/60/120 FPS.
87
+ const decay = lightDecay === 0 ? (age < 1 / 60 ? 1 : 0) : Math.pow(lightDecay, age * 60)
88
+ const fade = this.blank[i] ? 0 : decay
89
+ out.position[q] = this.position[p]; out.position[q + 1] = this.position[p + 1]; out.position[q + 2] = 0
90
+ out.color[q] = this.color[p] * fade; out.color[q + 1] = this.color[p + 1] * fade; out.color[q + 2] = this.color[p + 2] * fade
91
+ out.blank[j] = this.blank[i]
92
+ }
93
+ out.count = this.count
94
+ return out
95
+ }
96
+ }
97
+