Setting the file. One moment.
Interaction · Frost Sequence Camera Orbit · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page Three Mesh BVH LICENSE
(opens in a new tab)
source/src/core/ interaction.ts
TypeScript · 411 lines · 15 KB
from
"../dials/store"
;
8 import { damp } from "./ease" ;
9 import type { ErosionField } from "../erosion/ErosionField" ;
10 import { MAX_SEGMENTS } from "../erosion/ErosionField" ;
11 import type { Powder } from "../powder/Powder" ;
12
13 const DEPTH_STEPS = 4 ;
14 /** ?stroke=1 drives a synthetic sweep (headless checks). */
15 const FORCE_STROKE =
16 typeof location !== "undefined" && new URLSearchParams (location.search). has ( "stroke" );
17 /** ?strokeFrames=N stops the synthetic sweep after N frames (to watch what the field does afterwards). */
18 const STROKE_FRAMES =
19 typeof location !== "undefined"
20 ? parseInt ( new URLSearchParams (location.search). get ( "strokeFrames" ) || "1000000" , 10 )
21 : 1e6 ;
22 const STROKE_START =
23 typeof location !== "undefined"
24 ? parseInt ( new URLSearchParams (location.search). get ( "strokeStart" ) || "0" , 10 )
25 : 0 ;
26
27 export class Interaction {
28 readonly strokeSegments : THREE . Vector4 [] = Array. from (
29 { length: MAX_SEGMENTS * 2 },
30 () => new THREE . Vector4 (),
31 );
32 strokeCount = 0 ;
33 /** Smoothed stroke velocity in world units / s and its direction (kept after the stroke ends, speed decays). */
34 readonly strokeVel = new THREE . Vector3 ();
35 readonly strokeDir = new THREE . Vector3 ( 1 , 0.4 , 0 ). normalize ();
36 strokeSpeed = 0 ;
37 /** Debug: last entry/exit hit points in world space. */
38 readonly hitEntry = new THREE . Vector3 ();
39 readonly hitExit = new THREE . Vector3 ();
40 hasHit = false ;
41
42 private prevEntry = new THREE . Vector3 ();
43 private prevExit = new THREE . Vector3 ();
44 private prevEntryWorld = new THREE . Vector3 ();
45 private prevHit = false ;
46 private prevX = 0 ;
47 private prevY = 0 ;
48 /** Scripted strokes (hero triggers): a sweep along an object-space path, or a burst at a point. */
49 /** FROST: a queue instead of one sweep, so a shatter can run several parallel slices with staggered starts. */
50 private scripted : {
51 kind : "sweep" | "front" ;
52 from : THREE . Vector3 ;
53 to : THREE . Vector3 ;
54 normal ?: THREE . Vector3 ;
55 reach ?: number ;
56 t0 : number ;
57 delay : number ;
58 duration : number ;
59 radius : number ;
60 strength : number ;
61 through : boolean ;
62 prevT : number ;
63 }[] = [];
64 private scrollP = - 1 ;
65 private scrollPath = { from: new THREE . Vector3 ( - 1 , - 0.6 , 0 ), to: new THREE . Vector3 ( 1 , 0.6 , 0 ) };
66 private ray = new THREE . Ray ();
67 private inv = new THREE . Matrix4 ();
68 private tmp = new THREE . Vector3 ();
69 private objectRayOrigin = new THREE . Vector3 ();
70 private objectRayDirection = new THREE . Vector3 ();
71 private workA = new THREE . Vector3 ();
72 private workB = new THREE . Vector3 ();
73 private workDirection = new THREE . Vector3 ();
74 private velocity = new THREE . Vector3 ();
75 private rayHit = { entry: new THREE . Vector3 (), exit: new THREE . Vector3 (), tEntry: 0 , tExit: 0 };
76 private resetDone = true ;
77
78 constructor (
79 readonly shape : ShapeSpec ,
80 readonly objectGroup : THREE . Object3D ,
81 readonly camera : THREE . PerspectiveCamera ,
82 readonly erosion : ErosionField | null ,
83 readonly powder : Powder | null ,
84 /** V2: called when a reset completes (chunks snap home) */
85 readonly onReset ?: () => void ,
86 ) {}
87
88 /** Play a sweep across the object (object-space path, fractions of the bound). */
89 sweep (
90 opts : {
91 from ?: [ number , number , number ];
92 to ?: [ number , number , number ];
93 duration ?: number ;
94 radius ?: number ;
95 strength ?: number ;
96 through ?: boolean ;
97 delay ?: number ;
98 } = {},
99 ) {
100 const B = this .shape.bound;
101 const f = opts.from ?? [ - 1 , - 0.55 , 0 ],
102 to = opts.to ?? [ 1 , 0.55 , 0 ];
103 this .scripted. push ({
104 kind: "sweep" ,
105 from: new THREE . Vector3 (f[ 0 ] * B , f[ 1 ] * B , f[ 2 ] * B ),
106 to: new THREE . Vector3 (to[ 0 ] * B , to[ 1 ] * B , to[ 2 ] * B ),
107 t0: - 1 ,
108 delay: opts.delay ?? 0 ,
109 duration: opts.duration ?? 0.7 ,
110 radius: opts.radius ?? D .erosion.brushRadius,
111 strength: opts.strength ?? D .erosion.brushStrength * 1.5 ,
112 through: opts.through ?? true ,
113 prevT: 0 ,
114 });
115 }
116
117 /**
118 * Break front: a band the full slice-path length (plus half a bound each way) travels outward on both sides,
119 * eroding all it passes until the shape is gone. `reach` in bounds; eject direction stays the slice's.
120 */
121 front ( opts : {
122 from : [ number , number , number ];
123 to : [ number , number , number ];
124 delay ?: number ;
125 duration ?: number ;
126 reach ?: number ;
127 radius ?: number ;
128 strength ?: number ;
129 }) {
130 const B = this .shape.bound;
131 const [ fx , fy ] = opts.from,
132 [ tx , ty ] = opts.to;
133 const dx = tx - fx,
134 dy = ty - fy,
135 len = Math. hypot (dx, dy) || 1 ;
136 const ex = (dx / len) * 0.5 ,
137 ey = (dy / len) * 0.5 ;
138 this .scripted. push ({
139 kind: "front" ,
140 from: new THREE . Vector3 ((fx - ex) * B , (fy - ey) * B , 0 ),
141 to: new THREE . Vector3 ((tx + ex) * B , (ty + ey) * B , 0 ),
142 normal: new THREE . Vector3 ( - dy / len, dx / len, 0 ),
143 reach: (opts.reach ?? 1.3 ) * B ,
144 t0: - 1 ,
145 delay: opts.delay ?? 0 ,
146 duration: opts.duration ?? 1.5 ,
147 radius: opts.radius ?? D .erosion.brushRadius,
148 strength: opts.strength ?? D .erosion.brushStrength * 1.5 ,
149 through: true ,
150 prevT: 0 ,
151 });
152 }
153
154 /** FROST: drop every queued sweep (a reset). */
155 clearScripted () {
156 this .scripted. length = 0 ;
157 }
158
159 /** Burst at a world-space point (e.g. a click hit), eroding a sphere and throwing grains outward. */
160 burst ( worldPoint : THREE . Vector3 , radius : number , strength : number , duration = 0.18 ) {
161 const inv = new THREE . Matrix4 (). copy ( this .objectGroup.matrixWorld). invert ();
162 const p = worldPoint. clone (). applyMatrix4 (inv);
163 this .scripted. push ({
164 from: p,
165 to: p. clone (),
166 t0: - 1 ,
167 delay: 0 ,
168 duration,
169 radius,
170 strength,
171 through: false ,
172 prevT: 0 ,
173 });
174 const centre = new THREE . Vector3 (). setFromMatrixPosition ( this .objectGroup.matrixWorld);
175 const dir = worldPoint. clone (). sub (centre);
176 if (dir. lengthSq () < 1e-6 ) dir. set ( 0 , 1 , 0 );
177 this .strokeDir. copy (dir. normalize ());
178 this .strokeSpeed = D .erosion.brushSpeedRef * 1.5 ;
179 }
180
181 /** Scroll-driven erosion: erodes along a diagonal as `p` (0..1) advances; call every frame. */
182 sweepTo ( p : number ) {
183 this .scrollP = Math. max ( 0 , Math. min ( 1 , p));
184 }
185
186 private pushScripted ( t : number , dt : number ) {
187 if ( this .scripted. length === 0 ) return false ;
188 let pushed = false ;
189 for ( const sc of this .scripted) {
190 if (sc.t0 < 0 ) sc.t0 = t + sc.delay;
191 if (t < sc.t0) continue ;
192 const u = Math. min ( 1 , (t - sc.t0) / Math. max ( 0.01 , sc.duration));
193 const B = this .shape.bound;
194 if (sc.kind === "front" ) {
195 // the band starts fast and slows down; its radius never drops below the distance it moved this frame
196 const ease = ( x : number ) => 1 - ( 1 - x) * ( 1 - x);
197 const dPrev = sc.reach ! * ease (sc.prevT),
198 dNow = sc.reach ! * ease (u);
199 const r = Math. max (sc.radius, (dNow - dPrev) * 1.5 );
200 // one band per depth slice: the shapes are thin, so one at z = 0 usually covers them
201 const th = this .shape.thickness;
202 const zs = th <= r * 0.9 ? [ 0 ] : [ - th, 0 , th];
203 if ( this .strokeCount + zs. length * 2 > MAX_SEGMENTS ) break ;
204 for ( const s of [ 1 , - 1 ]) {
205 const a = this .workA. copy (sc.from). addScaledVector (sc.normal ! , s * dNow),
206 b = this .workB. copy (sc.to). addScaledVector (sc.normal ! , s * dNow);
207 for ( const z of zs) {
208 this .strokeSegments[ this .strokeCount * 2 ]. set (a.x, a.y, z, r);
209 this .strokeSegments[ this .strokeCount * 2 + 1 ]. set (b.x, b.y, z, sc.strength);
210 this .strokeCount ++ ;
211 }
212 }
213 this .strokeSpeed = Math. max ( this .strokeSpeed, (sc.reach ! / sc.duration) * 0.6 );
214 sc.prevT = u;
215 pushed = true ;
216 continue ;
217 }
218 const a = this .workA. copy (sc.from). lerp (sc.to, sc.prevT),
219 b = this .workB. copy (sc.from). lerp (sc.to, u);
220 const n = sc.through ? 3 : 1 ;
221 if ( this .strokeCount + n > MAX_SEGMENTS ) break ;
222 for ( let k = 0 ; k < n; k ++ ) {
223 const z = sc.through ? - B + ( 2 * B * (k + 0.5 )) / n : 0 ;
224 this .strokeSegments[ this .strokeCount * 2 ]. set (
225 a.x,
226 a.y,
227 sc.through ? z - B / n : a.z,
228 sc.radius,
229 );
230 this .strokeSegments[ this .strokeCount * 2 + 1 ]. set (
231 b.x,
232 b.y,
233 sc.through ? z + B / n : b.z,
234 sc.strength,
235 );
236 this .strokeCount ++ ;
237 }
238 if (sc.from. distanceToSquared (sc.to) > 1e-6 ) {
239 const dirW = this .workDirection
240 . copy (sc.to)
241 . sub (sc.from)
242 . transformDirection ( this .objectGroup.matrixWorld);
243 this .strokeDir. copy (dirW. normalize ());
244 this .strokeSpeed = Math. max (
245 this .strokeSpeed,
246 (sc.from. distanceTo (sc.to) / sc.duration) * 0.6 ,
247 );
248 }
249 sc.prevT = u;
250 pushed = true ;
251 }
252 this .scripted = this .scripted. filter (( sc ) => sc.prevT < 1 );
253 void dt;
254 return pushed;
255 }
256
257 private pushScroll ( dt : number ) {
258 if ( this .scrollP < 0 ) return false ;
259 const p = this .scrollP;
260 if ( this .prevScrollP < 0 ) {
261 this .prevScrollP = p;
262 return false ;
263 }
264 const dp = p - this .prevScrollP;
265 if (Math. abs (dp) < 0.0005 ) return false ;
266 const B = this .shape.bound;
267 const a = this .workA
268 . copy ( this .scrollPath.from)
269 . lerp ( this .scrollPath.to, this .prevScrollP)
270 . multiplyScalar ( B );
271 const b = this .workB. copy ( this .scrollPath.from). lerp ( this .scrollPath.to, p). multiplyScalar ( B );
272 if (dp > 0 ) {
273 for ( let k = 0 ; k < 3 && this .strokeCount < MAX_SEGMENTS ; k ++ ) {
274 const z = - B + ( 2 * B * (k + 0.5 )) / 3 ;
275 this .strokeSegments[ this .strokeCount * 2 ]. set (a.x, a.y, z - B / 3 , D .erosion.brushRadius);
276 this .strokeSegments[ this .strokeCount * 2 + 1 ]. set (
277 b.x,
278 b.y,
279 z + B / 3 ,
280 D .erosion.brushStrength * 2.5 ,
281 );
282 this .strokeCount ++ ;
283 }
284 const dirW = this .workDirection
285 . copy (b)
286 . sub (a)
287 . transformDirection ( this .objectGroup.matrixWorld);
288 if (dirW. lengthSq () > 1e-8 ) this .strokeDir. copy (dirW. normalize ());
289 this .strokeSpeed = Math. max (
290 this .strokeSpeed,
291 Math. min ( 6 , (Math. abs (dp) * 2 * B ) / Math. max (dt, 1 / 120 )),
292 );
293 }
294 this .prevScrollP = p;
295 return dp > 0 ;
296 }
297 private prevScrollP = - 1 ;
298
299 update ( t : number , dt : number ) {
300 this .strokeCount = 0 ;
301 const E = D .erosion;
302 // --- reset (click / R): fade the powder over `resetFade` ms, then clear everything
303 if (sim.resetRequestedAt >= 0 ) {
304 const u = (t - sim.resetRequestedAt) / Math. max ( 0.05 , D .healing.resetFade / 1000 );
305 sim.fade = Math. max ( 0 , 1 - u);
306 if (u >= 1 ) {
307 if ( this .erosion) this .erosion. clear ( this .erosion.renderer);
308 if ( this .powder && this .erosion) this .powder. reset ( this .erosion.renderer);
309 this . onReset ?.();
310 sim.resetRequestedAt = - 1 ;
311 sim.lastStrokeT = - 1e9 ;
312 sim.fade = 1 ;
313 }
314 } else sim.fade = damp (sim.fade, 1 , 0.15 , dt);
315
316 const moved = input.x !== this .prevX || input.y !== this .prevY;
317 let hit : ReturnType < typeof raycastSDF> = null ;
318 if (input.inside) {
319 // world ray from the cursor, into object space
320 this .tmp. set (input.x, input.y, 0.5 ). unproject ( this .camera);
321 this .ray.origin. copy ( this .camera.position);
322 this .ray.direction. copy ( this .tmp). sub ( this .camera.position). normalize ();
323 this .inv. copy ( this .objectGroup.matrixWorld). invert ();
324 const o = this .objectRayOrigin. copy ( this .ray.origin). applyMatrix4 ( this .inv);
325 const d = this .objectRayDirection. copy ( this .ray.direction). transformDirection ( this .inv);
326 hit = raycastSDF ( this .shape, o, d, 60 , this .rayHit);
327 }
328 sim.overObject = !! hit;
329 this .hasHit = !! hit;
330 if (hit) {
331 this .hitEntry. copy (hit.entry). applyMatrix4 ( this .objectGroup.matrixWorld);
332 this .hitExit. copy (hit.exit). applyMatrix4 ( this .objectGroup.matrixWorld);
333 }
334
335 let strokeThisFrame = false ;
336 if (dt > 0 && this . pushScripted (t, dt)) strokeThisFrame = true ;
337 if (dt > 0 && this . pushScroll (dt)) strokeThisFrame = true ;
338 const cursorErodes = ! heroFrame.enabled || heroFrame.cursorErodes;
339 if (
340 cursorErodes &&
341 hit &&
342 this .prevHit &&
343 moved &&
344 dt > 0 &&
345 this .strokeCount + DEPTH_STEPS <= MAX_SEGMENTS
346 ) {
347 const entryWorld = this .hitEntry;
348 const vel = this .velocity. copy (entryWorld). sub ( this .prevEntryWorld). divideScalar (dt);
349 const speed = vel. length ();
350 if (speed > 0.02 ) {
351 strokeThisFrame = true ;
352 this .strokeVel. lerp (vel, 0.5 );
353 this .strokeSpeed = damp ( this .strokeSpeed, Math. min (speed, E .brushSpeedRef * 3 ), 0.08 , dt);
354 if ( this .strokeVel. lengthSq () > 1e-6 ) this .strokeDir. copy ( this .strokeVel). normalize ();
355 const k = THREE .MathUtils. clamp (speed / E .brushSpeedRef, 0.12 , 2.5 );
356 const strength = E .brushStrength * k;
357 const radius = E .brushRadius * ( 0.72 + 0.28 * Math. min ( 1 , k));
358 for ( let s = 0 ; s < DEPTH_STEPS ; s ++ ) {
359 const f = (s / ( DEPTH_STEPS - 1 )) * E .throughDepth;
360 const idx = this .strokeCount + s;
361 this .strokeSegments[idx * 2 ]. set (
362 THREE .MathUtils. lerp ( this .prevEntry.x, this .prevExit.x, f),
363 THREE .MathUtils. lerp ( this .prevEntry.y, this .prevExit.y, f),
364 THREE .MathUtils. lerp ( this .prevEntry.z, this .prevExit.z, f),
365 radius,
366 );
367 this .strokeSegments[idx * 2 + 1 ]. set (
368 THREE .MathUtils. lerp (hit.entry.x, hit.exit.x, f),
369 THREE .MathUtils. lerp (hit.entry.y, hit.exit.y, f),
370 THREE .MathUtils. lerp (hit.entry.z, hit.exit.z, f),
371 strength,
372 );
373 }
374 this .strokeCount += DEPTH_STEPS ;
375 }
376 }
377 if (
378 ( D .debug.forceStroke ||
379 ( FORCE_STROKE && clock.frame >= STROKE_START && clock.frame < STROKE_FRAMES )) &&
380 dt > 0
381 ) {
382 // synthetic diagonal sweep (lower-left -> upper-right) for testing without a cursor
383 const ph = (t * 0.25 ) % 1 ;
384 const B = this .shape.bound * 0.9 ;
385 const ax = - B + ph * 2 * B ,
386 ay = - B * 0.7 + ph * 1.4 * B ;
387 const bx = ax + 0.15 ,
388 by = ay + 0.1 ;
389 this .strokeSegments[ 0 ]. set (ax, ay, - B , E .brushRadius);
390 this .strokeSegments[ 1 ]. set (bx, by, B , E .brushStrength);
391 this .strokeCount = 1 ;
392 this .strokeDir. set ( 1 , 0.7 , 0 ). normalize ();
393 this .strokeSpeed = E .brushSpeedRef;
394 strokeThisFrame = true ;
395 }
396 if ( ! strokeThisFrame) this .strokeSpeed = damp ( this .strokeSpeed, 0 , 1.2 , dt);
397 sim.strokeActive = strokeThisFrame;
398 // scroll-driven pages hold their erosion: healing is suppressed while suspended
399 if (heroFrame.enabled && heroFrame.healSuspended) sim.lastStrokeT = t;
400
401 if (hit) {
402 this .prevEntry. copy (hit.entry);
403 this .prevExit. copy (hit.exit);
404 this .prevEntryWorld. copy ( this .hitEntry);
405 }
406 this .prevHit = !! hit;
407 this .prevX = input.x;
408 this .prevY = input.y;
409 void this .resetDone;
410 }
411 }