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.
@@ -0,0 +1,224 @@
1
+ import { BufferAttribute, BufferGeometry, DynamicDrawUsage, Group, LineSegments, Mesh, Object3D, Vector2 } from 'three'
2
+ import { LaserScanner, SCANNER_DEFAULTS, TRAIL_POINTS } from './LaserScanner.js'
3
+
4
+ function number(value, min, max, name) {
5
+ if (!Number.isFinite(value) || value < min || value > max) throw new RangeError(`${name} must be between ${min} and ${max}.`)
6
+ return value
7
+ }
8
+ function dynamic(size, width = 3) { return new BufferAttribute(new Float32Array(size), width).setUsage(DynamicDrawUsage) }
9
+ function upload(attribute, size) {
10
+ attribute.clearUpdateRanges()
11
+ if (size) { attribute.addUpdateRange(0, size); attribute.needsUpdate = true }
12
+ }
13
+
14
+ /** @typedef {import('../loaders/ILDALoader.js').ILDAAsset} ILDAAsset */
15
+ /** @typedef {'Disabled' | 'Front' | 'Back'} BeamMode */
16
+ /**
17
+ * @typedef {Object} LaserShowOptions
18
+ * @property {number} [track] Projector track to play; defaults to the first track of the asset.
19
+ * @property {number} [capacity] Trail length in samples, 2 to 65536 (default 2048).
20
+ * @property {number} [width] Projection width in local units at zoom 1 (default 4.8).
21
+ * @property {number} [height] Projection height in local units at zoom 1 (default 3.2).
22
+ */
23
+
24
+ /**
25
+ * Renderer-independent part of a laser show: scene graph, scanner playback, segment compaction and buffer uploads.
26
+ * The image lies in local XY at Z=0; Front is +Z, Back is -Z, regardless of the camera.
27
+ * One instance plays one ILDA projector track. Assets may be shared; playback/GPU buffers are independent.
28
+ * A subclass supplies the two materials through _createMaterials(); the uniform handles it returns only need a `.value`.
29
+ */
30
+ export class LaserShowBase extends Group {
31
+ /**
32
+ * @param {ILDAAsset | null} [asset] Parsed ILDA file; call setData() later when omitted.
33
+ * @param {LaserShowOptions} [options]
34
+ */
35
+ constructor(asset = null, { track, capacity = TRAIL_POINTS, width = 4.8, height = 3.2 } = {}) {
36
+ super()
37
+ if (!Number.isInteger(capacity) || capacity < 2 || capacity > 65536) throw new RangeError('capacity must be an integer from 2 to 65536.')
38
+ this.type = 'LaserShow'; this.name = 'ILDA laser show'; this.isLaserShow = true
39
+ this.capacity = capacity; this.playing = true; this.disposed = false
40
+ /** @type {import('./LaserScanner.js').ScannerParams} */
41
+ this._params = { ...SCANNER_DEFAULTS }
42
+ /** @type {BeamMode} */
43
+ this._beamMode = 'Disabled'
44
+ this._zoom = 1; this._dirty = true
45
+ /** @type {ILDAAsset | null} */ this.asset = null
46
+ /** @type {number | undefined} */ this.track = undefined
47
+ /** @type {LaserScanner | null} */ this.scanner = null
48
+ this._projectionSize = new Vector2(width, height)
49
+ const { line, beam, scale, origin, clock, intensity, beamIntensity } = this._createMaterials()
50
+ this._scale = scale; this._origin = origin; this._clock = clock; this._intensity = intensity; this._beamIntensity = beamIntensity
51
+ this.projector = new Object3D(); this.projector.name = 'Projector aperture'; this.projector.position.set(-2.2, -1.4, 3)
52
+ this.add(this.projector)
53
+
54
+ const segments = capacity - 1
55
+ this._linePosition = dynamic(segments * 6); this._lineColor = dynamic(segments * 6)
56
+ const lineGeometry = new BufferGeometry()
57
+ lineGeometry.setAttribute('position', this._linePosition); lineGeometry.setAttribute('color', this._lineColor); lineGeometry.setDrawRange(0, 0)
58
+ this.projection = new LineSegments(lineGeometry, line)
59
+ this.projection.name = 'Laser projection'; this.projection.frustumCulled = false
60
+ this.add(this.projection)
61
+
62
+ this._beamPosition = dynamic(segments * 9); this._beamColor = dynamic(segments * 9)
63
+ const along = new Float32Array(segments * 3)
64
+ for (let i = 0; i < along.length; i += 3) { along[i + 1] = 1; along[i + 2] = 1 }
65
+ const beamGeometry = new BufferGeometry()
66
+ beamGeometry.setAttribute('position', this._beamPosition); beamGeometry.setAttribute('color', this._beamColor)
67
+ beamGeometry.setAttribute('beamAlong', new BufferAttribute(along, 1)); beamGeometry.setDrawRange(0, 0)
68
+ this.beams = new Mesh(beamGeometry, beam); this.beams.name = 'Soft projector beams'
69
+ this.beams.frustumCulled = false; this.beams.visible = false; this.add(this.beams)
70
+ this.setProjectionSize(width, height)
71
+ if (asset) this.setData(asset, track)
72
+ }
73
+
74
+ /**
75
+ * Creates the line and beam materials for one renderer family.
76
+ * @returns {{ line: import('three').Material, beam: import('three').Material, scale: { value: import('three').Vector3 }, origin: { value: import('three').Vector3 }, clock: { value: number }, intensity: { value: number }, beamIntensity: { value: number } }}
77
+ */
78
+ _createMaterials() { throw new Error('LaserShowBase is abstract: use the LaserShow of the three-ilda or three-ilda/webgl entry.') }
79
+
80
+ get pointRate() { return this._params.rate }
81
+ set pointRate(v) { this._params.rate = number(v, 1, 100000, 'pointRate') }
82
+ get gain() { return this._params.gain }
83
+ set gain(v) { this._params.gain = number(v, 0.001, 2, 'gain'); this._retune() }
84
+ get dampening() { return this._params.dampening }
85
+ set dampening(v) { this._params.dampening = number(v, 0, 1, 'dampening'); this._retune() }
86
+ get blankingOffset() { return this._params.blankingOffset }
87
+ set blankingOffset(v) { this._params.blankingOffset = Math.round(number(v, -100, 100, 'blankingOffset')); this._retune() }
88
+ get lightDecay() { return this._params.lightDecay }
89
+ set lightDecay(v) { this._params.lightDecay = number(v, 0, 1, 'lightDecay'); this._dirty = true }
90
+ get zoom() { return this._zoom }
91
+ set zoom(v) { this._zoom = number(v, 0, 4, 'zoom'); this._updateScale() }
92
+ get intensity() { return this._intensity.value }
93
+ set intensity(v) { this._intensity.value = number(v, 0, 100, 'intensity') }
94
+ get beamIntensity() { return this._beamIntensity.value }
95
+ set beamIntensity(v) { this._beamIntensity.value = number(v, 0, 2, 'beamIntensity') }
96
+ get beamMode() { return this._beamMode }
97
+ /** @param {BeamMode} mode */
98
+ set beamMode(mode) {
99
+ if (!['Disabled', 'Front', 'Back'].includes(mode)) throw new RangeError('beamMode must be Disabled, Front or Back.')
100
+ this._beamMode = mode; this.beams.visible = mode !== 'Disabled'
101
+ if (mode !== 'Disabled') this.projector.position.z = Math.max(0.1, Math.abs(this.projector.position.z)) * (mode === 'Front' ? 1 : -1)
102
+ this._dirty = true
103
+ }
104
+ get frame() { return this.scanner?.lastFrame ?? 0 }
105
+ get frameCount() { return this.scanner?.frames.length ?? 0 }
106
+ get time() { return this.scanner?.time ?? 0 }
107
+ get trackIds() { return this.asset ? [...this.asset.tracks.keys()] : [] }
108
+ get projectionSize() { return this._projectionSize.clone() }
109
+
110
+ /** @param {number} width @param {number} height @returns {this} */
111
+ setProjectionSize(width, height) {
112
+ this._projectionSize.set(number(width, 0.001, 10000, 'width'), number(height, 0.001, 10000, 'height'))
113
+ this._updateScale(); return this
114
+ }
115
+ _updateScale() { this._scale.value.set(this._projectionSize.x * this.zoom / 2, this._projectionSize.y * this.zoom / 2, 0) }
116
+ _retune() {
117
+ if (this.scanner && !this.playing) this.scanner.seekFrame(this.frame, this._params)
118
+ this._dirty = true
119
+ }
120
+ /**
121
+ * Replaces the played file, building a fresh scanner for one projector track.
122
+ * @param {ILDAAsset} asset
123
+ * @param {number} [track] Defaults to the first track of the asset.
124
+ * @returns {this}
125
+ */
126
+ setData(asset, track = asset.tracks.keys().next().value) {
127
+ if (this.disposed) throw new Error('LaserShow has been disposed.')
128
+ const frames = asset?.tracks?.get(track)
129
+ if (!frames?.length) throw new Error(`ILDA track ${track} does not exist.`)
130
+ const scanner = new LaserScanner(frames, this.capacity)
131
+ this.asset = asset; this.track = track; this.scanner = scanner
132
+ this.scanner.seekFrame(0, this._params); this._dirty = true; this.update(0)
133
+ return this
134
+ }
135
+ /** @param {number} track @returns {this} */
136
+ setTrack(track) { return this.setData(this.asset, track) }
137
+ /** @param {number} frame @returns {this} */
138
+ seekFrame(frame) {
139
+ if (!this.scanner) return this
140
+ number(frame, 0, this.frameCount - 1, 'frame')
141
+ this.scanner.seekFrame(Math.floor(frame), this._params); this._dirty = true; this.update(0)
142
+ return this
143
+ }
144
+ reset() { if (this.scanner) { this.scanner.reset(); this._dirty = true; this.update(0) } return this }
145
+
146
+ /**
147
+ * Advances playback and uploads the trail. Call once per application frame, including while paused to apply parameter changes.
148
+ * @param {number} [delta] Seconds since the previous call.
149
+ * @returns {this}
150
+ */
151
+ update(delta = 0) {
152
+ if (this.disposed || !this.scanner) return this
153
+ if (!Number.isFinite(delta) || delta < 0) throw new RangeError('delta must be a finite non-negative number.')
154
+ if (this.playing && delta > 0) { this.scanner.advance(delta, this._params); this._dirty = true }
155
+ this._origin.value.copy(this.projector.position); this._clock.value = this.time
156
+ if (!this._dirty) return this
157
+ const out = this.scanner.snapshot(this.lightDecay)
158
+ const lp = this._linePosition.array, lc = this._lineColor.array, bp = this._beamPosition.array, bc = this._beamColor.array
159
+ let lineSize = 0, beamSize = 0
160
+ for (let i = 1; i < out.count; i++) {
161
+ if (out.blank[i - 1] || out.blank[i]) continue
162
+ const a = (i - 1) * 3, b = i * 3
163
+ if (Math.max(out.color[a], out.color[a + 1], out.color[a + 2], out.color[b], out.color[b + 1], out.color[b + 2]) < 0.001) continue
164
+ for (let j = 0; j < 3; j++) {
165
+ lp[lineSize + j] = out.position[a + j]; lp[lineSize + 3 + j] = out.position[b + j]
166
+ lc[lineSize + j] = out.color[a + j]; lc[lineSize + 3 + j] = out.color[b + j]
167
+ }
168
+ lineSize += 6
169
+ if (!this.beams.visible) continue
170
+ for (let j = 0; j < 3; j++) {
171
+ bp[beamSize + j] = 0; bp[beamSize + 3 + j] = out.position[a + j]; bp[beamSize + 6 + j] = out.position[b + j]
172
+ const color = (out.color[a + j] + out.color[b + j]) * 0.5
173
+ bc[beamSize + j] = color; bc[beamSize + 3 + j] = color; bc[beamSize + 6 + j] = color
174
+ }
175
+ beamSize += 9
176
+ }
177
+ this.projection.geometry.setDrawRange(0, lineSize / 3)
178
+ upload(this._linePosition, lineSize); upload(this._lineColor, lineSize)
179
+ if (this.beams.visible) {
180
+ this.beams.geometry.setDrawRange(0, beamSize / 3)
181
+ upload(this._beamPosition, beamSize); upload(this._beamColor, beamSize)
182
+ }
183
+ this._dirty = false; return this
184
+ }
185
+
186
+ /**
187
+ * Clone shares the immutable asset, but copies scanner history and allocates independent GPU resources.
188
+ * @param {boolean} [recursive]
189
+ * @returns {this}
190
+ */
191
+ clone(recursive = true) {
192
+ return new this.constructor(null, { capacity: this.capacity, width: this._projectionSize.x, height: this._projectionSize.y }).copy(this, recursive)
193
+ }
194
+ /** @param {LaserShowBase} source @param {boolean} [recursive] @returns {this} */
195
+ copy(source, recursive = true) {
196
+ if (this.disposed || source.disposed) throw new Error('Cannot copy a disposed LaserShow.')
197
+ if (this.capacity !== source.capacity) throw new Error('copy() requires matching trail capacities; use clone() to preserve capacity.')
198
+ super.copy(source, false)
199
+ this._params = { ...source._params }; this.playing = source.playing
200
+ this.setProjectionSize(source._projectionSize.x, source._projectionSize.y)
201
+ this.zoom = source.zoom; this.intensity = source.intensity; this.beamIntensity = source.beamIntensity; this.beamMode = source.beamMode
202
+ this.projector.position.copy(source.projector.position)
203
+ this.projection.visible = source.projection.visible; this.beams.visible = source.beams.visible
204
+ if (source.asset) {
205
+ this.setData(source.asset, source.track)
206
+ for (const key of ['frame', 'index', 'lastFrame', 'time', 'fraction', 'write', 'count', 'samples', 'x', 'y', 'vx', 'vy']) this.scanner[key] = source.scanner[key]
207
+ for (const key of ['position', 'color', 'blank', 'born']) this.scanner[key].set(source.scanner[key])
208
+ } else {
209
+ this.asset = null; this.scanner = null; this.track = undefined
210
+ this.projection.geometry.setDrawRange(0, 0); this.beams.geometry.setDrawRange(0, 0)
211
+ }
212
+ for (const child of [...this.children]) if (![this.projector, this.projection, this.beams].includes(child)) this.remove(child)
213
+ if (recursive) for (const child of source.children) if (![source.projector, source.projection, source.beams].includes(child)) this.add(child.clone())
214
+ this._dirty = true; this.update(0); return this
215
+ }
216
+
217
+ /** Releases only this instance's GPU resources; shared ILDA data stays usable. */
218
+ dispose() {
219
+ if (this.disposed) return
220
+ this.projection.geometry.dispose(); this.projection.material.dispose()
221
+ this.beams.geometry.dispose(); this.beams.material.dispose()
222
+ this.disposed = true; this.dispatchEvent({ type: 'dispose' })
223
+ }
224
+ }
package/src/index.js ADDED
@@ -0,0 +1,10 @@
1
+ /** @typedef {import('./loaders/ILDALoader.js').ILDAAsset} ILDAAsset */
2
+ /** @typedef {import('./loaders/ILDALoader.js').ILDAFrame} ILDAFrame */
3
+ /** @typedef {import('./core/LaserScanner.js').ScannerParams} ScannerParams */
4
+ /** @typedef {import('./core/LaserShowBase.js').LaserShowOptions} LaserShowOptions */
5
+ /** @typedef {import('./core/LaserShowBase.js').BeamMode} BeamMode */
6
+ /** @typedef {import('./postprocessing/LaserShowPipeline.js').LaserShowPipelineOptions} LaserShowPipelineOptions */
7
+ export { ILDALoader, ILDA_DEFAULT_PALETTE } from './loaders/ILDALoader.js'
8
+ export { LaserScanner, SCANNER_DEFAULTS, TRAIL_POINTS } from './core/LaserScanner.js'
9
+ export { LaserShow } from './objects/LaserShow.js'
10
+ export { LaserShowPipeline } from './postprocessing/LaserShowPipeline.js'
@@ -0,0 +1,160 @@
1
+ import { FileLoader, Loader } from 'three'
2
+
3
+ const MAX_FILE_BYTES = 32 * 1024 * 1024, MAX_POINTS = 2_000_000
4
+ const FORMAT_SIZE = { 0: 8, 1: 6, 2: 3, 4: 10, 5: 8 }
5
+ const HEADER = 32
6
+
7
+ /**
8
+ * @typedef {Object} ILDAFrame
9
+ * @property {number} number Frame number from the file.
10
+ * @property {string} name Frame name from the file.
11
+ * @property {number} projector Projector ID from the file (zero-based).
12
+ * @property {number} count Number of points.
13
+ * @property {Float32Array} position Interleaved XYZ, normalized by 32768. Z is zero for 2D formats.
14
+ * @property {Float32Array} color Interleaved RGB in [0, 1], including colors of blanked points.
15
+ * @property {Uint8Array} blank One flag per point: 1 means laser off.
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} ILDAAsset
20
+ * @property {ILDAFrame[]} frames Point frames in file order; palette records are not frames.
21
+ * @property {Map<number, ILDAFrame[]>} tracks Frames grouped by projector ID, sharing objects with frames.
22
+ * @property {number} totalPoints Total number of points in all tracks.
23
+ * @property {number} blanked Number of blanked points.
24
+ * @property {number} paletteFallbacks Number of unknown palette indices rendered in white.
25
+ * @property {boolean} eof Whether an explicit end-of-file record was found.
26
+ * @property {number} bytes Input size in bytes.
27
+ * @property {number} parseTimeMs CPU parsing time; excludes downloading and local file reading.
28
+ */
29
+
30
+ /**
31
+ * ILDA IDTF loader for formats 0, 1, 2, 4 and 5. No renderer, DOM or player dependency.
32
+ * Uses the standard Three.js LoadingManager, FileLoader and inherited loadAsync().
33
+ *
34
+ * @example
35
+ * const loader = new ILDALoader(manager);
36
+ * const show = await loader.loadAsync('/shows/example.ild');
37
+ * const localShow = loader.parse(await file.arrayBuffer());
38
+ *
39
+ * @extends Loader
40
+ */
41
+ export class ILDALoader extends Loader {
42
+ /** @param {import('three').LoadingManager} [manager] */
43
+ constructor(manager) {
44
+ super(manager)
45
+ this.fallbackPalette = ILDA_DEFAULT_PALETTE
46
+ }
47
+
48
+ /**
49
+ * Sets colors for indexed files without a format-2 palette. Embedded palettes always win.
50
+ * Copies the array; pass null to restore the standard ILDA palette.
51
+ * @param {ReadonlyArray<ReadonlyArray<number>> | null} palette Between 1 and 256 RGB triplets of integer bytes.
52
+ * @returns {ILDALoader}
53
+ */
54
+ setFallbackPalette(palette) {
55
+ if (palette === null) { this.fallbackPalette = ILDA_DEFAULT_PALETTE; return this }
56
+ if (!Array.isArray(palette) || !palette.length || palette.length > 256 || Array.from(palette).some(rgb =>
57
+ !Array.isArray(rgb) || rgb.length !== 3 || Array.from(rgb).some(v => !Number.isInteger(v) || v < 0 || v > 255))) {
58
+ throw new TypeError('Expected 1–256 RGB triplets with integer values from 0 to 255.')
59
+ }
60
+ this.fallbackPalette = Object.freeze(palette.map(rgb => Object.freeze([...rgb])))
61
+ return this
62
+ }
63
+
64
+ /**
65
+ * @param {string} url URL or path, resolved by the loading manager.
66
+ * @param {(asset: ILDAAsset) => void} onLoad
67
+ * @param {(event: ProgressEvent) => void} [onProgress]
68
+ * @param {(error: Error) => void} [onError] Receives download and parsing errors.
69
+ */
70
+ load(url, onLoad, onProgress, onError) {
71
+ const loader = new FileLoader(this.manager)
72
+ loader.setPath(this.path)
73
+ loader.setResponseType('arraybuffer')
74
+ loader.setRequestHeader(this.requestHeader)
75
+ loader.setWithCredentials(this.withCredentials)
76
+ loader.load(url, buffer => {
77
+ try {
78
+ const asset = this.parse(buffer)
79
+ if (onLoad) onLoad(asset)
80
+ } catch (error) {
81
+ if (onError) onError(error)
82
+ else console.error(error)
83
+ this.manager.itemError(url)
84
+ }
85
+ }, onProgress, onError)
86
+ }
87
+
88
+ /**
89
+ * Parses already-loaded bytes synchronously. Does not create geometry or apply scanner settings.
90
+ * @param {ArrayBuffer} buffer
91
+ * @returns {ILDAAsset}
92
+ * @throws {Error} On invalid data, unsupported formats or the 32 MiB / 2M point limits.
93
+ */
94
+ parse(buffer) {
95
+ const start = performance.now()
96
+ const asset = parseILDA(buffer, this.fallbackPalette)
97
+ asset.parseTimeMs = performance.now() - start
98
+ return asset
99
+ }
100
+ }
101
+
102
+ // ILDA IDTF revision 011, appendix A. A file's format-2 palette overrides this per projector.
103
+ function defaultPalette() {
104
+ const p = []
105
+ for (let i = 0; i < 16; i++) p.push([255, i * 16, 0])
106
+ p.push([255, 255, 0]); for (let i = 7; i > 0; i--) p.push([i * 32, 255, 0])
107
+ p.push([0, 255, 0]); for (const v of [36, 73, 109, 146, 182, 219, 255]) p.push([0, 255, v])
108
+ for (const v of [227, 198, 170, 142, 113, 85, 56, 28, 0]) p.push([0, v, 255])
109
+ for (const v of [32, 64, 96, 128, 160, 192, 224, 255]) p.push([v, 0, 255])
110
+ for (const v of [32, 64, 96, 128, 160, 192, 224, 255]) p.push([255, v, 255])
111
+ for (const v of [224, 192, 160, 128, 96, 64, 32]) p.push([255, v, v])
112
+ return p
113
+ }
114
+ export const ILDA_DEFAULT_PALETTE = Object.freeze(defaultPalette().map(rgb => Object.freeze(rgb)))
115
+
116
+ /** One DataView and a cursor: no repeated copies of the remaining file. Returns all projector tracks. */
117
+ function parseILDA(buffer, fallbackPalette) {
118
+ if (!(buffer instanceof ArrayBuffer)) throw new Error('Expected a binary ILDA file.')
119
+ if (buffer.byteLength > MAX_FILE_BYTES) throw new Error('ILDALoader supports files up to 32 MiB.')
120
+ const v = new DataView(buffer), text = new TextDecoder('ascii'), palettes = new Map(), tracks = new Map(), frames = []
121
+ let offset = 0, totalPoints = 0, blanked = 0, paletteFallbacks = 0, eof = false
122
+ while (offset < v.byteLength) {
123
+ if (offset + HEADER > v.byteLength) throw new Error(`Incomplete ILDA header at byte ${offset}.`)
124
+ if (v.getUint32(offset) !== 0x494c4441) throw new Error(`Invalid ILDA signature at byte ${offset}.`)
125
+ const format = v.getUint8(offset + 7), count = v.getUint16(offset + 24), projector = v.getUint8(offset + 30)
126
+ const size = FORMAT_SIZE[format]
127
+ if (!size) throw new Error(`Unsupported ILDA format ${format}. Supported formats: 0, 1, 2, 4 and 5.`)
128
+ const number = v.getUint16(offset + 26), name = text.decode(new Uint8Array(buffer, offset + 8, 8)).replace(/\0/g, '').trim()
129
+ offset += HEADER
130
+ if (format !== 2 && count === 0) { eof = true; break }
131
+ if (offset + count * size > v.byteLength) throw new Error('The ILDA file ends in the middle of a frame.')
132
+ if (format === 2) {
133
+ if (count > 256 || count === 0) throw new Error('Invalid ILDA palette size.')
134
+ const palette = []
135
+ for (let i = 0; i < count; i++, offset += 3) palette.push([v.getUint8(offset), v.getUint8(offset + 1), v.getUint8(offset + 2)])
136
+ palettes.set(projector, palette); continue
137
+ }
138
+ totalPoints += count
139
+ if (totalPoints > MAX_POINTS) throw new Error('ILDALoader supports up to 2,000,000 points.')
140
+ const position = new Float32Array(count * 3), color = new Float32Array(count * 3), blank = new Uint8Array(count)
141
+ const palette = palettes.get(projector) || fallbackPalette, is3D = format === 0 || format === 4, indexed = format === 0 || format === 1
142
+ for (let i = 0; i < count; i++, offset += size) {
143
+ const j = i * 3, statusAt = offset + (is3D ? 6 : 4)
144
+ position[j] = v.getInt16(offset) / 32768; position[j + 1] = v.getInt16(offset + 2) / 32768
145
+ position[j + 2] = is3D ? v.getInt16(offset + 4) / 32768 : 0
146
+ blank[i] = (v.getUint8(statusAt) & 0x40) !== 0 ? 1 : 0; blanked += blank[i]
147
+ if (indexed) {
148
+ const rgb = palette[v.getUint8(statusAt + 1)]
149
+ if (!rgb) paletteFallbacks++
150
+ color[j] = (rgb?.[0] ?? 255) / 255; color[j + 1] = (rgb?.[1] ?? 255) / 255; color[j + 2] = (rgb?.[2] ?? 255) / 255
151
+ } else { // true-color point records store B, G, R; palette records store R, G, B
152
+ color[j] = v.getUint8(statusAt + 3) / 255; color[j + 1] = v.getUint8(statusAt + 2) / 255; color[j + 2] = v.getUint8(statusAt + 1) / 255
153
+ }
154
+ }
155
+ const frame = { number, name, projector, count, position, color, blank }
156
+ frames.push(frame); if (!tracks.has(projector)) tracks.set(projector, []); tracks.get(projector).push(frame)
157
+ }
158
+ if (!frames.length) throw new Error('The file contains no point frames.')
159
+ return { frames, tracks, totalPoints, blanked, paletteFallbacks, eof, bytes: buffer.byteLength }
160
+ }
@@ -0,0 +1,25 @@
1
+ import { AdditiveBlending, DoubleSide, LineBasicNodeMaterial, MeshBasicNodeMaterial, Vector3 } from 'three/webgpu'
2
+ import { attribute, float, mix, mx_noise_float, positionWorld, smoothstep, uniform, varying, vec3 } from 'three/tsl'
3
+ import { LaserShowBase } from '../core/LaserShowBase.js'
4
+
5
+ /**
6
+ * Laser show for THREE.WebGPURenderer (the WebGPU / TSL version): node materials with the projection and beam math in TSL.
7
+ * Playback, buffers and the public API live in LaserShowBase; ../webgl/LaserShow.js is the WebGL 2 version for WebGLRenderer.
8
+ */
9
+ export class LaserShow extends LaserShowBase {
10
+ _createMaterials() {
11
+ const scale = uniform(new Vector3()), origin = uniform(new Vector3()), clock = uniform(0), intensity = uniform(1), beamIntensity = uniform(0.5)
12
+ const line = new LineBasicNodeMaterial({ transparent: true, depthWrite: false, blending: AdditiveBlending })
13
+ line.positionNode = attribute('position', 'vec3').mul(scale)
14
+ line.colorNode = attribute('color', 'vec3').mul(intensity)
15
+ const beam = new MeshBasicNodeMaterial({ transparent: true, depthWrite: false, blending: AdditiveBlending, side: DoubleSide })
16
+ beam.forceSinglePass = true
17
+ beam.positionNode = mix(origin, attribute('position', 'vec3').mul(scale), attribute('beamAlong', 'float'))
18
+ const along = varying(attribute('beamAlong', 'float'))
19
+ const drift = positionWorld.mul(1.4).add(vec3(clock.mul(0.08), clock.mul(-0.12), 0))
20
+ const haze = mx_noise_float(drift).mul(0.3).add(0.7)
21
+ const envelope = smoothstep(0, 0.025, along).mul(float(1).sub(smoothstep(0.78, 1, along)))
22
+ beam.colorNode = attribute('color', 'vec3').mul(haze).mul(envelope).mul(beamIntensity)
23
+ return { line, beam, scale, origin, clock, intensity, beamIntensity }
24
+ }
25
+ }
@@ -0,0 +1,119 @@
1
+ import { Color, Layers, PassNode, RenderPipeline } from 'three/webgpu'
2
+ import { float, max, orthographicDepthToViewZ, pass, perspectiveDepthToViewZ, positionView, screenUV, smoothstep, uniform, vec2, vec3, vec4 } from 'three/tsl'
3
+ import { bloom } from 'three/addons/tsl/display/BloomNode.js'
4
+ import { gaussianBlur } from 'three/addons/tsl/display/GaussianBlurNode.js'
5
+
6
+ // Same scene and camera, with a black background only while rendering the light layer.
7
+ class BeamPass extends PassNode {
8
+ constructor(scene, camera) { super(PassNode.COLOR, scene, camera, { samples: 0, depthBuffer: false }); this.name = 'Projector beams'; this._clear = new Color() }
9
+ updateBefore(frame) {
10
+ const scene = this.scene, renderer = frame.renderer
11
+ const background = scene.background, backgroundNode = scene.backgroundNode, alpha = renderer.getClearAlpha()
12
+ renderer.getClearColor(this._clear)
13
+ try {
14
+ scene.background = null; scene.backgroundNode = null; renderer.setClearColor(0x000000, 0)
15
+ super.updateBefore(frame)
16
+ } finally {
17
+ scene.background = background; scene.backgroundNode = backgroundNode; renderer.setClearColor(this._clear, alpha)
18
+ }
19
+ }
20
+ }
21
+
22
+ /** @typedef {import('../core/LaserShowBase.js').LaserShowBase} LaserShowBase */
23
+ /**
24
+ * @typedef {Object} LaserShowPipelineOptions
25
+ * @property {number} [beamLayer] Layer reserved for beam meshes, 1 to 31 (default 31).
26
+ * @property {number} [strength] Bloom strength (default 15).
27
+ * @property {number} [radius] Bloom radius (default 1).
28
+ * @property {number} [threshold] Bloom luminance threshold (default 0).
29
+ * @property {number} [samples] MSAA samples of the scene pass (default 4).
30
+ * @property {boolean} [inspect] Label the passes for the r185 Inspector (default false).
31
+ */
32
+
33
+ /**
34
+ * Optional post-processing owned by the host application. Uses its scene, camera and renderer.
35
+ * Reserves beamLayer (default 31); all other objects keep their normal camera layers.
36
+ * One pipeline can serve multiple registered LaserShow objects in the same scene.
37
+ */
38
+ export class LaserShowPipeline {
39
+ /**
40
+ * @param {import('three/webgpu').WebGPURenderer} renderer
41
+ * @param {import('three').Scene} scene
42
+ * @param {import('three').Camera} camera
43
+ * @param {LaserShowPipelineOptions} [options]
44
+ */
45
+ constructor(renderer, scene, camera, { beamLayer = 31, strength = 15, radius = 1, threshold = 0, samples = 4, inspect = false } = {}) {
46
+ if (!Number.isInteger(beamLayer) || beamLayer < 1 || beamLayer > 31) throw new RangeError('beamLayer must be an integer from 1 to 31.')
47
+ this.renderer = renderer; this.camera = camera; this.beamLayer = beamLayer
48
+ /** @type {Map<LaserShowBase, { mask: number, opacity: unknown, onDispose: () => void }>} */
49
+ this.shows = new Map()
50
+ this.disposed = false; this._withBeams = false
51
+ const baseLayers = new Layers(); baseLayers.mask = camera.layers.mask; baseLayers.disable(beamLayer)
52
+ this._baseLayers = baseLayers
53
+ const beamLayers = new Layers(); beamLayers.set(beamLayer)
54
+ this.scenePass = pass(scene, camera, { samples }).setLayers(baseLayers)
55
+ this._near = uniform(camera.near); this._far = uniform(camera.far)
56
+ const depth = this.scenePass.getTextureNode('depth').sample(screenUV)
57
+ this._sceneViewZ = (camera.isPerspectiveCamera ? perspectiveDepthToViewZ : orthographicDepthToViewZ)(depth, this._near, this._far)
58
+ this.beamPass = new BeamPass(scene, camera).setLayers(beamLayers).setResolutionScale(0.5)
59
+ const label = (node, name) => inspect ? node.toInspector(name) : node
60
+ const sceneColor = label(this.scenePass.getTextureNode('output'), 'Scene / projection')
61
+ this.glow = bloom(sceneColor, strength, radius, threshold)
62
+ this.blur = gaussianBlur(this.beamPass.getTextureNode('output'), vec2(1, 1), 3)
63
+ const glowColor = label(this.glow, 'Laser bloom'), beamColor = label(this.blur, 'Soft projector beams')
64
+ this.projectionOutput = sceneColor.add(glowColor)
65
+ const protect = float(1).sub(smoothstep(0.025, 0.3, max(this.projectionOutput.r, max(this.projectionOutput.g, this.projectionOutput.b))))
66
+ const haze = vec3(1).sub(beamColor.rgb.negate().exp()).mul(0.14).mul(protect)
67
+ this.beamOutput = vec4(this.projectionOutput.rgb.add(haze), this.projectionOutput.a)
68
+ this.pipeline = new RenderPipeline(renderer, this.projectionOutput)
69
+ }
70
+
71
+ /** @param {LaserShowBase} show @returns {this} */
72
+ add(show) {
73
+ if (this.shows.has(show)) return this
74
+ if (!show.isLaserShow || show.disposed) throw new TypeError('Expected a live LaserShow.')
75
+ if (!show.beams.material.isNodeMaterial) throw new TypeError('Expected a LaserShow from the three-ilda entry; three-ilda/webgl shows need the webgl pipeline.')
76
+ if (show.beams.userData.laserPipeline) throw new Error('This LaserShow already belongs to another LaserShowPipeline.')
77
+ const saved = { mask: show.beams.layers.mask, opacity: show.beams.material.opacityNode, onDispose: () => this.remove(show) }
78
+ this.shows.set(show, saved); show.beams.userData.laserPipeline = this
79
+ show.beams.layers.set(this.beamLayer)
80
+ // Beam geometry is drawn separately, but the main scene depth still occludes it.
81
+ const difference = this._sceneViewZ.sub(positionView.z)
82
+ show.beams.material.opacityNode = float(1).sub(smoothstep(0.002, 0.015, difference))
83
+ show.beams.material.needsUpdate = true; show.addEventListener('dispose', saved.onDispose)
84
+ return this
85
+ }
86
+
87
+ /** @param {LaserShowBase} show @returns {this} */
88
+ remove(show) {
89
+ const saved = this.shows.get(show)
90
+ if (!saved) return this
91
+ show.beams.layers.mask = saved.mask; show.beams.material.opacityNode = saved.opacity
92
+ show.beams.material.needsUpdate = true; delete show.beams.userData.laserPipeline
93
+ show.removeEventListener('dispose', saved.onDispose); this.shows.delete(show)
94
+ return this
95
+ }
96
+
97
+ render() {
98
+ if (this.disposed) return
99
+ this._near.value = this.camera.near; this._far.value = this.camera.far
100
+ this._baseLayers.mask = this.camera.layers.mask; this._baseLayers.disable(this.beamLayer)
101
+ let beams = false
102
+ for (const show of this.shows.keys()) {
103
+ let visible = show.beams.visible && show.beamIntensity > 0
104
+ for (let parent = show; parent; parent = parent.parent) visible &&= parent.visible
105
+ if (visible) { beams = true; break }
106
+ }
107
+ if (beams !== this._withBeams) {
108
+ this._withBeams = beams; this.pipeline.outputNode = beams ? this.beamOutput : this.projectionOutput; this.pipeline.needsUpdate = true
109
+ }
110
+ this.pipeline.render()
111
+ }
112
+
113
+ dispose() {
114
+ if (this.disposed) return
115
+ for (const show of [...this.shows.keys()]) this.remove(show)
116
+ this.pipeline.dispose(); this.scenePass.dispose(); this.beamPass.dispose(); this.glow.dispose(); this.blur.dispose()
117
+ this.disposed = true
118
+ }
119
+ }
@@ -0,0 +1,85 @@
1
+ import { AdditiveBlending, DoubleSide, ShaderMaterial, Vector2, Vector3 } from 'three'
2
+ import { LaserShowBase } from '../core/LaserShowBase.js'
3
+
4
+ const LINE_VERTEX = /* glsl */`
5
+ uniform vec3 scale;
6
+ varying vec3 vColor;
7
+ void main() {
8
+ vColor = color;
9
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position * scale, 1.0);
10
+ }`
11
+
12
+ const LINE_FRAGMENT = /* glsl */`
13
+ uniform float intensity;
14
+ varying vec3 vColor;
15
+ void main() { gl_FragColor = vec4(vColor * intensity, 1.0); }`
16
+
17
+ const BEAM_VERTEX = /* glsl */`
18
+ uniform vec3 scale, origin;
19
+ attribute float beamAlong;
20
+ varying vec3 vColor, vWorld;
21
+ varying float vAlong, vViewZ;
22
+ void main() {
23
+ vColor = color; vAlong = beamAlong;
24
+ vec4 world = modelMatrix * vec4(mix(origin, position * scale, beamAlong), 1.0);
25
+ vec4 view = viewMatrix * world;
26
+ vWorld = world.xyz; vViewZ = view.z;
27
+ gl_Position = projectionMatrix * view;
28
+ }`
29
+
30
+ // DEPTH_FADE is defined by LaserShowPipeline, which also provides the scene depth uniforms.
31
+ const BEAM_FRAGMENT = /* glsl */`
32
+ uniform float clock, beamIntensity;
33
+ varying vec3 vColor, vWorld;
34
+ varying float vAlong, vViewZ;
35
+ #ifdef DEPTH_FADE
36
+ #include <packing>
37
+ uniform sampler2D tDepth;
38
+ uniform vec2 resolution;
39
+ uniform float cameraNear, cameraFar;
40
+ uniform bool orthographic;
41
+ #endif
42
+ // 3D gradient noise, the role mx_noise_float plays in the TSL material; values stay roughly within [-1, 1].
43
+ vec3 hash(vec3 p) {
44
+ p = vec3(dot(p, vec3(127.1, 311.7, 74.7)), dot(p, vec3(269.5, 183.3, 246.1)), dot(p, vec3(113.5, 271.9, 124.6)));
45
+ return -1.0 + 2.0 * fract(sin(p) * 43758.5453123);
46
+ }
47
+ float noise(vec3 p) {
48
+ vec3 i = floor(p), f = fract(p), u = f * f * (3.0 - 2.0 * f);
49
+ return mix(
50
+ mix(mix(dot(hash(i), f), dot(hash(i + vec3(1.0, 0.0, 0.0)), f - vec3(1.0, 0.0, 0.0)), u.x),
51
+ mix(dot(hash(i + vec3(0.0, 1.0, 0.0)), f - vec3(0.0, 1.0, 0.0)), dot(hash(i + vec3(1.0, 1.0, 0.0)), f - vec3(1.0, 1.0, 0.0)), u.x), u.y),
52
+ mix(mix(dot(hash(i + vec3(0.0, 0.0, 1.0)), f - vec3(0.0, 0.0, 1.0)), dot(hash(i + vec3(1.0, 0.0, 1.0)), f - vec3(1.0, 0.0, 1.0)), u.x),
53
+ mix(dot(hash(i + vec3(0.0, 1.0, 1.0)), f - vec3(0.0, 1.0, 1.0)), dot(hash(i + vec3(1.0, 1.0, 1.0)), f - vec3(1.0, 1.0, 1.0)), u.x), u.y), u.z);
54
+ }
55
+ void main() {
56
+ float envelope = smoothstep(0.0, 0.025, vAlong) * (1.0 - smoothstep(0.78, 1.0, vAlong));
57
+ float haze = 0.7 + 0.3 * noise(vWorld * 1.4 + vec3(clock * 0.08, clock * -0.12, 0.0));
58
+ float alpha = 1.0;
59
+ #ifdef DEPTH_FADE
60
+ float depth = texture2D(tDepth, gl_FragCoord.xy / resolution).x;
61
+ float sceneViewZ = orthographic ? orthographicDepthToViewZ(depth, cameraNear, cameraFar) : perspectiveDepthToViewZ(depth, cameraNear, cameraFar);
62
+ alpha = 1.0 - smoothstep(0.002, 0.015, sceneViewZ - vViewZ);
63
+ #endif
64
+ gl_FragColor = vec4(vColor * haze * envelope * beamIntensity, alpha);
65
+ }`
66
+
67
+ /**
68
+ * Laser show for THREE.WebGLRenderer (the WebGL 2 version): ShaderMaterials with the same math as the TSL variant.
69
+ * Playback, buffers and the public API live in LaserShowBase.
70
+ */
71
+ export class LaserShow extends LaserShowBase {
72
+ _createMaterials() {
73
+ const scale = { value: new Vector3() }, origin = { value: new Vector3() }, clock = { value: 0 }, intensity = { value: 1 }, beamIntensity = { value: 0.5 }
74
+ const line = new ShaderMaterial({
75
+ uniforms: { scale, intensity }, vertexShader: LINE_VERTEX, fragmentShader: LINE_FRAGMENT,
76
+ vertexColors: true, transparent: true, depthWrite: false, blending: AdditiveBlending,
77
+ })
78
+ const beam = new ShaderMaterial({
79
+ uniforms: { scale, origin, clock, beamIntensity, tDepth: { value: null }, resolution: { value: new Vector2(1, 1) }, cameraNear: { value: 0.1 }, cameraFar: { value: 1000 }, orthographic: { value: false } },
80
+ vertexShader: BEAM_VERTEX, fragmentShader: BEAM_FRAGMENT,
81
+ vertexColors: true, transparent: true, depthWrite: false, blending: AdditiveBlending, side: DoubleSide, forceSinglePass: true,
82
+ })
83
+ return { line, beam, scale, origin, clock, intensity, beamIntensity }
84
+ }
85
+ }