@3driseai/3drise-viewer 0.1.17 → 0.1.18

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.
Files changed (36) hide show
  1. package/dist/components/Galaxy.d.ts.map +1 -1
  2. package/dist/components/Galaxy.js +2 -0
  3. package/dist/components/ObjectNode.d.ts.map +1 -1
  4. package/dist/components/ObjectNode.js +9 -2
  5. package/dist/components/ParticlesGenerator.d.ts +10 -0
  6. package/dist/components/ParticlesGenerator.d.ts.map +1 -0
  7. package/dist/components/ParticlesGenerator.js +23 -0
  8. package/dist/components/effects/ShockwaveEffect.js +2 -2
  9. package/dist/components/index.d.ts +3 -0
  10. package/dist/components/index.d.ts.map +1 -1
  11. package/dist/components/index.js +2 -0
  12. package/dist/components/particles/BioluminescentWakeParticles.d.ts +12 -0
  13. package/dist/components/particles/BioluminescentWakeParticles.d.ts.map +1 -0
  14. package/dist/components/particles/BioluminescentWakeParticles.js +1123 -0
  15. package/dist/components/particles/BrownianDustParticles.d.ts +12 -0
  16. package/dist/components/particles/BrownianDustParticles.d.ts.map +1 -0
  17. package/dist/components/particles/BrownianDustParticles.js +757 -0
  18. package/dist/components/particles/ElectronOrbitalsParticles.d.ts +12 -0
  19. package/dist/components/particles/ElectronOrbitalsParticles.d.ts.map +1 -0
  20. package/dist/components/particles/ElectronOrbitalsParticles.js +944 -0
  21. package/dist/components/particles/GrinderSparksParticles.d.ts +12 -0
  22. package/dist/components/particles/GrinderSparksParticles.d.ts.map +1 -0
  23. package/dist/components/particles/GrinderSparksParticles.js +744 -0
  24. package/dist/components/particles/TidalStreamParticles.d.ts +12 -0
  25. package/dist/components/particles/TidalStreamParticles.d.ts.map +1 -0
  26. package/dist/components/particles/TidalStreamParticles.js +788 -0
  27. package/dist/components/particles/index.d.ts +11 -0
  28. package/dist/components/particles/index.d.ts.map +1 -0
  29. package/dist/components/particles/index.js +6 -0
  30. package/dist/utils/particleTrails.d.ts +70 -0
  31. package/dist/utils/particleTrails.d.ts.map +1 -0
  32. package/dist/utils/particleTrails.js +369 -0
  33. package/dist/utils/pointerField.d.ts +28 -0
  34. package/dist/utils/pointerField.d.ts.map +1 -0
  35. package/dist/utils/pointerField.js +103 -0
  36. package/package.json +2 -2
@@ -0,0 +1,757 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
3
+ import * as THREE from 'three';
4
+ import { useFrame } from '@react-three/fiber';
5
+ import { applyGenerativeAnimations } from '../../utils/handleGenerativeEffectsAnimations.js';
6
+ import { createPointerUniforms, stepPointerField, usePointerField } from '../../utils/pointerField.js';
7
+ import { createTrailUniforms, applyTrailConfig, createCpuTrail } from '../../utils/particleTrails.js';
8
+ import { useObjectReady } from '../../hooks/useObjectReady.js';
9
+ /**
10
+ * Sunbeam Dust — Dust drifting in window light: Langevin motion in Kolmogorov turbulence, mullion shadows, forward scattering, glints and bokeh; the cursor is a hand, a torch or a charged rod. Use for interiors and cinematic scenes.
11
+ */
12
+ // Sunbeam Dust — dust drifting through sunlight that falls through a mullioned window.
13
+ // Movement: Langevin dynamics per mote: exact Ornstein–Uhlenbeck relaxation toward the local air
14
+ // velocity (Stokes drag) + thermal kicks with sigma = sqrt(2 gamma kT) + gravity settling.
15
+ // The air is Kraichnan synthetic turbulence — divergence-free random Fourier modes with a
16
+ // Kolmogorov k^-5/3 spectrum — plus a warm updraft inside the beam.
17
+ // Colour: single scattering. Brightness = window footprint (mullion shadows with a penumbra that
18
+ // widens with distance, from the sun's 0.53 deg disc) x Henyey–Greenstein phase of the
19
+ // scattering angle. Tumbling flakes add specular glints when their normal bisects sun and
20
+ // eye. The shaft itself is a single-scattering raymarch through the room. A thin-lens camera model turns out-of-focus motes into bokeh discs (circle of
21
+ // confusion ~ aperture |d - focus| / d, energy conserved).
22
+ // Mouse (pointerMode):
23
+ // hand — a hand moving through the air: moving-sphere potential-flow dipole, a wake, vortex
24
+ // rings shed along fast strokes (Kelvin's self-propulsion speed) that decay; the hand
25
+ // also blocks the sun, so a soft shadow shaft runs down the beam behind it.
26
+ // torch — a small lamp: inverse-square point light scattered by motes and by the haze
27
+ // (Henyey–Greenstein, raymarched halo), and a buoyant thermal plume rising from it.
28
+ // charge — a charged rod: induced-dipole attraction pulls dust in; motes that touch it pick up
29
+ // charge and are Coulomb-repelled, then discharge and fall back — a breathing corona
30
+ // that glows faintly where motes carry charge.
31
+ // Trails (trailMode): streak = shutter-drag motion blur, comet = swirl threads, echo = afterglow.
32
+ // Every trail sample is lit by the same beam, shadow and torch as the motes.
33
+ //
34
+ // Generated from the approved lab module (particle lab, round 3). Sim units = object-local / size.
35
+ /** mulberry32 — seeded PRNG, returns a function giving floats in [0,1). */
36
+ function mulberry32(seed) {
37
+ let a = seed >>> 0;
38
+ return function () {
39
+ a = (a + 0x6d2b79f5) >>> 0;
40
+ let t = a;
41
+ t = Math.imul(t ^ (t >>> 15), t | 1);
42
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
43
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
44
+ };
45
+ }
46
+ /** Viewport-height hook so point sizes are pixel-correct at any canvas size. */
47
+ function attachViewH(obj, u) {
48
+ const scratch = new THREE.Vector2();
49
+ obj.onBeforeRender = (renderer) => {
50
+ renderer.getDrawingBufferSize(scratch);
51
+ u.uViewH.value = scratch.y;
52
+ };
53
+ }
54
+ function colorUniform(hex) {
55
+ return { value: new THREE.Color(hex) };
56
+ }
57
+ const POINTER_MODES = ['hand', 'torch', 'charge'];
58
+ const BX = 1, BY0 = 0.02, BY1 = 1.6, BZ = 1; // room box (sim units)
59
+ const GAMMA = 2.5; // Stokes drag rate (1/s)
60
+ const KT = 4e-4; // kT at temperature 1 (velocity variance)
61
+ const H_MAX = 1 / 30;
62
+ const BEAM_AZ = Math.atan2(0.8, 0.6); // sun from the back-left, shining toward +x/+z
63
+ const BEAM_ORIGIN = [0, 0.8, 0];
64
+ const WINDOW_ALONG = -1.0; // window plane, measured along the beam from BEAM_ORIGIN
65
+ const N_MODES = 6; // Kraichnan modes
66
+ const MAX_RINGS = 10;
67
+ const HAND = 0.12; // hand radius at pointerRadius 1
68
+ function createUniforms() {
69
+ return {
70
+ uTime: { value: 0 },
71
+ uSize: { value: 1 },
72
+ uViewH: { value: 800 },
73
+ uIntensity: { value: 1 },
74
+ uSpeed: { value: 1 },
75
+ uOpacity: { value: 0.9 },
76
+ uColor: colorUniform('#ffffff'),
77
+ uParticleCount: { value: 0 },
78
+ uTemperature: { value: 1 },
79
+ uTurbulence: { value: 1 },
80
+ uBeamAngle: { value: 38 },
81
+ uBeamWidth: { value: 0.32 },
82
+ uWindowPanes: { value: 3 },
83
+ uAnisotropy: { value: 0.7 },
84
+ uHazeDensity: { value: 1 },
85
+ uAperture: { value: 0.6 },
86
+ uAmbientColor: colorUniform('#ffffff'),
87
+ uPointerMode: { value: 0 },
88
+ uPointerStrength: { value: 1 },
89
+ uPointerRadius: { value: 1 },
90
+ uPointerColor: colorUniform('#ffffff'),
91
+ uFocus: { value: 2.5 },
92
+ uCamLocal: { value: new THREE.Vector3(0, 0.8, 3) },
93
+ ...createPointerUniforms(),
94
+ ...createTrailUniforms(),
95
+ };
96
+ }
97
+ function applyConfig(u, c, size) {
98
+ u.uSize.value = size;
99
+ u.uIntensity.value = c.intensity;
100
+ u.uSpeed.value = c.speed;
101
+ u.uOpacity.value = c.opacity;
102
+ u.uColor.value.set(c.color);
103
+ u.uParticleCount.value = c.particleCount;
104
+ u.uTemperature.value = c.temperature;
105
+ u.uTurbulence.value = c.turbulence;
106
+ u.uBeamAngle.value = c.beamAngle;
107
+ u.uBeamWidth.value = c.beamWidth;
108
+ u.uWindowPanes.value = c.windowPanes;
109
+ u.uAnisotropy.value = c.anisotropy;
110
+ u.uHazeDensity.value = c.hazeDensity;
111
+ u.uAperture.value = c.aperture;
112
+ u.uAmbientColor.value.set(c.ambientColor);
113
+ u.uPointerMode.value = POINTER_MODES.indexOf(c.pointerMode); // -1 = 'none' (off)
114
+ u.uPointerStrength.value = c.pointerStrength;
115
+ u.uPointerRadius.value = c.pointerRadius;
116
+ u.uPointerColor.value.set(c.pointerColor);
117
+ applyTrailConfig(u, c);
118
+ }
119
+ // Shared GLSL: beam geometry, window mask with penumbra, HG phase, hand shadow, bokeh size.
120
+ const BASE_U = 'uniform float uSize, uViewH, uIntensity, uOpacity;';
121
+ const BEAM_GLSL = /* glsl */ `
122
+ uniform float uTime;
123
+ uniform float uBeamAngle, uBeamWidth, uWindowPanes, uAnisotropy, uAperture, uFocus;
124
+ uniform vec3 uColor, uAmbientColor;
125
+ uniform vec3 uPointer, uPointerColor;
126
+ uniform float uPointerActive, uPointerStrength, uPointerRadius, uPointerMode;
127
+ const vec3 BEAM_O = vec3(${BEAM_ORIGIN.map((v) => v.toFixed(3)).join(', ')});
128
+ vec3 beamDir() {
129
+ float el = radians(uBeamAngle);
130
+ return normalize(vec3(cos(el) * ${Math.cos(BEAM_AZ).toFixed(5)}, -sin(el), cos(el) * ${Math.sin(BEAM_AZ).toFixed(5)}));
131
+ }
132
+ void beamBasis(vec3 L, out vec3 b1, out vec3 b2) {
133
+ b1 = normalize(cross(L, vec3(0.0, 1.0, 0.0)));
134
+ b2 = cross(b1, L);
135
+ }
136
+ // Window footprint with mullions; penumbra = tan(0.53 deg / 2) * 2 * distance from the window.
137
+ float windowMask(vec3 p, vec3 L) {
138
+ vec3 b1, b2; beamBasis(L, b1, b2);
139
+ vec3 d = p - BEAM_O;
140
+ float along = dot(d, L) - (${WINDOW_ALONG.toFixed(2)});
141
+ float pen = 0.01 + 0.0093 * max(along, 0.0);
142
+ float a = dot(d, b1), b = dot(d, b2);
143
+ float w = uBeamWidth, h = w * 1.4;
144
+ float m = (1.0 - smoothstep(w - pen, w + pen, abs(a))) * (1.0 - smoothstep(h - pen, h + pen, abs(b)));
145
+ float bar = 0.08 * w;
146
+ if (uWindowPanes > 1.5) {
147
+ float cw = 2.0 * w / uWindowPanes;
148
+ float fa = mod(a + w, cw);
149
+ float da = min(fa, cw - fa);
150
+ m *= smoothstep(bar - pen, bar + pen, da);
151
+ m *= smoothstep(bar - pen, bar + pen, abs(b - 0.3 * h)); // transom
152
+ }
153
+ return m;
154
+ }
155
+ float hg(float cosT, float g) {
156
+ float g2 = g * g;
157
+ float p = (1.0 - g2) / pow(max(1.0 + g2 - 2.0 * g * cosT, 1e-4), 1.5);
158
+ float p90 = (1.0 - g2) / pow(1.0 + g2, 1.5);
159
+ return p / p90;
160
+ }
161
+ // The hand (a sphere at the pointer) shades everything down-beam of it.
162
+ float handShadow(vec3 p, vec3 L) {
163
+ float s = uPointerActive * step(0.001, uPointerStrength) * (1.0 - step(0.5, uPointerMode));
164
+ if (s < 0.001) return 1.0;
165
+ vec3 d = p - uPointer;
166
+ float along = dot(d, L);
167
+ if (along < 0.0) return 1.0;
168
+ float R = ${HAND.toFixed(3)} * uPointerRadius;
169
+ float perp = length(d - along * L);
170
+ float pen = 0.01 + 0.0093 * along;
171
+ float sh = smoothstep(R * 0.85 - pen, R + pen, perp);
172
+ return mix(1.0, sh, s);
173
+ }
174
+ // Torch: an inverse-square point light at the cursor (radiant intensity ~ strength),
175
+ // returned as the in-scattered light toward the eye for a scatterer at p.
176
+ vec3 torchLight(vec3 p, vec3 toEye, float g) {
177
+ if (uPointerMode < 0.5 || uPointerMode > 1.5) return vec3(0.0);
178
+ vec3 d = p - uPointer;
179
+ float r2 = dot(d, d);
180
+ float reach = 0.5 * uPointerRadius;
181
+ float I = uPointerStrength * uPointerActive * 0.02 / (r2 + 0.004) * exp(-r2 / (reach * reach));
182
+ return uPointerColor * I * hg(dot(normalize(d + 1e-5), toEye), g);
183
+ }
184
+ float boxFade(vec3 p) {
185
+ float fx = ${BX.toFixed(2)} - abs(p.x);
186
+ float fz = ${BZ.toFixed(2)} - abs(p.z);
187
+ float fy = min(p.y - ${BY0.toFixed(2)}, ${BY1.toFixed(2)} - p.y);
188
+ return smoothstep(0.0, 0.12, min(min(fx, fz), fy));
189
+ }
190
+ `;
191
+ const moteVertex = /* glsl */ `
192
+ ${BASE_U}
193
+ ${BEAM_GLSL}
194
+ attribute float aCharge; // 0..1 contact charge (charged-rod mode)
195
+ attribute vec4 aSeed; // size seed, flake flag, tumble rate, phase
196
+ attribute vec3 aAxis; // tumble axis
197
+ varying vec3 vCol;
198
+ varying float vA;
199
+ varying float vBokeh;
200
+ vec3 rotateAxis(vec3 v, vec3 k, float a) {
201
+ return v * cos(a) + cross(k, v) * sin(a) + k * dot(k, v) * (1.0 - cos(a));
202
+ }
203
+ void main() {
204
+ vec3 L = beamDir();
205
+ vec4 wp = modelMatrix * vec4(position * uSize, 1.0);
206
+ vec3 toCam = normalize(cameraPosition - wp.xyz);
207
+ vec3 Lw = normalize(mat3(modelMatrix) * L);
208
+ float lit = windowMask(position, L) * handShadow(position, L);
209
+ float g = uAnisotropy * (0.75 + 0.25 * aSeed.x);
210
+ float phase = hg(dot(Lw, toCam), g);
211
+ // tumbling flake: glint when its normal bisects the sun direction and the eye
212
+ float glint = 0.0;
213
+ if (aSeed.y > 0.5) {
214
+ vec3 n0 = normalize(aAxis.yzx * vec3(1.0, -1.0, 1.0) + vec3(0.01));
215
+ vec3 n = rotateAxis(n0, normalize(aAxis), aSeed.z * uTime + aSeed.w);
216
+ float c = abs(dot(n, normalize(-Lw + toCam)));
217
+ glint = pow(c, 90.0) * 9.0;
218
+ }
219
+ float big = pow(aSeed.x, 5.0); // power-law size distribution
220
+ vec3 toCamL = normalize(transpose(mat3(modelMatrix)) * toCam);
221
+ vec3 torch = torchLight(position, toCamL, g);
222
+ float chg = aCharge * step(1.5, uPointerMode);
223
+ vec3 corona = uPointerColor * chg * 1.6;
224
+ vec3 col = uColor * lit * (phase * 0.6 + glint) + uAmbientColor * 0.06 + torch + corona;
225
+ float extra = dot(torch + corona, vec3(0.33));
226
+ float baseA = uOpacity * boxFade(position) * clamp(0.05 + 0.7 * lit * (phase + glint) + extra, 0.0, 4.0);
227
+ // thin lens: circle of confusion in pixels, energy spread over the disc
228
+ vec4 mv = viewMatrix * wp;
229
+ float dist = -mv.z;
230
+ float basePx = uSize * uViewH * 0.0055 * (0.6 + 2.2 * big) * (1.0 + 1.2 * chg) / dist;
231
+ float coc = uAperture * uViewH * 0.06 * abs(dist - uFocus) / max(dist, 0.05);
232
+ float px = sqrt(basePx * basePx + coc * coc);
233
+ float spread = clamp((basePx * basePx) / (px * px), 0.03, 1.0);
234
+ vBokeh = smoothstep(5.0, 16.0, coc);
235
+ vCol = col * uIntensity;
236
+ vA = baseA * mix(1.0, spread * 3.0, vBokeh);
237
+ gl_PointSize = clamp(px, 1.0, 96.0);
238
+ gl_Position = projectionMatrix * mv;
239
+ }
240
+ `;
241
+ // Light shaft: single-scattering raymarch through the room box (the medium the motes live in).
242
+ // Every sample is window mask x hand shadow x HG phase x a slowly drifting density, so mullion
243
+ // and hand shadows are crisp and the shaft is smooth. Additive, alpha = added light only, so it
244
+ // composites correctly on a transparent canvas.
245
+ const volVertex = /* glsl */ `
246
+ uniform float uSize;
247
+ varying vec3 vLocal;
248
+ void main() {
249
+ vLocal = position;
250
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position * uSize, 1.0);
251
+ }
252
+ `;
253
+ const volFragment = /* glsl */ `
254
+ ${BASE_U}
255
+ ${BEAM_GLSL}
256
+ uniform float uHazeDensity;
257
+ uniform vec3 uCamLocal;
258
+ varying vec3 vLocal;
259
+ float hash13(vec3 p) { p = fract(p * 0.1031); p += dot(p, p.zyx + 31.32); return fract((p.x + p.y) * p.z); }
260
+ float vnoise(vec3 p) {
261
+ vec3 i = floor(p), f = fract(p);
262
+ f = f * f * (3.0 - 2.0 * f);
263
+ return mix(mix(mix(hash13(i), hash13(i + vec3(1, 0, 0)), f.x), mix(hash13(i + vec3(0, 1, 0)), hash13(i + vec3(1, 1, 0)), f.x), f.y),
264
+ mix(mix(hash13(i + vec3(0, 0, 1)), hash13(i + vec3(1, 0, 1)), f.x), mix(hash13(i + vec3(0, 1, 1)), hash13(i + vec3(1, 1, 1)), f.x), f.y), f.z);
265
+ }
266
+ void main() {
267
+ vec3 ro = uCamLocal;
268
+ vec3 rd = normalize(vLocal - ro);
269
+ vec3 bmin = vec3(${(-BX).toFixed(2)}, ${BY0.toFixed(2)}, ${(-BZ).toFixed(2)});
270
+ vec3 bmax = vec3(${BX.toFixed(2)}, ${BY1.toFixed(2)}, ${BZ.toFixed(2)});
271
+ vec3 t0 = (bmin - ro) / rd, t1 = (bmax - ro) / rd;
272
+ vec3 tn3 = min(t0, t1), tf3 = max(t0, t1);
273
+ float tn = max(max(max(tn3.x, tn3.y), tn3.z), 0.0);
274
+ float tf = min(min(tf3.x, tf3.y), tf3.z);
275
+ if (tf <= tn) discard;
276
+ vec3 L = beamDir();
277
+ const int N = 44;
278
+ float dt = (tf - tn) / float(N);
279
+ // interleaved gradient noise: per-pixel start offset, stable over time (no flicker)
280
+ float jit = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
281
+ float acc = 0.0;
282
+ vec3 tacc = vec3(0.0);
283
+ bool torchOn = uPointerMode > 0.5 && uPointerMode < 1.5 && uPointerActive > 0.001;
284
+ for (int i = 0; i < N; i++) {
285
+ vec3 p = ro + rd * (tn + (float(i) + jit) * dt);
286
+ float m = windowMask(p, L);
287
+ if (m < 0.001 && !torchOn) continue;
288
+ float dens = (0.75 + 0.5 * vnoise(p * 3.2 + vec3(0.0, -uTime * 0.04, uTime * 0.02))) * boxFade(p);
289
+ acc += m * handShadow(p, L) * dens;
290
+ if (torchOn) tacc += torchLight(p, -rd, uAnisotropy * 0.5) * dens;
291
+ }
292
+ float k = dt * uHazeDensity * uOpacity * uIntensity;
293
+ vec3 rgb = uColor * (acc * hg(dot(L, -rd), uAnisotropy * 0.6) * 0.22 * k) + tacc * k * 0.35;
294
+ float I = max(max(rgb.r, rgb.g), rgb.b);
295
+ if (I < 0.0005) discard;
296
+ I = min(I, 1.0);
297
+ // colour * alpha = added light; alpha carries only the light (correct on a transparent canvas)
298
+ gl_FragColor = vec4(rgb / max(max(max(rgb.r, rgb.g), rgb.b), 1e-4), I);
299
+ }
300
+ `;
301
+ const fragment = /* glsl */ `
302
+ varying vec3 vCol;
303
+ varying float vA;
304
+ varying float vBokeh;
305
+ void main() {
306
+ vec2 d = gl_PointCoord * 2.0 - 1.0;
307
+ float r2 = dot(d, d);
308
+ if (r2 > 1.0) discard;
309
+ float spriteA = (exp(-r2 * 9.0) + 0.35 * exp(-r2 * 3.2)) * (1.0 - r2);
310
+ // bokeh: flat disc with a slightly brighter rim (spherical aberration), soft edge
311
+ float r = sqrt(r2);
312
+ float disc = (1.0 - smoothstep(0.82, 1.0, r)) * (0.7 + 0.3 * smoothstep(0.55, 0.92, r));
313
+ float a = mix(spriteA, disc * 0.5, vBokeh) * vA;
314
+ if (a < 0.002) discard;
315
+ gl_FragColor = vec4(vCol, a);
316
+ }
317
+ `;
318
+ // The cursor made visible: nothing for the hand (its shadow is the tell), a lamp bulb with a soft
319
+ // bloom for the torch, a bright tip with a corona for the charged rod.
320
+ const glyphVertex = /* glsl */ `
321
+ uniform float uSize, uViewH;
322
+ uniform vec3 uPointer;
323
+ uniform float uPointerMode, uPointerActive, uPointerStrength, uPointerRadius;
324
+ varying float vMode;
325
+ void main() {
326
+ vec4 mv = modelViewMatrix * vec4(uPointer * uSize, 1.0);
327
+ float on = uPointerActive * step(0.001, uPointerStrength) * step(0.5, uPointerMode);
328
+ gl_PointSize = clamp(uSize * uViewH * 0.16 * uPointerRadius / -mv.z, 0.0, 400.0) * step(0.02, on);
329
+ vMode = uPointerMode;
330
+ gl_Position = projectionMatrix * mv;
331
+ }
332
+ `;
333
+ const glyphFragment = /* glsl */ `
334
+ uniform vec3 uPointerColor;
335
+ uniform float uPointerActive, uPointerStrength, uOpacity;
336
+ varying float vMode;
337
+ void main() {
338
+ vec2 d = gl_PointCoord * 2.0 - 1.0;
339
+ float r2 = dot(d, d);
340
+ if (r2 > 1.0) discard;
341
+ float a;
342
+ if (vMode < 1.5) a = exp(-r2 * 60.0) * 1.2 + exp(-r2 * 9.0) * 0.25; // bulb + bloom
343
+ else a = exp(-r2 * 90.0) + 0.18 * exp(-pow((sqrt(r2) - 0.35) / 0.12, 2.0)); // tip + corona
344
+ a *= (1.0 - r2) * uPointerActive * min(uPointerStrength, 1.5) * uOpacity;
345
+ if (a < 0.003) discard;
346
+ gl_FragColor = vec4(mix(uPointerColor, vec3(1.0), 0.35 * exp(-r2 * 60.0)), a);
347
+ }
348
+ `;
349
+ // CPU copy of the beam direction (for the updraft).
350
+ function beamDirCPU(deg) {
351
+ const el = (deg * Math.PI) / 180;
352
+ return [Math.cos(el) * Math.cos(BEAM_AZ), -Math.sin(el), Math.cos(el) * Math.sin(BEAM_AZ)];
353
+ }
354
+ function build(u, c, size) {
355
+ const n = Math.max(1, Math.round(c.particleCount));
356
+ const rng = mulberry32(0xd057);
357
+ const pos = new Float32Array(n * 3);
358
+ const vel = new Float32Array(n * 3);
359
+ const seed = new Float32Array(n * 4);
360
+ const axis = new Float32Array(n * 3);
361
+ for (let i = 0; i < n; i++) {
362
+ pos[i * 3] = (rng() * 2 - 1) * BX;
363
+ pos[i * 3 + 1] = BY0 + rng() * (BY1 - BY0);
364
+ pos[i * 3 + 2] = (rng() * 2 - 1) * BZ;
365
+ seed[i * 4] = rng();
366
+ seed[i * 4 + 1] = rng() < 0.3 ? 1 : 0; // 30% are flat, glinting flakes
367
+ seed[i * 4 + 2] = 0.35 + rng() * 0.8; // tumble rate (rad/s)
368
+ seed[i * 4 + 3] = rng() * 6.2831853;
369
+ const ax = rng() * 2 - 1, ay = rng() * 2 - 1, az = rng() * 2 - 1, l = Math.hypot(ax, ay, az) || 1;
370
+ axis[i * 3] = ax / l;
371
+ axis[i * 3 + 1] = ay / l;
372
+ axis[i * 3 + 2] = az / l;
373
+ }
374
+ // ---- Kraichnan modes: k in [2.5, 10], energy ~ k^-5/3, sigma perpendicular to k ----
375
+ const mrng = mulberry32(0x7ab1);
376
+ const MK = new Float64Array(N_MODES * 3), MS = new Float64Array(N_MODES * 3), MW = new Float64Array(N_MODES * 2);
377
+ for (let m = 0; m < N_MODES; m++) {
378
+ const kmag = 2.5 * Math.pow(4, m / (N_MODES - 1));
379
+ let kx = mrng() * 2 - 1, ky = mrng() * 2 - 1, kz = mrng() * 2 - 1;
380
+ const kl = Math.hypot(kx, ky, kz) || 1;
381
+ kx /= kl;
382
+ ky /= kl;
383
+ kz /= kl;
384
+ let rx = mrng() * 2 - 1, ry = mrng() * 2 - 1, rz = mrng() * 2 - 1;
385
+ // sigma = normalize(r x k) is transverse -> each mode is divergence-free
386
+ let sx = ry * kz - rz * ky, sy = rz * kx - rx * kz, sz = rx * ky - ry * kx;
387
+ const sl = Math.hypot(sx, sy, sz) || 1;
388
+ const amp = Math.sqrt(Math.pow(kmag, -5 / 3) * kmag * 0.5); // sqrt(E(k) dk), log-spaced shells
389
+ MK[m * 3] = kx * kmag;
390
+ MK[m * 3 + 1] = ky * kmag;
391
+ MK[m * 3 + 2] = kz * kmag;
392
+ MS[m * 3] = (sx / sl) * amp;
393
+ MS[m * 3 + 1] = (sy / sl) * amp;
394
+ MS[m * 3 + 2] = (sz / sl) * amp;
395
+ MW[m * 2] = 0.12 * Math.sqrt(kmag) * (0.6 + 0.8 * mrng()); // eddy turnover ~ k^(2/3) (softened)
396
+ MW[m * 2 + 1] = mrng() * 6.2831853;
397
+ }
398
+ // ---- vortex rings shed by the hand ----
399
+ const RC_ = new Float64Array(MAX_RINGS * 3), RN = new Float64Array(MAX_RINGS * 3);
400
+ const RG = new Float64Array(MAX_RINGS), RR = new Float64Array(MAX_RINGS), RA = new Float64Array(MAX_RINGS);
401
+ let ringHead = 0, shedClock = 0;
402
+ const live = new Int32Array(MAX_RINGS);
403
+ const noise = mulberry32(0xb10e);
404
+ const r2n = () => noise() + noise() - 1; // triangular, variance 1/6 (scaled by sqrt 6 below)
405
+ let time = 0;
406
+ const hp = new THREE.Vector3(), hv = new THREE.Vector3();
407
+ const charge = new Float32Array(n); // contact charge per mote (charged-rod mode)
408
+ function step(h) {
409
+ time += h;
410
+ const T = KT * Math.max(u.uTemperature.value, 0);
411
+ const sig = Math.sqrt(2 * GAMMA * T * h) * 2.449;
412
+ const turb = 0.035 * u.uTurbulence.value;
413
+ const L = beamDirCPU(u.uBeamAngle.value);
414
+ const w = u.uBeamWidth.value;
415
+ let b1x = -L[2], b1z = L[0];
416
+ const bl = Math.hypot(b1x, b1z) || 1;
417
+ b1x /= bl;
418
+ b1z /= bl;
419
+ const decay = Math.exp(-GAMMA * h);
420
+ // hand
421
+ const S = u.uPointerStrength.value * u.uPointerActive.value;
422
+ const R = HAND * u.uPointerRadius.value;
423
+ hp.copy(u.uPointer.value);
424
+ hv.copy(u.uPointerVel.value);
425
+ const speed = hv.length();
426
+ const mode = u.uPointerMode.value;
427
+ const useHand = S > 0.01 && mode === 0;
428
+ const useTorch = S > 0.01 && mode === 1;
429
+ const useCharge = S > 0.01 && mode === 2;
430
+ // torch plume: buoyant column above the lamp, widening with height (sigma grows linearly,
431
+ // centreline speed falls as the plume spreads — momentum-conserving plume scaling)
432
+ const pr = 0.05 * u.uPointerRadius.value;
433
+ // charged rod: induced-dipole attraction ~ 1/r^3, Coulomb repulsion of charged motes ~ 1/r^2
434
+ const reach = 0.6 * u.uPointerRadius.value;
435
+ const KA = 0.012 * S * u.uPointerRadius.value, KR = 0.0045 * S, EPS = 0.03, RCON = 0.035 * u.uPointerRadius.value;
436
+ const dis = Math.exp(-h / 1.4);
437
+ // shed a ring every ~0.12 s of fast motion
438
+ shedClock += h;
439
+ if (useHand && speed > 0.3 && shedClock > 0.12) {
440
+ shedClock = 0;
441
+ const k = ringHead % MAX_RINGS;
442
+ ringHead++;
443
+ RC_[k * 3] = hp.x;
444
+ RC_[k * 3 + 1] = hp.y;
445
+ RC_[k * 3 + 2] = hp.z;
446
+ RN[k * 3] = hv.x / speed;
447
+ RN[k * 3 + 1] = hv.y / speed;
448
+ RN[k * 3 + 2] = hv.z / speed;
449
+ RG[k] = 1.1 * Math.min(speed, 4) * R * S;
450
+ RR[k] = R * 0.9;
451
+ RA[k] = 0;
452
+ }
453
+ // rings: self-propulsion (Kelvin: U = G / (4 pi a) (ln(8a/core) - 1/4)), growth, decay
454
+ for (let k = 0; k < MAX_RINGS; k++) {
455
+ if (RG[k] === 0)
456
+ continue;
457
+ RA[k] += h;
458
+ RG[k] *= Math.exp(-h / 2.2);
459
+ RR[k] *= 1 + 0.08 * h;
460
+ const core = 0.3 * RR[k];
461
+ const U = (RG[k] / (4 * Math.PI * RR[k])) * (Math.log(8 / 0.3) - 0.25);
462
+ RC_[k * 3] += RN[k * 3] * U * h;
463
+ RC_[k * 3 + 1] += RN[k * 3 + 1] * U * h;
464
+ RC_[k * 3 + 2] += RN[k * 3 + 2] * U * h;
465
+ if (RA[k] > 5 || RG[k] < 1e-4 || core <= 0)
466
+ RG[k] = 0;
467
+ }
468
+ const vn = speed > 1e-4 ? 1 / speed : 0;
469
+ // compact the live rings so empty slots cost nothing per mote
470
+ let nr = 0;
471
+ for (let k = 0; k < MAX_RINGS; k++)
472
+ if (RG[k] !== 0)
473
+ live[nr++] = k;
474
+ for (let i = 0, j = 0; i < n; i++, j += 3) {
475
+ const x = pos[j], y = pos[j + 1], z = pos[j + 2];
476
+ // Kraichnan turbulence
477
+ let ax = 0, ay = 0, az = 0;
478
+ for (let m = 0; m < N_MODES; m++) {
479
+ const cph = Math.cos(MK[m * 3] * x + MK[m * 3 + 1] * y + MK[m * 3 + 2] * z + MW[m * 2] * time + MW[m * 2 + 1]);
480
+ ax += MS[m * 3] * cph;
481
+ ay += MS[m * 3 + 1] * cph;
482
+ az += MS[m * 3 + 2] * cph;
483
+ }
484
+ ax *= turb;
485
+ ay *= turb;
486
+ az *= turb;
487
+ // warm updraft inside the beam
488
+ const dx0 = x - BEAM_ORIGIN[0], dz0 = z - BEAM_ORIGIN[2];
489
+ const across = Math.abs(dx0 * b1x + dz0 * b1z);
490
+ if (across < w)
491
+ ay += turb * 0.5 * (1 - across / w);
492
+ if (useHand) {
493
+ // moving sphere: potential-flow dipole outside, carried inside, plus a trailing wake
494
+ const qx = x - hp.x, qy = y - hp.y, qz = z - hp.z;
495
+ const r2 = qx * qx + qy * qy + qz * qz;
496
+ if (r2 < 36 * R * R) {
497
+ const r = Math.sqrt(r2) + 1e-6;
498
+ if (r > R) {
499
+ const k3 = (R * R * R) / (2 * r2 * r);
500
+ const vr = (hv.x * qx + hv.y * qy + hv.z * qz) / r;
501
+ ax += S * k3 * (3 * vr * qx / r - hv.x);
502
+ ay += S * k3 * (3 * vr * qy / r - hv.y);
503
+ az += S * k3 * (3 * vr * qz / r - hv.z);
504
+ }
505
+ else {
506
+ ax += S * hv.x;
507
+ ay += S * hv.y;
508
+ az += S * hv.z;
509
+ }
510
+ const behind = -(qx * hv.x + qy * hv.y + qz * hv.z) * vn;
511
+ if (behind > 0) {
512
+ const perp2 = r2 - behind * behind;
513
+ const wk = 0.6 * S * Math.exp(-perp2 / (R * R) - behind / (3 * R));
514
+ ax += hv.x * wk;
515
+ ay += hv.y * wk;
516
+ az += hv.z * wk;
517
+ }
518
+ }
519
+ }
520
+ else if (useTorch) {
521
+ const above = y - hp.y;
522
+ if (above > -0.06 && above < 1.2) {
523
+ const qx = x - hp.x, qz = z - hp.z;
524
+ const sgm = pr + 0.22 * Math.max(above, 0);
525
+ const rh2 = qx * qx + qz * qz;
526
+ if (rh2 < 9 * sgm * sgm) {
527
+ const wv = 0.16 * S * (pr / sgm) * Math.exp(-rh2 / (sgm * sgm)) * Math.min(1, (above + 0.06) / 0.06);
528
+ ay += wv;
529
+ ax -= qx * wv * 0.8;
530
+ az -= qz * wv * 0.8; // entrainment: air drawn into the plume
531
+ }
532
+ }
533
+ }
534
+ // vortex rings: circulation around the ring's core circle
535
+ for (let q = 0; q < nr; q++) {
536
+ const k = live[q];
537
+ const G = RG[k];
538
+ const a = RR[k];
539
+ const qx = x - RC_[k * 3], qy = y - RC_[k * 3 + 1], qz = z - RC_[k * 3 + 2];
540
+ if (qx * qx + qy * qy + qz * qz > 16 * a * a)
541
+ continue; // outside the ring's reach
542
+ const nx = RN[k * 3], ny = RN[k * 3 + 1], nz = RN[k * 3 + 2];
543
+ const zz = qx * nx + qy * ny + qz * nz;
544
+ const px = qx - zz * nx, py = qy - zz * ny, pz = qz - zz * nz;
545
+ const rho = Math.sqrt(px * px + py * py + pz * pz) + 1e-6;
546
+ const dr = rho - a;
547
+ const s2 = dr * dr + zz * zz;
548
+ if (s2 > 9 * a * a)
549
+ continue;
550
+ const core = 0.3 * a;
551
+ const f = (G / (6.2831853 * (s2 + core * core))) * Math.exp(-s2 / (2.25 * a * a));
552
+ // t = z * rho_hat - (rho - a) * n (flow runs through the ring along +n)
553
+ ax += f * (zz * px / rho - dr * nx);
554
+ ay += f * (zz * py / rho - dr * ny);
555
+ az += f * (zz * pz / rho - dr * nz);
556
+ }
557
+ const settle = -0.004 * (0.4 + seed[i * 4]);
558
+ // exact OU relaxation toward the air velocity + thermal kick
559
+ vel[j] = ax + (vel[j] - ax) * decay + sig * r2n();
560
+ vel[j + 1] = ay + settle + (vel[j + 1] - ay - settle) * decay + sig * r2n();
561
+ vel[j + 2] = az + (vel[j + 2] - az) * decay + sig * r2n();
562
+ charge[i] *= dis;
563
+ if (useCharge) {
564
+ const qx = x - hp.x, qy = y - hp.y, qz = z - hp.z;
565
+ const r2 = qx * qx + qy * qy + qz * qz;
566
+ if (r2 < reach * reach) {
567
+ const r = Math.sqrt(r2) + 1e-6;
568
+ if (r < RCON)
569
+ charge[i] = 1; // contact: the mote takes the rod's charge
570
+ const c = charge[i];
571
+ const f = (-KA * (1 - c)) / (r2 * r + EPS * EPS * EPS) + (KR * c) / (r2 + EPS * EPS);
572
+ const fr = Math.max(Math.min(f, 3), -3) / r;
573
+ vel[j] += qx * fr * h;
574
+ vel[j + 1] += qy * fr * h;
575
+ vel[j + 2] += qz * fr * h;
576
+ }
577
+ }
578
+ let nx = x + vel[j] * h, ny = y + vel[j + 1] * h, nz = z + vel[j + 2] * h;
579
+ // periodic room (the shader fades motes at the walls, so wrapping never pops)
580
+ if (nx > BX)
581
+ nx -= 2 * BX;
582
+ else if (nx < -BX)
583
+ nx += 2 * BX;
584
+ if (nz > BZ)
585
+ nz -= 2 * BZ;
586
+ else if (nz < -BZ)
587
+ nz += 2 * BZ;
588
+ if (ny > BY1)
589
+ ny -= BY1 - BY0;
590
+ else if (ny < BY0)
591
+ ny += BY1 - BY0;
592
+ pos[j] = nx;
593
+ pos[j + 1] = ny;
594
+ pos[j + 2] = nz;
595
+ }
596
+ }
597
+ for (let s = 0; s < 60; s++)
598
+ step(H_MAX); // velocities into equilibrium with the air
599
+ const geo = new THREE.BufferGeometry();
600
+ const posAttr = new THREE.BufferAttribute(pos, 3).setUsage(THREE.DynamicDrawUsage);
601
+ geo.setAttribute('position', posAttr);
602
+ geo.setAttribute('aSeed', new THREE.BufferAttribute(seed, 4));
603
+ geo.setAttribute('aAxis', new THREE.BufferAttribute(axis, 3));
604
+ const chargeAttr = new THREE.BufferAttribute(charge, 1).setUsage(THREE.DynamicDrawUsage);
605
+ geo.setAttribute('aCharge', chargeAttr);
606
+ const mat = new THREE.ShaderMaterial({
607
+ uniforms: u, vertexShader: moteVertex, fragmentShader: fragment,
608
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
609
+ });
610
+ const motes = new THREE.Points(geo, mat);
611
+ motes.frustumCulled = false;
612
+ attachViewH(motes, u);
613
+ // the light shaft volume: the room box, drawn from its back faces so it works from inside too
614
+ const volGeo = new THREE.BoxGeometry(2 * BX, BY1 - BY0, 2 * BZ);
615
+ volGeo.translate(0, (BY0 + BY1) / 2, 0);
616
+ const volMat = new THREE.ShaderMaterial({
617
+ uniforms: u, vertexShader: volVertex, fragmentShader: volFragment,
618
+ transparent: true, depthWrite: false, depthTest: false, side: THREE.BackSide, blending: THREE.AdditiveBlending,
619
+ });
620
+ const haze = new THREE.Mesh(volGeo, volMat);
621
+ haze.frustumCulled = false;
622
+ const ggeo = new THREE.BufferGeometry();
623
+ ggeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(3), 3));
624
+ const gmat = new THREE.ShaderMaterial({
625
+ uniforms: u, vertexShader: glyphVertex, fragmentShader: glyphFragment,
626
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
627
+ });
628
+ const glyph = new THREE.Points(ggeo, gmat);
629
+ glyph.frustumCulled = false;
630
+ const object = new THREE.Group();
631
+ object.add(haze, motes, glyph);
632
+ const anchor = new THREE.Vector3(...BEAM_ORIGIN);
633
+ const focusPt = new THREE.Vector3();
634
+ // ---- trails: every sample lit by the same beam, hand shadow and torch as the motes ----
635
+ const trailGLSL = /* glsl */ `
636
+ ${BEAM_GLSL}
637
+ uniform vec3 uCamLocal;
638
+ vec3 trailShade(vec3 p, float f) {
639
+ vec3 L = beamDir();
640
+ vec3 toEye = normalize(uCamLocal - p);
641
+ float lit = windowMask(p, L) * handShadow(p, L) * boxFade(p);
642
+ return uColor * lit * hg(dot(L, toEye), uAnisotropy) * 1.1 + torchLight(p, toEye, uAnisotropy) + uAmbientColor * 0.04;
643
+ }
644
+ vec3 trailView(vec3 mv, out float mag) { mag = 1.0; return mv; }
645
+ `;
646
+ const materials = [mat, volMat, gmat];
647
+ let trail = null, trailKey = '';
648
+ function buildTrails(mode, share) {
649
+ const key = mode + ':' + share;
650
+ if (key === trailKey)
651
+ return;
652
+ trailKey = key;
653
+ if (trail) {
654
+ object.remove(trail.object);
655
+ trail.dispose();
656
+ trail = null;
657
+ }
658
+ materials.length = 3;
659
+ trail = createCpuTrail(mode, { u, n, pos, vel, glsl: trailGLSL, sizePx: 0.007, spacing: 0.003 });
660
+ if (!trail)
661
+ return;
662
+ object.add(trail.object);
663
+ materials.push(...trail.materials);
664
+ }
665
+ buildTrails(c.trailMode, c.trailShare);
666
+ return {
667
+ object,
668
+ materials,
669
+ apply(cfg) { if (cfg)
670
+ buildTrails(cfg.trailMode, cfg.trailShare); },
671
+ update(dt, camera, pointer) {
672
+ const rdt = Math.min(Math.max(dt, 0), 1 / 20);
673
+ const d = rdt * u.uSpeed.value;
674
+ u.uTime.value += d;
675
+ if (camera) {
676
+ // autofocus on the middle of the beam
677
+ object.localToWorld(focusPt.copy(anchor).multiplyScalar(u.uSize.value));
678
+ u.uFocus.value = camera.position.distanceTo(focusPt);
679
+ object.worldToLocal(u.uCamLocal.value.copy(camera.position)).divideScalar(u.uSize.value);
680
+ if (pointer)
681
+ stepPointerField(u, pointer, camera, object, u.uSize.value, rdt, anchor);
682
+ }
683
+ u.uVelScale.value = u.uSpeed.value;
684
+ if (d > 0) {
685
+ const steps = Math.ceil(d / H_MAX);
686
+ for (let s = 0; s < steps; s++)
687
+ step(d / steps);
688
+ }
689
+ posAttr.needsUpdate = true;
690
+ chargeAttr.needsUpdate = true;
691
+ if (trail)
692
+ trail.update(d);
693
+ },
694
+ dispose() {
695
+ geo.dispose();
696
+ mat.dispose();
697
+ volGeo.dispose();
698
+ volMat.dispose();
699
+ ggeo.dispose();
700
+ gmat.dispose();
701
+ if (trail)
702
+ trail.dispose();
703
+ },
704
+ _state: { pos, vel, RG, charge },
705
+ };
706
+ }
707
+ /** Defaults for every knob, so an older saved config still renders (identical to the catalog entry). */
708
+ const DEFAULTS = {
709
+ intensity: 1,
710
+ speed: 1,
711
+ opacity: 0.9,
712
+ size: 1,
713
+ color: '#ffd7a1',
714
+ particleCount: 5000,
715
+ temperature: 1,
716
+ turbulence: 1,
717
+ beamAngle: 38,
718
+ beamWidth: 0.32,
719
+ windowPanes: 3,
720
+ anisotropy: 0.7,
721
+ hazeDensity: 1,
722
+ aperture: 0.6,
723
+ ambientColor: '#7892b8',
724
+ pointerMode: 'hand',
725
+ pointerStrength: 1,
726
+ pointerRadius: 1,
727
+ pointerColor: '#ffe7c2',
728
+ trailMode: 'comet',
729
+ trailLength: 2.5,
730
+ trailOpacity: 0.5,
731
+ trailColor: '#ffb870',
732
+ trailShare: 0.3,
733
+ };
734
+ const BrownianDustParticles = ({ size, config, animations, onObjectReady, onObjectDispose }) => {
735
+ const rootRef = useRef(null);
736
+ const animCache = useRef({});
737
+ useObjectReady(rootRef, config, onObjectReady, onObjectDispose);
738
+ const s = size > 0 ? size : 1;
739
+ const cfg = useMemo(() => ({ ...DEFAULTS, ...config }), [config]);
740
+ // uniforms outlive the objects, so keyframe animations survive a rebuild
741
+ const uniforms = useMemo(() => createUniforms(), []);
742
+ // rebuilt only when a structural knob changes (trails rebuild inside apply())
743
+ const instance = useMemo(() => build(uniforms, cfg, s), [uniforms, cfg.particleCount]);
744
+ useEffect(() => () => instance.dispose(), [instance]);
745
+ useLayoutEffect(() => {
746
+ applyConfig(uniforms, cfg, s);
747
+ instance.apply(cfg);
748
+ }, [instance, uniforms, cfg, s]);
749
+ const pointer = usePointerField();
750
+ useFrame((state, delta) => {
751
+ instance.update(Math.min(delta, 0.1), state.camera, pointer);
752
+ if (animations?.length)
753
+ applyGenerativeAnimations(instance.materials, animations, animCache.current, Date.now(), false);
754
+ });
755
+ return (_jsx("group", { ref: rootRef, children: _jsx("primitive", { object: instance.object }) }));
756
+ };
757
+ export default BrownianDustParticles;