@plasius/gpu-shared 0.1.10 → 0.1.13

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +51 -1
  2. package/README.md +59 -2
  3. package/assets/brigantine.gltf +549 -24
  4. package/assets/cutter.gltf +538 -0
  5. package/assets/harbor-dock.gltf +680 -0
  6. package/assets/lighthouse.gltf +604 -0
  7. package/dist/chunk-2FIFSBB4.js +74 -0
  8. package/dist/chunk-2FIFSBB4.js.map +1 -0
  9. package/dist/chunk-DABW627O.js +113 -0
  10. package/dist/chunk-DABW627O.js.map +1 -0
  11. package/dist/chunk-DQX4DXBR.js +369 -0
  12. package/dist/chunk-DQX4DXBR.js.map +1 -0
  13. package/dist/chunk-NCPJWLX3.js +17 -0
  14. package/dist/chunk-NCPJWLX3.js.map +1 -0
  15. package/dist/gltf-loader-WAM23F37.js +9 -0
  16. package/dist/index.cjs +1265 -281
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.js +19 -6
  19. package/dist/index.js.map +1 -1
  20. package/dist/showcase-inline-assets-B7U7VX5H.js +7 -0
  21. package/dist/{showcase-runtime-55OVDYWT.js → showcase-runtime-PN7N3FZY.js} +818 -239
  22. package/dist/showcase-runtime-PN7N3FZY.js.map +1 -0
  23. package/package.json +15 -1
  24. package/src/asset-url.js +62 -11
  25. package/src/feature-flags.js +1 -0
  26. package/src/gltf-loader.js +322 -32
  27. package/src/i18n.js +71 -0
  28. package/src/index.d.ts +115 -1
  29. package/src/index.js +9 -1
  30. package/src/showcase-inline-assets.js +3 -0
  31. package/src/showcase-runtime.js +924 -190
  32. package/src/translations/en-GB.js +55 -0
  33. package/dist/chunk-DGUM43GV.js +0 -11
  34. package/dist/chunk-OTCJ3VOK.js +0 -35
  35. package/dist/chunk-OTCJ3VOK.js.map +0 -1
  36. package/dist/chunk-QBMXJ3V2.js +0 -142
  37. package/dist/chunk-QBMXJ3V2.js.map +0 -1
  38. package/dist/gltf-loader-LKALCZAV.js +0 -8
  39. package/dist/showcase-runtime-55OVDYWT.js.map +0 -1
  40. /package/dist/{chunk-DGUM43GV.js.map → gltf-loader-WAM23F37.js.map} +0 -0
  41. /package/dist/{gltf-loader-LKALCZAV.js.map → showcase-inline-assets-B7U7VX5H.js.map} +0 -0
@@ -32,12 +32,18 @@ import {
32
32
 
33
33
  import { resolveShowcaseAssetUrl } from "./asset-url.js";
34
34
  import { loadGltfModel } from "./gltf-loader.js";
35
+ import { GPU_SHOWCASE_REALISTIC_MODELS_FEATURE } from "./feature-flags.js";
36
+ import {
37
+ createGpuSharedTranslator,
38
+ gpuSharedTranslationKeys,
39
+ } from "./i18n.js";
35
40
 
36
41
  const STYLE_ID = "plasius-shared-3d-showcase-style";
37
42
  const ROOT_CLASS = "plasius-showcase-root";
38
- const DEFAULT_TITLE = "Flag by the Sea";
39
- const DEFAULT_SUBTITLE =
40
- "Shared 3D validation scene using GLTF ships, cloth, fluid continuity, adaptive performance, and telemetry.";
43
+ const CAPTURE_CLASS = "plasius-showcase-root--capture";
44
+ const DEFAULT_CANVAS_WIDTH = 1280;
45
+ const DEFAULT_CANVAS_HEIGHT = 720;
46
+ const CAPTURE_CANVAS_PIXEL_BUDGET = 1920 * 1080;
41
47
  const SHIP_SCALE = 1.1;
42
48
  const HARBOR_BOUNDS = Object.freeze({
43
49
  minX: -11.2,
@@ -56,33 +62,69 @@ const CAMERA_PRESETS = Object.freeze({
56
62
  });
57
63
  export const showcaseFocusModes = Object.freeze(Object.keys(CAMERA_PRESETS));
58
64
 
59
- const SCENE_NOTES = Object.freeze([
60
- "Ships are loaded from a GLTF asset and carry mass, damping, restitution, and hull extents from node extras.",
61
- "Moonlight sets the cold ambient read while deck lanterns and harbor torches provide warm local contrast.",
62
- "Cloth and fluid continuity stay coherent across near, mid, far, and horizon bands even in the darker night palette.",
63
- "Performance pressure reduces visual detail before mass-weighted authoritative collision motion is touched.",
65
+ const FOCUS_MODE_TRANSLATION_KEYS = Object.freeze({
66
+ integrated: gpuSharedTranslationKeys.focusIntegrated,
67
+ lighting: gpuSharedTranslationKeys.focusLighting,
68
+ cloth: gpuSharedTranslationKeys.focusCloth,
69
+ fluid: gpuSharedTranslationKeys.focusFluid,
70
+ physics: gpuSharedTranslationKeys.focusPhysics,
71
+ performance: gpuSharedTranslationKeys.focusPerformance,
72
+ debug: gpuSharedTranslationKeys.focusDebug,
73
+ });
74
+
75
+ const SCENE_NOTE_KEYS = Object.freeze([
76
+ gpuSharedTranslationKeys.noteAssetLoading,
77
+ gpuSharedTranslationKeys.noteMoonlight,
78
+ gpuSharedTranslationKeys.noteContinuity,
79
+ gpuSharedTranslationKeys.notePerformance,
64
80
  ]);
65
81
 
66
- const UNIT_BOX_MESH = Object.freeze({
67
- positions: Object.freeze([
68
- -0.5, -0.5, -0.5,
69
- 0.5, -0.5, -0.5,
70
- 0.5, 0.5, -0.5,
71
- -0.5, 0.5, -0.5,
72
- -0.5, -0.5, 0.5,
73
- 0.5, -0.5, 0.5,
74
- 0.5, 0.5, 0.5,
75
- -0.5, 0.5, 0.5,
76
- ]),
77
- indices: Object.freeze([
78
- 0, 1, 2, 0, 2, 3,
79
- 5, 4, 7, 5, 7, 6,
80
- 4, 0, 3, 4, 3, 7,
81
- 1, 5, 6, 1, 6, 2,
82
- 3, 2, 6, 3, 6, 7,
83
- 4, 5, 1, 4, 1, 0,
84
- ]),
85
- });
82
+ const PHYSICS_SCENE_NOTE_KEYS = Object.freeze([
83
+ gpuSharedTranslationKeys.notePhysicsSnapshots,
84
+ gpuSharedTranslationKeys.notePhysicsCollisions,
85
+ gpuSharedTranslationKeys.notePhysicsLighting,
86
+ ]);
87
+
88
+ const LEGACY_HARBOR_LAYOUT = Object.freeze([
89
+ Object.freeze({
90
+ position: Object.freeze({ x: -8.2, y: 1.1, z: -0.9 }),
91
+ rotationY: -0.16,
92
+ scale: 5.4,
93
+ color: { r: 0.32, g: 0.27, b: 0.23, a: 1 },
94
+ accent: 0.06,
95
+ }),
96
+ Object.freeze({
97
+ position: Object.freeze({ x: -5.7, y: 0.45, z: 1.4 }),
98
+ rotationY: -0.08,
99
+ scale: { x: 6.8, y: 0.3, z: 2.1 },
100
+ color: { r: 0.31, g: 0.31, b: 0.34, a: 1 },
101
+ accent: 0.04,
102
+ }),
103
+ Object.freeze({
104
+ position: Object.freeze({ x: -10.4, y: 0.28, z: 0.8 }),
105
+ rotationY: 0.22,
106
+ scale: { x: 1.2, y: 0.9, z: 1.2 },
107
+ color: { r: 0.31, g: 0.35, b: 0.39, a: 1 },
108
+ accent: 0.02,
109
+ }),
110
+ ]);
111
+
112
+ const SHOWCASE_ENVIRONMENT_LAYOUT = Object.freeze([
113
+ Object.freeze({
114
+ assetKey: "harbor-dock",
115
+ position: Object.freeze({ x: -4.6, y: 0.16, z: 0.7 }),
116
+ rotationY: -0.08,
117
+ scale: 0.84,
118
+ accent: 0.04,
119
+ }),
120
+ Object.freeze({
121
+ assetKey: "lighthouse",
122
+ position: Object.freeze({ x: -9.8, y: 0, z: -0.58 }),
123
+ rotationY: 0.12,
124
+ scale: 0.56,
125
+ accent: 0.08,
126
+ }),
127
+ ]);
86
128
 
87
129
  const SHIP_LANTERNS = Object.freeze([
88
130
  Object.freeze({ x: 0.94, y: 1.54, z: 2.52, glow: 1 }),
@@ -90,6 +132,10 @@ const SHIP_LANTERNS = Object.freeze([
90
132
  Object.freeze({ x: 0.62, y: 1.42, z: -2.18, glow: 0.88 }),
91
133
  Object.freeze({ x: -0.58, y: 1.46, z: -2.04, glow: 0.84 }),
92
134
  ]);
135
+ const CUTTER_LANTERNS = Object.freeze([
136
+ Object.freeze({ x: 0.42, y: 1.04, z: 1.18, glow: 0.94 }),
137
+ Object.freeze({ x: -0.42, y: 1.04, z: 1.12, glow: 0.88 }),
138
+ ]);
93
139
 
94
140
  const HARBOR_TORCHES = Object.freeze([
95
141
  Object.freeze({ x: -5.2, y: 1.25, z: 1.36, glow: 1.1 }),
@@ -128,6 +174,11 @@ function injectStyles() {
128
174
  radial-gradient(circle at 82% 18%, rgba(240, 188, 103, 0.08), transparent 18%),
129
175
  linear-gradient(180deg, #04101d 0%, #0b1930 42%, #081321 100%);
130
176
  }
177
+ .${ROOT_CLASS}.${CAPTURE_CLASS} {
178
+ min-height: 100vh;
179
+ overflow: hidden;
180
+ background: #030710;
181
+ }
131
182
  .${ROOT_CLASS},
132
183
  .${ROOT_CLASS} * {
133
184
  box-sizing: border-box;
@@ -207,6 +258,40 @@ function injectStyles() {
207
258
  border: 1px solid rgba(159, 185, 223, 0.12);
208
259
  background: linear-gradient(180deg, #071220 0%, #132440 42%, #10344b 42%, #05111d 100%);
209
260
  }
261
+ .${CAPTURE_CLASS} .plasius-demo {
262
+ width: 100vw;
263
+ height: 100vh;
264
+ padding: 0;
265
+ display: block;
266
+ }
267
+ .${CAPTURE_CLASS} .plasius-demo__hero,
268
+ .${CAPTURE_CLASS} .plasius-demo__toolbar,
269
+ .${CAPTURE_CLASS} .plasius-demo__legend,
270
+ .${CAPTURE_CLASS} .plasius-demo__sidebar,
271
+ .${CAPTURE_CLASS} .plasius-demo__footer {
272
+ display: none;
273
+ }
274
+ .${CAPTURE_CLASS} .plasius-demo__layout {
275
+ display: block;
276
+ height: 100%;
277
+ }
278
+ .${CAPTURE_CLASS} .plasius-demo__canvas-panel {
279
+ height: 100%;
280
+ padding: 0;
281
+ border: 0;
282
+ border-radius: 0;
283
+ background: transparent;
284
+ box-shadow: none;
285
+ backdrop-filter: none;
286
+ }
287
+ .${CAPTURE_CLASS} .plasius-demo__canvas {
288
+ width: 100%;
289
+ height: 100%;
290
+ aspect-ratio: auto;
291
+ border: 0;
292
+ border-radius: 0;
293
+ background: #030710;
294
+ }
210
295
  .plasius-demo__toolbar {
211
296
  position: absolute;
212
297
  top: 26px;
@@ -381,6 +466,15 @@ function transformPoint(point, transform) {
381
466
  return addVec3(rotated, transform.position);
382
467
  }
383
468
 
469
+ function transformDirection(direction, transform) {
470
+ const scale =
471
+ typeof transform.scale === "number"
472
+ ? { x: transform.scale, y: transform.scale, z: transform.scale }
473
+ : transform.scale;
474
+ const scaled = vec3(direction.x * scale.x, direction.y * scale.y, direction.z * scale.z);
475
+ return normalizeVec3(rotateY(scaled, transform.rotationY));
476
+ }
477
+
384
478
  function projectPoint(point, camera, viewport) {
385
479
  const relative = subVec3(point, camera.eye);
386
480
  const viewX = dotVec3(relative, camera.right);
@@ -406,6 +500,92 @@ function colorToRgba(color, alpha = 1) {
406
500
  return `rgba(${r}, ${g}, ${b}, ${clamp(alpha, 0, 1)})`;
407
501
  }
408
502
 
503
+ function mixColor(a, b, t) {
504
+ return {
505
+ r: mix(a.r, b.r, t),
506
+ g: mix(a.g, b.g, t),
507
+ b: mix(a.b, b.b, t),
508
+ a: mix(a.a ?? 1, b.a ?? 1, t),
509
+ };
510
+ }
511
+
512
+ function multiplyColor(a, b) {
513
+ return {
514
+ r: a.r * b.r,
515
+ g: a.g * b.g,
516
+ b: a.b * b.b,
517
+ a: (a.a ?? 1) * (b.a ?? 1),
518
+ };
519
+ }
520
+
521
+ function createLegacyMeshPrimitive(mesh) {
522
+ return Object.freeze({
523
+ name: mesh.name ?? "legacy-mesh",
524
+ positions: mesh.positions,
525
+ indices: mesh.indices,
526
+ normals: null,
527
+ colors: null,
528
+ material: Object.freeze({
529
+ name: "legacy-material",
530
+ color: mesh.color ?? { r: 0.56, g: 0.33, b: 0.22, a: 1 },
531
+ roughness: 0.88,
532
+ metallic: 0.08,
533
+ emissive: Object.freeze({ r: 0, g: 0, b: 0 }),
534
+ }),
535
+ });
536
+ }
537
+
538
+ function isFeatureEnabled(featureFlags, featureName, fallback = true) {
539
+ const directValue =
540
+ typeof featureFlags?.[featureName] === "boolean"
541
+ ? featureFlags[featureName]
542
+ : featureFlags?.flags?.[featureName];
543
+ if (typeof directValue === "boolean") {
544
+ return directValue;
545
+ }
546
+
547
+ const enabledValue =
548
+ typeof featureFlags?.enabled?.[featureName] === "boolean"
549
+ ? featureFlags.enabled[featureName]
550
+ : undefined;
551
+ if (typeof enabledValue === "boolean") {
552
+ return enabledValue;
553
+ }
554
+
555
+ return fallback;
556
+ }
557
+
558
+ function getMeshPrimitives(mesh) {
559
+ return Array.isArray(mesh?.primitives) && mesh.primitives.length > 0
560
+ ? mesh.primitives
561
+ : [createLegacyMeshPrimitive(mesh)];
562
+ }
563
+
564
+ function tintPrimitiveColor(material, colorOverride) {
565
+ if (!colorOverride) {
566
+ return material.color;
567
+ }
568
+
569
+ const name = String(material.name ?? "").toLowerCase();
570
+ if (name.includes("sail") || name.includes("glass") || name.includes("roof")) {
571
+ return material.color;
572
+ }
573
+
574
+ const tintAmount = name.includes("hull")
575
+ ? 0.54
576
+ : name.includes("trim")
577
+ ? 0.22
578
+ : name.includes("deck")
579
+ ? 0.12
580
+ : 0;
581
+
582
+ if (tintAmount <= 0) {
583
+ return material.color;
584
+ }
585
+
586
+ return mixColor(material.color, multiplyColor(material.color, colorOverride), tintAmount);
587
+ }
588
+
409
589
  function projectShadowPoint(point, lightDir, planeY) {
410
590
  const shadowDir = scaleVec3(lightDir, -1);
411
591
  if (Math.abs(shadowDir.y) < 0.0001) {
@@ -430,6 +610,64 @@ function shadeColor(base, normal, lightDir, heightBias = 0, accent = 0) {
430
610
  };
431
611
  }
432
612
 
613
+ function getMaterialSeed(materialName) {
614
+ let seed = 0;
615
+ for (let index = 0; index < materialName.length; index += 1) {
616
+ seed += materialName.charCodeAt(index) * (index + 1);
617
+ }
618
+ return seed;
619
+ }
620
+
621
+ function getMaterialDetailStrength(material, surfaceType) {
622
+ const name = String(material?.name ?? "").toLowerCase();
623
+ if (surfaceType === "water" || name.includes("glass")) {
624
+ return 0.018;
625
+ }
626
+ if (name.includes("wood") || name.includes("timber") || name.includes("plank")) {
627
+ return 0.13;
628
+ }
629
+ if (name.includes("stone") || name.includes("concrete") || name.includes("plaster")) {
630
+ return 0.1;
631
+ }
632
+ if (name.includes("roof") || name.includes("crate")) {
633
+ return 0.09;
634
+ }
635
+ if (name.includes("paint")) {
636
+ return 0.045;
637
+ }
638
+ if (name.includes("metal")) {
639
+ return 0.035;
640
+ }
641
+ return 0.04;
642
+ }
643
+
644
+ function applyMaterialDetail(color, material, worldCenter, normal, surfaceType) {
645
+ const materialName = String(material?.name ?? surfaceType ?? "material");
646
+ const detailStrength = getMaterialDetailStrength(material, surfaceType);
647
+ const sample =
648
+ worldCenter.x * 3.17 +
649
+ worldCenter.y * 5.29 +
650
+ worldCenter.z * 7.83 +
651
+ getMaterialSeed(materialName) * 0.013;
652
+ const grain = (pseudoRandom(sample) - 0.5) * detailStrength;
653
+ const lowerSurface = smoothstep(7.5, -0.8, worldCenter.y);
654
+ const verticalSurface = 1 - clamp(Math.abs(normal.y), 0, 1);
655
+ const materialLowerWear =
656
+ /stone|concrete|plaster|paint|wood|timber|plank|crate/.test(materialName.toLowerCase())
657
+ ? lowerSurface * verticalSurface * 0.055
658
+ : 0;
659
+ const wetlineWear =
660
+ surfaceType === "ship" && worldCenter.y < 0.72
661
+ ? smoothstep(0.72, -0.1, worldCenter.y) * 0.05
662
+ : 0;
663
+
664
+ return {
665
+ r: clamp(color.r * (1 + grain) - materialLowerWear - wetlineWear, 0, 1),
666
+ g: clamp(color.g * (1 + grain * 0.82) - materialLowerWear * 0.9 - wetlineWear, 0, 1),
667
+ b: clamp(color.b * (1 + grain * 0.62) - materialLowerWear * 0.68 - wetlineWear * 0.75, 0, 1),
668
+ };
669
+ }
670
+
433
671
  function buildCamera(state, canvas) {
434
672
  const preset = CAMERA_PRESETS[state.focus] ?? CAMERA_PRESETS.integrated;
435
673
  const yaw = state.camera.yaw ?? preset.yaw;
@@ -455,49 +693,153 @@ function buildCamera(state, canvas) {
455
693
  };
456
694
  }
457
695
 
458
- function buildTrianglesFromMesh(mesh, transform, baseColor, camera, viewport, triangles, accent = 0) {
459
- for (let index = 0; index < mesh.indices.length; index += 3) {
460
- const aIndex = mesh.indices[index] * 3;
461
- const bIndex = mesh.indices[index + 1] * 3;
462
- const cIndex = mesh.indices[index + 2] * 3;
696
+ function buildTrianglesFromMesh(
697
+ mesh,
698
+ transform,
699
+ colorOverride,
700
+ camera,
701
+ viewport,
702
+ triangles,
703
+ options = {}
704
+ ) {
705
+ const primitives = getMeshPrimitives(mesh);
706
+ for (const primitive of primitives) {
707
+ const resolvedColor = tintPrimitiveColor(primitive.material, colorOverride);
708
+ for (let index = 0; index < primitive.indices.length; index += 3) {
709
+ const aIndex = primitive.indices[index] * 3;
710
+ const bIndex = primitive.indices[index + 1] * 3;
711
+ const cIndex = primitive.indices[index + 2] * 3;
712
+
713
+ const a = transformPoint(
714
+ vec3(
715
+ primitive.positions[aIndex],
716
+ primitive.positions[aIndex + 1],
717
+ primitive.positions[aIndex + 2]
718
+ ),
719
+ transform
720
+ );
721
+ const b = transformPoint(
722
+ vec3(
723
+ primitive.positions[bIndex],
724
+ primitive.positions[bIndex + 1],
725
+ primitive.positions[bIndex + 2]
726
+ ),
727
+ transform
728
+ );
729
+ const c = transformPoint(
730
+ vec3(
731
+ primitive.positions[cIndex],
732
+ primitive.positions[cIndex + 1],
733
+ primitive.positions[cIndex + 2]
734
+ ),
735
+ transform
736
+ );
463
737
 
464
- const a = transformPoint(
465
- vec3(mesh.positions[aIndex], mesh.positions[aIndex + 1], mesh.positions[aIndex + 2]),
466
- transform
467
- );
468
- const b = transformPoint(
469
- vec3(mesh.positions[bIndex], mesh.positions[bIndex + 1], mesh.positions[bIndex + 2]),
470
- transform
471
- );
472
- const c = transformPoint(
473
- vec3(mesh.positions[cIndex], mesh.positions[cIndex + 1], mesh.positions[cIndex + 2]),
474
- transform
475
- );
738
+ const ab = subVec3(b, a);
739
+ const ac = subVec3(c, a);
740
+ const faceNormal = normalizeVec3(crossVec3(ab, ac));
741
+ let normal = faceNormal;
742
+ if (Array.isArray(primitive.normals)) {
743
+ const aNormal = transformDirection(
744
+ vec3(
745
+ primitive.normals[aIndex],
746
+ primitive.normals[aIndex + 1],
747
+ primitive.normals[aIndex + 2]
748
+ ),
749
+ transform
750
+ );
751
+ const bNormal = transformDirection(
752
+ vec3(
753
+ primitive.normals[bIndex],
754
+ primitive.normals[bIndex + 1],
755
+ primitive.normals[bIndex + 2]
756
+ ),
757
+ transform
758
+ );
759
+ const cNormal = transformDirection(
760
+ vec3(
761
+ primitive.normals[cIndex],
762
+ primitive.normals[cIndex + 1],
763
+ primitive.normals[cIndex + 2]
764
+ ),
765
+ transform
766
+ );
767
+ normal = normalizeVec3(
768
+ scaleVec3(addVec3(addVec3(aNormal, bNormal), cNormal), 1 / 3)
769
+ );
770
+ }
476
771
 
477
- const ab = subVec3(b, a);
478
- const ac = subVec3(c, a);
479
- const normal = normalizeVec3(crossVec3(ab, ac));
480
- const viewDir = normalizeVec3(subVec3(camera.eye, a));
481
- if (dotVec3(normal, viewDir) <= 0) {
482
- continue;
483
- }
772
+ const viewDir = normalizeVec3(subVec3(camera.eye, a));
773
+ if (dotVec3(faceNormal, viewDir) <= 0) {
774
+ continue;
775
+ }
484
776
 
485
- const projected = [projectPoint(a, camera, viewport), projectPoint(b, camera, viewport), projectPoint(c, camera, viewport)];
486
- if (projected.some((value) => value === null)) {
487
- continue;
488
- }
777
+ const projected = [
778
+ projectPoint(a, camera, viewport),
779
+ projectPoint(b, camera, viewport),
780
+ projectPoint(c, camera, viewport),
781
+ ];
782
+ if (projected.some((value) => value === null)) {
783
+ continue;
784
+ }
489
785
 
490
- triangles.push({
491
- points: projected,
492
- depth: (projected[0].depth + projected[1].depth + projected[2].depth) / 3,
493
- worldCenter: scaleVec3(addVec3(addVec3(a, b), c), 1 / 3),
494
- normal,
495
- baseColor,
496
- accent,
497
- });
786
+ triangles.push({
787
+ points: projected,
788
+ depth: (projected[0].depth + projected[1].depth + projected[2].depth) / 3,
789
+ worldCenter: scaleVec3(addVec3(addVec3(a, b), c), 1 / 3),
790
+ normal,
791
+ baseColor: resolvedColor,
792
+ accent: options.accent ?? 0,
793
+ material: primitive.material,
794
+ reflection: options.reflection ?? 0,
795
+ surfaceType: options.surfaceType ?? "solid",
796
+ });
797
+ }
498
798
  }
499
799
  }
500
800
 
801
+ async function loadShowcaseAssetCatalog() {
802
+ const [brigantine, cutter, lighthouse, harborDock] = await Promise.all([
803
+ loadGltfModel(resolveShowcaseAssetUrl("brigantine")),
804
+ loadGltfModel(resolveShowcaseAssetUrl("cutter")),
805
+ loadGltfModel(resolveShowcaseAssetUrl("lighthouse")),
806
+ loadGltfModel(resolveShowcaseAssetUrl("harbor-dock")),
807
+ ]);
808
+
809
+ return Object.freeze({
810
+ primaryShipKey: "brigantine",
811
+ ships: Object.freeze({
812
+ brigantine,
813
+ cutter,
814
+ }),
815
+ environment: Object.freeze({
816
+ lighthouse,
817
+ "harbor-dock": harborDock,
818
+ }),
819
+ });
820
+ }
821
+
822
+ function createLegacyShowcaseAssetCatalog() {
823
+ const brigantine = loadGltfModel(resolveShowcaseAssetUrl("brigantine"));
824
+ return Promise.resolve(brigantine).then((primary) =>
825
+ Object.freeze({
826
+ primaryShipKey: "brigantine",
827
+ ships: Object.freeze({
828
+ brigantine: primary,
829
+ }),
830
+ environment: Object.freeze({}),
831
+ })
832
+ );
833
+ }
834
+
835
+ function resolveShipModel(state, ship, fallbackModel = null) {
836
+ return (
837
+ state.assetCatalog?.ships?.[ship.modelKey ?? state.assetCatalog?.primaryShipKey ?? "brigantine"] ??
838
+ fallbackModel ??
839
+ state.shipModel
840
+ );
841
+ }
842
+
501
843
  function createPerformanceGovernor() {
502
844
  const fluidDetail = createQualityLadderAdapter({
503
845
  id: "fluid-detail",
@@ -554,6 +896,7 @@ function createPerformanceGovernor() {
554
896
  }
555
897
 
556
898
  function buildDemoDom(root, options) {
899
+ const t = options.translate;
557
900
  root.innerHTML = `
558
901
  <main class="plasius-demo">
559
902
  <section class="plasius-demo__hero">
@@ -563,56 +906,55 @@ function buildDemoDom(root, options) {
563
906
  <p class="plasius-demo__lead">${options.subtitle}</p>
564
907
  </section>
565
908
  <section class="plasius-panel plasius-demo__status">
566
- <p id="demoStatus" class="plasius-demo__status-badge">Booting 3D scene…</p>
909
+ <p id="demoStatus" class="plasius-demo__status-badge">${t(gpuSharedTranslationKeys.statusBooting)}</p>
567
910
  <p id="demoDetails" class="plasius-demo__status-text">
568
- Preparing a moonlit harbor scene, GLTF hull data, cloth and fluid continuity plans, and adaptive quality metadata.
911
+ ${t(gpuSharedTranslationKeys.detailsBooting)}
569
912
  </p>
570
913
  </section>
571
914
  </section>
572
915
  <section class="plasius-demo__layout">
573
916
  <section class="plasius-panel plasius-demo__canvas-panel">
574
- <canvas id="demoCanvas" class="plasius-demo__canvas" width="1280" height="720"></canvas>
917
+ <canvas id="demoCanvas" class="plasius-demo__canvas" width="${DEFAULT_CANVAS_WIDTH}" height="${DEFAULT_CANVAS_HEIGHT}"></canvas>
575
918
  <div class="plasius-demo__toolbar">
576
- <button id="pauseButton" type="button">Pause</button>
919
+ <button id="pauseButton" type="button">${t(gpuSharedTranslationKeys.pause)}</button>
577
920
  <label class="plasius-toggle">
578
921
  <input id="stressToggle" type="checkbox" />
579
- Stress mode
922
+ ${t(gpuSharedTranslationKeys.stressMode)}
580
923
  </label>
581
924
  <label class="plasius-toggle">
582
- Focus
925
+ ${t(gpuSharedTranslationKeys.focus)}
583
926
  <select id="focusMode">
584
- <option value="integrated">integrated</option>
585
- <option value="lighting">lighting</option>
586
- <option value="cloth">cloth</option>
587
- <option value="fluid">fluid</option>
588
- <option value="physics">physics</option>
589
- <option value="performance">performance</option>
590
- <option value="debug">debug</option>
927
+ ${showcaseFocusModes
928
+ .map(
929
+ (mode) =>
930
+ `<option value="${mode}">${t(FOCUS_MODE_TRANSLATION_KEYS[mode])}</option>`
931
+ )
932
+ .join("")}
591
933
  </select>
592
934
  </label>
593
935
  </div>
594
936
  <div class="plasius-demo__legend">
595
- <strong>Scene</strong>
596
- GLTF ships carry hull mass and damping metadata.<br />
597
- Lanterns and torches warm the moonlit harbor.<br />
598
- Mass-aware collisions stay authoritative near the camera.
937
+ <strong>${t(gpuSharedTranslationKeys.legendTitle)}</strong>
938
+ ${t(gpuSharedTranslationKeys.legendShipMetadata)}<br />
939
+ ${t(gpuSharedTranslationKeys.legendLighting)}<br />
940
+ ${t(gpuSharedTranslationKeys.legendCollisions)}
599
941
  </div>
600
942
  </section>
601
943
  <aside class="plasius-demo__sidebar">
602
944
  <section class="plasius-panel plasius-demo__card">
603
- <h2>Scene State</h2>
945
+ <h2>${t(gpuSharedTranslationKeys.sceneState)}</h2>
604
946
  <ul id="sceneMetrics" class="plasius-demo__metrics"></ul>
605
947
  </section>
606
948
  <section class="plasius-panel plasius-demo__card">
607
- <h2>Quality + Budgets</h2>
949
+ <h2>${t(gpuSharedTranslationKeys.qualityBudgets)}</h2>
608
950
  <ul id="qualityMetrics" class="plasius-demo__metrics"></ul>
609
951
  </section>
610
952
  <section class="plasius-panel plasius-demo__card">
611
- <h2>Debug Telemetry</h2>
953
+ <h2>${t(gpuSharedTranslationKeys.debugTelemetry)}</h2>
612
954
  <ul id="debugMetrics" class="plasius-demo__metrics"></ul>
613
955
  </section>
614
956
  <section class="plasius-panel plasius-demo__card">
615
- <h2>Notes</h2>
957
+ <h2>${t(gpuSharedTranslationKeys.notes)}</h2>
616
958
  <ul id="sceneNotes" class="plasius-demo__metrics"></ul>
617
959
  </section>
618
960
  </aside>
@@ -638,6 +980,12 @@ function buildDemoDom(root, options) {
638
980
  }
639
981
 
640
982
  function buildSceneSnapshot(state, shipModel) {
983
+ const shipPhysics = Object.freeze(
984
+ Object.fromEntries(
985
+ state.ships.map((ship) => [ship.id, resolveShipModel(state, ship, shipModel)?.physics ?? null])
986
+ )
987
+ );
988
+
641
989
  return Object.freeze({
642
990
  focus: state.focus,
643
991
  frame: state.frame,
@@ -659,6 +1007,7 @@ function buildSceneSnapshot(state, shipModel) {
659
1007
  state.ships.map((ship) =>
660
1008
  Object.freeze({
661
1009
  id: ship.id,
1010
+ modelKey: ship.modelKey ?? "brigantine",
662
1011
  position: Object.freeze({ ...ship.position }),
663
1012
  velocity: Object.freeze({ ...ship.velocity }),
664
1013
  rotationY: ship.rotationY,
@@ -679,12 +1028,13 @@ function buildSceneSnapshot(state, shipModel) {
679
1028
  )
680
1029
  ),
681
1030
  shipPhysics: shipModel?.physics ?? null,
1031
+ shipModels: shipPhysics,
682
1032
  physics: Object.freeze({
683
1033
  profile: state.physics.profile,
684
1034
  plan: state.physics.plan,
685
1035
  manifest: state.physics.manifest,
686
1036
  snapshot: state.physics.snapshot,
687
- shipPhysics: shipModel?.physics ?? null,
1037
+ shipPhysics,
688
1038
  }),
689
1039
  });
690
1040
  }
@@ -727,6 +1077,83 @@ function readVisualNumber(value, fallback) {
727
1077
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
728
1078
  }
729
1079
 
1080
+ function readPositiveNumber(value, fallback) {
1081
+ return typeof value === "number" && Number.isFinite(value) && value > 0
1082
+ ? value
1083
+ : fallback;
1084
+ }
1085
+
1086
+ function isTruthyCaptureValue(value) {
1087
+ return value === "1" || value === "true" || value === "scene" || value === "video";
1088
+ }
1089
+
1090
+ function resolveCaptureSettings(options) {
1091
+ const explicitCaptureMode =
1092
+ typeof options.captureMode === "boolean" ? options.captureMode : undefined;
1093
+ let captureMode = explicitCaptureMode ?? false;
1094
+ let renderScale = readPositiveNumber(options.renderScale, undefined);
1095
+
1096
+ try {
1097
+ const params = new URLSearchParams(window.location.search);
1098
+ if (explicitCaptureMode === undefined) {
1099
+ captureMode =
1100
+ isTruthyCaptureValue(params.get("capture")) ||
1101
+ params.get("presentation") === "capture";
1102
+ }
1103
+ renderScale = readPositiveNumber(Number(params.get("renderScale")), renderScale);
1104
+ } catch {
1105
+ // Query-string capture controls are optional and only available in browsers.
1106
+ }
1107
+
1108
+ return {
1109
+ captureMode,
1110
+ renderScale,
1111
+ };
1112
+ }
1113
+
1114
+ function getCanvasDisplaySize(canvas) {
1115
+ const rect =
1116
+ typeof canvas.getBoundingClientRect === "function"
1117
+ ? canvas.getBoundingClientRect()
1118
+ : null;
1119
+ const width = Math.round(
1120
+ readPositiveNumber(rect?.width, readPositiveNumber(canvas.clientWidth, canvas.width))
1121
+ );
1122
+ const height = Math.round(
1123
+ readPositiveNumber(rect?.height, readPositiveNumber(canvas.clientHeight, canvas.height))
1124
+ );
1125
+
1126
+ return {
1127
+ width: Math.max(1, width || DEFAULT_CANVAS_WIDTH),
1128
+ height: Math.max(1, height || DEFAULT_CANVAS_HEIGHT),
1129
+ };
1130
+ }
1131
+
1132
+ function resizeCanvasToDisplaySize(canvas, state) {
1133
+ const { width, height } = getCanvasDisplaySize(canvas);
1134
+ const deviceScale = readPositiveNumber(globalThis.devicePixelRatio, 1);
1135
+ const requestedScale = readPositiveNumber(state.renderScale, deviceScale);
1136
+ const maxScale = state.captureMode ? 2 : 1.5;
1137
+ let scale = clamp(requestedScale, 1, maxScale);
1138
+ const pixelBudget = state.captureMode
1139
+ ? CAPTURE_CANVAS_PIXEL_BUDGET
1140
+ : DEFAULT_CANVAS_WIDTH * DEFAULT_CANVAS_HEIGHT * 1.5;
1141
+ const projectedPixels = width * height * scale * scale;
1142
+
1143
+ if (projectedPixels > pixelBudget) {
1144
+ scale = Math.sqrt(pixelBudget / Math.max(1, width * height));
1145
+ }
1146
+
1147
+ const targetWidth = Math.max(1, Math.round(width * scale));
1148
+ const targetHeight = Math.max(1, Math.round(height * scale));
1149
+ if (canvas.width !== targetWidth || canvas.height !== targetHeight) {
1150
+ canvas.width = targetWidth;
1151
+ canvas.height = targetHeight;
1152
+ }
1153
+
1154
+ state.renderScale = scale;
1155
+ }
1156
+
730
1157
  function resolveClothPresentation(state, meshDetail) {
731
1158
  const clothPlan = createClothRepresentationPlan({
732
1159
  garmentId: "shore-flag",
@@ -1215,6 +1642,14 @@ function sampleWave(state, x, z, time) {
1215
1642
  );
1216
1643
  }
1217
1644
 
1645
+ function resolveFluidBandContinuity(continuity, band) {
1646
+ if (continuity?.bands && continuity.bands[band]) {
1647
+ return continuity.bands[band];
1648
+ }
1649
+
1650
+ return continuity ?? { amplitudeFloor: 1, frequencyFloor: 1 };
1651
+ }
1652
+
1218
1653
  function buildWaterMotionEffects(state) {
1219
1654
  const wakeTrails = [];
1220
1655
  const rippleRings = state.waveImpulses.map((impulse) => {
@@ -1297,6 +1732,7 @@ function buildWaterBands(state, fluidDetail, visuals) {
1297
1732
  fluidPlan.representations.find((entry) => entry.band === bandSpec.band) ??
1298
1733
  fluidPlan.representations[0];
1299
1734
  const continuity = createFluidContinuityEnvelope({ fluidBodyId: "harbor" });
1735
+ const bandContinuity = resolveFluidBandContinuity(continuity, bandSpec.band);
1300
1736
  const bandResolution =
1301
1737
  bandSpec.band === "near"
1302
1738
  ? fluidDetail.nearResolution
@@ -1320,7 +1756,7 @@ function buildWaterBands(state, fluidDetail, visuals) {
1320
1756
  const y =
1321
1757
  bandSpec.y +
1322
1758
  sampleWave(state, x, z, state.time) *
1323
- continuity.amplitudeFloor *
1759
+ bandContinuity.amplitudeFloor *
1324
1760
  (bandSpec.band === "near" ? 0.9 : bandSpec.band === "mid" ? 0.55 : 0.3);
1325
1761
  positions.push(vec3(x, y, z));
1326
1762
  }
@@ -1338,7 +1774,7 @@ function buildWaterBands(state, fluidDetail, visuals) {
1338
1774
  bandMeshes.push({
1339
1775
  band: bandSpec.band,
1340
1776
  representation,
1341
- continuity,
1777
+ continuity: bandContinuity,
1342
1778
  rows,
1343
1779
  cols,
1344
1780
  positions,
@@ -1366,6 +1802,7 @@ function buildWaterBands(state, fluidDetail, visuals) {
1366
1802
  }
1367
1803
 
1368
1804
  function createSceneState(options) {
1805
+ const translate = options.translate;
1369
1806
  const { governor, fluidDetail, clothDetail, lightingDetail } = createPerformanceGovernor();
1370
1807
  const physicsProfile = defaultPhysicsWorkerProfile;
1371
1808
  const physicsPlan = createPhysicsSimulationPlan(physicsProfile);
@@ -1373,7 +1810,7 @@ function createSceneState(options) {
1373
1810
  const debugSession = createGpuDebugSession({
1374
1811
  enabled: true,
1375
1812
  adapter: {
1376
- label: "3D showcase",
1813
+ label: translate(gpuSharedTranslationKeys.debugAdapterShowcase),
1377
1814
  memoryCapacityHintBytes: 6 * 1024 * 1024 * 1024,
1378
1815
  coreCountHint: 12,
1379
1816
  },
@@ -1383,23 +1820,27 @@ function createSceneState(options) {
1383
1820
  owner: "renderer",
1384
1821
  category: "texture",
1385
1822
  sizeBytes: 1280 * 720 * 4,
1386
- label: "Main color buffer",
1823
+ label: translate(gpuSharedTranslationKeys.debugMainColorBuffer),
1387
1824
  });
1388
1825
  debugSession.trackAllocation({
1389
1826
  id: "showcase.shadow-impression",
1390
1827
  owner: "lighting",
1391
1828
  category: "texture",
1392
1829
  sizeBytes: 12 * 1024 * 1024,
1393
- label: "Shadow impression atlas",
1830
+ label: translate(gpuSharedTranslationKeys.debugShadowImpressionAtlas),
1394
1831
  });
1395
1832
 
1396
1833
  return {
1834
+ translate,
1397
1835
  focus: options.focus,
1398
1836
  governor,
1399
1837
  fluidDetail,
1400
1838
  clothDetail,
1401
1839
  lightingDetail,
1402
1840
  debugSession,
1841
+ showcaseRealisticModelsEnabled: options.realisticModelsEnabled !== false,
1842
+ captureMode: options.captureMode === true,
1843
+ renderScale: readPositiveNumber(options.renderScale, undefined),
1403
1844
  packageState: undefined,
1404
1845
  demoDescription: null,
1405
1846
  demoVisuals: null,
@@ -1414,6 +1855,7 @@ function createSceneState(options) {
1414
1855
  ships: [
1415
1856
  {
1416
1857
  id: "northwind",
1858
+ modelKey: "brigantine",
1417
1859
  position: vec3(-5.2, 0, 7.2),
1418
1860
  velocity: vec3(2.35, 0, -1.08),
1419
1861
  rotationY: 0.58,
@@ -1424,17 +1866,18 @@ function createSceneState(options) {
1424
1866
  throttleResponse: 0.46,
1425
1867
  rudderResponse: 0.54,
1426
1868
  wanderPhase: 0.35,
1427
- lanterns: SHIP_LANTERNS,
1869
+ lanterns: CUTTER_LANTERNS,
1428
1870
  lanternStrength: 1.06,
1429
1871
  collisionRadiusScale: 1.04,
1430
1872
  },
1431
1873
  {
1432
1874
  id: "tidecaller",
1875
+ modelKey: "cutter",
1433
1876
  position: vec3(4.8, 0, 4.4),
1434
1877
  velocity: vec3(-2.15, 0, 1.74),
1435
1878
  rotationY: -2.48,
1436
1879
  angularVelocity: -0.2,
1437
- tint: { r: 0.48, g: 0.28, b: 0.19 },
1880
+ tint: { r: 0.58, g: 0.24, b: 0.16 },
1438
1881
  massScale: 0.84,
1439
1882
  cruiseSpeed: 2.68,
1440
1883
  throttleResponse: 0.7,
@@ -1458,6 +1901,7 @@ function createSceneState(options) {
1458
1901
  manifest: physicsManifest,
1459
1902
  snapshot: null,
1460
1903
  },
1904
+ assetCatalog: null,
1461
1905
  shipModel: null,
1462
1906
  };
1463
1907
  }
@@ -1544,10 +1988,51 @@ function drawSkyAndShore(ctx, canvas, state, nearLighting, reflectionStrength, s
1544
1988
  }
1545
1989
  }
1546
1990
 
1547
- function drawTriangles(ctx, triangles, lightDir, reflectionStrength, camera, shadowStrength) {
1991
+ function resolveLocalLightContribution(triangle, lightSources) {
1992
+ const contribution = { r: 0, g: 0, b: 0 };
1993
+ if (!Array.isArray(lightSources) || triangle.surfaceType === "water") {
1994
+ return contribution;
1995
+ }
1996
+
1997
+ const normal = normalizeVec3(triangle.normal);
1998
+ for (const source of lightSources.slice(0, 8)) {
1999
+ const delta = subVec3(source.point, triangle.worldCenter);
2000
+ const distance = lengthVec3(delta);
2001
+ const attenuation =
2002
+ (source.glowScale ?? 1) / Math.max(1, 0.68 + distance * distance * 0.2);
2003
+ if (attenuation < 0.012) {
2004
+ continue;
2005
+ }
2006
+
2007
+ const lightDir = normalizeVec3(delta);
2008
+ const facing = clamp(dotVec3(normal, lightDir), 0, 1);
2009
+ const response = attenuation * (0.18 + facing * 0.82);
2010
+ const glowColor = source.glowColor ?? source.coreColor ?? { r: 1, g: 0.72, b: 0.4 };
2011
+ contribution.r += glowColor.r * response * 0.32;
2012
+ contribution.g += glowColor.g * response * 0.26;
2013
+ contribution.b += glowColor.b * response * 0.18;
2014
+ }
2015
+
2016
+ return contribution;
2017
+ }
2018
+
2019
+ function drawTriangles(
2020
+ ctx,
2021
+ triangles,
2022
+ lightDir,
2023
+ reflectionStrength,
2024
+ camera,
2025
+ shadowStrength,
2026
+ localLights = []
2027
+ ) {
1548
2028
  triangles.sort((left, right) => right.depth - left.depth);
1549
2029
  for (const triangle of triangles) {
1550
2030
  const surfaceNormal = normalizeVec3(triangle.normal);
2031
+ const material = triangle.material ?? {
2032
+ roughness: 0.88,
2033
+ metallic: 0.08,
2034
+ emissive: { r: 0, g: 0, b: 0 },
2035
+ };
1551
2036
  const shaded = shadeColor(
1552
2037
  triangle.baseColor,
1553
2038
  surfaceNormal,
@@ -1555,19 +2040,42 @@ function drawTriangles(ctx, triangles, lightDir, reflectionStrength, camera, sha
1555
2040
  clamp((triangle.worldCenter.y + 3) / 10, 0, 1),
1556
2041
  triangle.accent
1557
2042
  );
1558
- const reflection = triangle.worldCenter.y < 0.8 ? reflectionStrength : 0;
2043
+ const reflection = reflectionStrength * (triangle.reflection ?? 0);
1559
2044
  const viewDir = normalizeVec3(subVec3(camera.eye, triangle.worldCenter));
1560
2045
  const reflectedLight = reflectVec3(scaleVec3(lightDir, -1), surfaceNormal);
1561
- const gloss = triangle.worldCenter.y < 0.9 ? 1 : triangle.accent > 0.05 ? 0.55 : 0.3;
1562
- const specular = Math.pow(clamp(dotVec3(reflectedLight, viewDir), 0, 1), triangle.worldCenter.y < 0.9 ? 18 : 12) * gloss;
1563
- const occlusion = triangle.worldCenter.y < 0.9 ? shadowStrength * 0.035 : 0;
1564
- const fill = colorToRgba(
2046
+ const gloss = mix(0.78, 0.14, clamp(material.roughness ?? 0.88, 0, 1)) + (material.metallic ?? 0) * 0.18;
2047
+ const specularPower = mix(26, 7, clamp(material.roughness ?? 0.88, 0, 1));
2048
+ const specular =
2049
+ Math.pow(clamp(dotVec3(reflectedLight, viewDir), 0, 1), specularPower) * gloss;
2050
+ const emissive = material.emissive ?? { r: 0, g: 0, b: 0 };
2051
+ const localLight = resolveLocalLightContribution(triangle, localLights);
2052
+ const occlusion = triangle.surfaceType === "water" ? shadowStrength * 0.018 : shadowStrength * 0.04;
2053
+ const detailed = applyMaterialDetail(
1565
2054
  {
1566
- r: clamp(shaded.r + reflection * 0.08 + specular * 0.14 - occlusion, 0, 1),
1567
- g: clamp(shaded.g + reflection * 0.08 + specular * 0.15 - occlusion, 0, 1),
1568
- b: clamp(shaded.b + reflection * 0.16 + specular * 0.2 - occlusion * 0.5, 0, 1),
2055
+ r: clamp(
2056
+ shaded.r + reflection * 0.08 + specular * 0.16 + emissive.r * 0.42 + localLight.r - occlusion,
2057
+ 0,
2058
+ 1
2059
+ ),
2060
+ g: clamp(
2061
+ shaded.g + reflection * 0.08 + specular * 0.16 + emissive.g * 0.42 + localLight.g - occlusion,
2062
+ 0,
2063
+ 1
2064
+ ),
2065
+ b: clamp(
2066
+ shaded.b + reflection * 0.16 + specular * 0.22 + emissive.b * 0.46 + localLight.b - occlusion * 0.5,
2067
+ 0,
2068
+ 1
2069
+ ),
1569
2070
  },
1570
- 0.98
2071
+ material,
2072
+ triangle.worldCenter,
2073
+ surfaceNormal,
2074
+ triangle.surfaceType
2075
+ );
2076
+ const fill = colorToRgba(
2077
+ detailed,
2078
+ triangle.baseColor.a ?? 0.98
1571
2079
  );
1572
2080
  ctx.fillStyle = fill;
1573
2081
  ctx.beginPath();
@@ -1606,78 +2114,111 @@ function renderProjectedShadow(ctx, worldPoints, camera, viewport, lightDir, opt
1606
2114
  ctx.restore();
1607
2115
  }
1608
2116
 
1609
- function pushHarborGeometry(camera, viewport, triangles, visuals) {
1610
- const harborObjects = [
1611
- {
1612
- position: vec3(-8.2, 1.1, -0.9),
1613
- rotationY: -0.16,
1614
- scale: { x: 5.4, y: 2.4, z: 4.2 },
1615
- color: visuals.harborWall,
1616
- accent: 0.06,
1617
- },
1618
- {
1619
- position: vec3(-5.7, 0.45, 1.4),
1620
- rotationY: -0.08,
1621
- scale: { x: 6.8, y: 0.3, z: 2.1 },
1622
- color: visuals.harborDeck,
1623
- accent: 0.04,
1624
- },
1625
- {
1626
- position: vec3(-10.4, 0.28, 0.8),
1627
- rotationY: 0.22,
1628
- scale: { x: 1.2, y: 0.9, z: 1.2 },
1629
- color: visuals.harborTower,
1630
- accent: 0.02,
1631
- },
1632
- ];
2117
+ function pushHarborGeometry(camera, viewport, triangles, state) {
2118
+ if (!state.showcaseRealisticModelsEnabled) {
2119
+ for (const object of LEGACY_HARBOR_LAYOUT) {
2120
+ buildTrianglesFromMesh(
2121
+ { positions: [object], indices: [0], normals: null, colors: null, material: createLegacyMeshPrimitive({})?.material, bounds: null, name: "legacy-structure" },
2122
+ {
2123
+ position: object.position,
2124
+ rotationY: object.rotationY,
2125
+ scale: object.scale,
2126
+ },
2127
+ object.color,
2128
+ camera,
2129
+ viewport,
2130
+ triangles,
2131
+ {
2132
+ accent: object.accent,
2133
+ reflection: 0,
2134
+ surfaceType: "structure",
2135
+ }
2136
+ );
2137
+ }
2138
+
2139
+ return;
2140
+ }
2141
+
2142
+ for (const placement of SHOWCASE_ENVIRONMENT_LAYOUT) {
2143
+ const mesh = state.assetCatalog?.environment?.[placement.assetKey] ?? null;
2144
+ if (!mesh) {
2145
+ continue;
2146
+ }
1633
2147
 
1634
- for (const object of harborObjects) {
1635
2148
  buildTrianglesFromMesh(
1636
- UNIT_BOX_MESH,
2149
+ mesh,
1637
2150
  {
1638
- position: object.position,
1639
- rotationY: object.rotationY,
1640
- scale: object.scale,
2151
+ position: vec3(placement.position.x, placement.position.y, placement.position.z),
2152
+ rotationY: placement.rotationY,
2153
+ scale: placement.scale,
1641
2154
  },
1642
- object.color,
2155
+ null,
1643
2156
  camera,
1644
2157
  viewport,
1645
2158
  triangles,
1646
- object.accent
2159
+ {
2160
+ accent: placement.accent,
2161
+ reflection: 0,
2162
+ surfaceType: "structure",
2163
+ }
1647
2164
  );
1648
2165
  }
1649
2166
  }
1650
2167
 
1651
2168
  function renderShipRigging(ctx, ship, camera, viewport) {
1652
2169
  const transform = { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE };
1653
- const mastBase = transformPoint(vec3(0, 0.38, -0.4), transform);
1654
- const mastTop = transformPoint(vec3(0, 3.8, -0.2), transform);
1655
- const aftBase = transformPoint(vec3(-0.25, 0.32, -1.9), transform);
1656
- const aftTop = transformPoint(vec3(-0.15, 2.7, -1.75), transform);
1657
- const sailA = transformPoint(vec3(0.08, 3.2, -0.2), transform);
1658
- const sailB = transformPoint(vec3(0.12, 1.2, -0.5), transform);
1659
- const sailC = transformPoint(vec3(2.25, 2.25, 0.15), transform);
1660
- const projected = [mastBase, mastTop, aftBase, aftTop, sailA, sailB, sailC].map((point) =>
1661
- projectPoint(point, camera, viewport)
1662
- );
2170
+ const layout =
2171
+ ship.modelKey === "cutter"
2172
+ ? {
2173
+ lineColor: "rgba(85, 89, 97, 0.92)",
2174
+ sailColor: "rgba(218, 232, 244, 0.28)",
2175
+ points: [
2176
+ vec3(0, 0.88, -0.32),
2177
+ vec3(0, 2.4, -0.28),
2178
+ vec3(0.1, 1.92, -0.3),
2179
+ vec3(1.18, 1.72, -0.18),
2180
+ vec3(1.04, 1.08, -0.12),
2181
+ ],
2182
+ mastPairs: [[0, 1], [2, 3]],
2183
+ sailTriangle: [2, 3, 4],
2184
+ }
2185
+ : {
2186
+ lineColor: "rgba(73, 54, 45, 0.94)",
2187
+ sailColor: "rgba(238, 232, 214, 0.88)",
2188
+ points: [
2189
+ vec3(0, 0.38, -0.4),
2190
+ vec3(0, 3.8, -0.2),
2191
+ vec3(-0.25, 0.32, -1.9),
2192
+ vec3(-0.15, 2.7, -1.75),
2193
+ vec3(0.08, 3.2, -0.2),
2194
+ vec3(0.12, 1.2, -0.5),
2195
+ vec3(2.25, 2.25, 0.15),
2196
+ ],
2197
+ mastPairs: [[0, 1], [2, 3]],
2198
+ sailTriangle: [4, 5, 6],
2199
+ };
2200
+ const projected = layout.points
2201
+ .map((point) => transformPoint(point, transform))
2202
+ .map((point) => projectPoint(point, camera, viewport));
1663
2203
  if (projected.some((value) => value === null)) {
1664
2204
  return;
1665
2205
  }
1666
2206
 
1667
- ctx.strokeStyle = "rgba(73, 54, 45, 0.94)";
1668
- ctx.lineWidth = 3.5;
2207
+ ctx.strokeStyle = layout.lineColor;
2208
+ ctx.lineWidth = ship.modelKey === "cutter" ? 2.2 : 3.5;
1669
2209
  ctx.beginPath();
1670
- ctx.moveTo(projected[0].x, projected[0].y);
1671
- ctx.lineTo(projected[1].x, projected[1].y);
1672
- ctx.moveTo(projected[2].x, projected[2].y);
1673
- ctx.lineTo(projected[3].x, projected[3].y);
2210
+ for (const [from, to] of layout.mastPairs) {
2211
+ ctx.moveTo(projected[from].x, projected[from].y);
2212
+ ctx.lineTo(projected[to].x, projected[to].y);
2213
+ }
1674
2214
  ctx.stroke();
1675
2215
 
1676
- ctx.fillStyle = "rgba(238, 232, 214, 0.88)";
2216
+ const [a, b, c] = layout.sailTriangle;
2217
+ ctx.fillStyle = layout.sailColor;
1677
2218
  ctx.beginPath();
1678
- ctx.moveTo(projected[4].x, projected[4].y);
1679
- ctx.lineTo(projected[5].x, projected[5].y);
1680
- ctx.lineTo(projected[6].x, projected[6].y);
2219
+ ctx.moveTo(projected[a].x, projected[a].y);
2220
+ ctx.lineTo(projected[b].x, projected[b].y);
2221
+ ctx.lineTo(projected[c].x, projected[c].y);
1681
2222
  ctx.closePath();
1682
2223
  ctx.fill();
1683
2224
  }
@@ -1932,10 +2473,10 @@ function resolveBoundaryCollision(ship, state, shipModel) {
1932
2473
  }
1933
2474
  }
1934
2475
 
1935
- function resolveShipCollision(state, a, b, shipModel) {
2476
+ function resolveShipCollision(state, a, b, shipModelA, shipModelB) {
1936
2477
  const delta = subVec3(b.position, a.position);
1937
- const radiusA = getShipCollisionRadius(a, shipModel);
1938
- const radiusB = getShipCollisionRadius(b, shipModel);
2478
+ const radiusA = getShipCollisionRadius(a, shipModelA);
2479
+ const radiusB = getShipCollisionRadius(b, shipModelB);
1939
2480
  const distance = Math.hypot(delta.x, delta.z);
1940
2481
  const minDistance = radiusA + radiusB;
1941
2482
  if (distance >= minDistance) {
@@ -1948,8 +2489,8 @@ function resolveShipCollision(state, a, b, shipModel) {
1948
2489
  : normalizeVec3(vec3(Math.cos(state.time * 5.2), 0, Math.sin(state.time * 4.8)));
1949
2490
  const tangent = vec3(-normal.z, 0, normal.x);
1950
2491
  const penetration = minDistance - distance;
1951
- const invMassA = getShipInverseMass(a, shipModel);
1952
- const invMassB = getShipInverseMass(b, shipModel);
2492
+ const invMassA = getShipInverseMass(a, shipModelA);
2493
+ const invMassB = getShipInverseMass(b, shipModelB);
1953
2494
  const invMassSum = invMassA + invMassB;
1954
2495
  const correction = scaleVec3(normal, (penetration / Math.max(0.0001, invMassSum)) * 0.72);
1955
2496
  a.position = subVec3(a.position, scaleVec3(correction, invMassA));
@@ -1957,7 +2498,11 @@ function resolveShipCollision(state, a, b, shipModel) {
1957
2498
 
1958
2499
  const relativeVelocity = subVec3(b.velocity, a.velocity);
1959
2500
  const velocityAlongNormal = dotVec3(relativeVelocity, normal);
1960
- const restitution = readPhysicsNumber(shipModel.physics, "restitution", 0.22) * 0.88;
2501
+ const restitution =
2502
+ ((readPhysicsNumber(shipModelA.physics, "restitution", 0.22) +
2503
+ readPhysicsNumber(shipModelB.physics, "restitution", 0.22)) /
2504
+ 2) *
2505
+ 0.88;
1961
2506
  if (velocityAlongNormal < 0) {
1962
2507
  const impulseMagnitude =
1963
2508
  (-(1 + restitution) * velocityAlongNormal) / Math.max(0.0001, invMassSum);
@@ -1976,10 +2521,10 @@ function resolveShipCollision(state, a, b, shipModel) {
1976
2521
  b.velocity = addVec3(b.velocity, scaleVec3(frictionImpulse, invMassB));
1977
2522
 
1978
2523
  a.angularVelocity -=
1979
- tangentSpeed * radiusA * getShipInverseInertia(a, shipModel) * 0.2 +
2524
+ tangentSpeed * radiusA * getShipInverseInertia(a, shipModelA) * 0.2 +
1980
2525
  impulseMagnitude * 0.00024;
1981
2526
  b.angularVelocity +=
1982
- tangentSpeed * radiusB * getShipInverseInertia(b, shipModel) * 0.2 +
2527
+ tangentSpeed * radiusB * getShipInverseInertia(b, shipModelB) * 0.2 +
1983
2528
  impulseMagnitude * 0.00024;
1984
2529
 
1985
2530
  const impactSpeed = Math.abs(velocityAlongNormal);
@@ -2019,14 +2564,19 @@ function updateShips(state, dt, shipModel) {
2019
2564
  state.contactCount = 0;
2020
2565
 
2021
2566
  for (const ship of state.ships) {
2022
- updateShipMotion(state, ship, dt, shipModel);
2023
- resolveBoundaryCollision(ship, state, shipModel);
2567
+ const activeShipModel = resolveShipModel(state, ship, shipModel);
2568
+ updateShipMotion(state, ship, dt, activeShipModel);
2569
+ resolveBoundaryCollision(ship, state, activeShipModel);
2024
2570
  }
2025
2571
 
2026
2572
  for (let index = 0; index < state.ships.length; index += 1) {
2027
2573
  for (let otherIndex = index + 1; otherIndex < state.ships.length; otherIndex += 1) {
2574
+ const shipA = state.ships[index];
2575
+ const shipB = state.ships[otherIndex];
2576
+ const shipModelA = resolveShipModel(state, shipA, shipModel);
2577
+ const shipModelB = resolveShipModel(state, shipB, shipModel);
2028
2578
  collided =
2029
- resolveShipCollision(state, state.ships[index], state.ships[otherIndex], shipModel) ||
2579
+ resolveShipCollision(state, shipA, shipB, shipModelA, shipModelB) ||
2030
2580
  collided;
2031
2581
  }
2032
2582
  }
@@ -2318,6 +2868,108 @@ function renderWaterLightReflection(ctx, source, state, camera, viewport) {
2318
2868
  ctx.restore();
2319
2869
  }
2320
2870
 
2871
+ function renderLighthouseBeam(ctx, state, camera, viewport, visuals) {
2872
+ const lighthousePlacement = SHOWCASE_ENVIRONMENT_LAYOUT.find(
2873
+ (placement) => placement.assetKey === "lighthouse"
2874
+ );
2875
+ if (!lighthousePlacement || !state.showcaseRealisticModelsEnabled) {
2876
+ return;
2877
+ }
2878
+
2879
+ const source = transformPoint(
2880
+ vec3(0, 11.34, 0),
2881
+ {
2882
+ position: vec3(
2883
+ lighthousePlacement.position.x,
2884
+ lighthousePlacement.position.y,
2885
+ lighthousePlacement.position.z
2886
+ ),
2887
+ rotationY: lighthousePlacement.rotationY,
2888
+ scale: lighthousePlacement.scale,
2889
+ }
2890
+ );
2891
+ const sweep = state.time * 0.22 + 0.8;
2892
+ const direction = normalizeVec3(vec3(Math.sin(sweep), -0.07, Math.cos(sweep)));
2893
+ const spread = perpendicularOnWater(direction);
2894
+ const farCenter = addVec3(source, scaleVec3(direction, 34));
2895
+ const left = addVec3(farCenter, scaleVec3(spread, 7.4));
2896
+ const right = addVec3(farCenter, scaleVec3(spread, -7.4));
2897
+ const projectedSource = projectPoint(source, camera, viewport);
2898
+ const projectedLeft = projectPoint(left, camera, viewport);
2899
+ const projectedRight = projectPoint(right, camera, viewport);
2900
+ if (!projectedSource || !projectedLeft || !projectedRight) {
2901
+ return;
2902
+ }
2903
+
2904
+ const pulse = 0.72 + Math.sin(state.time * 1.7) * 0.08;
2905
+ ctx.save();
2906
+ ctx.globalCompositeOperation = "screen";
2907
+ ctx.fillStyle = colorToRgba(visuals.torchCore, 0.055 * pulse);
2908
+ ctx.beginPath();
2909
+ ctx.moveTo(projectedSource.x, projectedSource.y);
2910
+ ctx.lineTo(projectedLeft.x, projectedLeft.y);
2911
+ ctx.lineTo(projectedRight.x, projectedRight.y);
2912
+ ctx.closePath();
2913
+ ctx.fill();
2914
+
2915
+ const beamLength = Math.hypot(
2916
+ projectedLeft.x - projectedSource.x,
2917
+ projectedLeft.y - projectedSource.y
2918
+ );
2919
+ const core = ctx.createRadialGradient(
2920
+ projectedSource.x,
2921
+ projectedSource.y,
2922
+ 2,
2923
+ projectedSource.x,
2924
+ projectedSource.y,
2925
+ clamp(beamLength * 0.22, 18, 80)
2926
+ );
2927
+ core.addColorStop(0, colorToRgba(visuals.torchCore, 0.58));
2928
+ core.addColorStop(0.5, colorToRgba(visuals.torchGlow, 0.18));
2929
+ core.addColorStop(1, colorToRgba(visuals.torchGlow, 0));
2930
+ ctx.fillStyle = core;
2931
+ ctx.beginPath();
2932
+ ctx.arc(projectedSource.x, projectedSource.y, clamp(beamLength * 0.18, 14, 64), 0, Math.PI * 2);
2933
+ ctx.fill();
2934
+ ctx.restore();
2935
+ }
2936
+
2937
+ function renderAtmosphericGrade(ctx, canvas, state, visuals) {
2938
+ const vignette = ctx.createRadialGradient(
2939
+ canvas.width * 0.5,
2940
+ canvas.height * 0.48,
2941
+ canvas.width * 0.2,
2942
+ canvas.width * 0.5,
2943
+ canvas.height * 0.5,
2944
+ canvas.width * 0.72
2945
+ );
2946
+ vignette.addColorStop(0, "rgba(0, 0, 0, 0)");
2947
+ vignette.addColorStop(0.68, "rgba(0, 0, 0, 0.08)");
2948
+ vignette.addColorStop(1, "rgba(0, 0, 0, 0.32)");
2949
+ ctx.fillStyle = vignette;
2950
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
2951
+
2952
+ const seaHaze = ctx.createLinearGradient(0, canvas.height * 0.34, 0, canvas.height);
2953
+ seaHaze.addColorStop(0, "rgba(0, 0, 0, 0)");
2954
+ seaHaze.addColorStop(0.5, visuals.ambientMist);
2955
+ seaHaze.addColorStop(1, "rgba(3, 8, 16, 0.18)");
2956
+ ctx.fillStyle = seaHaze;
2957
+ ctx.fillRect(0, canvas.height * 0.34, canvas.width, canvas.height * 0.66);
2958
+
2959
+ if (state.captureMode) {
2960
+ ctx.save();
2961
+ ctx.globalCompositeOperation = "screen";
2962
+ for (let index = 0; index < 70; index += 1) {
2963
+ const x = pseudoRandom(index * 19 + 3) * canvas.width;
2964
+ const y = pseudoRandom(index * 23 + 7) * canvas.height;
2965
+ const alpha = 0.008 + pseudoRandom(index * 31 + 11) * 0.012;
2966
+ ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
2967
+ ctx.fillRect(x, y, 1.1, 1.1);
2968
+ }
2969
+ ctx.restore();
2970
+ }
2971
+ }
2972
+
2321
2973
  function renderWaterMotionEffects(ctx, effects, camera, viewport) {
2322
2974
  ctx.save();
2323
2975
  ctx.globalCompositeOperation = "screen";
@@ -2450,6 +3102,15 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2450
3102
  normal,
2451
3103
  baseColor: bandMesh.color,
2452
3104
  accent: bandAccent,
3105
+ material: {
3106
+ name: "water-surface",
3107
+ color: bandMesh.color,
3108
+ roughness: 0.2,
3109
+ metallic: 0,
3110
+ emissive: { r: 0, g: 0, b: 0 },
3111
+ },
3112
+ reflection: 1,
3113
+ surfaceType: "water",
2453
3114
  });
2454
3115
  }
2455
3116
  }
@@ -2457,7 +3118,7 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2457
3118
  const waterMotionEffects = buildWaterMotionEffects(state);
2458
3119
  const lightSources = collectSceneLightSources(state, visuals);
2459
3120
 
2460
- pushHarborGeometry(camera, viewport, sceneTriangles, visuals);
3121
+ pushHarborGeometry(camera, viewport, sceneTriangles, state);
2461
3122
  const cloth = buildClothSurface(
2462
3123
  state,
2463
3124
  state,
@@ -2480,24 +3141,47 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2480
3141
  normal,
2481
3142
  baseColor: cloth.color,
2482
3143
  accent: cloth.band === "near" ? 0.1 : 0.04,
3144
+ material: {
3145
+ name: "flag-cloth",
3146
+ color: cloth.color,
3147
+ roughness: 0.94,
3148
+ metallic: 0,
3149
+ emissive: { r: 0, g: 0, b: 0 },
3150
+ },
3151
+ reflection: 0,
3152
+ surfaceType: "cloth",
2483
3153
  });
2484
3154
  }
2485
3155
 
2486
3156
  for (const ship of state.ships) {
3157
+ const activeShipModel = resolveShipModel(state, ship, shipModel);
2487
3158
  buildTrianglesFromMesh(
2488
- shipModel,
3159
+ activeShipModel,
2489
3160
  { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE },
2490
3161
  ship.tint,
2491
3162
  camera,
2492
3163
  viewport,
2493
3164
  sceneTriangles,
2494
- nearLighting.rtParticipation.directShadows === "premium" ? 0.08 : 0.02
3165
+ {
3166
+ accent: nearLighting.rtParticipation.directShadows === "premium" ? 0.08 : 0.02,
3167
+ reflection: 0,
3168
+ surfaceType: "ship",
3169
+ }
2495
3170
  );
2496
3171
  }
2497
3172
 
2498
3173
  drawTriangles(ctx, waterTriangles, lightDir, reflectionStrength, camera, shadowStrength);
2499
3174
  for (const ship of state.ships) {
2500
- renderShipShadow(ctx, shipModel, ship, state, camera, viewport, lightDir, shadowStrength);
3175
+ renderShipShadow(
3176
+ ctx,
3177
+ resolveShipModel(state, ship, shipModel),
3178
+ ship,
3179
+ state,
3180
+ camera,
3181
+ viewport,
3182
+ lightDir,
3183
+ shadowStrength
3184
+ );
2501
3185
  }
2502
3186
  renderFlagShadow(ctx, cloth, camera, viewport, lightDir, shadowStrength);
2503
3187
  for (const source of lightSources.reflectionLights) {
@@ -2505,9 +3189,18 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2505
3189
  }
2506
3190
  renderWaterMotionEffects(ctx, waterMotionEffects, camera, viewport);
2507
3191
  renderWaterHighlights(ctx, water.bandMeshes, camera, viewport);
2508
- drawTriangles(ctx, sceneTriangles, lightDir, reflectionStrength, camera, shadowStrength);
3192
+ drawTriangles(
3193
+ ctx,
3194
+ sceneTriangles,
3195
+ lightDir,
3196
+ reflectionStrength,
3197
+ camera,
3198
+ shadowStrength,
3199
+ lightSources.directLights
3200
+ );
2509
3201
  renderFlagPole(ctx, camera, viewport);
2510
3202
  renderClothAccent(ctx, cloth, camera, viewport);
3203
+ renderLighthouseBeam(ctx, state, camera, viewport, visuals);
2511
3204
  for (const source of lightSources.directLights) {
2512
3205
  renderDirectLightGlow(ctx, source, camera, viewport);
2513
3206
  }
@@ -2515,6 +3208,7 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2515
3208
  renderShipRigging(ctx, ship, camera, viewport);
2516
3209
  }
2517
3210
  renderSprays(ctx, state.sprays, camera, viewport);
3211
+ renderAtmosphericGrade(ctx, canvas, state, visuals);
2518
3212
 
2519
3213
  const debugSnapshot = state.debugSession.getSnapshot();
2520
3214
  const quality = {
@@ -2525,11 +3219,11 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2525
3219
 
2526
3220
  const sceneMetrics = [
2527
3221
  `focus: ${state.focus}`,
2528
- `ships: ${state.ships.length} active GLTF hulls`,
2529
- `moonlight: cold overhead key + ${HARBOR_TORCHES.length + state.ships.length * SHIP_LANTERNS.length} warm deck and harbor lights`,
3222
+ `ships: ${state.ships.length} active GLTF hulls across ${new Set(state.ships.map((ship) => ship.modelKey)).size} model families`,
3223
+ `moonlight: cold overhead key + ${HARBOR_TORCHES.length + state.ships.reduce((total, ship) => total + (Array.isArray(ship.lanterns) ? ship.lanterns.length : 0), 0)} warm deck and harbor lights`,
2530
3224
  `physics snapshot: ${state.physics.snapshot.stage} (${state.physics.snapshot.stability})`,
2531
3225
  `physics contacts: ${state.contactCount}`,
2532
- `mass split: ${state.ships.map((ship) => `${ship.id} ${(getShipMass(ship, shipModel) / 1000).toFixed(1)}t`).join(" · ")}`,
3226
+ `mass split: ${state.ships.map((ship) => `${ship.id} ${(getShipMass(ship, resolveShipModel(state, ship, shipModel)) / 1000).toFixed(1)}t`).join(" · ")}`,
2533
3227
  `cloth band: ${cloth.band} -> ${cloth.representation.output}`,
2534
3228
  `fluid near band: ${water.bandMeshes[0].representation.output}`,
2535
3229
  `lighting profile: ${lightingPlan.profile} (${lightingDistanceBands.length} bands)`,
@@ -2552,12 +3246,8 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2552
3246
  ];
2553
3247
  const sceneNotes =
2554
3248
  state.focus === "physics"
2555
- ? [
2556
- "Stable world snapshots are taken after the authoritative rigid-body commit and before visual follow-up work.",
2557
- "The ships collide with mass-weighted impulses and positional correction, so the heavier hull keeps more of its line.",
2558
- "Moonlight keeps the overall read legible while lanterns and torches make collision moments easy to track against the water.",
2559
- ]
2560
- : SCENE_NOTES;
3249
+ ? PHYSICS_SCENE_NOTE_KEYS.map((key) => state.translate(key))
3250
+ : SCENE_NOTE_KEYS.map((key) => state.translate(key));
2561
3251
  const custom = state.demoDescription ?? null;
2562
3252
 
2563
3253
  setListContent(
@@ -2577,13 +3267,23 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2577
3267
  dom.status.textContent =
2578
3268
  typeof custom?.status === "string"
2579
3269
  ? custom.status
2580
- : `3D scene live · ${state.lastDecision.metrics.fps.toFixed(1)} FPS`;
3270
+ : state.translate(gpuSharedTranslationKeys.statusLive, {
3271
+ fps: state.lastDecision.metrics.fps.toFixed(1),
3272
+ });
2581
3273
  dom.details.textContent =
2582
3274
  typeof custom?.details === "string"
2583
3275
  ? custom.details
2584
3276
  : state.focus === "physics"
2585
- ? `Stable world snapshots are emitted from ${state.physics.plan.snapshotStageId} after the authoritative solver; the heavier hull now carries momentum through mass-aware collision impulses while cloth and fluid remain downstream.`
2586
- : `Moonlit GLTF ships collide on ${shipModel.physics.shape ?? "box"} physics volumes; lantern reflections, cloth, and fluid remain continuous while the governor pressure is ${state.lastDecision.pressureLevel}.`;
3277
+ ? state.translate(gpuSharedTranslationKeys.detailsPhysics, {
3278
+ snapshotStageId: state.physics.plan.snapshotStageId,
3279
+ })
3280
+ : state.showcaseRealisticModelsEnabled
3281
+ ? state.translate(gpuSharedTranslationKeys.detailsRealistic, {
3282
+ pressureLevel: state.lastDecision.pressureLevel,
3283
+ })
3284
+ : state.translate(gpuSharedTranslationKeys.detailsLegacy, {
3285
+ pressureLevel: state.lastDecision.pressureLevel,
3286
+ });
2587
3287
  }
2588
3288
 
2589
3289
  function updateSceneState(state, dt, shipModel) {
@@ -2615,15 +3315,18 @@ function syncTextState(state, shipModel) {
2615
3315
  stress: state.stress,
2616
3316
  ships: state.ships.map((ship) => ({
2617
3317
  id: ship.id,
3318
+ modelKey: ship.modelKey ?? "brigantine",
2618
3319
  x: Number(ship.position.x.toFixed(2)),
2619
3320
  y: Number(ship.position.y.toFixed(2)),
2620
3321
  z: Number(ship.position.z.toFixed(2)),
2621
3322
  vx: Number(ship.velocity.x.toFixed(2)),
2622
3323
  vz: Number(ship.velocity.z.toFixed(2)),
2623
- massKg: Math.round(getShipMass(ship, shipModel)),
3324
+ massKg: Math.round(getShipMass(ship, resolveShipModel(state, ship, shipModel))),
2624
3325
  lanterns: Array.isArray(ship.lanterns) ? ship.lanterns.length : 0,
2625
3326
  })),
2626
- shipPhysics: shipModel.physics,
3327
+ shipPhysics: Object.fromEntries(
3328
+ state.ships.map((ship) => [ship.id, resolveShipModel(state, ship, shipModel)?.physics ?? null])
3329
+ ),
2627
3330
  sprays: state.sprays.length,
2628
3331
  waveImpulses: state.waveImpulses.length,
2629
3332
  pressure: state.lastDecision?.pressureLevel ?? "stable",
@@ -2647,23 +3350,39 @@ function syncTextState(state, shipModel) {
2647
3350
  };
2648
3351
  }
2649
3352
 
2650
- export async function mountGpuShowcase(options = {}) {
3353
+ export async function mountGpuShowcase(options = {}, featureFlags = null) {
2651
3354
  injectStyles();
2652
3355
  const root = options.root ?? document.body;
2653
3356
  root.classList?.add?.(ROOT_CLASS);
3357
+ const captureSettings = resolveCaptureSettings(options);
3358
+ if (captureSettings.captureMode) {
3359
+ root.classList?.add?.(CAPTURE_CLASS);
3360
+ }
2654
3361
  const previousMarkup = root.innerHTML;
2655
3362
  const previousRenderGameToText = window.render_game_to_text;
2656
3363
  const previousAdvanceTime = window.advanceTime;
2657
3364
  const focus = options.focus ?? new URLSearchParams(window.location.search).get("focus") ?? "integrated";
3365
+ const translate = createGpuSharedTranslator(options.translate);
2658
3366
  const dom = buildDemoDom(root, {
2659
3367
  packageName: options.packageName ?? "@plasius/gpu-demo-viewer",
2660
- title: options.title ?? DEFAULT_TITLE,
2661
- subtitle: options.subtitle ?? DEFAULT_SUBTITLE,
3368
+ title: options.title ?? translate(gpuSharedTranslationKeys.showcaseTitle),
3369
+ subtitle: options.subtitle ?? translate(gpuSharedTranslationKeys.showcaseSubtitle),
3370
+ translate,
2662
3371
  });
2663
3372
  dom.focusMode.value = focus;
3373
+ const state = createSceneState({
3374
+ focus,
3375
+ translate,
3376
+ realisticModelsEnabled: isFeatureEnabled(featureFlags, GPU_SHOWCASE_REALISTIC_MODELS_FEATURE, true),
3377
+ captureMode: captureSettings.captureMode,
3378
+ renderScale: captureSettings.renderScale,
3379
+ });
3380
+ const assetCatalog = await (state.showcaseRealisticModelsEnabled
3381
+ ? loadShowcaseAssetCatalog()
3382
+ : createLegacyShowcaseAssetCatalog());
3383
+ const shipModel = assetCatalog.ships[assetCatalog.primaryShipKey];
2664
3384
 
2665
- const state = createSceneState({ focus });
2666
- const shipModel = await loadGltfModel(resolveShowcaseAssetUrl());
3385
+ state.assetCatalog = assetCatalog;
2667
3386
  state.shipModel = shipModel;
2668
3387
  state.packageState =
2669
3388
  typeof options.createState === "function" ? options.createState() : undefined;
@@ -2676,6 +3395,8 @@ export async function mountGpuShowcase(options = {}) {
2676
3395
  if (!ctx) {
2677
3396
  throw new Error("2D canvas context is required for the shared showcase.");
2678
3397
  }
3398
+ ctx.imageSmoothingEnabled = true;
3399
+ ctx.imageSmoothingQuality = "high";
2679
3400
  let animationFrameId = 0;
2680
3401
  let destroyed = false;
2681
3402
  const renderFrame = (nowMs) => {
@@ -2697,6 +3418,7 @@ export async function mountGpuShowcase(options = {}) {
2697
3418
  }
2698
3419
 
2699
3420
  state.demoDescription = resolveSceneDescription(state, options, shipModel).description;
3421
+ resizeCanvasToDisplaySize(dom.canvas, state);
2700
3422
  renderScene(ctx, dom.canvas, state, shipModel, dom);
2701
3423
  syncTextState(state, shipModel);
2702
3424
  animationFrameId = requestAnimationFrame(renderFrame);
@@ -2704,7 +3426,9 @@ export async function mountGpuShowcase(options = {}) {
2704
3426
 
2705
3427
  const handlePauseClick = () => {
2706
3428
  state.paused = !state.paused;
2707
- dom.pauseButton.textContent = state.paused ? "Resume" : "Pause";
3429
+ dom.pauseButton.textContent = state.paused
3430
+ ? state.translate(gpuSharedTranslationKeys.resume)
3431
+ : state.translate(gpuSharedTranslationKeys.pause);
2708
3432
  };
2709
3433
  const handleStressChange = () => {
2710
3434
  state.stress = dom.stressToggle.checked;
@@ -2741,6 +3465,7 @@ export async function mountGpuShowcase(options = {}) {
2741
3465
  state.packageState = undefined;
2742
3466
  }
2743
3467
  root.classList?.remove?.(ROOT_CLASS);
3468
+ root.classList?.remove?.(CAPTURE_CLASS);
2744
3469
  root.innerHTML = previousMarkup;
2745
3470
  if (typeof previousRenderGameToText === "function") {
2746
3471
  window.render_game_to_text = previousRenderGameToText;
@@ -2762,6 +3487,13 @@ export async function mountGpuShowcase(options = {}) {
2762
3487
  }
2763
3488
 
2764
3489
  function updatePhysicsSnapshot(state, shipModel) {
3490
+ const rigidBodyShapes = Object.fromEntries(
3491
+ state.ships.map((ship) => [
3492
+ ship.id,
3493
+ resolveShipModel(state, ship, shipModel)?.physics?.shape ?? "box",
3494
+ ])
3495
+ );
3496
+
2765
3497
  state.physics.snapshot = createPhysicsWorldSnapshot({
2766
3498
  frameId: `showcase-${state.frame}`,
2767
3499
  tick: state.frame,
@@ -2778,12 +3510,14 @@ function updatePhysicsSnapshot(state, shipModel) {
2778
3510
  contactCount: state.contactCount,
2779
3511
  snapshotStageId: state.physics.plan.snapshotStageId,
2780
3512
  rigidBodyShape: shipModel.physics.shape ?? "box",
3513
+ rigidBodyShapes,
2781
3514
  },
2782
3515
  });
2783
3516
  }
2784
3517
 
2785
3518
  export {
2786
3519
  advanceShowcaseClothSimulationState as __testOnlyAdvanceShowcaseClothSimulationState,
3520
+ buildWaterBands as __testOnlyBuildWaterBands,
2787
3521
  buildWaterMotionEffects as __testOnlyBuildWaterMotionEffects,
2788
3522
  collectSceneLightSources as __testOnlyCollectSceneLightSources,
2789
3523
  createShowcaseClothSimulationState as __testOnlyCreateShowcaseClothSimulationState,