@3driseai/3drise-viewer 0.1.19 → 0.1.21
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/dist/components/CreatedObject.d.ts.map +1 -1
- package/dist/components/CreatedObject.js +18 -14
- package/dist/components/ModelMaterialPassContext.d.ts +7 -0
- package/dist/components/ModelMaterialPassContext.d.ts.map +1 -0
- package/dist/components/ModelMaterialPassContext.js +7 -0
- package/dist/components/ModelPrimitive.d.ts.map +1 -1
- package/dist/components/ModelPrimitive.js +7 -1
- package/dist/components/ParticlesGenerator.d.ts.map +1 -1
- package/dist/components/ParticlesGenerator.js +6 -0
- package/dist/components/particles/ChladniSandParticles.d.ts +15 -0
- package/dist/components/particles/ChladniSandParticles.d.ts.map +1 -0
- package/dist/components/particles/ChladniSandParticles.js +912 -0
- package/dist/components/particles/FaradayPowderParticles.d.ts +15 -0
- package/dist/components/particles/FaradayPowderParticles.d.ts.map +1 -0
- package/dist/components/particles/FaradayPowderParticles.js +783 -0
- package/dist/components/particles/NeonRainParticles.d.ts +15 -0
- package/dist/components/particles/NeonRainParticles.d.ts.map +1 -0
- package/dist/components/particles/NeonRainParticles.js +716 -0
- package/dist/components/particles/index.d.ts +6 -0
- package/dist/components/particles/index.d.ts.map +1 -1
- package/dist/components/particles/index.js +3 -0
- package/dist/constants.d.ts +1 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +1 -1
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useMaterialApplication.d.ts.map +1 -1
- package/dist/hooks/useMaterialApplication.js +1 -0
- package/dist/hooks/useNodeOverrides.d.ts +13 -0
- package/dist/hooks/useNodeOverrides.d.ts.map +1 -0
- package/dist/hooks/useNodeOverrides.js +254 -0
- package/package.json +2 -2
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
|
3
|
+
import * as THREE from 'three';
|
|
4
|
+
import { useFrame } from '@react-three/fiber';
|
|
5
|
+
import { GENERATIVE_PARTICLES_ANIMATABLE } from '@3driseai/3drise-core';
|
|
6
|
+
import { applyConfigKeyframeAnimations } from '../../utils/configAnimations.js';
|
|
7
|
+
import { createPointerUniforms, stepPointerField, usePointerField } from '../../utils/pointerField.js';
|
|
8
|
+
import { createTrailUniforms, applyTrailConfig, createCpuTrail } from '../../utils/particleTrails.js';
|
|
9
|
+
import { useObjectReady } from '../../hooks/useObjectReady.js';
|
|
10
|
+
import { useConfigLiveHandle } from '../../hooks/useConfigLive.js';
|
|
11
|
+
/**
|
|
12
|
+
* Chladni Sand — Sand on an invisible vibrating plate: resonant plate modes throw grains onto the nodal lines, drawing Chladni figures that form and re-form; the cursor bows, touches or taps the plate. Use for science, sound and abstract scenes.
|
|
13
|
+
*/
|
|
14
|
+
// Chladni Sand — sand dancing on a bowed metal plate (cymatics, Chladni 1787). Particles only: the
|
|
15
|
+
// plate itself is invisible; the sand draws the figures.
|
|
16
|
+
// Movement: the plate is a centre-clamped square plate. Its standing waves are the classic Chladni
|
|
17
|
+
// combinations phi = cos(m pi u) cos(n pi v) +/- cos(n pi u) cos(m pi v), each ringing with a
|
|
18
|
+
// Lorentzian resonance around its own frequency (the + and - pair are split, the seed adds
|
|
19
|
+
// small plate imperfections). The drive frequency glides slowly through the resonances, so
|
|
20
|
+
// figures form, dissolve and re-form. Where the plate's acceleration exceeds g (Gamma > 1)
|
|
21
|
+
// grains are thrown up; each hop lands a random step away with a step length that grows
|
|
22
|
+
// with the local vibration, so grains random-walk out of the moving regions and freeze on
|
|
23
|
+
// the nodal lines. Grains that hop off the edge fall away and are sprinkled back on.
|
|
24
|
+
// Colour: the sand is lit by a low raking light over its own pile height (density -> heightfield
|
|
25
|
+
// -> normal), so the ridges read in relief; a few dark mineral grains; grains in flight
|
|
26
|
+
// leave the pile's shadow and catch the light in the second colour.
|
|
27
|
+
// Mouse (pointerMode; the cursor lives on the plate plane):
|
|
28
|
+
// bow — a violin bow on the nearest point of the edge: modes with an antinode there are
|
|
29
|
+
// favoured and bowing speed pumps energy in; rosin dust puffs off the edge.
|
|
30
|
+
// finger — a fingertip touching the plate forces a node there: modes that move under it are
|
|
31
|
+
// damped, so the figure snaps to one with a nodal line through the finger.
|
|
32
|
+
// tap — taps along the cursor path: a dispersive bending wave rings outward, throwing grains
|
|
33
|
+
// in an expanding ring, and the broadband ring-down scrambles the figure for a moment.
|
|
34
|
+
// Trails (trailMode): streak = long exposure (hop arcs), comet = migration paths, echo = dust haze.
|
|
35
|
+
//
|
|
36
|
+
// Generated from the approved lab module (particle lab, batch 3). Sim units = object-local / size.
|
|
37
|
+
// ---------------------------------------------------------------------------------------------
|
|
38
|
+
// Engine features
|
|
39
|
+
// ---------------------------------------------------------------------------------------------
|
|
40
|
+
const QUALITY_SCALE = { low: 0.5, medium: 1, high: 1.6 };
|
|
41
|
+
/** mulberry32 — seeded PRNG, returns a function giving floats in [0,1). */
|
|
42
|
+
function mulberry32(seed) {
|
|
43
|
+
let a = seed >>> 0;
|
|
44
|
+
return function () {
|
|
45
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
46
|
+
let t = a;
|
|
47
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
48
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
49
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Standard normal from a uniform PRNG (Box–Muller, one value per call). */
|
|
53
|
+
function gauss(rng) {
|
|
54
|
+
const u = Math.max(rng(), 1e-9);
|
|
55
|
+
const v = rng();
|
|
56
|
+
return Math.sqrt(-2 * Math.log(u)) * Math.cos(6.283185307 * v);
|
|
57
|
+
}
|
|
58
|
+
/** Viewport-height hook so point sizes are pixel-correct at any canvas size. */
|
|
59
|
+
function attachViewH(obj, u) {
|
|
60
|
+
const scratch = new THREE.Vector2();
|
|
61
|
+
obj.onBeforeRender = (renderer) => {
|
|
62
|
+
renderer.getDrawingBufferSize(scratch);
|
|
63
|
+
u.uViewH.value = scratch.y;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function colorUniform(hex) {
|
|
67
|
+
return { value: new THREE.Color(hex) };
|
|
68
|
+
}
|
|
69
|
+
function qualityScale(q) { return QUALITY_SCALE[q] || 1; }
|
|
70
|
+
function seedMix(base, seed) {
|
|
71
|
+
let h = (base ^ Math.imul((Math.round(seed) | 0) + 0x9e37, 0x85ebca6b)) >>> 0;
|
|
72
|
+
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0;
|
|
73
|
+
return (h ^ (h >>> 16)) >>> 0;
|
|
74
|
+
}
|
|
75
|
+
function createFixedStep(H, maxSteps) {
|
|
76
|
+
let acc = 0;
|
|
77
|
+
return {
|
|
78
|
+
H,
|
|
79
|
+
run(d, step) {
|
|
80
|
+
acc += Math.max(d, 0);
|
|
81
|
+
let k = 0;
|
|
82
|
+
while (acc >= H && k < maxSteps) {
|
|
83
|
+
step(H);
|
|
84
|
+
acc -= H;
|
|
85
|
+
k++;
|
|
86
|
+
}
|
|
87
|
+
if (k === maxSteps)
|
|
88
|
+
acc = Math.min(acc, H);
|
|
89
|
+
return acc / H;
|
|
90
|
+
},
|
|
91
|
+
reset() { acc = 0; },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function createPickProxy(center, radius) {
|
|
95
|
+
const geo = new THREE.SphereGeometry(1, 16, 10);
|
|
96
|
+
const mat = new THREE.MeshBasicMaterial({ visible: false });
|
|
97
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
98
|
+
mesh.name = 'pickProxy';
|
|
99
|
+
mesh.position.set(center[0], center[1], center[2]);
|
|
100
|
+
mesh.scale.setScalar(radius);
|
|
101
|
+
return { mesh, dispose() { geo.dispose(); mat.dispose(); } };
|
|
102
|
+
}
|
|
103
|
+
const _frustum = new THREE.Frustum();
|
|
104
|
+
const _pm = new THREE.Matrix4();
|
|
105
|
+
const _sphere = new THREE.Sphere();
|
|
106
|
+
function inView(object, camera, center, radius, size) {
|
|
107
|
+
if (!camera || !camera.projectionMatrix)
|
|
108
|
+
return true;
|
|
109
|
+
object.updateWorldMatrix(true, false);
|
|
110
|
+
_sphere.center.set(center[0], center[1], center[2]).multiplyScalar(size).applyMatrix4(object.matrixWorld);
|
|
111
|
+
_sphere.radius = radius * size * object.matrixWorld.getMaxScaleOnAxis();
|
|
112
|
+
_pm.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
|
|
113
|
+
_frustum.setFromProjectionMatrix(_pm);
|
|
114
|
+
return _frustum.intersectsSphere(_sphere);
|
|
115
|
+
}
|
|
116
|
+
const POINTER_MODES = ['bow', 'finger', 'tap'];
|
|
117
|
+
const HX = 1; // half size of the plate (sim units)
|
|
118
|
+
const NG = 97; // vibration grid (odd -> a sample at the clamped centre)
|
|
119
|
+
const DG = 72; // sand heightfield grid
|
|
120
|
+
const NMAX = 10; // highest mode index
|
|
121
|
+
const H_STEP = 1 / 60;
|
|
122
|
+
const G_SIM = 7.5; // gravity for the hops (slow, calm)
|
|
123
|
+
const GAIN = 1.7; // Gamma per unit of normalised displacement at amplitude 1
|
|
124
|
+
const SWEEP_T = 46; // seconds per sweep cycle
|
|
125
|
+
const RING_UP = 0.45; // mode amplitude time constant (the plate rings up / down)
|
|
126
|
+
const MAX_TAPS = 4;
|
|
127
|
+
const NPUFF = 320;
|
|
128
|
+
const BOUNDS = { center: [0, 0, 0], radius: 1.55 };
|
|
129
|
+
const TRAIL_GAIN = { streak: 3, comet: 2.5, echo: 5 };
|
|
130
|
+
function createUniforms() {
|
|
131
|
+
return {
|
|
132
|
+
uTime: { value: 0 },
|
|
133
|
+
uSize: { value: 1 },
|
|
134
|
+
uViewH: { value: 800 },
|
|
135
|
+
uIntensity: { value: 1 },
|
|
136
|
+
uSpeed: { value: 1 },
|
|
137
|
+
uOpacity: { value: 0.9 },
|
|
138
|
+
uColor: colorUniform('#ffffff'),
|
|
139
|
+
uParticleCount: { value: 0 },
|
|
140
|
+
uFrequency: { value: 5 },
|
|
141
|
+
uSweep: { value: 0.3 },
|
|
142
|
+
uDamping: { value: 0.05 },
|
|
143
|
+
uAmplitude: { value: 1 },
|
|
144
|
+
uHopColor: colorUniform('#ffffff'),
|
|
145
|
+
uLightAngle: { value: 22 },
|
|
146
|
+
uGrainSize: { value: 1 },
|
|
147
|
+
uPointerMode: { value: 0 },
|
|
148
|
+
uPointerStrength: { value: 1 },
|
|
149
|
+
uPointerRadius: { value: 1 },
|
|
150
|
+
uPointerColor: colorUniform('#ffffff'),
|
|
151
|
+
uTrailGain: { value: 1 },
|
|
152
|
+
...createPointerUniforms(),
|
|
153
|
+
...createTrailUniforms(),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function applyConfig(u, c, size) {
|
|
157
|
+
u.uSize.value = size;
|
|
158
|
+
u.uIntensity.value = c.intensity;
|
|
159
|
+
u.uSpeed.value = c.speed;
|
|
160
|
+
u.uOpacity.value = c.opacity;
|
|
161
|
+
u.uColor.value.set(c.color);
|
|
162
|
+
u.uParticleCount.value = c.particleCount;
|
|
163
|
+
u.uFrequency.value = c.frequency;
|
|
164
|
+
u.uSweep.value = c.sweep;
|
|
165
|
+
u.uDamping.value = c.damping;
|
|
166
|
+
u.uAmplitude.value = c.amplitude;
|
|
167
|
+
u.uHopColor.value.set(c.hopColor);
|
|
168
|
+
u.uLightAngle.value = c.lightAngle;
|
|
169
|
+
u.uGrainSize.value = c.grainSize;
|
|
170
|
+
u.uPointerMode.value = POINTER_MODES.indexOf(c.pointerMode); // -1 = 'none' (off)
|
|
171
|
+
u.uPointerStrength.value = c.pointerStrength;
|
|
172
|
+
u.uPointerRadius.value = c.pointerRadius;
|
|
173
|
+
u.uPointerColor.value.set(c.pointerColor);
|
|
174
|
+
applyTrailConfig(u, c);
|
|
175
|
+
}
|
|
176
|
+
const BASE_U = 'uniform float uSize, uViewH, uIntensity, uOpacity;';
|
|
177
|
+
// ---- grains: lit over their heightfield; in flight they take the hop colour ----
|
|
178
|
+
const grainVertex = /* glsl */ `
|
|
179
|
+
${BASE_U}
|
|
180
|
+
uniform vec3 uColor, uHopColor;
|
|
181
|
+
uniform float uGrainSize;
|
|
182
|
+
attribute float aShade; // raking-light factor (1 = flat); >= 1.15 means the grain is in flight
|
|
183
|
+
attribute float aSeed; // size / mineral seed
|
|
184
|
+
varying vec3 vCol;
|
|
185
|
+
varying float vA;
|
|
186
|
+
void main() {
|
|
187
|
+
vec4 mv = modelViewMatrix * vec4(position * uSize, 1.0);
|
|
188
|
+
// mineral mix: a few dark grains (magnetite), the rest quartz of slightly varying tone
|
|
189
|
+
float tone = aSeed < 0.07 ? 0.38 : 0.82 + 0.36 * fract(aSeed * 17.3);
|
|
190
|
+
float flying = step(1.15, aShade);
|
|
191
|
+
vCol = mix(uColor * tone * aShade, uHopColor * min(aShade, 1.6), flying) * uIntensity;
|
|
192
|
+
vA = uOpacity;
|
|
193
|
+
float px = uGrainSize * uSize * uViewH * 0.0085 * (0.75 + 0.5 * aSeed) / -mv.z;
|
|
194
|
+
gl_PointSize = clamp(px, 1.0, 24.0);
|
|
195
|
+
gl_Position = projectionMatrix * mv;
|
|
196
|
+
}
|
|
197
|
+
`;
|
|
198
|
+
const grainFragment = /* glsl */ `
|
|
199
|
+
varying vec3 vCol;
|
|
200
|
+
varying float vA;
|
|
201
|
+
void main() {
|
|
202
|
+
vec2 d = gl_PointCoord * 2.0 - 1.0;
|
|
203
|
+
float r2 = dot(d, d);
|
|
204
|
+
if (r2 > 1.0) discard;
|
|
205
|
+
// a small lit sphere, a touch brighter toward the light side
|
|
206
|
+
float a = (1.0 - smoothstep(0.55, 1.0, r2)) * (1.0 - 0.125 * (d.x - d.y)) * vA;
|
|
207
|
+
if (a < 0.01) discard;
|
|
208
|
+
gl_FragColor = vec4(vCol, a);
|
|
209
|
+
}
|
|
210
|
+
`;
|
|
211
|
+
// rosin dust off the bowed edge
|
|
212
|
+
const puffVertex = /* glsl */ `
|
|
213
|
+
${BASE_U}
|
|
214
|
+
attribute float aAge;
|
|
215
|
+
varying float vA;
|
|
216
|
+
void main() {
|
|
217
|
+
vec4 mv = modelViewMatrix * vec4(position * uSize, 1.0);
|
|
218
|
+
vA = aAge < 0.0 ? 0.0 : exp(-aAge / 0.5) * smoothstep(0.0, 0.05, aAge) * uOpacity;
|
|
219
|
+
gl_PointSize = clamp(uSize * uViewH * 0.009 * (1.0 + 2.0 * max(aAge, 0.0)) / -mv.z, 1.0, 30.0) * step(0.003, vA);
|
|
220
|
+
gl_Position = projectionMatrix * mv;
|
|
221
|
+
}
|
|
222
|
+
`;
|
|
223
|
+
const puffFragment = /* glsl */ `
|
|
224
|
+
uniform vec3 uPointerColor;
|
|
225
|
+
uniform float uIntensity;
|
|
226
|
+
varying float vA;
|
|
227
|
+
void main() {
|
|
228
|
+
vec2 d = gl_PointCoord * 2.0 - 1.0;
|
|
229
|
+
float r2 = dot(d, d);
|
|
230
|
+
if (r2 > 1.0) discard;
|
|
231
|
+
float a = exp(-r2 * 3.5) * (1.0 - r2) * vA * 0.6;
|
|
232
|
+
if (a < 0.004) discard;
|
|
233
|
+
gl_FragColor = vec4(uPointerColor * uIntensity, a);
|
|
234
|
+
}
|
|
235
|
+
`;
|
|
236
|
+
function build(u, c, size) {
|
|
237
|
+
const q = qualityScale(c.quality);
|
|
238
|
+
const n = Math.max(500, Math.round(c.particleCount * q));
|
|
239
|
+
const rng = mulberry32(seedMix(0xc4a1, c.seed));
|
|
240
|
+
// ---- modes: Chladni pairs with split +/- frequencies and small seeded imperfections ----
|
|
241
|
+
const modes = [];
|
|
242
|
+
for (let m = 0; m <= NMAX; m++)
|
|
243
|
+
for (let nn = m; nn <= NMAX; nn++) {
|
|
244
|
+
for (const s of m === nn ? [1] : [1, -1]) {
|
|
245
|
+
const f0 = Math.sqrt(m * m + nn * nn);
|
|
246
|
+
if (f0 < 1.9)
|
|
247
|
+
continue;
|
|
248
|
+
modes.push({ m, n: nn, s, f: f0 * (1 + (m === nn ? 0 : s * 0.035)) * (1 + 0.012 * gauss(rng)) });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const K = modes.length;
|
|
252
|
+
// cosine tables on the grid, and each mode's RMS so every shape has unit RMS
|
|
253
|
+
const C = new Float32Array((NMAX + 1) * NG);
|
|
254
|
+
for (let m = 0; m <= NMAX; m++)
|
|
255
|
+
for (let i = 0; i < NG; i++)
|
|
256
|
+
C[m * NG + i] = Math.cos((m * Math.PI * i) / (NG - 1));
|
|
257
|
+
const norm = new Float32Array(K);
|
|
258
|
+
for (let k = 0; k < K; k++) {
|
|
259
|
+
const { m, n: nn, s } = modes[k];
|
|
260
|
+
let acc = 0;
|
|
261
|
+
for (let j = 0; j < NG; j++)
|
|
262
|
+
for (let i = 0; i < NG; i++) {
|
|
263
|
+
const v = C[m * NG + i] * C[nn * NG + j] + s * C[nn * NG + i] * C[m * NG + j];
|
|
264
|
+
acc += v * v;
|
|
265
|
+
}
|
|
266
|
+
norm[k] = 1 / Math.sqrt(acc / (NG * NG) + 1e-9);
|
|
267
|
+
}
|
|
268
|
+
function phiAt(k, x, z) {
|
|
269
|
+
const md = modes[k];
|
|
270
|
+
const a = (x + HX) * Math.PI / (2 * HX), b = (z + HX) * Math.PI / (2 * HX);
|
|
271
|
+
return (Math.cos(md.m * a) * Math.cos(md.n * b) + md.s * Math.cos(md.n * a) * Math.cos(md.m * b)) * norm[k];
|
|
272
|
+
}
|
|
273
|
+
const A = new Float32Array(K); // resonant amplitudes (ring up / down)
|
|
274
|
+
const E = new Float32Array(K); // tap ring-down (signed)
|
|
275
|
+
const W = new Float32Array(NG * NG);
|
|
276
|
+
const active = new Int32Array(K);
|
|
277
|
+
const T = new Float32Array(K);
|
|
278
|
+
const topQ = new Int32Array(8), topV = new Float32Array(8);
|
|
279
|
+
let stepNo = 0;
|
|
280
|
+
// ---- grains ----
|
|
281
|
+
const pos = new Float32Array(n * 3), vel = new Float32Array(n * 3);
|
|
282
|
+
const shade = new Float32Array(n);
|
|
283
|
+
const seedA = new Float32Array(n);
|
|
284
|
+
const EXP = new Float32Array(n);
|
|
285
|
+
const ground = new Uint8Array(n); // 1 resting on the plate
|
|
286
|
+
const thr = new Float32Array(n);
|
|
287
|
+
const pileY = new Float32Array(n); // per-grain jitter inside the pile
|
|
288
|
+
function sprinkle(i, warm) {
|
|
289
|
+
const j = i * 3;
|
|
290
|
+
pos[j] = (rng() * 2 - 1) * HX * 0.97;
|
|
291
|
+
pos[j + 2] = (rng() * 2 - 1) * HX * 0.97;
|
|
292
|
+
if (warm) {
|
|
293
|
+
pos[j + 1] = 0;
|
|
294
|
+
ground[i] = 1;
|
|
295
|
+
vel[j] = vel[j + 1] = vel[j + 2] = 0;
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
pos[j + 1] = 0.35 + 0.35 * rng();
|
|
299
|
+
ground[i] = 0;
|
|
300
|
+
vel[j] = 0;
|
|
301
|
+
vel[j + 1] = -0.6;
|
|
302
|
+
vel[j + 2] = 0;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
for (let i = 0; i < n; i++) {
|
|
306
|
+
sprinkle(i, true);
|
|
307
|
+
seedA[i] = rng();
|
|
308
|
+
thr[i] = 1 + 0.12 * gauss(rng);
|
|
309
|
+
pileY[i] = rng();
|
|
310
|
+
shade[i] = 1;
|
|
311
|
+
}
|
|
312
|
+
// ---- pointer agents ----
|
|
313
|
+
const TP = new Float32Array(MAX_TAPS * 4);
|
|
314
|
+
for (let k = 0; k < MAX_TAPS; k++)
|
|
315
|
+
TP[k * 4 + 2] = 99;
|
|
316
|
+
let tapHead = 0, tapClock = 0, lastTapX = 1e9, lastTapZ = 1e9;
|
|
317
|
+
const PP = new Float32Array(NPUFF * 3), PV = new Float32Array(NPUFF * 3), PA = new Float32Array(NPUFF);
|
|
318
|
+
PA.fill(-1);
|
|
319
|
+
let puffHead = 0, puffAcc = 0;
|
|
320
|
+
const hp = new THREE.Vector3(), hv = new THREE.Vector3();
|
|
321
|
+
const WARM = 200 * H_STEP * 2;
|
|
322
|
+
let time = -WARM; // the sweep's phase is 0 when the object appears
|
|
323
|
+
// one uniform draw per call keeps the hop statistics deterministic
|
|
324
|
+
const hr = mulberry32(seedMix(0x7ab5, c.seed));
|
|
325
|
+
function sampleW(x, z, out) {
|
|
326
|
+
let fx = (x + HX) / (2 * HX) * (NG - 1), fz = (z + HX) / (2 * HX) * (NG - 1);
|
|
327
|
+
if (fx < 0)
|
|
328
|
+
fx = 0;
|
|
329
|
+
else if (fx > NG - 1.001)
|
|
330
|
+
fx = NG - 1.001;
|
|
331
|
+
if (fz < 0)
|
|
332
|
+
fz = 0;
|
|
333
|
+
else if (fz > NG - 1.001)
|
|
334
|
+
fz = NG - 1.001;
|
|
335
|
+
const i0 = fx | 0, j0 = fz | 0, tx = fx - i0, tz = fz - j0;
|
|
336
|
+
const o = j0 * NG + i0;
|
|
337
|
+
const w00 = W[o], w10 = W[o + 1], w01 = W[o + NG], w11 = W[o + NG + 1];
|
|
338
|
+
const a = w00 + (w10 - w00) * tx, b = w01 + (w11 - w01) * tx;
|
|
339
|
+
out[0] = a + (b - a) * tz;
|
|
340
|
+
const inv = (NG - 1) / (2 * HX);
|
|
341
|
+
out[1] = ((w10 - w00) + ((w11 - w01) - (w10 - w00)) * tz) * inv;
|
|
342
|
+
out[2] = (b - a) * inv;
|
|
343
|
+
}
|
|
344
|
+
const smp = new Float32Array(3);
|
|
345
|
+
function step(h) {
|
|
346
|
+
time += h;
|
|
347
|
+
const amp = Math.max(u.uAmplitude.value, 0);
|
|
348
|
+
const fEff = u.uFrequency.value * Math.pow(2, u.uSweep.value * Math.sin((6.2831853 * time) / SWEEP_T));
|
|
349
|
+
const bw = Math.max(u.uDamping.value, 0.005);
|
|
350
|
+
// pointer
|
|
351
|
+
const mode = u.uPointerMode.value;
|
|
352
|
+
const S = u.uPointerStrength.value, act = u.uPointerActive.value, Rr = u.uPointerRadius.value;
|
|
353
|
+
hp.copy(u.uPointer.value);
|
|
354
|
+
hv.copy(u.uPointerVel.value);
|
|
355
|
+
const spd = Math.hypot(hv.x, hv.z);
|
|
356
|
+
const bowS = mode === 0 && S > 0.01 ? S * act * Math.min(1, spd / 0.6) : 0;
|
|
357
|
+
const fingS = mode === 1 && S > 0.01 && Math.abs(hp.x) < HX && Math.abs(hp.z) < HX ? S * act : 0;
|
|
358
|
+
// bow point: nearest point on the plate edge
|
|
359
|
+
let bx = Math.max(-HX, Math.min(HX, hp.x)), bz = Math.max(-HX, Math.min(HX, hp.z));
|
|
360
|
+
if (HX - Math.abs(bx) < HX - Math.abs(bz))
|
|
361
|
+
bx = Math.sign(bx || 1) * HX;
|
|
362
|
+
else
|
|
363
|
+
bz = Math.sign(bz || 1) * HX;
|
|
364
|
+
const ex = Math.abs(bx) === HX ? 0 : 1, ez = 1 - ex; // direction along the edge
|
|
365
|
+
// taps
|
|
366
|
+
const useTap = mode === 2 && S > 0.01;
|
|
367
|
+
tapClock += h;
|
|
368
|
+
if (useTap && act > 0.6 && Math.abs(hp.x) < HX && Math.abs(hp.z) < HX) {
|
|
369
|
+
const moved = Math.hypot(hp.x - lastTapX, hp.z - lastTapZ);
|
|
370
|
+
if ((tapClock > 0.4 || moved > 0.16) && tapClock > 0.12) {
|
|
371
|
+
const k = tapHead % MAX_TAPS;
|
|
372
|
+
tapHead++;
|
|
373
|
+
TP[k * 4] = hp.x;
|
|
374
|
+
TP[k * 4 + 1] = hp.z;
|
|
375
|
+
TP[k * 4 + 2] = 0;
|
|
376
|
+
TP[k * 4 + 3] = S * Rr;
|
|
377
|
+
tapClock = 0;
|
|
378
|
+
lastTapX = hp.x;
|
|
379
|
+
lastTapZ = hp.z;
|
|
380
|
+
// broadband ring-down: every mode is struck in proportion to its shape at the tap
|
|
381
|
+
for (let q = 0; q < K; q++)
|
|
382
|
+
E[q] += 0.35 * S * phiAt(q, hp.x, hp.z) / (1 + modes[q].f / 5); // a light global ring-down
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
for (let k = 0; k < MAX_TAPS; k++)
|
|
386
|
+
TP[k * 4 + 2] += h;
|
|
387
|
+
const decE = Math.exp(-h / 0.7);
|
|
388
|
+
const kA = 1 - Math.exp(-h / (RING_UP / (1 + 3 * Math.max(bowS, fingS))));
|
|
389
|
+
const bwEff = bw * (1 + 6 * bowS);
|
|
390
|
+
let maxBase = 0, maxNew = 0;
|
|
391
|
+
for (let q = 0; q < K; q++) {
|
|
392
|
+
const d0 = (fEff - modes[q].f) / (bw * modes[q].f);
|
|
393
|
+
const t0 = 1 / ((1 + d0 * d0) * (1 + d0 * d0)); // response power: clean, well-separated figures
|
|
394
|
+
if (t0 > maxBase)
|
|
395
|
+
maxBase = t0;
|
|
396
|
+
const d = (fEff - modes[q].f) / (bwEff * modes[q].f);
|
|
397
|
+
let t = 1 / ((1 + d * d) * (1 + d * d));
|
|
398
|
+
if (bowS > 0) {
|
|
399
|
+
// edge coupling, averaged over the bow width
|
|
400
|
+
let cpl = 0;
|
|
401
|
+
for (let s = -2; s <= 2; s++) {
|
|
402
|
+
const o = s * 0.03 * Rr;
|
|
403
|
+
const p = phiAt(q, Math.max(-HX, Math.min(HX, bx + ex * o)), Math.max(-HX, Math.min(HX, bz + ez * o)));
|
|
404
|
+
cpl += p * p;
|
|
405
|
+
}
|
|
406
|
+
t *= (1 - bowS * 0.95) + bowS * 0.95 * 1.3 * cpl / 5;
|
|
407
|
+
}
|
|
408
|
+
if (fingS > 0) {
|
|
409
|
+
let sup = 0;
|
|
410
|
+
for (let s = 0; s < 5; s++) {
|
|
411
|
+
const ang = s * 1.2566;
|
|
412
|
+
const r = s === 0 ? 0 : 0.035 * Rr;
|
|
413
|
+
const p = phiAt(q, hp.x + r * Math.cos(ang), hp.z + r * Math.sin(ang));
|
|
414
|
+
sup += p * p;
|
|
415
|
+
}
|
|
416
|
+
t *= Math.exp(-5 * fingS * sup / 5);
|
|
417
|
+
}
|
|
418
|
+
T[q] = t;
|
|
419
|
+
if (t > maxNew)
|
|
420
|
+
maxNew = t;
|
|
421
|
+
}
|
|
422
|
+
// the drive's energy goes into the modes the bow / finger allow: keep the loudest one at the
|
|
423
|
+
// undisturbed level (a bow pumps a little more in, a finger takes a little out)
|
|
424
|
+
const target = bowS > 0 ? Math.max(maxBase, 0.6) * (1 + 1.2 * bowS) : fingS > 0 ? maxBase * (1 - 0.2 * fingS / (1 + fingS)) : maxBase;
|
|
425
|
+
const rescale = (bowS > 0 || fingS > 0) && maxNew > 1e-6 ? target / maxNew : 1;
|
|
426
|
+
let nAct = 0, peak = 0;
|
|
427
|
+
for (let q = 0; q < K; q++) {
|
|
428
|
+
const t = T[q] * rescale;
|
|
429
|
+
A[q] += (t - A[q]) * kA;
|
|
430
|
+
E[q] *= decE;
|
|
431
|
+
const v = A[q] + E[q];
|
|
432
|
+
if (Math.abs(v) > 0.03)
|
|
433
|
+
active[nAct++] = q;
|
|
434
|
+
if (Math.abs(v) > peak)
|
|
435
|
+
peak = Math.abs(v);
|
|
436
|
+
}
|
|
437
|
+
stepNo++;
|
|
438
|
+
// vibration field on the grid (modes ring up over ~0.45 s, so every other step is plenty)
|
|
439
|
+
if ((stepNo & 1) === 1 || nAct === 0) {
|
|
440
|
+
W.fill(0);
|
|
441
|
+
for (let a = 0; a < nAct; a++) {
|
|
442
|
+
const q = active[a];
|
|
443
|
+
const { m, n: nn, s } = modes[q];
|
|
444
|
+
const cc = (A[q] + E[q]) * norm[q];
|
|
445
|
+
for (let j = 0; j < NG; j++) {
|
|
446
|
+
const cmj = C[m * NG + j], cnj = C[nn * NG + j];
|
|
447
|
+
const row = j * NG;
|
|
448
|
+
for (let i = 0; i < NG; i++)
|
|
449
|
+
W[row + i] += cc * (C[m * NG + i] * cnj + s * C[nn * NG + i] * cmj);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// rosin dust
|
|
454
|
+
if (bowS > 0.05) {
|
|
455
|
+
puffAcc += bowS * 90 * h;
|
|
456
|
+
while (puffAcc >= 1) {
|
|
457
|
+
puffAcc -= 1;
|
|
458
|
+
const k = puffHead % NPUFF;
|
|
459
|
+
puffHead++;
|
|
460
|
+
const o = (hr() - 0.5) * 0.08 * Rr;
|
|
461
|
+
PP[k * 3] = bx + ex * o;
|
|
462
|
+
PP[k * 3 + 1] = 0.005;
|
|
463
|
+
PP[k * 3 + 2] = bz + ez * o;
|
|
464
|
+
const outx = ex === 0 ? Math.sign(bx) : 0, outz = ez === 0 ? Math.sign(bz) : 0;
|
|
465
|
+
PV[k * 3] = outx * (0.06 + 0.1 * hr()) + (hr() - 0.5) * 0.08;
|
|
466
|
+
PV[k * 3 + 1] = 0.04 + 0.12 * hr();
|
|
467
|
+
PV[k * 3 + 2] = outz * (0.06 + 0.1 * hr()) + (hr() - 0.5) * 0.08;
|
|
468
|
+
PA[k] = 0;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
for (let k = 0; k < NPUFF; k++) {
|
|
472
|
+
if (PA[k] < 0)
|
|
473
|
+
continue;
|
|
474
|
+
PA[k] += h;
|
|
475
|
+
if (PA[k] > 2.5) {
|
|
476
|
+
PA[k] = -1;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
const dmp = Math.exp(-h / 0.6);
|
|
480
|
+
PV[k * 3] *= dmp;
|
|
481
|
+
PV[k * 3 + 1] = PV[k * 3 + 1] * dmp - 0.02 * h;
|
|
482
|
+
PV[k * 3 + 2] *= dmp;
|
|
483
|
+
PP[k * 3] += PV[k * 3] * h;
|
|
484
|
+
PP[k * 3 + 1] += PV[k * 3 + 1] * h;
|
|
485
|
+
PP[k * 3 + 2] += PV[k * 3 + 2] * h;
|
|
486
|
+
}
|
|
487
|
+
// ---- grains ----
|
|
488
|
+
const gain = amp * GAIN;
|
|
489
|
+
const HOP = 0.62, HSTEP = 0.22, DRIFT = 0.05;
|
|
490
|
+
const fric = Math.exp(-h / 0.04);
|
|
491
|
+
const decEx = Math.exp(-h / Math.max(0.5 * u.uTrailLength.value, 0.1));
|
|
492
|
+
let nTap = 0;
|
|
493
|
+
for (let k = 0; k < MAX_TAPS; k++)
|
|
494
|
+
if (TP[k * 4 + 2] < 1.4 && TP[k * 4 + 3] > 0)
|
|
495
|
+
nTap++;
|
|
496
|
+
// direct contact: the fingertip ploughs through the sand; the bow's stick-slip rattles the edge
|
|
497
|
+
const RF = 0.07 * Rr, RB = 0.28 * Rr;
|
|
498
|
+
const handOn = fingS > 0.02 || bowS > 0.02;
|
|
499
|
+
const fvx = Math.max(-1.5, Math.min(1.5, hv.x)), fvz = Math.max(-1.5, Math.min(1.5, hv.z));
|
|
500
|
+
for (let i = 0; i < n; i++) {
|
|
501
|
+
const j = i * 3;
|
|
502
|
+
let x = pos[j], y = pos[j + 1], z = pos[j + 2];
|
|
503
|
+
let vx = vel[j], vy = vel[j + 1], vz = vel[j + 2];
|
|
504
|
+
const onPlate = x > -HX && x < HX && z > -HX && z < HX;
|
|
505
|
+
// resting grains with no velocity are re-checked every third step (the field changes slowly)
|
|
506
|
+
if (ground[i] && vx === 0 && vz === 0 && !nTap && !handOn && onPlate && (i + stepNo) % 3 !== 0) {
|
|
507
|
+
EXP[i] *= decEx;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (onPlate)
|
|
511
|
+
sampleW(x, z, smp);
|
|
512
|
+
else {
|
|
513
|
+
smp[0] = 0;
|
|
514
|
+
smp[1] = 0;
|
|
515
|
+
smp[2] = 0;
|
|
516
|
+
}
|
|
517
|
+
const w = smp[0], gx = smp[1], gz = smp[2];
|
|
518
|
+
const aw = w < 0 ? -w : w;
|
|
519
|
+
const G = Math.min(gain * aw, 6);
|
|
520
|
+
if (!onPlate) {
|
|
521
|
+
// fell off the edge: free fall, then a fresh sprinkle from above
|
|
522
|
+
ground[i] = 0;
|
|
523
|
+
vy -= G_SIM * h;
|
|
524
|
+
x += vx * h;
|
|
525
|
+
y += vy * h;
|
|
526
|
+
z += vz * h;
|
|
527
|
+
if (y < -0.6) {
|
|
528
|
+
pos[j] = x;
|
|
529
|
+
pos[j + 1] = y;
|
|
530
|
+
pos[j + 2] = z;
|
|
531
|
+
sprinkle(i, false);
|
|
532
|
+
x = pos[j];
|
|
533
|
+
y = pos[j + 1];
|
|
534
|
+
z = pos[j + 2];
|
|
535
|
+
vx = vel[j];
|
|
536
|
+
vy = vel[j + 1];
|
|
537
|
+
vz = vel[j + 2];
|
|
538
|
+
if (trail)
|
|
539
|
+
trail.reset(i);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
else if (ground[i]) {
|
|
543
|
+
if (G > thr[i]) {
|
|
544
|
+
// hop: height from the excess acceleration, a random step that grows with the vibration
|
|
545
|
+
const ex2 = Math.sqrt(G - thr[i]);
|
|
546
|
+
vy = HOP * ex2 * (0.55 + 0.8 * hr());
|
|
547
|
+
const ang = 6.2831853 * hr();
|
|
548
|
+
const st = HSTEP * ex2 * (0.3 + hr());
|
|
549
|
+
const sg = w >= 0 ? 1 : -1;
|
|
550
|
+
let dx = -DRIFT * sg * gx, dz = -DRIFT * sg * gz;
|
|
551
|
+
const dl = Math.hypot(dx, dz);
|
|
552
|
+
if (dl > 0.12) {
|
|
553
|
+
dx *= 0.12 / dl;
|
|
554
|
+
dz *= 0.12 / dl;
|
|
555
|
+
}
|
|
556
|
+
vx = Math.cos(ang) * st + dx;
|
|
557
|
+
vz = Math.sin(ang) * st + dz;
|
|
558
|
+
ground[i] = 0;
|
|
559
|
+
y = 0.001;
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
vx *= fric;
|
|
563
|
+
vz *= fric;
|
|
564
|
+
vy = 0;
|
|
565
|
+
if (vx * vx + vz * vz < 1e-6) {
|
|
566
|
+
vx = 0;
|
|
567
|
+
vz = 0;
|
|
568
|
+
}
|
|
569
|
+
x += vx * h;
|
|
570
|
+
z += vz * h;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
vy -= G_SIM * h;
|
|
575
|
+
x += vx * h;
|
|
576
|
+
y += vy * h;
|
|
577
|
+
z += vz * h;
|
|
578
|
+
if (y <= 0 && x > -HX && x < HX && z > -HX && z < HX) {
|
|
579
|
+
y = 0;
|
|
580
|
+
vy = 0;
|
|
581
|
+
vx *= 0.25;
|
|
582
|
+
vz *= 0.25;
|
|
583
|
+
ground[i] = 1;
|
|
584
|
+
if (vx * vx + vz * vz < 1e-6) {
|
|
585
|
+
vx = 0;
|
|
586
|
+
vz = 0;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (handOn && onPlate) {
|
|
591
|
+
if (fingS > 0.02) {
|
|
592
|
+
const dx = x - hp.x, dz = z - hp.z, d2 = dx * dx + dz * dz;
|
|
593
|
+
if (d2 < RF * RF && y < 0.03) {
|
|
594
|
+
// pushed out of the fingertip's footprint, carried along with its motion: a groove
|
|
595
|
+
const d = Math.sqrt(d2) + 1e-5;
|
|
596
|
+
x = hp.x + dx / d * RF * 1.02;
|
|
597
|
+
z = hp.z + dz / d * RF * 1.02;
|
|
598
|
+
vx = dx / d * 0.25 * fingS + fvx * 0.6;
|
|
599
|
+
vz = dz / d * 0.25 * fingS + fvz * 0.6;
|
|
600
|
+
if (hr() < 0.25) {
|
|
601
|
+
vy = 0.25 + 0.3 * hr();
|
|
602
|
+
ground[i] = 0;
|
|
603
|
+
y = Math.max(y, 0.001);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (bowS > 0.02 && ground[i]) {
|
|
608
|
+
const dx = x - bx, dz = z - bz, d2 = dx * dx + dz * dz;
|
|
609
|
+
if (d2 < RB * RB && hr() < bowS * 0.35 * (1 - Math.sqrt(d2) / RB)) {
|
|
610
|
+
// grains near the bowed edge chatter: sharp hops, thrown in from the edge
|
|
611
|
+
const ang = 6.2831853 * hr();
|
|
612
|
+
vy = 0.5 + 0.6 * hr() * bowS;
|
|
613
|
+
vx = Math.cos(ang) * 0.2 - (ex === 0 ? Math.sign(bx) * 0.25 : 0);
|
|
614
|
+
vz = Math.sin(ang) * 0.2 - (ez === 0 ? Math.sign(bz) * 0.25 : 0);
|
|
615
|
+
ground[i] = 0;
|
|
616
|
+
y = 0.001;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
// tap bending waves throw the grains they pass
|
|
621
|
+
if (nTap && onPlate && ground[i]) {
|
|
622
|
+
for (let k = 0; k < MAX_TAPS; k++) {
|
|
623
|
+
const age = TP[k * 4 + 2];
|
|
624
|
+
if (age >= 1.4 || TP[k * 4 + 3] <= 0)
|
|
625
|
+
continue;
|
|
626
|
+
const dx = x - TP[k * 4], dz = z - TP[k * 4 + 1];
|
|
627
|
+
const r = Math.sqrt(dx * dx + dz * dz) + 1e-4;
|
|
628
|
+
// the bending wave is damped within the tap's reach (pointerRadius): a local ring
|
|
629
|
+
const reach = 0.3 * TP[k * 4 + 3] / Math.max(S, 0.01);
|
|
630
|
+
const rf = 0.8 * age, wd = 0.025 + 0.04 * age;
|
|
631
|
+
if (rf > 1.3 * reach)
|
|
632
|
+
continue;
|
|
633
|
+
const off = r - rf;
|
|
634
|
+
if (off * off < wd * wd) {
|
|
635
|
+
const e = S * Math.exp(-(rf * rf) / (reach * reach)) * (r < 0.25 * reach ? 1.3 : 1);
|
|
636
|
+
if (hr() < 0.85) {
|
|
637
|
+
vy = Math.min(1.3, 0.9 * e * (0.6 + 0.8 * hr()));
|
|
638
|
+
vx = dx / r * 0.4 * e + (hr() - 0.5) * 0.12;
|
|
639
|
+
vz = dz / r * 0.4 * e + (hr() - 0.5) * 0.12;
|
|
640
|
+
ground[i] = 0;
|
|
641
|
+
y = Math.max(y, 0.001);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
pos[j] = x;
|
|
647
|
+
pos[j + 1] = y;
|
|
648
|
+
pos[j + 2] = z;
|
|
649
|
+
vel[j] = vx;
|
|
650
|
+
vel[j + 1] = vy;
|
|
651
|
+
vel[j + 2] = vz;
|
|
652
|
+
// exposure memory of the grain's speed (trails are shaded by it)
|
|
653
|
+
const sp = Math.sqrt(vx * vx + vy * vy + vz * vz);
|
|
654
|
+
const e = EXP[i] * decEx;
|
|
655
|
+
EXP[i] = sp > e ? sp : e;
|
|
656
|
+
}
|
|
657
|
+
lastPeak = peak;
|
|
658
|
+
}
|
|
659
|
+
let lastPeak = 0;
|
|
660
|
+
let trail = null, trailKey = '';
|
|
661
|
+
// ---- sand heightfield -> raking-light shade per grain (render only) ----
|
|
662
|
+
const DC = new Float32Array(DG * DG), DS = new Float32Array(DG * DG), HS = new Float32Array(DG * DG);
|
|
663
|
+
const rpos = new Float32Array(n * 3);
|
|
664
|
+
function shadeGrains() {
|
|
665
|
+
DC.fill(0);
|
|
666
|
+
for (let i = 0; i < n; i++) {
|
|
667
|
+
if (!ground[i])
|
|
668
|
+
continue;
|
|
669
|
+
const gx = Math.floor((pos[i * 3] + HX) / (2 * HX) * DG), gz = Math.floor((pos[i * 3 + 2] + HX) / (2 * HX) * DG);
|
|
670
|
+
if (gx < 0 || gz < 0 || gx >= DG || gz >= DG)
|
|
671
|
+
continue;
|
|
672
|
+
DC[gz * DG + gx] += 1;
|
|
673
|
+
}
|
|
674
|
+
// one 3x3 blur, then height ~ sqrt(count) (a grain pile's volume spreads into its footprint)
|
|
675
|
+
const avg = n / (DG * DG);
|
|
676
|
+
for (let j = 0; j < DG; j++)
|
|
677
|
+
for (let i = 0; i < DG; i++) {
|
|
678
|
+
let s = 0, wsum = 0;
|
|
679
|
+
for (let b = -1; b <= 1; b++)
|
|
680
|
+
for (let a = -1; a <= 1; a++) {
|
|
681
|
+
const ii = i + a, jj = j + b;
|
|
682
|
+
if (ii < 0 || jj < 0 || ii >= DG || jj >= DG)
|
|
683
|
+
continue;
|
|
684
|
+
const ww = a === 0 && b === 0 ? 4 : (a === 0 || b === 0 ? 2 : 1);
|
|
685
|
+
s += DC[jj * DG + ii] * ww;
|
|
686
|
+
wsum += ww;
|
|
687
|
+
}
|
|
688
|
+
DS[j * DG + i] = Math.sqrt(s / wsum / avg);
|
|
689
|
+
}
|
|
690
|
+
const el = (u.uLightAngle.value * Math.PI) / 180;
|
|
691
|
+
const Lx = -0.78 * Math.cos(el), Ly = Math.sin(el), Lz = 0.62 * Math.cos(el);
|
|
692
|
+
const Ll = Math.hypot(Lx, Ly, Lz);
|
|
693
|
+
const cell = (2 * HX) / DG, HK = 0.006;
|
|
694
|
+
for (let j = 0; j < DG; j++)
|
|
695
|
+
for (let i = 0; i < DG; i++) {
|
|
696
|
+
const hL = DS[j * DG + Math.max(i - 1, 0)], hR = DS[j * DG + Math.min(i + 1, DG - 1)];
|
|
697
|
+
const hD = DS[Math.max(j - 1, 0) * DG + i], hU = DS[Math.min(j + 1, DG - 1) * DG + i];
|
|
698
|
+
const dx = (hR - hL) * HK / (2 * cell) * 3, dz = (hU - hD) * HK / (2 * cell) * 3;
|
|
699
|
+
const nl = Math.hypot(dx, 1, dz);
|
|
700
|
+
const ndl = (-dx * Lx + Ly - dz * Lz) / (nl * Ll);
|
|
701
|
+
HS[j * DG + i] = Math.max(0.12, 0.35 + 0.65 * Math.max(ndl, 0) / (Ly / Ll));
|
|
702
|
+
}
|
|
703
|
+
for (let i = 0; i < n; i++) {
|
|
704
|
+
const j = i * 3;
|
|
705
|
+
rpos[j] = pos[j];
|
|
706
|
+
rpos[j + 2] = pos[j + 2];
|
|
707
|
+
const gx = Math.floor((pos[j] + HX) / (2 * HX) * DG), gz = Math.floor((pos[j + 2] + HX) / (2 * HX) * DG);
|
|
708
|
+
const inside = gx >= 0 && gz >= 0 && gx < DG && gz < DG;
|
|
709
|
+
const hgt = inside ? DS[gz * DG + gx] * HK : 0;
|
|
710
|
+
if (ground[i]) {
|
|
711
|
+
rpos[j + 1] = hgt * (0.35 + 0.65 * pileY[i]) + 0.002;
|
|
712
|
+
shade[i] = inside ? Math.min(HS[gz * DG + gx], 1.1) : 1;
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
rpos[j + 1] = pos[j + 1] + 0.002 + hgt * 0.5;
|
|
716
|
+
shade[i] = 1.15 + Math.min(pos[j + 1] * 8, 0.4);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const stepper = createFixedStep(H_STEP, 4);
|
|
721
|
+
for (let s = 0; s < 200; s++)
|
|
722
|
+
step(H_STEP * 2); // ~7 s of hops at a coarse step: the first figure has formed
|
|
723
|
+
shadeGrains();
|
|
724
|
+
// ---- geometry ----
|
|
725
|
+
const geo = new THREE.BufferGeometry();
|
|
726
|
+
const posAttr = new THREE.BufferAttribute(rpos, 3).setUsage(THREE.DynamicDrawUsage);
|
|
727
|
+
const shAttr = new THREE.BufferAttribute(shade, 1).setUsage(THREE.DynamicDrawUsage);
|
|
728
|
+
geo.setAttribute('position', posAttr);
|
|
729
|
+
geo.setAttribute('aShade', shAttr);
|
|
730
|
+
geo.setAttribute('aSeed', new THREE.BufferAttribute(seedA, 1));
|
|
731
|
+
const mat = new THREE.ShaderMaterial({
|
|
732
|
+
uniforms: u, vertexShader: grainVertex, fragmentShader: grainFragment,
|
|
733
|
+
transparent: true, depthWrite: false,
|
|
734
|
+
});
|
|
735
|
+
const grains = new THREE.Points(geo, mat);
|
|
736
|
+
grains.frustumCulled = false;
|
|
737
|
+
grains.renderOrder = 2;
|
|
738
|
+
attachViewH(grains, u);
|
|
739
|
+
const fgeo = new THREE.BufferGeometry();
|
|
740
|
+
const ppAttr = new THREE.BufferAttribute(PP, 3).setUsage(THREE.DynamicDrawUsage);
|
|
741
|
+
const paAttr = new THREE.BufferAttribute(PA, 1).setUsage(THREE.DynamicDrawUsage);
|
|
742
|
+
fgeo.setAttribute('position', ppAttr);
|
|
743
|
+
fgeo.setAttribute('aAge', paAttr);
|
|
744
|
+
const fmat = new THREE.ShaderMaterial({
|
|
745
|
+
uniforms: u, vertexShader: puffVertex, fragmentShader: puffFragment,
|
|
746
|
+
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
|
|
747
|
+
});
|
|
748
|
+
const puffs = new THREE.Points(fgeo, fmat);
|
|
749
|
+
puffs.frustumCulled = false;
|
|
750
|
+
puffs.renderOrder = 3;
|
|
751
|
+
const object = new THREE.Group();
|
|
752
|
+
const proxy = createPickProxy(BOUNDS.center, BOUNDS.radius);
|
|
753
|
+
object.add(grains, puffs, proxy.mesh);
|
|
754
|
+
const anchor = new THREE.Vector3(0, 0, 0);
|
|
755
|
+
const up = new THREE.Vector3(0, 1, 0);
|
|
756
|
+
// ---- trails: grains in motion, shaded by an exposure memory of their speed ----
|
|
757
|
+
const trailGLSL = /* glsl */ `
|
|
758
|
+
uniform vec3 uColor, uHopColor;
|
|
759
|
+
uniform float uTrailGain;
|
|
760
|
+
vec3 trailShade(vec3 p, float f) {
|
|
761
|
+
float e = clamp(TRAIL_K * 2.2, 0.0, 1.0);
|
|
762
|
+
return mix(uColor, uHopColor, 0.5) * e * 0.55 * uTrailGain;
|
|
763
|
+
}
|
|
764
|
+
vec3 trailView(vec3 mv, out float mag) { mag = 1.0; return mv; }
|
|
765
|
+
`;
|
|
766
|
+
const materials = [mat, fmat];
|
|
767
|
+
function buildTrails(mode, share) {
|
|
768
|
+
const key = mode + ':' + share;
|
|
769
|
+
if (key === trailKey)
|
|
770
|
+
return;
|
|
771
|
+
trailKey = key;
|
|
772
|
+
if (trail) {
|
|
773
|
+
object.remove(trail.object);
|
|
774
|
+
trail.dispose();
|
|
775
|
+
trail = null;
|
|
776
|
+
}
|
|
777
|
+
materials.length = 2;
|
|
778
|
+
u.uTrailGain.value = TRAIL_GAIN[mode] || 1;
|
|
779
|
+
trail = createCpuTrail(mode, { u, n, pos: rpos, vel, k: EXP, glsl: trailGLSL, sizePx: 0.005, spacing: 0.002, sub: 8 });
|
|
780
|
+
if (!trail)
|
|
781
|
+
return;
|
|
782
|
+
trail.object.renderOrder = 1;
|
|
783
|
+
object.add(trail.object);
|
|
784
|
+
materials.push(...trail.materials);
|
|
785
|
+
}
|
|
786
|
+
buildTrails(c.trailMode, c.trailShare);
|
|
787
|
+
let shadeTick = 0;
|
|
788
|
+
function upload() {
|
|
789
|
+
if ((shadeTick++ & 1) === 0)
|
|
790
|
+
shadeGrains();
|
|
791
|
+
else {
|
|
792
|
+
for (let i = 0; i < n; i++) {
|
|
793
|
+
if (ground[i])
|
|
794
|
+
continue;
|
|
795
|
+
const j = i * 3;
|
|
796
|
+
rpos[j] = pos[j];
|
|
797
|
+
rpos[j + 1] = pos[j + 1] + 0.002;
|
|
798
|
+
rpos[j + 2] = pos[j + 2];
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
posAttr.needsUpdate = true;
|
|
802
|
+
shAttr.needsUpdate = true;
|
|
803
|
+
ppAttr.needsUpdate = true;
|
|
804
|
+
paAttr.needsUpdate = true;
|
|
805
|
+
}
|
|
806
|
+
return {
|
|
807
|
+
object,
|
|
808
|
+
materials,
|
|
809
|
+
apply(cfg) { if (cfg)
|
|
810
|
+
buildTrails(cfg.trailMode, cfg.trailShare); },
|
|
811
|
+
update(dt, camera, pointer) {
|
|
812
|
+
const rdt = Math.min(Math.max(dt, 0), 1 / 20);
|
|
813
|
+
if (camera && !inView(object, camera, BOUNDS.center, BOUNDS.radius, u.uSize.value))
|
|
814
|
+
return;
|
|
815
|
+
const d = rdt * u.uSpeed.value;
|
|
816
|
+
u.uTime.value += d;
|
|
817
|
+
if (camera && pointer)
|
|
818
|
+
stepPointerField(u, pointer, camera, object, u.uSize.value, rdt, anchor, up);
|
|
819
|
+
u.uVelScale.value = 1;
|
|
820
|
+
let simD = 0;
|
|
821
|
+
stepper.run(d, (hh) => { step(hh); simD += hh; });
|
|
822
|
+
if (simD > 0) {
|
|
823
|
+
upload();
|
|
824
|
+
if (trail)
|
|
825
|
+
trail.update(simD);
|
|
826
|
+
}
|
|
827
|
+
},
|
|
828
|
+
seek(t) { for (let s = 0; s < Math.round(t / H_STEP); s++)
|
|
829
|
+
step(H_STEP); upload(); },
|
|
830
|
+
dispose() {
|
|
831
|
+
geo.dispose();
|
|
832
|
+
mat.dispose();
|
|
833
|
+
fgeo.dispose();
|
|
834
|
+
fmat.dispose();
|
|
835
|
+
proxy.dispose();
|
|
836
|
+
if (trail)
|
|
837
|
+
trail.dispose();
|
|
838
|
+
},
|
|
839
|
+
_state: { pos, vel, ground, A, E, W, get peak() { return lastPeak; } },
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
/** Defaults for every knob, so an older saved config still renders (identical to the catalog entry). */
|
|
843
|
+
const DEFAULTS = {
|
|
844
|
+
intensity: 1,
|
|
845
|
+
speed: 1,
|
|
846
|
+
opacity: 0.9,
|
|
847
|
+
size: 1,
|
|
848
|
+
color: '#f1e3c2',
|
|
849
|
+
particleCount: 20000,
|
|
850
|
+
frequency: 4.63,
|
|
851
|
+
sweep: 0.4,
|
|
852
|
+
damping: 0.025,
|
|
853
|
+
amplitude: 1,
|
|
854
|
+
hopColor: '#fff3d6',
|
|
855
|
+
lightAngle: 22,
|
|
856
|
+
grainSize: 1,
|
|
857
|
+
seed: 1,
|
|
858
|
+
quality: 'medium',
|
|
859
|
+
pointerMode: 'bow',
|
|
860
|
+
pointerStrength: 1,
|
|
861
|
+
pointerRadius: 1,
|
|
862
|
+
pointerColor: '#fff4dc',
|
|
863
|
+
trailMode: 'streak',
|
|
864
|
+
trailLength: 1.6,
|
|
865
|
+
trailOpacity: 0.6,
|
|
866
|
+
trailColor: '#ffcf8a',
|
|
867
|
+
trailShare: 0.5,
|
|
868
|
+
};
|
|
869
|
+
/** Knobs that rebuild the simulation: they change on commit, never through the live path. */
|
|
870
|
+
const STRUCTURAL = ['particleCount', 'seed', 'quality'];
|
|
871
|
+
/** Knobs an animation (or mouse move) may drive per frame — generated from applyConfig. */
|
|
872
|
+
const ANIMATABLE = new Set(GENERATIVE_PARTICLES_ANIMATABLE.chladniSand.map((p) => p.value));
|
|
873
|
+
const ChladniSandParticles = ({ id, size, config, animations, onObjectReady, onObjectDispose }) => {
|
|
874
|
+
const rootRef = useRef(null);
|
|
875
|
+
useObjectReady(rootRef, config, onObjectReady, onObjectDispose);
|
|
876
|
+
const s = size > 0 ? size : 1;
|
|
877
|
+
const cfg = useMemo(() => ({ ...DEFAULTS, ...config }), [config]);
|
|
878
|
+
// uniforms outlive the objects, so keyframe animations survive a rebuild
|
|
879
|
+
const uniforms = useMemo(() => createUniforms(), []);
|
|
880
|
+
// rebuilt only when a structural knob changes (trails rebuild inside apply())
|
|
881
|
+
const instance = useMemo(() => build(uniforms, cfg, s), [uniforms, cfg.particleCount, cfg.seed, cfg.quality]);
|
|
882
|
+
useEffect(() => () => instance.dispose(), [instance]);
|
|
883
|
+
useLayoutEffect(() => {
|
|
884
|
+
applyConfig(uniforms, cfg, s);
|
|
885
|
+
instance.apply(cfg);
|
|
886
|
+
}, [instance, uniforms, cfg, s]);
|
|
887
|
+
// Slider drags and keyframe animations are both overrides on the committed config.
|
|
888
|
+
// This is the one place they are merged and pushed, so they compose instead of
|
|
889
|
+
// overwriting each other; the animation wins, because it is the active driver.
|
|
890
|
+
const keyframes = animations?.particles?.animations;
|
|
891
|
+
const animOverride = useRef({});
|
|
892
|
+
const animFrom = useRef({});
|
|
893
|
+
const animStart = useRef(0);
|
|
894
|
+
// Editing a keyframe restarts its cycle rather than dropping the user mid-way.
|
|
895
|
+
useEffect(() => { animStart.current = performance.now(); animFrom.current = {}; }, [keyframes]);
|
|
896
|
+
const push = useCallback((overrides) => {
|
|
897
|
+
const merged = { ...cfg, ...overrides, ...animOverride.current };
|
|
898
|
+
const z = merged.size;
|
|
899
|
+
applyConfig(uniforms, merged, z && z > 0 ? z : s);
|
|
900
|
+
}, [cfg, uniforms, s]);
|
|
901
|
+
const live = useConfigLiveHandle(rootRef, id, 'particles', cfg, STRUCTURAL, push);
|
|
902
|
+
const pointer = usePointerField();
|
|
903
|
+
useFrame((state, delta) => {
|
|
904
|
+
instance.update(Math.min(delta, 0.1), state.camera, pointer);
|
|
905
|
+
if (keyframes?.length) {
|
|
906
|
+
applyConfigKeyframeAnimations(keyframes, animFrom.current, cfg, animOverride.current, performance.now() - animStart.current, ANIMATABLE);
|
|
907
|
+
push(live.current);
|
|
908
|
+
}
|
|
909
|
+
});
|
|
910
|
+
return (_jsx("group", { ref: rootRef, children: _jsx("primitive", { object: instance.object }) }));
|
|
911
|
+
};
|
|
912
|
+
export default ChladniSandParticles;
|