@plasius/gpu-shared 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,1551 @@
1
+ import {
2
+ clothGarmentKinds,
3
+ clothProfileNames,
4
+ createClothContinuityEnvelope,
5
+ createClothRepresentationPlan,
6
+ selectClothRepresentationBand,
7
+ } from "@plasius/gpu-cloth";
8
+ import {
9
+ fluidBodyKinds,
10
+ fluidProfileNames,
11
+ createFluidContinuityEnvelope,
12
+ createFluidRepresentationPlan,
13
+ selectFluidRepresentationBand,
14
+ } from "@plasius/gpu-fluid";
15
+ import {
16
+ createLightingBandPlan,
17
+ defaultLightingProfile,
18
+ getLightingProfile,
19
+ lightingDistanceBands,
20
+ } from "@plasius/gpu-lighting";
21
+ import {
22
+ createDeviceProfile,
23
+ createGpuPerformanceGovernor,
24
+ createQualityLadderAdapter,
25
+ } from "@plasius/gpu-performance";
26
+ import { createGpuDebugSession } from "@plasius/gpu-debug";
27
+ import {
28
+ createPhysicsSimulationPlan,
29
+ createPhysicsWorldSnapshot,
30
+ defaultPhysicsWorkerProfile,
31
+ getPhysicsWorkerManifest,
32
+ } from "@plasius/gpu-physics/browser";
33
+
34
+ import { loadGltfModel } from "./gltf-loader.js";
35
+
36
+ const STYLE_ID = "plasius-shared-3d-showcase-style";
37
+ const DEFAULT_TITLE = "Flag by the Sea";
38
+ const DEFAULT_SUBTITLE =
39
+ "Shared 3D validation scene using GLTF ships, cloth, fluid continuity, adaptive performance, and telemetry.";
40
+ const CAMERA_PRESETS = Object.freeze({
41
+ integrated: Object.freeze({ yaw: -0.55, pitch: 0.34, distance: 27, target: [0, 2.2, 0] }),
42
+ lighting: Object.freeze({ yaw: -0.28, pitch: 0.28, distance: 23, target: [0, 2.8, 0] }),
43
+ cloth: Object.freeze({ yaw: -1.1, pitch: 0.25, distance: 15, target: [-8.4, 5.3, -1.5] }),
44
+ fluid: Object.freeze({ yaw: -0.4, pitch: 0.18, distance: 18, target: [0, 1.2, 6] }),
45
+ physics: Object.freeze({ yaw: -0.12, pitch: 0.27, distance: 16, target: [0, 1.8, 6.8] }),
46
+ performance: Object.freeze({ yaw: -0.65, pitch: 0.36, distance: 24, target: [0, 2.2, 0] }),
47
+ debug: Object.freeze({ yaw: -0.7, pitch: 0.32, distance: 24, target: [0, 2.2, 0] }),
48
+ });
49
+ export const showcaseFocusModes = Object.freeze(Object.keys(CAMERA_PRESETS));
50
+
51
+ const SCENE_NOTES = Object.freeze([
52
+ "Ships are loaded from a GLTF asset and carry physics metadata from node extras.",
53
+ "Near-field lighting uses the ray-traced-primary shadow and reflection path before stepping down by distance band.",
54
+ "Cloth and fluid continuity stay coherent across near, mid, far, and horizon bands.",
55
+ "Performance pressure reduces visual detail before authoritative collision motion is touched.",
56
+ ]);
57
+
58
+ const UNIT_BOX_MESH = Object.freeze({
59
+ positions: Object.freeze([
60
+ -0.5, -0.5, -0.5,
61
+ 0.5, -0.5, -0.5,
62
+ 0.5, 0.5, -0.5,
63
+ -0.5, 0.5, -0.5,
64
+ -0.5, -0.5, 0.5,
65
+ 0.5, -0.5, 0.5,
66
+ 0.5, 0.5, 0.5,
67
+ -0.5, 0.5, 0.5,
68
+ ]),
69
+ indices: Object.freeze([
70
+ 0, 1, 2, 0, 2, 3,
71
+ 5, 4, 7, 5, 7, 6,
72
+ 4, 0, 3, 4, 3, 7,
73
+ 1, 5, 6, 1, 6, 2,
74
+ 3, 2, 6, 3, 6, 7,
75
+ 4, 5, 1, 4, 1, 0,
76
+ ]),
77
+ });
78
+
79
+ export function resolveShowcaseAssetUrl(baseUrl = import.meta.url) {
80
+ return new URL("../assets/brigantine.gltf", baseUrl);
81
+ }
82
+
83
+ function injectStyles() {
84
+ if (document.getElementById(STYLE_ID)) {
85
+ return;
86
+ }
87
+
88
+ const style = document.createElement("style");
89
+ style.id = STYLE_ID;
90
+ style.textContent = `
91
+ :root {
92
+ color-scheme: light;
93
+ --plasius-paper: #f4f7f8;
94
+ --plasius-ink: #152028;
95
+ --plasius-muted: #5c6f7b;
96
+ --plasius-accent: #8f5634;
97
+ --plasius-panel: rgba(255, 255, 255, 0.82);
98
+ --plasius-border: rgba(21, 32, 40, 0.12);
99
+ --plasius-shadow: 0 20px 48px rgba(15, 24, 31, 0.16);
100
+ }
101
+ * {
102
+ box-sizing: border-box;
103
+ }
104
+ body {
105
+ margin: 0;
106
+ min-height: 100vh;
107
+ font-family: "Fraunces", "Iowan Old Style", serif;
108
+ color: var(--plasius-ink);
109
+ background:
110
+ radial-gradient(circle at top left, rgba(255, 247, 238, 0.92), transparent 34%),
111
+ linear-gradient(180deg, #f6f8fb 0%, #d2dee6 48%, #b6c4ce 100%);
112
+ }
113
+ .plasius-demo {
114
+ width: min(1560px, calc(100vw - 32px));
115
+ margin: 0 auto;
116
+ padding: 28px 0 40px;
117
+ display: grid;
118
+ gap: 20px;
119
+ }
120
+ .plasius-demo__hero,
121
+ .plasius-demo__layout {
122
+ display: grid;
123
+ gap: 20px;
124
+ }
125
+ .plasius-demo__hero {
126
+ grid-template-columns: minmax(0, 1.5fr) minmax(320px, 0.85fr);
127
+ align-items: start;
128
+ }
129
+ .plasius-panel {
130
+ border: 1px solid var(--plasius-border);
131
+ border-radius: 24px;
132
+ background: var(--plasius-panel);
133
+ box-shadow: var(--plasius-shadow);
134
+ backdrop-filter: blur(12px);
135
+ }
136
+ .plasius-demo__hero-card,
137
+ .plasius-demo__status {
138
+ padding: 20px 22px;
139
+ }
140
+ .plasius-demo__eyebrow {
141
+ margin: 0 0 8px;
142
+ text-transform: uppercase;
143
+ letter-spacing: 0.18em;
144
+ font-size: 12px;
145
+ color: rgba(21, 32, 40, 0.56);
146
+ }
147
+ .plasius-demo h1,
148
+ .plasius-demo h2,
149
+ .plasius-demo h3 {
150
+ margin: 0;
151
+ }
152
+ .plasius-demo__lead {
153
+ margin: 12px 0 0;
154
+ color: var(--plasius-muted);
155
+ line-height: 1.6;
156
+ max-width: 760px;
157
+ }
158
+ .plasius-demo__status-badge {
159
+ width: fit-content;
160
+ margin: 0;
161
+ padding: 8px 12px;
162
+ border-radius: 999px;
163
+ background: rgba(143, 86, 52, 0.12);
164
+ color: var(--plasius-accent);
165
+ font-weight: 700;
166
+ }
167
+ .plasius-demo__status-text {
168
+ margin: 10px 0 0;
169
+ color: var(--plasius-muted);
170
+ line-height: 1.6;
171
+ }
172
+ .plasius-demo__layout {
173
+ grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.68fr);
174
+ align-items: start;
175
+ }
176
+ .plasius-demo__canvas-panel {
177
+ padding: 18px;
178
+ position: relative;
179
+ }
180
+ .plasius-demo__canvas {
181
+ width: 100%;
182
+ aspect-ratio: 16 / 9;
183
+ display: block;
184
+ border-radius: 20px;
185
+ border: 1px solid rgba(21, 32, 40, 0.08);
186
+ background: linear-gradient(180deg, #dce8ef 0%, #a9bfd0 42%, #0f5168 42%, #092433 100%);
187
+ }
188
+ .plasius-demo__toolbar {
189
+ position: absolute;
190
+ top: 26px;
191
+ left: 26px;
192
+ display: flex;
193
+ gap: 12px;
194
+ flex-wrap: wrap;
195
+ align-items: center;
196
+ }
197
+ .plasius-demo button,
198
+ .plasius-demo label,
199
+ .plasius-demo select {
200
+ font-family: "JetBrains Mono", monospace;
201
+ font-size: 13px;
202
+ }
203
+ .plasius-demo button,
204
+ .plasius-demo .plasius-toggle,
205
+ .plasius-demo select {
206
+ border: 1px solid rgba(21, 32, 40, 0.12);
207
+ border-radius: 999px;
208
+ background: rgba(255, 255, 255, 0.84);
209
+ color: var(--plasius-ink);
210
+ padding: 10px 14px;
211
+ }
212
+ .plasius-toggle {
213
+ display: inline-flex;
214
+ align-items: center;
215
+ gap: 8px;
216
+ }
217
+ .plasius-demo__sidebar {
218
+ display: grid;
219
+ gap: 18px;
220
+ }
221
+ .plasius-demo__card {
222
+ padding: 18px;
223
+ }
224
+ .plasius-demo__metrics,
225
+ .plasius-demo__metrics li {
226
+ margin: 0;
227
+ padding: 0;
228
+ list-style: none;
229
+ }
230
+ .plasius-demo__metrics {
231
+ margin-top: 12px;
232
+ display: grid;
233
+ gap: 8px;
234
+ color: var(--plasius-muted);
235
+ line-height: 1.55;
236
+ }
237
+ .plasius-demo__metrics li {
238
+ border-top: 1px solid rgba(21, 32, 40, 0.08);
239
+ padding-top: 8px;
240
+ }
241
+ .plasius-demo__legend {
242
+ position: absolute;
243
+ right: 24px;
244
+ bottom: 24px;
245
+ padding: 10px 14px;
246
+ border-radius: 16px;
247
+ background: rgba(255, 255, 255, 0.84);
248
+ border: 1px solid rgba(21, 32, 40, 0.1);
249
+ color: var(--plasius-muted);
250
+ font-size: 12px;
251
+ line-height: 1.45;
252
+ }
253
+ .plasius-demo__legend strong {
254
+ display: block;
255
+ color: var(--plasius-ink);
256
+ margin-bottom: 4px;
257
+ }
258
+ .plasius-demo__footer {
259
+ margin-top: 4px;
260
+ color: rgba(21, 32, 40, 0.66);
261
+ font-size: 13px;
262
+ line-height: 1.6;
263
+ }
264
+ @media (max-width: 1200px) {
265
+ .plasius-demo__hero,
266
+ .plasius-demo__layout {
267
+ grid-template-columns: 1fr;
268
+ }
269
+ }
270
+ `;
271
+ document.head.appendChild(style);
272
+ }
273
+
274
+ function clamp(value, min, max) {
275
+ return Math.max(min, Math.min(max, value));
276
+ }
277
+
278
+ function mix(a, b, t) {
279
+ return a + (b - a) * t;
280
+ }
281
+
282
+ function vec3(x = 0, y = 0, z = 0) {
283
+ return { x, y, z };
284
+ }
285
+
286
+ function addVec3(a, b) {
287
+ return vec3(a.x + b.x, a.y + b.y, a.z + b.z);
288
+ }
289
+
290
+ function subVec3(a, b) {
291
+ return vec3(a.x - b.x, a.y - b.y, a.z - b.z);
292
+ }
293
+
294
+ function scaleVec3(a, s) {
295
+ return vec3(a.x * s, a.y * s, a.z * s);
296
+ }
297
+
298
+ function dotVec3(a, b) {
299
+ return a.x * b.x + a.y * b.y + a.z * b.z;
300
+ }
301
+
302
+ function crossVec3(a, b) {
303
+ return vec3(
304
+ a.y * b.z - a.z * b.y,
305
+ a.z * b.x - a.x * b.z,
306
+ a.x * b.y - a.y * b.x
307
+ );
308
+ }
309
+
310
+ function lengthVec3(a) {
311
+ return Math.hypot(a.x, a.y, a.z);
312
+ }
313
+
314
+ function normalizeVec3(a) {
315
+ const length = lengthVec3(a) || 1;
316
+ return vec3(a.x / length, a.y / length, a.z / length);
317
+ }
318
+
319
+ function reflectVec3(vector, normal) {
320
+ const unitNormal = normalizeVec3(normal);
321
+ return subVec3(vector, scaleVec3(unitNormal, 2 * dotVec3(vector, unitNormal)));
322
+ }
323
+
324
+ function rotateY(point, angle) {
325
+ const cosine = Math.cos(angle);
326
+ const sine = Math.sin(angle);
327
+ return vec3(
328
+ point.x * cosine - point.z * sine,
329
+ point.y,
330
+ point.x * sine + point.z * cosine
331
+ );
332
+ }
333
+
334
+ function transformPoint(point, transform) {
335
+ const scale =
336
+ typeof transform.scale === "number"
337
+ ? { x: transform.scale, y: transform.scale, z: transform.scale }
338
+ : transform.scale;
339
+ const scaled = vec3(point.x * scale.x, point.y * scale.y, point.z * scale.z);
340
+ const rotated = rotateY(scaled, transform.rotationY);
341
+ return addVec3(rotated, transform.position);
342
+ }
343
+
344
+ function projectPoint(point, camera, viewport) {
345
+ const relative = subVec3(point, camera.eye);
346
+ const viewX = dotVec3(relative, camera.right);
347
+ const viewY = dotVec3(relative, camera.up);
348
+ const viewZ = dotVec3(relative, camera.forward);
349
+ if (viewZ <= 0.1) {
350
+ return null;
351
+ }
352
+ const focal = 1 / Math.tan((camera.fov * Math.PI) / 360);
353
+ const ndcX = (viewX * focal) / (viewZ * camera.aspect);
354
+ const ndcY = (viewY * focal) / viewZ;
355
+ return {
356
+ x: (ndcX * 0.5 + 0.5) * viewport.width,
357
+ y: (-ndcY * 0.5 + 0.5) * viewport.height,
358
+ depth: viewZ,
359
+ };
360
+ }
361
+
362
+ function colorToRgba(color, alpha = 1) {
363
+ const r = Math.round(clamp(color.r, 0, 1) * 255);
364
+ const g = Math.round(clamp(color.g, 0, 1) * 255);
365
+ const b = Math.round(clamp(color.b, 0, 1) * 255);
366
+ return `rgba(${r}, ${g}, ${b}, ${clamp(alpha, 0, 1)})`;
367
+ }
368
+
369
+ function projectShadowPoint(point, lightDir, planeY) {
370
+ const shadowDir = scaleVec3(lightDir, -1);
371
+ if (Math.abs(shadowDir.y) < 0.0001) {
372
+ return null;
373
+ }
374
+
375
+ const distance = (planeY - point.y) / shadowDir.y;
376
+ if (!Number.isFinite(distance) || distance < 0) {
377
+ return null;
378
+ }
379
+
380
+ return addVec3(point, scaleVec3(shadowDir, distance));
381
+ }
382
+
383
+ function shadeColor(base, normal, lightDir, heightBias = 0, accent = 0) {
384
+ const diffuse = clamp(dotVec3(normalizeVec3(normal), lightDir), 0, 1);
385
+ const brightness = 0.24 + diffuse * 0.72 + heightBias * 0.08 + accent;
386
+ return {
387
+ r: clamp(base.r * brightness, 0, 1),
388
+ g: clamp(base.g * brightness, 0, 1),
389
+ b: clamp(base.b * (brightness + 0.03), 0, 1),
390
+ };
391
+ }
392
+
393
+ function buildCamera(state, canvas) {
394
+ const preset = CAMERA_PRESETS[state.focus] ?? CAMERA_PRESETS.integrated;
395
+ const yaw = state.camera.yaw ?? preset.yaw;
396
+ const pitch = state.camera.pitch ?? preset.pitch;
397
+ const distance = state.camera.distance ?? preset.distance;
398
+ const target = state.camera.target ?? vec3(...preset.target);
399
+ const eye = vec3(
400
+ target.x + Math.sin(yaw) * Math.cos(pitch) * distance,
401
+ target.y + Math.sin(pitch) * distance,
402
+ target.z + Math.cos(yaw) * Math.cos(pitch) * distance
403
+ );
404
+ const forward = normalizeVec3(subVec3(target, eye));
405
+ const right = normalizeVec3(crossVec3(forward, vec3(0, 1, 0)));
406
+ const up = normalizeVec3(crossVec3(right, forward));
407
+ return {
408
+ eye,
409
+ target,
410
+ forward,
411
+ right,
412
+ up,
413
+ fov: 54,
414
+ aspect: canvas.width / canvas.height,
415
+ };
416
+ }
417
+
418
+ function buildTrianglesFromMesh(mesh, transform, baseColor, camera, viewport, triangles, accent = 0) {
419
+ for (let index = 0; index < mesh.indices.length; index += 3) {
420
+ const aIndex = mesh.indices[index] * 3;
421
+ const bIndex = mesh.indices[index + 1] * 3;
422
+ const cIndex = mesh.indices[index + 2] * 3;
423
+
424
+ const a = transformPoint(
425
+ vec3(mesh.positions[aIndex], mesh.positions[aIndex + 1], mesh.positions[aIndex + 2]),
426
+ transform
427
+ );
428
+ const b = transformPoint(
429
+ vec3(mesh.positions[bIndex], mesh.positions[bIndex + 1], mesh.positions[bIndex + 2]),
430
+ transform
431
+ );
432
+ const c = transformPoint(
433
+ vec3(mesh.positions[cIndex], mesh.positions[cIndex + 1], mesh.positions[cIndex + 2]),
434
+ transform
435
+ );
436
+
437
+ const ab = subVec3(b, a);
438
+ const ac = subVec3(c, a);
439
+ const normal = normalizeVec3(crossVec3(ab, ac));
440
+ const viewDir = normalizeVec3(subVec3(camera.eye, a));
441
+ if (dotVec3(normal, viewDir) <= 0) {
442
+ continue;
443
+ }
444
+
445
+ const projected = [projectPoint(a, camera, viewport), projectPoint(b, camera, viewport), projectPoint(c, camera, viewport)];
446
+ if (projected.some((value) => value === null)) {
447
+ continue;
448
+ }
449
+
450
+ triangles.push({
451
+ points: projected,
452
+ depth: (projected[0].depth + projected[1].depth + projected[2].depth) / 3,
453
+ worldCenter: scaleVec3(addVec3(addVec3(a, b), c), 1 / 3),
454
+ normal,
455
+ baseColor,
456
+ accent,
457
+ });
458
+ }
459
+ }
460
+
461
+ function createPerformanceGovernor() {
462
+ const fluidDetail = createQualityLadderAdapter({
463
+ id: "fluid-detail",
464
+ domain: "geometry",
465
+ levels: [
466
+ { id: "low", config: { nearResolution: 10, midResolution: 6, splashCount: 10 }, estimatedCostMs: 0.8 },
467
+ { id: "medium", config: { nearResolution: 16, midResolution: 8, splashCount: 18 }, estimatedCostMs: 1.4 },
468
+ { id: "high", config: { nearResolution: 24, midResolution: 12, splashCount: 28 }, estimatedCostMs: 2.4 },
469
+ ],
470
+ initialLevel: "high",
471
+ });
472
+
473
+ const clothDetail = createQualityLadderAdapter({
474
+ id: "cloth-detail",
475
+ domain: "cloth",
476
+ levels: [
477
+ { id: "low", config: { cols: 10, rows: 7 }, estimatedCostMs: 0.7 },
478
+ { id: "medium", config: { cols: 16, rows: 11 }, estimatedCostMs: 1.3 },
479
+ { id: "high", config: { cols: 24, rows: 16 }, estimatedCostMs: 2.1 },
480
+ ],
481
+ initialLevel: "high",
482
+ });
483
+
484
+ const lightingDetail = createQualityLadderAdapter({
485
+ id: "lighting-detail",
486
+ domain: "lighting",
487
+ levels: [
488
+ { id: "low", config: { shadowStrength: 0.18, reflectionStrength: 0.08 }, estimatedCostMs: 0.5 },
489
+ { id: "medium", config: { shadowStrength: 0.34, reflectionStrength: 0.16 }, estimatedCostMs: 1.0 },
490
+ { id: "high", config: { shadowStrength: 0.5, reflectionStrength: 0.24 }, estimatedCostMs: 1.8 },
491
+ ],
492
+ initialLevel: "high",
493
+ });
494
+
495
+ const governor = createGpuPerformanceGovernor({
496
+ device: createDeviceProfile({
497
+ deviceClass: "desktop",
498
+ mode: "flat",
499
+ refreshRateHz: 60,
500
+ supportedFrameRates: [60, 90],
501
+ supportsWebGpu: true,
502
+ }),
503
+ modules: [fluidDetail, clothDetail, lightingDetail],
504
+ adaptation: {
505
+ sampleWindowSize: 10,
506
+ minimumSamplesBeforeAdjustment: 4,
507
+ degradeCooldownFrames: 1,
508
+ upgradeCooldownFrames: 4,
509
+ minStableFramesForRecovery: 3,
510
+ },
511
+ });
512
+
513
+ return { governor, fluidDetail, clothDetail, lightingDetail };
514
+ }
515
+
516
+ function buildDemoDom(root, options) {
517
+ root.innerHTML = `
518
+ <main class="plasius-demo">
519
+ <section class="plasius-demo__hero">
520
+ <section class="plasius-panel plasius-demo__hero-card">
521
+ <p class="plasius-demo__eyebrow">${options.packageName}</p>
522
+ <h1>${options.title}</h1>
523
+ <p class="plasius-demo__lead">${options.subtitle}</p>
524
+ </section>
525
+ <section class="plasius-panel plasius-demo__status">
526
+ <p id="demoStatus" class="plasius-demo__status-badge">Booting 3D scene…</p>
527
+ <p id="demoDetails" class="plasius-demo__status-text">
528
+ Preparing GLTF assets, cloth and fluid continuity plans, and adaptive quality metadata.
529
+ </p>
530
+ </section>
531
+ </section>
532
+ <section class="plasius-demo__layout">
533
+ <section class="plasius-panel plasius-demo__canvas-panel">
534
+ <canvas id="demoCanvas" class="plasius-demo__canvas" width="1280" height="720"></canvas>
535
+ <div class="plasius-demo__toolbar">
536
+ <button id="pauseButton" type="button">Pause</button>
537
+ <label class="plasius-toggle">
538
+ <input id="stressToggle" type="checkbox" />
539
+ Stress mode
540
+ </label>
541
+ <label class="plasius-toggle">
542
+ Focus
543
+ <select id="focusMode">
544
+ <option value="integrated">integrated</option>
545
+ <option value="lighting">lighting</option>
546
+ <option value="cloth">cloth</option>
547
+ <option value="fluid">fluid</option>
548
+ <option value="physics">physics</option>
549
+ <option value="performance">performance</option>
550
+ <option value="debug">debug</option>
551
+ </select>
552
+ </label>
553
+ </div>
554
+ <div class="plasius-demo__legend">
555
+ <strong>Scene</strong>
556
+ GLTF ships carry collision metadata.<br />
557
+ Flag cloth and ocean waves scale by distance band.<br />
558
+ Ray-traced shadow and reflection style is preserved near the camera.
559
+ </div>
560
+ </section>
561
+ <aside class="plasius-demo__sidebar">
562
+ <section class="plasius-panel plasius-demo__card">
563
+ <h2>Scene State</h2>
564
+ <ul id="sceneMetrics" class="plasius-demo__metrics"></ul>
565
+ </section>
566
+ <section class="plasius-panel plasius-demo__card">
567
+ <h2>Quality + Budgets</h2>
568
+ <ul id="qualityMetrics" class="plasius-demo__metrics"></ul>
569
+ </section>
570
+ <section class="plasius-panel plasius-demo__card">
571
+ <h2>Debug Telemetry</h2>
572
+ <ul id="debugMetrics" class="plasius-demo__metrics"></ul>
573
+ </section>
574
+ <section class="plasius-panel plasius-demo__card">
575
+ <h2>Notes</h2>
576
+ <ul id="sceneNotes" class="plasius-demo__metrics"></ul>
577
+ </section>
578
+ </aside>
579
+ </section>
580
+ <p class="plasius-demo__footer">
581
+ This visual example is shared across the GPU packages to keep manual validation fast and consistent.
582
+ </p>
583
+ </main>
584
+ `;
585
+
586
+ return {
587
+ status: root.querySelector("#demoStatus"),
588
+ details: root.querySelector("#demoDetails"),
589
+ canvas: root.querySelector("#demoCanvas"),
590
+ pauseButton: root.querySelector("#pauseButton"),
591
+ stressToggle: root.querySelector("#stressToggle"),
592
+ focusMode: root.querySelector("#focusMode"),
593
+ sceneMetrics: root.querySelector("#sceneMetrics"),
594
+ qualityMetrics: root.querySelector("#qualityMetrics"),
595
+ debugMetrics: root.querySelector("#debugMetrics"),
596
+ sceneNotes: root.querySelector("#sceneNotes"),
597
+ };
598
+ }
599
+
600
+ function buildClothSurface(model, state, meshDetail) {
601
+ const clothPlan = createClothRepresentationPlan({
602
+ garmentId: "shore-flag",
603
+ kind: state.focus === "cloth" ? "flag" : clothGarmentKinds[0],
604
+ profile: state.focus === "cloth" ? "cinematic" : clothProfileNames[0],
605
+ supportsRayTracing: true,
606
+ nearFieldMaxMeters: 18,
607
+ midFieldMaxMeters: 55,
608
+ farFieldMaxMeters: 180,
609
+ });
610
+ const cameraDistance = lengthVec3(subVec3(state.camera.target, state.camera.eye ?? vec3(...CAMERA_PRESETS[state.focus].target)));
611
+ const band = selectClothRepresentationBand(cameraDistance, clothPlan.thresholds);
612
+ const representation =
613
+ clothPlan.representations.find((entry) => entry.band === band) ?? clothPlan.representations[0];
614
+ const continuity = createClothContinuityEnvelope({ garmentId: "shore-flag" });
615
+
616
+ const cols = meshDetail.cols;
617
+ const rows = meshDetail.rows;
618
+ const origin = vec3(-3.5, 5.9, 2.4);
619
+ const width = 4.8;
620
+ const height = 2.7;
621
+ const positions = [];
622
+ const indices = [];
623
+ const time = state.time;
624
+
625
+ for (let row = 0; row < rows; row += 1) {
626
+ for (let column = 0; column < cols; column += 1) {
627
+ const u = column / (cols - 1);
628
+ const v = row / (rows - 1);
629
+ const gust = Math.sin(time * 1.9 + v * 3.2 + u * 2.1) * continuity.broadMotionFloor;
630
+ const wrinkle = Math.sin(time * 4.4 + u * 9.2 + v * 5.6) * continuity.wrinkleFloor * 0.22;
631
+ const x = origin.x + u * 1.8 + gust * 0.55 * (u * 0.9);
632
+ const y = origin.y - height * v + wrinkle * 0.2;
633
+ const z = origin.z + width * u + gust * 0.72 * (u * 0.85);
634
+ const flap = Math.cos(time * 2.7 + u * 7.4 + v * 3.8) * continuity.broadMotionFloor * 0.28;
635
+ positions.push(vec3(x + flap, y, z));
636
+ }
637
+ }
638
+
639
+ for (let row = 0; row < rows - 1; row += 1) {
640
+ for (let column = 0; column < cols - 1; column += 1) {
641
+ const a = row * cols + column;
642
+ const b = a + 1;
643
+ const c = a + cols + 1;
644
+ const d = a + cols;
645
+ indices.push(a, b, c, a, c, d);
646
+ }
647
+ }
648
+
649
+ return {
650
+ clothPlan,
651
+ band,
652
+ representation,
653
+ continuity,
654
+ positions,
655
+ indices,
656
+ grid: { rows, cols },
657
+ };
658
+ }
659
+
660
+ function sampleWave(x, z, time) {
661
+ return (
662
+ Math.sin(x * 0.18 + time * 1.2) * 0.55 +
663
+ Math.cos(z * 0.12 + time * 0.9) * 0.35 +
664
+ Math.sin((x + z) * 0.08 + time * 1.6) * 0.22
665
+ );
666
+ }
667
+
668
+ function buildWaterBands(state, fluidDetail) {
669
+ const fluidPlan = createFluidRepresentationPlan({
670
+ fluidBodyId: "harbor",
671
+ kind: state.focus === "fluid" ? "ocean" : fluidBodyKinds[0],
672
+ profile: state.focus === "fluid" ? "cinematic" : fluidProfileNames[0],
673
+ supportsRayTracing: true,
674
+ nearFieldMaxMeters: 40,
675
+ midFieldMaxMeters: 150,
676
+ farFieldMaxMeters: 600,
677
+ });
678
+
679
+ const bandMeshes = [];
680
+ const bandExtents = Object.freeze([
681
+ { band: "near", width: 20, depth: 18, step: 1, y: 0.2 },
682
+ { band: "mid", width: 34, depth: 28, step: 2, y: 0.05 },
683
+ { band: "far", width: 54, depth: 42, step: 3.5, y: -0.05 },
684
+ { band: "horizon", width: 80, depth: 76, step: 7, y: -0.14 },
685
+ ]);
686
+
687
+ for (const bandSpec of bandExtents) {
688
+ const representation =
689
+ fluidPlan.representations.find((entry) => entry.band === bandSpec.band) ??
690
+ fluidPlan.representations[0];
691
+ const continuity = createFluidContinuityEnvelope({ fluidBodyId: "harbor" });
692
+ const bandResolution =
693
+ bandSpec.band === "near"
694
+ ? fluidDetail.nearResolution
695
+ : bandSpec.band === "mid"
696
+ ? fluidDetail.midResolution
697
+ : bandSpec.band === "far"
698
+ ? 5
699
+ : 3;
700
+ const cols = Math.max(4, bandResolution * 2);
701
+ const rows = Math.max(4, bandResolution + 2);
702
+ const positions = [];
703
+ const indices = [];
704
+ const originX = -bandSpec.width * 0.5;
705
+ const originZ = -6;
706
+ for (let row = 0; row < rows; row += 1) {
707
+ for (let column = 0; column < cols; column += 1) {
708
+ const u = column / (cols - 1);
709
+ const v = row / (rows - 1);
710
+ const x = originX + bandSpec.width * u;
711
+ const z = originZ + bandSpec.depth * v;
712
+ const y =
713
+ bandSpec.y +
714
+ sampleWave(x, z, state.time) *
715
+ continuity.amplitudeFloor *
716
+ (bandSpec.band === "near" ? 0.9 : bandSpec.band === "mid" ? 0.55 : 0.3);
717
+ positions.push(vec3(x, y, z));
718
+ }
719
+ }
720
+ for (let row = 0; row < rows - 1; row += 1) {
721
+ for (let column = 0; column < cols - 1; column += 1) {
722
+ const a = row * cols + column;
723
+ const b = a + 1;
724
+ const c = a + cols + 1;
725
+ const d = a + cols;
726
+ indices.push(a, b, c, a, c, d);
727
+ }
728
+ }
729
+
730
+ bandMeshes.push({
731
+ band: bandSpec.band,
732
+ representation,
733
+ continuity,
734
+ rows,
735
+ cols,
736
+ positions,
737
+ indices,
738
+ color:
739
+ bandSpec.band === "near"
740
+ ? { r: 0.12, g: 0.36, b: 0.46 }
741
+ : bandSpec.band === "mid"
742
+ ? { r: 0.15, g: 0.42, b: 0.54 }
743
+ : bandSpec.band === "far"
744
+ ? { r: 0.22, g: 0.48, b: 0.6 }
745
+ : { r: 0.34, g: 0.58, b: 0.7 },
746
+ });
747
+ }
748
+
749
+ return { fluidPlan, bandMeshes };
750
+ }
751
+
752
+ function createSceneState(options) {
753
+ const { governor, fluidDetail, clothDetail, lightingDetail } = createPerformanceGovernor();
754
+ const physicsProfile = defaultPhysicsWorkerProfile;
755
+ const physicsPlan = createPhysicsSimulationPlan(physicsProfile);
756
+ const physicsManifest = getPhysicsWorkerManifest(physicsProfile);
757
+ const debugSession = createGpuDebugSession({
758
+ enabled: true,
759
+ adapter: {
760
+ label: "3D showcase",
761
+ memoryCapacityHintBytes: 6 * 1024 * 1024 * 1024,
762
+ coreCountHint: 12,
763
+ },
764
+ });
765
+ debugSession.trackAllocation({
766
+ id: "showcase.color",
767
+ owner: "renderer",
768
+ category: "texture",
769
+ sizeBytes: 1280 * 720 * 4,
770
+ label: "Main color buffer",
771
+ });
772
+ debugSession.trackAllocation({
773
+ id: "showcase.shadow-impression",
774
+ owner: "lighting",
775
+ category: "texture",
776
+ sizeBytes: 12 * 1024 * 1024,
777
+ label: "Shadow impression atlas",
778
+ });
779
+
780
+ return {
781
+ focus: options.focus,
782
+ governor,
783
+ fluidDetail,
784
+ clothDetail,
785
+ lightingDetail,
786
+ debugSession,
787
+ time: 0,
788
+ lastTimeMs: null,
789
+ paused: false,
790
+ stress: false,
791
+ camera: {
792
+ ...CAMERA_PRESETS[options.focus],
793
+ target: vec3(...CAMERA_PRESETS[options.focus].target),
794
+ },
795
+ ships: [
796
+ {
797
+ id: "northwind",
798
+ position: vec3(-5.2, 0, 7.2),
799
+ velocity: vec3(2.1, 0, -1.6),
800
+ rotationY: 0.42,
801
+ angularVelocity: 0.18,
802
+ tint: { r: 0.62, g: 0.39, b: 0.23 },
803
+ },
804
+ {
805
+ id: "tidecaller",
806
+ position: vec3(4.8, 0, 4.4),
807
+ velocity: vec3(-1.85, 0, 1.25),
808
+ rotationY: -2.62,
809
+ angularVelocity: -0.14,
810
+ tint: { r: 0.48, g: 0.28, b: 0.19 },
811
+ },
812
+ ],
813
+ sprays: [],
814
+ frame: 0,
815
+ collisionCount: 0,
816
+ collisionFlash: 0,
817
+ physics: {
818
+ profile: physicsProfile,
819
+ plan: physicsPlan,
820
+ manifest: physicsManifest,
821
+ snapshot: null,
822
+ },
823
+ shipModel: null,
824
+ };
825
+ }
826
+
827
+ function setListContent(element, values) {
828
+ element.innerHTML = values.map((value) => `<li>${value}</li>`).join("");
829
+ }
830
+
831
+ function drawSkyAndShore(ctx, canvas, state, nearLighting, reflectionStrength, shadowStrength) {
832
+ const premiumShadows = nearLighting.primaryShadowSource === "ray-traced-primary";
833
+ const sky = ctx.createLinearGradient(0, 0, 0, canvas.height * 0.5);
834
+ sky.addColorStop(0, premiumShadows ? "#f0f7fb" : "#e8f1f7");
835
+ sky.addColorStop(0.6, premiumShadows ? "#c7d9e5" : "#b9ceda");
836
+ sky.addColorStop(1, premiumShadows ? "#84a7bd" : "#7b9bb0");
837
+ ctx.fillStyle = sky;
838
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
839
+
840
+ const shoreline = ctx.createLinearGradient(0, canvas.height * 0.45, 0, canvas.height);
841
+ shoreline.addColorStop(0, premiumShadows ? "#235064" : "#264c5f");
842
+ shoreline.addColorStop(0.52, premiumShadows ? "#153e53" : "#173d4f");
843
+ shoreline.addColorStop(1, "#0b2433");
844
+ ctx.fillStyle = shoreline;
845
+ ctx.fillRect(0, canvas.height * 0.45, canvas.width, canvas.height * 0.55);
846
+
847
+ const sunX = mix(canvas.width * 0.16, canvas.width * 0.84, (Math.sin(state.time * 0.12) + 1) * 0.5);
848
+ const sunY = canvas.height * 0.14 + Math.cos(state.time * 0.12) * 22;
849
+ const sun = ctx.createRadialGradient(sunX, sunY, 10, sunX, sunY, 90);
850
+ sun.addColorStop(0, "rgba(255, 244, 210, 0.9)");
851
+ sun.addColorStop(1, "rgba(255, 244, 210, 0)");
852
+ ctx.fillStyle = sun;
853
+ ctx.beginPath();
854
+ ctx.arc(sunX, sunY, 90, 0, Math.PI * 2);
855
+ ctx.fill();
856
+
857
+ const track = ctx.createLinearGradient(sunX, canvas.height * 0.46, sunX, canvas.height * 0.96);
858
+ track.addColorStop(0, `rgba(255, 243, 214, ${0.08 + reflectionStrength * 0.18})`);
859
+ track.addColorStop(0.42, `rgba(224, 242, 255, ${0.04 + reflectionStrength * 0.2})`);
860
+ track.addColorStop(1, "rgba(224, 242, 255, 0)");
861
+ ctx.save();
862
+ ctx.globalCompositeOperation = "screen";
863
+ ctx.fillStyle = track;
864
+ ctx.beginPath();
865
+ ctx.ellipse(sunX, canvas.height * 0.72, 46 + shadowStrength * 60, canvas.height * 0.26, 0, 0, Math.PI * 2);
866
+ ctx.fill();
867
+ ctx.restore();
868
+
869
+ if (state.collisionFlash > 0.01) {
870
+ ctx.fillStyle = `rgba(255, 243, 228, ${state.collisionFlash * 0.14})`;
871
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
872
+ }
873
+ }
874
+
875
+ function drawTriangles(ctx, triangles, lightDir, reflectionStrength, camera, shadowStrength) {
876
+ triangles.sort((left, right) => right.depth - left.depth);
877
+ for (const triangle of triangles) {
878
+ const surfaceNormal = normalizeVec3(triangle.normal);
879
+ const shaded = shadeColor(
880
+ triangle.baseColor,
881
+ surfaceNormal,
882
+ lightDir,
883
+ clamp((triangle.worldCenter.y + 3) / 10, 0, 1),
884
+ triangle.accent
885
+ );
886
+ const reflection = triangle.worldCenter.y < 0.8 ? reflectionStrength : 0;
887
+ const viewDir = normalizeVec3(subVec3(camera.eye, triangle.worldCenter));
888
+ const reflectedLight = reflectVec3(scaleVec3(lightDir, -1), surfaceNormal);
889
+ const gloss = triangle.worldCenter.y < 0.9 ? 1 : triangle.accent > 0.05 ? 0.55 : 0.3;
890
+ const specular = Math.pow(clamp(dotVec3(reflectedLight, viewDir), 0, 1), triangle.worldCenter.y < 0.9 ? 18 : 12) * gloss;
891
+ const occlusion = triangle.worldCenter.y < 0.9 ? shadowStrength * 0.035 : 0;
892
+ const fill = colorToRgba(
893
+ {
894
+ r: clamp(shaded.r + reflection * 0.08 + specular * 0.14 - occlusion, 0, 1),
895
+ g: clamp(shaded.g + reflection * 0.08 + specular * 0.15 - occlusion, 0, 1),
896
+ b: clamp(shaded.b + reflection * 0.16 + specular * 0.2 - occlusion * 0.5, 0, 1),
897
+ },
898
+ 0.98
899
+ );
900
+ ctx.fillStyle = fill;
901
+ ctx.beginPath();
902
+ ctx.moveTo(triangle.points[0].x, triangle.points[0].y);
903
+ ctx.lineTo(triangle.points[1].x, triangle.points[1].y);
904
+ ctx.lineTo(triangle.points[2].x, triangle.points[2].y);
905
+ ctx.closePath();
906
+ ctx.fill();
907
+ }
908
+ }
909
+
910
+ function renderProjectedShadow(ctx, worldPoints, camera, viewport, lightDir, options = {}) {
911
+ const planeY = options.planeY ?? 0;
912
+ const projected = worldPoints
913
+ .map((point) => projectShadowPoint(point, lightDir, planeY))
914
+ .filter(Boolean)
915
+ .map((point) => projectPoint(point, camera, viewport))
916
+ .filter(Boolean);
917
+
918
+ if (projected.length < 3) {
919
+ return;
920
+ }
921
+
922
+ ctx.save();
923
+ ctx.globalCompositeOperation = "multiply";
924
+ ctx.fillStyle = options.color ?? `rgba(12, 24, 36, ${clamp(options.alpha ?? 0.16, 0, 0.5)})`;
925
+ ctx.shadowColor = options.color ?? "rgba(12, 24, 36, 0.22)";
926
+ ctx.shadowBlur = options.blur ?? 18;
927
+ ctx.beginPath();
928
+ ctx.moveTo(projected[0].x, projected[0].y);
929
+ for (let index = 1; index < projected.length; index += 1) {
930
+ ctx.lineTo(projected[index].x, projected[index].y);
931
+ }
932
+ ctx.closePath();
933
+ ctx.fill();
934
+ ctx.restore();
935
+ }
936
+
937
+ function pushHarborGeometry(camera, viewport, triangles) {
938
+ const harborObjects = [
939
+ {
940
+ position: vec3(-8.2, 1.1, -0.9),
941
+ rotationY: -0.16,
942
+ scale: { x: 5.4, y: 2.4, z: 4.2 },
943
+ color: { r: 0.48, g: 0.4, b: 0.32 },
944
+ accent: 0.06,
945
+ },
946
+ {
947
+ position: vec3(-5.7, 0.45, 1.4),
948
+ rotationY: -0.08,
949
+ scale: { x: 6.8, y: 0.3, z: 2.1 },
950
+ color: { r: 0.5, g: 0.34, b: 0.22 },
951
+ accent: 0.04,
952
+ },
953
+ {
954
+ position: vec3(-10.4, 0.28, 0.8),
955
+ rotationY: 0.22,
956
+ scale: { x: 1.2, y: 0.9, z: 1.2 },
957
+ color: { r: 0.34, g: 0.32, b: 0.36 },
958
+ accent: 0.02,
959
+ },
960
+ ];
961
+
962
+ for (const object of harborObjects) {
963
+ buildTrianglesFromMesh(
964
+ UNIT_BOX_MESH,
965
+ {
966
+ position: object.position,
967
+ rotationY: object.rotationY,
968
+ scale: object.scale,
969
+ },
970
+ object.color,
971
+ camera,
972
+ viewport,
973
+ triangles,
974
+ object.accent
975
+ );
976
+ }
977
+ }
978
+
979
+ function renderShipRigging(ctx, ship, camera, viewport) {
980
+ const transform = { position: ship.position, rotationY: ship.rotationY, scale: 1.1 };
981
+ const mastBase = transformPoint(vec3(0, 0.38, -0.4), transform);
982
+ const mastTop = transformPoint(vec3(0, 3.8, -0.2), transform);
983
+ const aftBase = transformPoint(vec3(-0.25, 0.32, -1.9), transform);
984
+ const aftTop = transformPoint(vec3(-0.15, 2.7, -1.75), transform);
985
+ const sailA = transformPoint(vec3(0.08, 3.2, -0.2), transform);
986
+ const sailB = transformPoint(vec3(0.12, 1.2, -0.5), transform);
987
+ const sailC = transformPoint(vec3(2.25, 2.25, 0.15), transform);
988
+ const projected = [mastBase, mastTop, aftBase, aftTop, sailA, sailB, sailC].map((point) =>
989
+ projectPoint(point, camera, viewport)
990
+ );
991
+ if (projected.some((value) => value === null)) {
992
+ return;
993
+ }
994
+
995
+ ctx.strokeStyle = "rgba(73, 54, 45, 0.94)";
996
+ ctx.lineWidth = 3.5;
997
+ ctx.beginPath();
998
+ ctx.moveTo(projected[0].x, projected[0].y);
999
+ ctx.lineTo(projected[1].x, projected[1].y);
1000
+ ctx.moveTo(projected[2].x, projected[2].y);
1001
+ ctx.lineTo(projected[3].x, projected[3].y);
1002
+ ctx.stroke();
1003
+
1004
+ ctx.fillStyle = "rgba(238, 232, 214, 0.88)";
1005
+ ctx.beginPath();
1006
+ ctx.moveTo(projected[4].x, projected[4].y);
1007
+ ctx.lineTo(projected[5].x, projected[5].y);
1008
+ ctx.lineTo(projected[6].x, projected[6].y);
1009
+ ctx.closePath();
1010
+ ctx.fill();
1011
+ }
1012
+
1013
+ function renderClothAccent(ctx, cloth, camera, viewport) {
1014
+ const projected = cloth.positions.map((point) => projectPoint(point, camera, viewport));
1015
+ ctx.strokeStyle = "rgba(255, 241, 226, 0.92)";
1016
+ ctx.lineWidth = 1.7;
1017
+
1018
+ for (
1019
+ let row = 0;
1020
+ row < cloth.grid.rows;
1021
+ row += Math.max(1, Math.floor(cloth.grid.rows / 5))
1022
+ ) {
1023
+ ctx.beginPath();
1024
+ let started = false;
1025
+ for (let column = 0; column < cloth.grid.cols; column += 1) {
1026
+ const point = projected[row * cloth.grid.cols + column];
1027
+ if (!point) {
1028
+ continue;
1029
+ }
1030
+ if (!started) {
1031
+ ctx.moveTo(point.x, point.y);
1032
+ started = true;
1033
+ } else {
1034
+ ctx.lineTo(point.x, point.y);
1035
+ }
1036
+ }
1037
+ if (started) {
1038
+ ctx.stroke();
1039
+ }
1040
+ }
1041
+
1042
+ const borderIndices = [
1043
+ 0,
1044
+ cloth.grid.cols - 1,
1045
+ cloth.grid.rows * cloth.grid.cols - 1,
1046
+ (cloth.grid.rows - 1) * cloth.grid.cols,
1047
+ ];
1048
+ ctx.fillStyle = "rgba(164, 44, 28, 0.95)";
1049
+ for (const index of borderIndices) {
1050
+ const point = projected[index];
1051
+ if (!point) {
1052
+ continue;
1053
+ }
1054
+ ctx.beginPath();
1055
+ ctx.arc(point.x, point.y, 2.8, 0, Math.PI * 2);
1056
+ ctx.fill();
1057
+ }
1058
+ }
1059
+
1060
+ function renderWaterHighlights(ctx, waterBands, camera, viewport) {
1061
+ for (const band of waterBands) {
1062
+ if (band.band === "horizon") {
1063
+ continue;
1064
+ }
1065
+ const interval = band.band === "near" ? 2 : 3;
1066
+ const alpha = band.band === "near" ? 0.22 : 0.14;
1067
+ ctx.strokeStyle = `rgba(232, 247, 255, ${alpha})`;
1068
+ ctx.lineWidth = band.band === "near" ? 1.3 : 0.9;
1069
+ for (let row = interval; row < band.rows - 1; row += interval) {
1070
+ ctx.beginPath();
1071
+ let started = false;
1072
+ for (let column = 0; column < band.cols; column += 1) {
1073
+ const point = projectPoint(
1074
+ band.positions[row * band.cols + column],
1075
+ camera,
1076
+ viewport
1077
+ );
1078
+ if (!point) {
1079
+ continue;
1080
+ }
1081
+ if (!started) {
1082
+ ctx.moveTo(point.x, point.y);
1083
+ started = true;
1084
+ } else {
1085
+ ctx.lineTo(point.x, point.y);
1086
+ }
1087
+ }
1088
+ if (started) {
1089
+ ctx.stroke();
1090
+ }
1091
+ }
1092
+ }
1093
+ }
1094
+
1095
+ function spawnSpray(state, point, intensity) {
1096
+ const count = state.fluidDetail.getSnapshot().currentLevel.config.splashCount;
1097
+ for (let index = 0; index < count; index += 1) {
1098
+ const angle = (index / count) * Math.PI * 2;
1099
+ const speed = 0.9 + Math.random() * intensity * 0.45;
1100
+ state.sprays.push({
1101
+ position: vec3(point.x, point.y, point.z),
1102
+ velocity: vec3(Math.cos(angle) * speed * 0.35, 1.1 + Math.random() * 0.8, Math.sin(angle) * speed * 0.25),
1103
+ life: 1.2 + Math.random() * 0.4,
1104
+ });
1105
+ }
1106
+ }
1107
+
1108
+ function updateShips(state, dt, shipModel) {
1109
+ const physics = shipModel.physics;
1110
+ const halfExtents = physics.halfExtents ?? [1.35, 0.95, 3.9];
1111
+ let collided = false;
1112
+ for (const ship of state.ships) {
1113
+ ship.position = addVec3(ship.position, scaleVec3(ship.velocity, dt));
1114
+ ship.rotationY += ship.angularVelocity * dt;
1115
+ ship.velocity = scaleVec3(ship.velocity, 1 - (physics.linearDamping ?? 0.04) * dt);
1116
+ ship.angularVelocity *= 1 - (physics.angularDamping ?? 0.08) * dt;
1117
+ ship.position.y = sampleWave(ship.position.x, ship.position.z, state.time) * 0.22 + (physics.waterline ?? 0.42);
1118
+ if (Math.abs(ship.position.x) > 10) {
1119
+ ship.velocity.x *= -1;
1120
+ ship.angularVelocity *= -1;
1121
+ }
1122
+ if (ship.position.z < 2 || ship.position.z > 16) {
1123
+ ship.velocity.z *= -1;
1124
+ ship.angularVelocity *= -1;
1125
+ }
1126
+ }
1127
+
1128
+ const [a, b] = state.ships;
1129
+ const dx = b.position.x - a.position.x;
1130
+ const dz = b.position.z - a.position.z;
1131
+ const overlapX = Math.abs(dx) < halfExtents[0] * 1.7;
1132
+ const overlapZ = Math.abs(dz) < halfExtents[2] * 0.8;
1133
+ if (overlapX && overlapZ) {
1134
+ const restitution = physics.restitution ?? 0.22;
1135
+ const swapX = a.velocity.x;
1136
+ const swapZ = a.velocity.z;
1137
+ a.velocity.x = -b.velocity.x * (0.86 + restitution);
1138
+ a.velocity.z = -b.velocity.z * (0.82 + restitution);
1139
+ b.velocity.x = -swapX * (0.86 + restitution);
1140
+ b.velocity.z = -swapZ * (0.82 + restitution);
1141
+ a.angularVelocity += 0.55;
1142
+ b.angularVelocity -= 0.55;
1143
+ const contactPoint = vec3((a.position.x + b.position.x) * 0.5, (a.position.y + b.position.y) * 0.5 + 0.1, (a.position.z + b.position.z) * 0.5);
1144
+ spawnSpray(state, contactPoint, Math.abs(dx) + Math.abs(dz));
1145
+ state.collisionCount += 1;
1146
+ collided = true;
1147
+ }
1148
+ state.collisionFlash = collided ? 1 : Math.max(0, state.collisionFlash - dt * 1.8);
1149
+ }
1150
+
1151
+ function updateSpray(state, dt) {
1152
+ state.sprays = state.sprays
1153
+ .map((particle) => {
1154
+ const nextVelocity = vec3(particle.velocity.x, particle.velocity.y - 4.2 * dt, particle.velocity.z);
1155
+ const nextPosition = addVec3(particle.position, scaleVec3(nextVelocity, dt));
1156
+ return {
1157
+ position: nextPosition,
1158
+ velocity: nextVelocity,
1159
+ life: particle.life - dt,
1160
+ };
1161
+ })
1162
+ .filter((particle) => particle.life > 0 && particle.position.y > -0.2);
1163
+ }
1164
+
1165
+ function recordTelemetry(state, frameTimeMs) {
1166
+ const frameId = `showcase-${state.frame}`;
1167
+ const quality = {
1168
+ fluid: state.fluidDetail.getSnapshot(),
1169
+ cloth: state.clothDetail.getSnapshot(),
1170
+ lighting: state.lightingDetail.getSnapshot(),
1171
+ };
1172
+ const synthetic = frameTimeMs + state.sprays.length * 0.1 + (state.stress ? 6.5 : 0);
1173
+ const decision = state.governor.recordFrame({ frameTimeMs: synthetic });
1174
+ const queueDepth = Math.min(32, Math.round(6 + state.sprays.length / 2 + (state.stress ? 10 : 0)));
1175
+ const readyLaneDepth = Math.min(
1176
+ 16,
1177
+ 4 + Math.round(Math.max(0, Math.sin(state.time * 1.7 + 0.8)) * 9)
1178
+ );
1179
+ state.debugSession.recordQueue({
1180
+ owner: "renderer",
1181
+ queueClass: "render",
1182
+ depth: queueDepth,
1183
+ capacity: 32,
1184
+ frameId,
1185
+ });
1186
+ state.debugSession.recordReadyLane({
1187
+ owner: "lighting",
1188
+ queueClass: "lighting",
1189
+ laneId: "critical",
1190
+ priority: 920,
1191
+ depth: readyLaneDepth,
1192
+ capacity: 16,
1193
+ frameId,
1194
+ });
1195
+ state.debugSession.recordDispatch({
1196
+ owner: "lighting",
1197
+ queueClass: "lighting",
1198
+ jobType: "lighting.integration",
1199
+ frameId,
1200
+ durationMs: quality.lighting.currentLevel.estimatedCostMs ?? 1.2,
1201
+ workgroups: { x: quality.fluid.currentLevel.config.nearResolution, y: 1, z: 1 },
1202
+ workgroupSize: { x: 8, y: 8, z: 1 },
1203
+ });
1204
+ state.debugSession.recordDependencyUnlock({
1205
+ owner: "scene",
1206
+ queueClass: "render",
1207
+ sourceJobType: "physics.resolve",
1208
+ unlockedJobType: "lighting.integration",
1209
+ priority: 920,
1210
+ unlockCount: 2 + Math.round(Math.max(0, Math.sin(state.time * 1.1)) * 4),
1211
+ frameId,
1212
+ });
1213
+ state.debugSession.recordPipelinePhase({
1214
+ owner: "scene",
1215
+ pipeline: "scene-preparation",
1216
+ stage: "stable-visual-snapshot",
1217
+ frameId,
1218
+ durationMs: synthetic * 0.38,
1219
+ snapshotAgeMs: Math.max(0, synthetic - 8),
1220
+ });
1221
+ state.debugSession.recordFrame({
1222
+ frameId,
1223
+ frameTimeMs: synthetic,
1224
+ targetFrameTimeMs: 16.67,
1225
+ gpuBusyMs: synthetic * 0.56,
1226
+ dropped: synthetic > 18,
1227
+ });
1228
+ return decision;
1229
+ }
1230
+
1231
+ function renderSprays(ctx, sprays, camera, viewport) {
1232
+ for (const spray of sprays) {
1233
+ const projected = projectPoint(spray.position, camera, viewport);
1234
+ if (!projected) {
1235
+ continue;
1236
+ }
1237
+ const radius = clamp((1 / projected.depth) * 260, 1.5, 7.5);
1238
+ ctx.fillStyle = `rgba(225, 243, 250, ${clamp(spray.life / 1.6, 0, 0.9)})`;
1239
+ ctx.beginPath();
1240
+ ctx.arc(projected.x, projected.y, radius, 0, Math.PI * 2);
1241
+ ctx.fill();
1242
+ }
1243
+ }
1244
+
1245
+ function renderFlagPole(ctx, camera, viewport) {
1246
+ const base = projectPoint(vec3(-3.5, 0.7, 2.4), camera, viewport);
1247
+ const top = projectPoint(vec3(-3.5, 6.3, 2.4), camera, viewport);
1248
+ if (!base || !top) {
1249
+ return;
1250
+ }
1251
+ ctx.strokeStyle = "rgba(77, 52, 41, 0.95)";
1252
+ ctx.lineWidth = 6;
1253
+ ctx.beginPath();
1254
+ ctx.moveTo(base.x, base.y);
1255
+ ctx.lineTo(top.x, top.y);
1256
+ ctx.stroke();
1257
+ }
1258
+
1259
+ function renderShipShadow(ctx, shipModel, ship, state, camera, viewport, lightDir, shadowStrength) {
1260
+ const bounds = shipModel.bounds;
1261
+ const keelY = (shipModel.physics.waterline ?? 0.42) - 0.28;
1262
+ const transform = { position: ship.position, rotationY: ship.rotationY, scale: 1.1 };
1263
+ const hullCorners = [
1264
+ vec3(bounds.min[0], keelY, bounds.min[2]),
1265
+ vec3(bounds.max[0], keelY, bounds.min[2]),
1266
+ vec3(bounds.max[0], keelY, bounds.max[2]),
1267
+ vec3(bounds.min[0], keelY, bounds.max[2]),
1268
+ ].map((point) => transformPoint(point, transform));
1269
+
1270
+ renderProjectedShadow(ctx, hullCorners, camera, viewport, lightDir, {
1271
+ planeY: sampleWave(ship.position.x, ship.position.z, state.time) * 0.24 - 0.03,
1272
+ alpha: 0.08 + shadowStrength * 0.2,
1273
+ blur: 14 + shadowStrength * 24,
1274
+ });
1275
+ }
1276
+
1277
+ function renderFlagShadow(ctx, cloth, camera, viewport, lightDir, shadowStrength) {
1278
+ const clothPoints = [
1279
+ cloth.positions[0],
1280
+ cloth.positions[cloth.grid.cols - 1],
1281
+ cloth.positions[cloth.positions.length - 1],
1282
+ cloth.positions[cloth.positions.length - cloth.grid.cols],
1283
+ ];
1284
+
1285
+ renderProjectedShadow(ctx, clothPoints, camera, viewport, lightDir, {
1286
+ planeY: 0.56,
1287
+ alpha: 0.05 + shadowStrength * 0.16,
1288
+ blur: 12 + shadowStrength * 20,
1289
+ });
1290
+ }
1291
+
1292
+ function renderScene(ctx, canvas, state, shipModel, dom) {
1293
+ const viewport = { width: canvas.width, height: canvas.height };
1294
+ const camera = buildCamera(state, canvas);
1295
+ state.camera.eye = camera.eye;
1296
+ const lightingPlan = createLightingBandPlan({
1297
+ profile: state.focus === "lighting" ? defaultLightingProfile : getLightingProfile(defaultLightingProfile).name,
1298
+ importance: state.focus === "lighting" ? "critical" : "high",
1299
+ });
1300
+ const nearLighting = lightingPlan.bands.find((entry) => entry.band === "near") ?? lightingPlan.bands[0];
1301
+ const lightDir = normalizeVec3(vec3(-0.45, 0.85, -0.24));
1302
+ const lightingSnapshot = state.lightingDetail.getSnapshot();
1303
+ const reflectionStrength = lightingSnapshot.currentLevel.config.reflectionStrength;
1304
+ const shadowStrength = lightingSnapshot.currentLevel.config.shadowStrength;
1305
+ drawSkyAndShore(ctx, canvas, state, nearLighting, reflectionStrength, shadowStrength);
1306
+
1307
+ const triangles = [];
1308
+ pushHarborGeometry(camera, viewport, triangles);
1309
+ const water = buildWaterBands(state, state.fluidDetail.getSnapshot().currentLevel.config);
1310
+ for (const bandMesh of water.bandMeshes) {
1311
+ const bandAccent = bandMesh.band === "near" ? 0.06 : bandMesh.band === "mid" ? 0.04 : 0;
1312
+ for (let index = 0; index < bandMesh.indices.length; index += 3) {
1313
+ const a = bandMesh.positions[bandMesh.indices[index]];
1314
+ const b = bandMesh.positions[bandMesh.indices[index + 1]];
1315
+ const c = bandMesh.positions[bandMesh.indices[index + 2]];
1316
+ const normal = normalizeVec3(crossVec3(subVec3(b, a), subVec3(c, a)));
1317
+ const projected = [projectPoint(a, camera, viewport), projectPoint(b, camera, viewport), projectPoint(c, camera, viewport)];
1318
+ if (projected.some((value) => value === null)) {
1319
+ continue;
1320
+ }
1321
+ triangles.push({
1322
+ points: projected,
1323
+ depth: (projected[0].depth + projected[1].depth + projected[2].depth) / 3,
1324
+ worldCenter: scaleVec3(addVec3(addVec3(a, b), c), 1 / 3),
1325
+ normal,
1326
+ baseColor: bandMesh.color,
1327
+ accent: bandAccent,
1328
+ });
1329
+ }
1330
+ }
1331
+
1332
+ const cloth = buildClothSurface(state, state, state.clothDetail.getSnapshot().currentLevel.config);
1333
+ for (let index = 0; index < cloth.indices.length; index += 3) {
1334
+ const a = cloth.positions[cloth.indices[index]];
1335
+ const b = cloth.positions[cloth.indices[index + 1]];
1336
+ const c = cloth.positions[cloth.indices[index + 2]];
1337
+ const normal = normalizeVec3(crossVec3(subVec3(b, a), subVec3(c, a)));
1338
+ const projected = [projectPoint(a, camera, viewport), projectPoint(b, camera, viewport), projectPoint(c, camera, viewport)];
1339
+ if (projected.some((value) => value === null)) {
1340
+ continue;
1341
+ }
1342
+ triangles.push({
1343
+ points: projected,
1344
+ depth: (projected[0].depth + projected[1].depth + projected[2].depth) / 3,
1345
+ worldCenter: scaleVec3(addVec3(addVec3(a, b), c), 1 / 3),
1346
+ normal,
1347
+ baseColor: { r: 0.76, g: 0.24, b: 0.18 },
1348
+ accent: cloth.band === "near" ? 0.1 : 0.04,
1349
+ });
1350
+ }
1351
+
1352
+ for (const ship of state.ships) {
1353
+ buildTrianglesFromMesh(
1354
+ shipModel,
1355
+ { position: ship.position, rotationY: ship.rotationY, scale: 1.1 },
1356
+ ship.tint,
1357
+ camera,
1358
+ viewport,
1359
+ triangles,
1360
+ nearLighting.rtParticipation.directShadows === "premium" ? 0.08 : 0.02
1361
+ );
1362
+ }
1363
+
1364
+ for (const ship of state.ships) {
1365
+ renderShipShadow(ctx, shipModel, ship, state, camera, viewport, lightDir, shadowStrength);
1366
+ }
1367
+ renderFlagShadow(ctx, cloth, camera, viewport, lightDir, shadowStrength);
1368
+
1369
+ drawTriangles(ctx, triangles, lightDir, reflectionStrength, camera, shadowStrength);
1370
+ renderWaterHighlights(ctx, water.bandMeshes, camera, viewport);
1371
+ renderFlagPole(ctx, camera, viewport);
1372
+ renderClothAccent(ctx, cloth, camera, viewport);
1373
+ for (const ship of state.ships) {
1374
+ renderShipRigging(ctx, ship, camera, viewport);
1375
+ }
1376
+ renderSprays(ctx, state.sprays, camera, viewport);
1377
+
1378
+ const debugSnapshot = state.debugSession.getSnapshot();
1379
+ const quality = {
1380
+ fluid: state.fluidDetail.getSnapshot(),
1381
+ cloth: state.clothDetail.getSnapshot(),
1382
+ lighting: lightingSnapshot,
1383
+ };
1384
+
1385
+ const sceneMetrics = [
1386
+ `focus: ${state.focus}`,
1387
+ `ships: ${state.ships.length} active GLTF hulls`,
1388
+ `physics snapshot: ${state.physics.snapshot.stage} (${state.physics.snapshot.stability})`,
1389
+ `physics contacts: ${state.physics.snapshot.contactCount ?? 0}`,
1390
+ `cloth band: ${cloth.band} -> ${cloth.representation.output}`,
1391
+ `fluid near band: ${water.bandMeshes[0].representation.output}`,
1392
+ `lighting profile: ${lightingPlan.profile} (${lightingDistanceBands.length} bands)`,
1393
+ ];
1394
+ const qualityMetrics = [
1395
+ `fluid detail: ${quality.fluid.currentLevel.id} (${quality.fluid.currentLevel.config.nearResolution} near cells)`,
1396
+ `cloth detail: ${quality.cloth.currentLevel.id} (${quality.cloth.currentLevel.config.cols}x${quality.cloth.currentLevel.config.rows})`,
1397
+ `lighting detail: ${quality.lighting.currentLevel.id}`,
1398
+ `near shadows: ${nearLighting.primaryShadowSource}`,
1399
+ `near reflections: ${nearLighting.rtParticipation.reflections}`,
1400
+ `governor pressure: ${state.lastDecision.pressureLevel}`,
1401
+ `frame avg: ${state.lastDecision.metrics.averageFrameTimeMs.toFixed(2)} ms`,
1402
+ ];
1403
+ const debugMetrics = [
1404
+ `queue samples: ${debugSnapshot.queues.sampleCount}`,
1405
+ `dispatch avg: ${(debugSnapshot.dispatch.averageDurationMs ?? 0).toFixed(2)} ms`,
1406
+ `ready lane peak: ${debugSnapshot.dag.peakReadyLaneDepth.toFixed(1)}`,
1407
+ `pipeline samples: ${debugSnapshot.pipeline.sampleCount}`,
1408
+ `tracked memory: ${(debugSnapshot.memory.totalTrackedBytes / (1024 * 1024)).toFixed(1)} MB`,
1409
+ ];
1410
+ const sceneNotes =
1411
+ state.focus === "physics"
1412
+ ? [
1413
+ "Stable world snapshots are taken after the authoritative rigid-body commit and before visual follow-up work.",
1414
+ "The ships collide on GLTF-derived hull volumes while cloth and fluid remain downstream visual consumers.",
1415
+ "Near-field lighting keeps the ray-traced-primary shadow impression so the collision read stays crisp.",
1416
+ ]
1417
+ : SCENE_NOTES;
1418
+
1419
+ setListContent(dom.sceneMetrics, sceneMetrics);
1420
+ setListContent(dom.qualityMetrics, qualityMetrics);
1421
+ setListContent(dom.debugMetrics, debugMetrics);
1422
+ setListContent(dom.sceneNotes, sceneNotes);
1423
+
1424
+ dom.status.textContent = `3D scene live · ${state.lastDecision.metrics.fps.toFixed(1)} FPS`;
1425
+ dom.details.textContent =
1426
+ state.focus === "physics"
1427
+ ? `Stable world snapshots are emitted from ${state.physics.plan.snapshotStageId} after the authoritative solver; GLTF ships collide on ${shipModel.physics.shape ?? "box"} volumes while visual follow-up remains downstream.`
1428
+ : `GLTF ships are colliding with ${shipModel.physics.shape ?? "box"} physics volumes; cloth and fluid remain continuous while the governor pressure is ${state.lastDecision.pressureLevel}.`;
1429
+ }
1430
+
1431
+ function updateSceneState(state, dt, shipModel) {
1432
+ updateShips(state, dt, shipModel);
1433
+ updateSpray(state, dt);
1434
+ updatePhysicsSnapshot(state, shipModel);
1435
+ }
1436
+
1437
+ function syncTextState(state, shipModel) {
1438
+ const snapshot = {
1439
+ coordinateSystem: "right-handed world; +x right, +y up, +z forward from the shore",
1440
+ focus: state.focus,
1441
+ stress: state.stress,
1442
+ ships: state.ships.map((ship) => ({
1443
+ id: ship.id,
1444
+ x: Number(ship.position.x.toFixed(2)),
1445
+ y: Number(ship.position.y.toFixed(2)),
1446
+ z: Number(ship.position.z.toFixed(2)),
1447
+ vx: Number(ship.velocity.x.toFixed(2)),
1448
+ vz: Number(ship.velocity.z.toFixed(2)),
1449
+ })),
1450
+ shipPhysics: shipModel.physics,
1451
+ sprays: state.sprays.length,
1452
+ pressure: state.lastDecision?.pressureLevel ?? "stable",
1453
+ physics: {
1454
+ profile: state.physics.profile,
1455
+ snapshotStageId: state.physics.plan.snapshotStageId,
1456
+ workerJobCount: state.physics.manifest.jobs.length,
1457
+ snapshot: state.physics.snapshot,
1458
+ },
1459
+ };
1460
+ window.render_game_to_text = () => JSON.stringify(snapshot);
1461
+ window.advanceTime = (ms) => {
1462
+ const step = Math.max(1, Math.round(ms / (1000 / 60)));
1463
+ for (let index = 0; index < step; index += 1) {
1464
+ state.frame += 1;
1465
+ state.time += 1 / 60;
1466
+ updateSceneState(state, 1 / 60, shipModel);
1467
+ state.lastDecision = recordTelemetry(state, 16.67 + (state.stress ? 6.5 : 0));
1468
+ }
1469
+ };
1470
+ }
1471
+
1472
+ export async function mountGpuShowcase(options = {}) {
1473
+ injectStyles();
1474
+ const root = options.root ?? document.body;
1475
+ const focus = options.focus ?? new URLSearchParams(window.location.search).get("focus") ?? "integrated";
1476
+ const dom = buildDemoDom(root, {
1477
+ packageName: options.packageName ?? "@plasius/gpu-demo-viewer",
1478
+ title: options.title ?? DEFAULT_TITLE,
1479
+ subtitle: options.subtitle ?? DEFAULT_SUBTITLE,
1480
+ });
1481
+ dom.focusMode.value = focus;
1482
+
1483
+ const state = createSceneState({ focus });
1484
+ const shipModel = await loadGltfModel(resolveShowcaseAssetUrl());
1485
+ state.shipModel = shipModel;
1486
+ updatePhysicsSnapshot(state, shipModel);
1487
+ state.lastDecision = recordTelemetry(state, 16.4);
1488
+ syncTextState(state, shipModel);
1489
+
1490
+ const ctx = dom.canvas.getContext("2d");
1491
+ const renderFrame = (nowMs) => {
1492
+ if (!state.paused) {
1493
+ if (state.lastTimeMs == null) {
1494
+ state.lastTimeMs = nowMs;
1495
+ }
1496
+ const dt = Math.min(0.033, (nowMs - state.lastTimeMs) / 1000);
1497
+ state.lastTimeMs = nowMs;
1498
+ state.time += dt;
1499
+ state.frame += 1;
1500
+ updateSceneState(state, dt, shipModel);
1501
+ const syntheticFrame = 14.2 + state.sprays.length * 0.1 + (state.stress ? 6.4 : 0);
1502
+ state.lastDecision = recordTelemetry(state, syntheticFrame);
1503
+ }
1504
+
1505
+ renderScene(ctx, dom.canvas, state, shipModel, dom);
1506
+ syncTextState(state, shipModel);
1507
+ requestAnimationFrame(renderFrame);
1508
+ };
1509
+
1510
+ dom.pauseButton.addEventListener("click", () => {
1511
+ state.paused = !state.paused;
1512
+ dom.pauseButton.textContent = state.paused ? "Resume" : "Pause";
1513
+ });
1514
+ dom.stressToggle.addEventListener("change", () => {
1515
+ state.stress = dom.stressToggle.checked;
1516
+ });
1517
+ dom.focusMode.addEventListener("change", () => {
1518
+ state.focus = dom.focusMode.value;
1519
+ Object.assign(state.camera, {
1520
+ ...CAMERA_PRESETS[state.focus],
1521
+ target: vec3(...CAMERA_PRESETS[state.focus].target),
1522
+ });
1523
+ });
1524
+
1525
+ requestAnimationFrame(renderFrame);
1526
+ return {
1527
+ state,
1528
+ shipModel,
1529
+ canvas: dom.canvas,
1530
+ };
1531
+ }
1532
+
1533
+ function updatePhysicsSnapshot(state, shipModel) {
1534
+ state.physics.snapshot = createPhysicsWorldSnapshot({
1535
+ frameId: `showcase-${state.frame}`,
1536
+ tick: state.frame,
1537
+ simulationTimeMs: Number((state.time * 1000).toFixed(2)),
1538
+ profile: state.physics.profile,
1539
+ authoritativeTransformRevision: state.frame,
1540
+ secondarySimulationRevision: state.frame,
1541
+ animationInputRevision: state.frame,
1542
+ bodyCount: state.ships.length + 2,
1543
+ dynamicBodyCount: state.ships.length,
1544
+ contactCount: state.collisionFlash > 0.02 ? 1 : 0,
1545
+ metadata: {
1546
+ collisionCount: state.collisionCount,
1547
+ snapshotStageId: state.physics.plan.snapshotStageId,
1548
+ rigidBodyShape: shipModel.physics.shape ?? "box",
1549
+ },
1550
+ });
1551
+ }