@mrigx/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,834 @@
1
+ export interface MrigConfig {
2
+ fluidViscosity?: number;
3
+ refractionScale?: number;
4
+ tearLimit?: number;
5
+ rippleHeight?: number;
6
+ abyssIntensity?: number;
7
+ parallaxIntensity?: number;
8
+ }
9
+
10
+ const DEFAULT_CONFIG: Required<MrigConfig> = {
11
+ fluidViscosity: 0.99,
12
+ refractionScale: 0.05,
13
+ tearLimit: 0.015,
14
+ rippleHeight: 1.0,
15
+ abyssIntensity: 0.0,
16
+ parallaxIntensity: 1.0
17
+ };
18
+ /**
19
+ * @file MrigEngine.ts
20
+ * @description The core WebGPU rendering engine and physics simulator for the Mrig Optical UI.
21
+ * Highly optimized, well-documented, and mathematically rigorous.
22
+ */
23
+
24
+ import { WebGPUManager } from './WebGPUManager';
25
+ import { AtlasManager } from './AtlasManager';
26
+ import { toCanvas } from 'html-to-image';
27
+ import type { SurfaceData, InteractionSource } from './types';
28
+ import { simulationWGSL, surfaceMaskWGSL, opticalWGSL } from './shaders';
29
+ import { resolveMaterialProps } from './utils';
30
+
31
+ export class MrigEngine {
32
+ public gpuManager: WebGPUManager;
33
+ public atlasManager: AtlasManager;
34
+ private canvas: HTMLCanvasElement | null = null;
35
+
36
+ // Pipeline state
37
+ private simPipeline!: GPURenderPipeline;
38
+ private maskPipeline!: GPURenderPipeline;
39
+ private opticalPipeline!: GPURenderPipeline;
40
+
41
+ // Resources
42
+ private simTextures: GPUTexture[] = [];
43
+ private maskTexture!: GPUTexture;
44
+ private domTexture!: GPUTexture;
45
+ private dummyDomTexture!: GPUTexture;
46
+ private uniformBuffer!: GPUBuffer;
47
+ private uniformData!: Float32Array;
48
+ private sampler!: GPUSampler;
49
+
50
+ // Bind Groups
51
+ private simBindGroups: GPUBindGroup[] = [];
52
+ private maskBindGroup!: GPUBindGroup;
53
+ private opticalBindGroups: GPUBindGroup[] = [];
54
+
55
+ // Data tracking
56
+ private surfaces: Map<string, SurfaceData> = new Map();
57
+ private interactions: InteractionSource[] = [];
58
+ private icePatches: { x: number, y: number, life: number }[] = [];
59
+
60
+ // Loop state
61
+ private animationId: number = 0;
62
+ private frameIndex: number = 0;
63
+ private startTime: number = performance.now();
64
+ private isRunning: boolean = false;
65
+ private isInitialized: boolean = false;
66
+
67
+ // Atlas Rasterization Queue
68
+ private rasterQueue = new Map<string, { data: SurfaceData, el: HTMLElement }>();
69
+ private isRasterizing = false;
70
+ private rasterStateListeners: ((state: boolean) => void)[] = [];
71
+ public onRasterStateChange(cb: (state: boolean) => void) {
72
+ this.rasterStateListeners.push(cb);
73
+ return () => { this.rasterStateListeners = this.rasterStateListeners.filter(l => l !== cb); };
74
+ }
75
+ private setRasterState(state: boolean) {
76
+ if (this.isRasterizing !== state) {
77
+ this.isRasterizing = state;
78
+ this.rasterStateListeners.forEach(cb => cb(state));
79
+ }
80
+ }
81
+
82
+
83
+ constructor() {
84
+ if (typeof window !== 'undefined') {
85
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
86
+ this.prefersReducedMotion = mq.matches;
87
+ mq.addEventListener('change', e => this.prefersReducedMotion = e.matches);
88
+ }
89
+ this.gpuManager = new WebGPUManager();
90
+ this.atlasManager = new AtlasManager(this);
91
+ this.handleScroll = this.handleScroll.bind(this);
92
+ this.handlePointerMove = this.handlePointerMove.bind(this);
93
+ this.handlePointerDown = this.handlePointerDown.bind(this);
94
+ this.handleSelectionChange = this.handleSelectionChange.bind(this);
95
+
96
+ }
97
+
98
+ /**
99
+ * Initializes the WebGPU context, builds pipelines, and allocates textures.
100
+ */
101
+ public async init(canvas: HTMLCanvasElement): Promise<boolean> {
102
+ if (this.isInitialized) return true;
103
+ this.isInitialized = true;
104
+
105
+ this.canvas = canvas;
106
+ const success = await this.gpuManager.init(canvas);
107
+ if (!success) return false;
108
+
109
+ this.createPipelines();
110
+ this.atlasManager.init();
111
+ this.createTextures();
112
+
113
+ // Attach listeners
114
+ window.addEventListener('scroll', this.handleScroll, { passive: true });
115
+ window.addEventListener('mousemove', this.handlePointerMove, { passive: true });
116
+ window.addEventListener('mousedown', this.handlePointerDown, { passive: true });
117
+ window.addEventListener('mouseup', this.handlePointerUp, { passive: true });
118
+ document.addEventListener('selectionchange', this.handleSelectionChange, { passive: true });
119
+
120
+ this.isRunning = true;
121
+ this.renderLoop();
122
+ return true;
123
+ }
124
+
125
+ private previousHighlightCount = 0;
126
+ private lastScrollY: number = window.scrollY || 0;
127
+ private accumulatedScroll: number = 0;
128
+ private prefersReducedMotion = false;
129
+ private transitionState = 1.0;
130
+ private transitionY = 0.0;
131
+ private config: Required<MrigConfig> = { ...DEFAULT_CONFIG };
132
+
133
+ public setConfig(newConfig: MrigConfig) {
134
+ this.config = { ...this.config, ...newConfig };
135
+ }
136
+ public setTransition(opacity: number, y: number) {
137
+ this.transitionState = opacity;
138
+ this.transitionY = y;
139
+ }
140
+
141
+ public setSelectionRects(rects: {x:number, y:number, w:number, h:number}[]) {
142
+ // Clear old highlights
143
+ for (let i = 0; i < this.previousHighlightCount; i++) {
144
+ this.unregisterSurface('hl_' + i);
145
+ }
146
+ this.previousHighlightCount = 0;
147
+
148
+ let registeredCount = 0;
149
+ for (let i = 0; i < rects.length; i++) {
150
+ const rect = rects[i];
151
+ if (rect.w === 0 || rect.h === 0) continue;
152
+ this.registerSurface('hl_' + registeredCount, {
153
+ x: rect.x,
154
+ y: rect.y,
155
+ width: rect.w,
156
+ height: rect.h,
157
+ config: {
158
+ material: 'crystal',
159
+ materialStrength: 3.0,
160
+ borderRadius: 0,
161
+ isHighlight: true
162
+ } as any
163
+ });
164
+ registeredCount++;
165
+ }
166
+ this.previousHighlightCount = registeredCount;
167
+ }
168
+
169
+ private handleSelectionChange() {
170
+ const selection = window.getSelection();
171
+
172
+ // Clear old highlights
173
+ for (let i = 0; i < this.previousHighlightCount; i++) {
174
+ this.unregisterSurface('hl_' + i);
175
+ }
176
+ this.previousHighlightCount = 0;
177
+
178
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
179
+
180
+ let range;
181
+ try {
182
+ range = selection.getRangeAt(0);
183
+ } catch (e) {
184
+ return;
185
+ }
186
+
187
+ const rects = range.getClientRects();
188
+
189
+ const canvasWidth = window.innerWidth;
190
+ const canvasHeight = window.innerHeight;
191
+
192
+ let registeredCount = 0;
193
+ for (let i = 0; i < rects.length; i++) {
194
+ const rect = rects[i];
195
+ if (rect.width === 0 || rect.height === 0) continue;
196
+
197
+ // Register the selection rect as an optical surface!
198
+ this.registerSurface('hl_' + registeredCount, {
199
+ x: rect.left / canvasWidth,
200
+ y: rect.top / canvasHeight,
201
+ width: rect.width / canvasWidth,
202
+ height: rect.height / canvasHeight,
203
+ config: {
204
+ material: 'crystal',
205
+ materialStrength: 3.0,
206
+ borderRadius: 0,
207
+ isHighlight: true
208
+ } as any
209
+ });
210
+ registeredCount++;
211
+ }
212
+ this.previousHighlightCount = registeredCount;
213
+ }
214
+
215
+ public destroy(): void {
216
+ this.isRunning = false;
217
+ cancelAnimationFrame(this.animationId);
218
+ window.removeEventListener('scroll', this.handleScroll);
219
+ window.removeEventListener('mousemove', this.handlePointerMove);
220
+ window.removeEventListener('mousedown', this.handlePointerDown);
221
+ window.removeEventListener('mouseup', this.handlePointerUp);
222
+ document.removeEventListener('selectionchange', this.handleSelectionChange);
223
+ this.simTextures.forEach(t => t.destroy());
224
+ if (this.maskTexture) this.maskTexture.destroy();
225
+ if (this.uniformBuffer) this.uniformBuffer.destroy();
226
+ this.atlasManager.destroy();
227
+ this.gpuManager.destroy();
228
+ }
229
+
230
+ /**
231
+ * Compiles the WGSL shaders and creates the render pipelines.
232
+ */
233
+ private createPipelines() {
234
+ const device = this.gpuManager.device!;
235
+ const format = this.gpuManager.format;
236
+
237
+ const simModule = device.createShaderModule({ code: simulationWGSL });
238
+ const maskModule = device.createShaderModule({ code: surfaceMaskWGSL });
239
+ const opticalModule = device.createShaderModule({ code: opticalWGSL });
240
+
241
+ // 1. Simulation Pipeline
242
+ this.simPipeline = device.createRenderPipeline({
243
+ layout: 'auto',
244
+ vertex: { module: simModule, entryPoint: 'vs_main' },
245
+ fragment: {
246
+ module: simModule,
247
+ entryPoint: 'fs_main',
248
+ targets: [{ format: 'rgba16float' }]
249
+ },
250
+ primitive: { topology: 'triangle-list' }
251
+ });
252
+
253
+ // 2. Surface Mask Pipeline (Draws instanced quads for surfaces)
254
+ this.maskPipeline = device.createRenderPipeline({
255
+ layout: 'auto',
256
+ vertex: { module: maskModule, entryPoint: 'vs_main' },
257
+ fragment: {
258
+ module: maskModule,
259
+ entryPoint: 'fs_main',
260
+ targets: [{
261
+ format: 'rgba16float',
262
+ blend: {
263
+ color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
264
+ alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' }
265
+ }
266
+ }]
267
+ },
268
+ primitive: { topology: 'triangle-list' }
269
+ });
270
+
271
+ // 3. Final Optical Pipeline
272
+ this.opticalPipeline = device.createRenderPipeline({
273
+ layout: 'auto',
274
+ vertex: { module: opticalModule, entryPoint: 'vs_main' },
275
+ fragment: {
276
+ module: opticalModule,
277
+ entryPoint: 'fs_main',
278
+ targets: [{ format }]
279
+ },
280
+ primitive: { topology: 'triangle-list' }
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Allocates the ping-pong simulation textures, mask texture, and uniform buffers.
286
+ */
287
+ private createTextures() {
288
+ const device = this.gpuManager.device!;
289
+ if (!this.canvas) return;
290
+
291
+ // Simulation uses half-resolution for performance and fluid dispersion
292
+ const simWidth = Math.max(1, Math.floor(this.canvas.width * 0.5));
293
+ const simHeight = Math.max(1, Math.floor(this.canvas.height * 0.5));
294
+
295
+ // Cleanup old textures if recreating
296
+ this.simTextures.forEach(t => t.destroy());
297
+ if (this.maskTexture) this.maskTexture.destroy();
298
+
299
+ this.simTextures = [];
300
+ for (let i = 0; i < 2; i++) {
301
+ this.simTextures.push(device.createTexture({
302
+ size: [simWidth, simHeight, 1],
303
+ format: 'rgba16float',
304
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
305
+ }));
306
+ }
307
+
308
+ this.maskTexture = device.createTexture({
309
+ size: [this.canvas.width, this.canvas.height, 1],
310
+ format: 'rgba16float',
311
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
312
+ });
313
+
314
+ this.sampler = device.createSampler({
315
+ magFilter: 'linear',
316
+ minFilter: 'linear',
317
+ });
318
+
319
+ if (!this.dummyDomTexture) {
320
+ this.dummyDomTexture = device.createTexture({
321
+ size: [1, 1, 1],
322
+ format: 'rgba8unorm',
323
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
324
+ });
325
+ device.queue.writeTexture(
326
+ { texture: this.dummyDomTexture },
327
+ new Uint8Array([0, 0, 0, 0]),
328
+ { bytesPerRow: 4 },
329
+ [1, 1, 1]
330
+ );
331
+ this.domTexture = this.dummyDomTexture;
332
+ }
333
+
334
+ // Uniform Buffer Layout:
335
+ // 0-11: Globals (time, scroll, resolution, counts, etc) -> 12 floats
336
+ // 12-267: 32 Interaction Sources (8 floats each = 256 floats)
337
+ // 268-779: 64 Surfaces (8 floats each = 512 floats)
338
+ // Total: 780 floats (3120 bytes). We round up to 4096 for alignment safety.
339
+ if (!this.uniformBuffer) {
340
+ this.uniformData = new Float32Array(2048);
341
+ this.uniformBuffer = device.createBuffer({
342
+ size: this.uniformData.byteLength,
343
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
344
+ });
345
+ }
346
+
347
+ this.updateBindGroups();
348
+ }
349
+
350
+ /**
351
+ * Updates bind groups, usually called when textures (like domTexture) are recreated.
352
+ */
353
+ private updateBindGroups() {
354
+ const device = this.gpuManager.device!;
355
+
356
+ this.maskBindGroup = device.createBindGroup({
357
+ layout: this.maskPipeline.getBindGroupLayout(0),
358
+ entries: [{ binding: 0, resource: { buffer: this.uniformBuffer } }]
359
+ });
360
+
361
+ this.simBindGroups = [];
362
+ this.opticalBindGroups = [];
363
+ for (let i = 0; i < 2; i++) {
364
+ this.simBindGroups.push(device.createBindGroup({
365
+ layout: this.simPipeline.getBindGroupLayout(0),
366
+ entries: [
367
+ { binding: 0, resource: { buffer: this.uniformBuffer } },
368
+ { binding: 1, resource: this.simTextures[i].createView() },
369
+ { binding: 2, resource: this.sampler },
370
+ { binding: 3, resource: this.maskTexture.createView() }
371
+ ]
372
+ }));
373
+
374
+ this.opticalBindGroups.push(device.createBindGroup({
375
+ layout: this.opticalPipeline.getBindGroupLayout(0),
376
+ entries: [
377
+ { binding: 0, resource: { buffer: this.uniformBuffer } },
378
+ { binding: 1, resource: this.simTextures[i].createView() },
379
+ { binding: 2, resource: this.sampler },
380
+ { binding: 3, resource: this.maskTexture.createView() },
381
+ { binding: 4, resource: this.domTexture.createView() },
382
+ { binding: 5, resource: (this.atlasManager.texture || this.dummyDomTexture).createView() }
383
+ ]
384
+ }));
385
+ }
386
+ }
387
+
388
+ // --- Interaction & Surface API ---
389
+
390
+ public registerSurface(id: string, data: SurfaceData, el?: HTMLElement) {
391
+ // Instantly update the surface bounds for zero-latency GPU movement
392
+ this.surfaces.set(id, data);
393
+
394
+ // If dynamic, queue it for background rasterization to avoid blocking the main thread
395
+ if (data.config.isDynamic && el && this.atlasManager) {
396
+ this.rasterQueue.set(id, { data, el });
397
+ this.processRasterQueue();
398
+ }
399
+ }
400
+
401
+ private processRasterQueue() {
402
+ if (this.isRasterizing || this.rasterQueue.size === 0) return;
403
+ this.setRasterState(true);
404
+
405
+ const runIdle = (window as any).requestIdleCallback || ((cb: any) => setTimeout(cb, 1));
406
+
407
+ runIdle(async () => {
408
+ // Get the first item in the queue
409
+ const nextEntry = this.rasterQueue.entries().next().value;
410
+ if (!nextEntry) {
411
+ this.setRasterState(false);
412
+ return;
413
+ }
414
+ const [id, payload] = nextEntry;
415
+ this.rasterQueue.delete(id);
416
+
417
+ try {
418
+ const dpr = window.devicePixelRatio || 1;
419
+ // The heavy CPU work
420
+ const canvas = await toCanvas(payload.el, { backgroundColor: 'transparent', skipFonts: true, pixelRatio: dpr });
421
+
422
+ // Ensure the surface wasn't unregistered while we were rendering
423
+ if (this.surfaces.has(id)) {
424
+ const region = this.atlasManager.uploadSprite(id, canvas);
425
+ if (region) {
426
+ // We mutate the live data object in the surfaces map
427
+ const liveData = this.surfaces.get(id);
428
+ if (liveData) liveData.atlasUV = region.uv;
429
+ }
430
+ }
431
+ } catch (e) {
432
+ console.warn('Mrig Atlas upload failed for', id, e);
433
+ }
434
+
435
+ this.setRasterState(false);
436
+ // Recursively process the rest of the queue
437
+ if (this.rasterQueue.size > 0) {
438
+ this.processRasterQueue();
439
+ }
440
+ });
441
+ }
442
+
443
+ public unregisterSurface(id: string) {
444
+ this.surfaces.delete(id);
445
+ }
446
+
447
+ public triggerDisturbance(x: number, y: number, strength: number = 1.0, radius: number = 0.06, type: number = 0.0, vx: number = 0, vy: number = 0) {
448
+ const dpr = window.devicePixelRatio || 1;
449
+ const docY = y + (window.scrollY / (window.innerHeight || 1));
450
+ this.interactions.push({ x, y, vx, vy, radius, strength, isActive: 1.0, type, docY });
451
+ }
452
+
453
+ /**
454
+ * Replaces the DOM texture used for optical refraction, triggering a bind group rebuild.
455
+ */
456
+ public updateDomTexture(newTexture: GPUTexture) {
457
+ const oldTexture = this.domTexture;
458
+ this.domTexture = newTexture;
459
+ this.updateBindGroups();
460
+
461
+ if (oldTexture && oldTexture !== this.dummyDomTexture) {
462
+ oldTexture.destroy();
463
+ }
464
+ }
465
+
466
+ private handleScroll() {}
467
+
468
+ private lastMoveTime = 0;
469
+ private lastPointerX = 0;
470
+ private cursorState = { x: 0.5, y: 0.5, isText: 0.0, isClicking: 0.0 };
471
+ private lastPointerY = 0;
472
+ private handlePointerMove(e: MouseEvent) {
473
+ this.cursorState.x = e.clientX / window.innerWidth;
474
+ this.cursorState.y = e.clientY / window.innerHeight;
475
+ const target = e.target as HTMLElement;
476
+ if (target && target.tagName) {
477
+ const tag = target.tagName;
478
+ this.cursorState.isText = (tag === 'P' || tag === 'H1' || tag === 'H2' || tag === 'H3' || tag === 'H4' || tag === 'H5' || tag === 'H6' || tag === 'SPAN' || tag === 'A' || tag === 'LI' || tag === 'TD' || tag === 'TH' || (tag === 'INPUT' && (target as HTMLInputElement).type === 'text')) ? 1.0 : 0.0;
479
+ }
480
+ if (!this.canvas) return;
481
+ const now = performance.now();
482
+ const dt = Math.max(1, now - this.lastMoveTime);
483
+ if (dt < 16) return;
484
+ this.lastMoveTime = now;
485
+
486
+ const rect = this.canvas.getBoundingClientRect();
487
+ const x = (e.clientX - rect.left) / rect.width;
488
+ const y = (e.clientY - rect.top) / rect.height;
489
+
490
+ const vx = (x - this.lastPointerX) / dt;
491
+ const vy = (y - this.lastPointerY) / dt;
492
+ this.lastPointerX = x;
493
+ this.lastPointerY = y;
494
+
495
+ this.triggerDisturbance(x, y, 0.1, 0.015, 0.0, vx, vy);
496
+ }
497
+
498
+
499
+ private handlePointerUp = (e: MouseEvent) => {
500
+ this.cursorState.isClicking = 0.0;
501
+ };
502
+ private handlePointerDown(e: MouseEvent) {
503
+ this.cursorState.isClicking = 1.0;
504
+ if (!this.canvas) return;
505
+ const rect = this.canvas.getBoundingClientRect();
506
+ const x = (e.clientX - rect.left) / rect.width;
507
+ const y = (e.clientY - rect.top) / rect.height;
508
+ this.triggerDisturbance(x, y, 0.3, 0.02, 0.0); // Tighter click ripple
509
+ }
510
+
511
+ private boats: InteractionSource[] = [];
512
+
513
+ /**
514
+ * Syncs CPU state (surfaces, interactions, time) to the GPU uniform buffer.
515
+ */
516
+ private updateUniforms() {
517
+
518
+ const time = (performance.now() - this.startTime) / 1000.0;
519
+ const dpr = window.devicePixelRatio || 1;
520
+ const normScroll = window.scrollY / (window.innerHeight || 1);
521
+
522
+ // Find true DOM bounds of the first section
523
+ const homeSection = document.querySelector('section');
524
+ const homeHeightNorm = homeSection ? (homeSection.offsetHeight / (window.innerHeight || 1)) : 1.0;
525
+
526
+ // Initialize autonomous boats
527
+ if (this.boats.length === 0) {
528
+ for (let i = 0; i < 3; i++) {
529
+ this.boats.push({
530
+ x: 0.2 + Math.random() * 0.6,
531
+ y: 0,
532
+ docY: 0.2 + Math.random() * (homeHeightNorm - 0.4),
533
+ vx: 0,
534
+ vy: 0,
535
+ radius: 0.07,
536
+ strength: 0.4,
537
+ isActive: 1.0,
538
+ type: 1.0, // Boat type
539
+ angle: Math.random() * Math.PI * 2
540
+ });
541
+ }
542
+ }
543
+
544
+ // Update boats organic roaming (anchored to document space)
545
+ const aspect = this.canvas ? (this.canvas.width / this.canvas.height) : 1.0;
546
+ const lhX = 1.0 - (0.35 / aspect);
547
+ const lhDocY = 0.40;
548
+
549
+ // Age ice patches
550
+ this.icePatches.forEach(p => p.life -= 0.0015);
551
+ this.icePatches = this.icePatches.filter(p => p.life > 0);
552
+
553
+ this.boats.forEach(b => {
554
+ let inIce = false;
555
+ for (let j = 0; j < this.icePatches.length; j++) {
556
+ const p = this.icePatches[j];
557
+ const dist = Math.hypot(b.x - p.x, b.y - p.y);
558
+ if (dist < 0.06) { inIce = true; break; }
559
+ }
560
+
561
+ const speed = inIce ? 0.000004 : 0.000024; // Lowered engine power by 12x to account for momentum accumulation
562
+
563
+ // Accumulate desired steering forces
564
+ let desiredVx = Math.cos(b.angle!) * speed;
565
+ let desiredVy = Math.sin(b.angle!) * speed;
566
+
567
+ // 1. Avoid Lighthouse (Collision Prevention)
568
+ const distLh = Math.sqrt(Math.pow(b.x - lhX, 2) + Math.pow(b.docY! - lhDocY, 2));
569
+ if (distLh < 0.15) {
570
+ const avoidForce = (0.15 - distLh) * 0.002;
571
+ desiredVx += ((b.x - lhX) / distLh) * avoidForce;
572
+ desiredVy += ((b.docY! - lhDocY) / distLh) * avoidForce;
573
+ }
574
+
575
+ // 2. Avoid Other Boats (Collision Prevention)
576
+ this.boats.forEach(other => {
577
+ if (other === b) return;
578
+ const dx = b.x - other.x;
579
+ const dy = b.docY! - other.docY!;
580
+ const distSq = dx*dx + dy*dy;
581
+ if (distSq < 0.015) {
582
+ const dist = Math.max(0.0001, Math.sqrt(distSq));
583
+ const avoidForce = (0.12 - dist) * 0.002;
584
+ desiredVx += (dx / dist) * avoidForce;
585
+ desiredVy += (dy / dist) * avoidForce;
586
+ }
587
+ });
588
+
589
+ // 2.5 Follow Cursor Waves
590
+ this.interactions.forEach(interaction => {
591
+ if (interaction.isActive > 0 && interaction.docY !== undefined && interaction.type !== 1.0) {
592
+ const dx = b.x - interaction.x;
593
+ const dy = b.docY! - interaction.docY;
594
+ const distSq = dx*dx + dy*dy;
595
+ const dist = Math.max(0.0001, Math.sqrt(distSq));
596
+ if (dist < 0.08) {
597
+ // Pushing it along the wave current (cursor velocity)
598
+ const pushForce = (0.08 - dist) * 2.0; // Reduced force for Newtonian momentum
599
+ desiredVx += interaction.vx * pushForce;
600
+ desiredVy += interaction.vy * pushForce;
601
+
602
+ // Outward radial push from the ripple
603
+ const radialPush = (0.08 - dist) * 0.0002;
604
+ desiredVx += (dx / dist) * radialPush;
605
+ desiredVy += (dy / dist) * radialPush;
606
+ }
607
+ }
608
+ });
609
+
610
+ // 3. Soft boundaries
611
+ const margin = 0.1;
612
+ const turnForce = 0.001;
613
+ if (b.x < margin) desiredVx += turnForce;
614
+ if (b.x > 1.0 - margin) desiredVx -= turnForce;
615
+ if (b.docY! < margin) desiredVy += turnForce;
616
+ if (b.docY! > homeHeightNorm - margin) desiredVy -= turnForce;
617
+
618
+ // 4. True Newtonian Physics
619
+ // Instead of erasing forces, we accumulate them!
620
+ b.vx += desiredVx;
621
+ b.vy += desiredVy;
622
+
623
+ // Fluid drag
624
+ b.vx *= 0.92;
625
+ b.vy *= 0.92;
626
+
627
+ // Boat Hull naturally tries to align with its velocity vector (like a weather vane)
628
+ const currentSpeed = Math.hypot(b.vx, b.vy);
629
+ if (currentSpeed > 0.0001) {
630
+ let targetAngle = Math.atan2(b.vy, b.vx);
631
+ let diff = targetAngle - b.angle!;
632
+ while (diff > Math.PI) diff -= Math.PI * 2;
633
+ while (diff < -Math.PI) diff += Math.PI * 2;
634
+
635
+ // Turn slowly towards velocity vector (Creates natural wobble/drift when hit by side waves!)
636
+ let maxTurn = inIce ? 0.01 : 0.06;
637
+ b.angle! += Math.max(-maxTurn, Math.min(maxTurn, diff));
638
+ }
639
+
640
+ b.x += b.vx;
641
+ if (b.docY === undefined) b.docY = b.y;
642
+ b.docY += b.vy;
643
+
644
+ // Map to screen coordinates based on scroll
645
+ b.y = b.docY - normScroll;
646
+ b.isActive = 1.0;
647
+
648
+
649
+ });
650
+
651
+ // Globals
652
+ this.uniformData[0] = time;
653
+ this.uniformData[1] = window.scrollY * dpr;
654
+ this.uniformData[2] = this.canvas!.width;
655
+ this.uniformData[3] = this.canvas!.height;
656
+
657
+ const u32 = new Uint32Array(this.uniformData.buffer);
658
+
659
+ // Combine pointers (decayed separately) and autonomous boats
660
+ const activeInteractions = [
661
+ ...this.interactions.slice(0, 29),
662
+ ...this.boats
663
+ ].slice(0, 32);
664
+
665
+ const activeSurfaces = Array.from(this.surfaces.values()).slice(0, 128);
666
+
667
+ u32[4] = activeInteractions.length;
668
+ u32[5] = activeSurfaces.length;
669
+ this.uniformData[6] = (document.documentElement.scrollHeight || window.innerHeight) * dpr;
670
+ const rawScrollDelta = window.scrollY - this.lastScrollY;
671
+ this.lastScrollY = window.scrollY;
672
+ if (rawScrollDelta !== 0 && this.previousHighlightCount > 0) { this.handleSelectionChange(); }
673
+
674
+ this.accumulatedScroll += rawScrollDelta * dpr;
675
+ const pixelShift = Math.round(this.accumulatedScroll);
676
+ this.accumulatedScroll -= pixelShift; // keep remainder
677
+
678
+ this.uniformData[7] = pixelShift; // Pass exact integer pixel shift
679
+ this.uniformData[8] = (document.documentElement.scrollWidth || window.innerWidth) * dpr;
680
+ this.uniformData[9] = this.prefersReducedMotion ? 1.0 : 0.0;
681
+ this.uniformData[10] = this.transitionState;
682
+ this.uniformData[11] = this.transitionY * dpr;
683
+ this.uniformData[12] = this.config.fluidViscosity;
684
+ this.uniformData[13] = this.config.refractionScale;
685
+ this.uniformData[14] = this.config.tearLimit;
686
+ this.uniformData[15] = this.config.rippleHeight;
687
+ this.uniformData[16] = this.config.abyssIntensity;
688
+ this.uniformData[17] = this.config.parallaxIntensity;
689
+ this.uniformData[20] = this.cursorState.x;
690
+ this.uniformData[21] = this.cursorState.y;
691
+ this.uniformData[22] = this.cursorState.isText;
692
+ this.uniformData[23] = this.cursorState.isClicking;
693
+
694
+ // Interactions
695
+ for (let i = 0; i < activeInteractions.length; i++) {
696
+ const src = activeInteractions[i];
697
+ const offset = 24 + i * 8;
698
+ this.uniformData[offset + 0] = src.x;
699
+ this.uniformData[offset + 1] = src.y;
700
+ this.uniformData[offset + 2] = src.vx;
701
+ this.uniformData[offset + 3] = src.vy;
702
+ this.uniformData[offset + 4] = src.radius;
703
+ this.uniformData[offset + 5] = src.strength;
704
+ this.uniformData[offset + 6] = src.isActive;
705
+ // Pack type and angle into padding. Base 1000 = boat.
706
+ this.uniformData[offset + 7] = src.type === 1.0 ? (1000.0 + src.angle!) : (src.type === 2.0 ? 2000.0 : 0.0);
707
+ }
708
+
709
+ // Surfaces
710
+ for (let i = 0; i < activeSurfaces.length; i++) {
711
+ const surf = activeSurfaces[i];
712
+ const offset = 280 + i * 12; // 12 floats per surface
713
+ this.uniformData[offset + 0] = surf.x;
714
+ this.uniformData[offset + 1] = surf.y;
715
+ this.uniformData[offset + 2] = surf.width;
716
+ this.uniformData[offset + 3] = surf.height;
717
+ this.uniformData[offset + 4] = surf.config.borderRadius / window.innerHeight;
718
+ const props = resolveMaterialProps(surf.config.material);
719
+ this.uniformData[offset + 5] = (0.2 + surf.config.materialStrength * 0.8) * props.refMult;
720
+ this.uniformData[offset + 6] = surf.config.materialStrength * props.highMult;
721
+ this.uniformData[offset + 7] = (surf.config as any).isHighlight ? 1.0 : 0.0;
722
+
723
+ if (surf.atlasUV) {
724
+ this.uniformData[offset + 8] = surf.atlasUV[0];
725
+ this.uniformData[offset + 9] = surf.atlasUV[1];
726
+ this.uniformData[offset + 10] = surf.atlasUV[2];
727
+ this.uniformData[offset + 11] = surf.atlasUV[3];
728
+ } else {
729
+ this.uniformData[offset + 8] = 0.0;
730
+ this.uniformData[offset + 9] = 0.0;
731
+ this.uniformData[offset + 10] = 0.0;
732
+ this.uniformData[offset + 11] = 0.0;
733
+ }
734
+ }
735
+
736
+ this.gpuManager.device!.queue.writeBuffer(
737
+ this.uniformBuffer, 0, this.uniformData.buffer, this.uniformData.byteOffset, this.uniformData.byteLength
738
+ );
739
+ }
740
+
741
+ /**
742
+ * The core WebGPU render loop executing the 3-pass physical pipeline.
743
+ */
744
+ private renderLoop() {
745
+ if (!this.isRunning || !this.gpuManager.device) return;
746
+
747
+ this.updateUniforms();
748
+
749
+ // Decay interactions after updating uniforms so they get rendered at least once
750
+ this.interactions = this.interactions
751
+ .map(i => ({ ...i, isActive: i.isActive - 0.25 }))
752
+ .filter(i => i.isActive > 0);
753
+
754
+ const readIndex = this.frameIndex % 2;
755
+ const writeIndex = (this.frameIndex + 1) % 2;
756
+ const commandEncoder = this.gpuManager.device.createCommandEncoder();
757
+
758
+ // Pass 1: Draw Surface Masks (Instanced Quads)
759
+ const surfaceCount = Math.min(this.surfaces.size, 64);
760
+ const maskPass = commandEncoder.beginRenderPass({
761
+ colorAttachments: [{
762
+ view: this.maskTexture.createView(),
763
+ clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 0.0 },
764
+ loadOp: 'clear',
765
+ storeOp: 'store',
766
+ }]
767
+ });
768
+ if (surfaceCount > 0) {
769
+ maskPass.setPipeline(this.maskPipeline);
770
+ maskPass.setBindGroup(0, this.maskBindGroup);
771
+ maskPass.draw(6, surfaceCount, 0, 0);
772
+ }
773
+ maskPass.end();
774
+
775
+ // Pass 2: Wave Simulation (Ping-Pong Compute over Fragment)
776
+ const simPass = commandEncoder.beginRenderPass({
777
+ colorAttachments: [{
778
+ view: this.simTextures[writeIndex].createView(),
779
+ clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 0.0 },
780
+ loadOp: 'clear',
781
+ storeOp: 'store',
782
+ }]
783
+ });
784
+ simPass.setPipeline(this.simPipeline);
785
+ simPass.setBindGroup(0, this.simBindGroups[readIndex]);
786
+ simPass.draw(3, 1, 0, 0);
787
+ simPass.end();
788
+
789
+ // Pass 3: Final Optical Compositing (Draws to screen)
790
+ const opticalPass = commandEncoder.beginRenderPass({
791
+ colorAttachments: [{
792
+ view: this.gpuManager.context!.getCurrentTexture().createView(),
793
+ clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
794
+ loadOp: 'clear',
795
+ storeOp: 'store',
796
+ }]
797
+ });
798
+ opticalPass.setPipeline(this.opticalPipeline);
799
+ opticalPass.setBindGroup(0, this.opticalBindGroups[writeIndex]);
800
+ opticalPass.draw(3, 1, 0, 0);
801
+ opticalPass.end();
802
+
803
+ this.gpuManager.device.queue.submit([commandEncoder.finish()]);
804
+ this.frameIndex++;
805
+ this.animationId = requestAnimationFrame(() => this.renderLoop());
806
+ }
807
+ }
808
+
809
+
810
+
811
+
812
+
813
+
814
+
815
+
816
+
817
+
818
+
819
+
820
+
821
+
822
+
823
+
824
+
825
+
826
+
827
+
828
+
829
+
830
+
831
+
832
+
833
+
834
+