@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.
- package/package.json +26 -0
- package/src/AtlasManager.ts +125 -0
- package/src/DomRasterSystem.ts +113 -0
- package/src/MrigEngine.ts +834 -0
- package/src/WebGPUManager.ts +65 -0
- package/src/index.ts +5 -0
- package/src/shaders.ts +772 -0
- package/src/types.ts +38 -0
- package/src/utils.ts +26 -0
- package/tsconfig.json +32 -0
- package/vite.config.ts +35 -0
|
@@ -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
|
+
|