@plasius/gpu-shared 0.1.11 → 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 +36 -3
  2. package/README.md +55 -1
  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 +1255 -279
  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-2ZNPKD7D.js → showcase-runtime-PN7N3FZY.js} +808 -237
  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 +912 -188
  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-2ZNPKD7D.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",
@@ -1375,6 +1802,7 @@ function buildWaterBands(state, fluidDetail, visuals) {
1375
1802
  }
1376
1803
 
1377
1804
  function createSceneState(options) {
1805
+ const translate = options.translate;
1378
1806
  const { governor, fluidDetail, clothDetail, lightingDetail } = createPerformanceGovernor();
1379
1807
  const physicsProfile = defaultPhysicsWorkerProfile;
1380
1808
  const physicsPlan = createPhysicsSimulationPlan(physicsProfile);
@@ -1382,7 +1810,7 @@ function createSceneState(options) {
1382
1810
  const debugSession = createGpuDebugSession({
1383
1811
  enabled: true,
1384
1812
  adapter: {
1385
- label: "3D showcase",
1813
+ label: translate(gpuSharedTranslationKeys.debugAdapterShowcase),
1386
1814
  memoryCapacityHintBytes: 6 * 1024 * 1024 * 1024,
1387
1815
  coreCountHint: 12,
1388
1816
  },
@@ -1392,23 +1820,27 @@ function createSceneState(options) {
1392
1820
  owner: "renderer",
1393
1821
  category: "texture",
1394
1822
  sizeBytes: 1280 * 720 * 4,
1395
- label: "Main color buffer",
1823
+ label: translate(gpuSharedTranslationKeys.debugMainColorBuffer),
1396
1824
  });
1397
1825
  debugSession.trackAllocation({
1398
1826
  id: "showcase.shadow-impression",
1399
1827
  owner: "lighting",
1400
1828
  category: "texture",
1401
1829
  sizeBytes: 12 * 1024 * 1024,
1402
- label: "Shadow impression atlas",
1830
+ label: translate(gpuSharedTranslationKeys.debugShadowImpressionAtlas),
1403
1831
  });
1404
1832
 
1405
1833
  return {
1834
+ translate,
1406
1835
  focus: options.focus,
1407
1836
  governor,
1408
1837
  fluidDetail,
1409
1838
  clothDetail,
1410
1839
  lightingDetail,
1411
1840
  debugSession,
1841
+ showcaseRealisticModelsEnabled: options.realisticModelsEnabled !== false,
1842
+ captureMode: options.captureMode === true,
1843
+ renderScale: readPositiveNumber(options.renderScale, undefined),
1412
1844
  packageState: undefined,
1413
1845
  demoDescription: null,
1414
1846
  demoVisuals: null,
@@ -1423,6 +1855,7 @@ function createSceneState(options) {
1423
1855
  ships: [
1424
1856
  {
1425
1857
  id: "northwind",
1858
+ modelKey: "brigantine",
1426
1859
  position: vec3(-5.2, 0, 7.2),
1427
1860
  velocity: vec3(2.35, 0, -1.08),
1428
1861
  rotationY: 0.58,
@@ -1433,17 +1866,18 @@ function createSceneState(options) {
1433
1866
  throttleResponse: 0.46,
1434
1867
  rudderResponse: 0.54,
1435
1868
  wanderPhase: 0.35,
1436
- lanterns: SHIP_LANTERNS,
1869
+ lanterns: CUTTER_LANTERNS,
1437
1870
  lanternStrength: 1.06,
1438
1871
  collisionRadiusScale: 1.04,
1439
1872
  },
1440
1873
  {
1441
1874
  id: "tidecaller",
1875
+ modelKey: "cutter",
1442
1876
  position: vec3(4.8, 0, 4.4),
1443
1877
  velocity: vec3(-2.15, 0, 1.74),
1444
1878
  rotationY: -2.48,
1445
1879
  angularVelocity: -0.2,
1446
- tint: { r: 0.48, g: 0.28, b: 0.19 },
1880
+ tint: { r: 0.58, g: 0.24, b: 0.16 },
1447
1881
  massScale: 0.84,
1448
1882
  cruiseSpeed: 2.68,
1449
1883
  throttleResponse: 0.7,
@@ -1467,6 +1901,7 @@ function createSceneState(options) {
1467
1901
  manifest: physicsManifest,
1468
1902
  snapshot: null,
1469
1903
  },
1904
+ assetCatalog: null,
1470
1905
  shipModel: null,
1471
1906
  };
1472
1907
  }
@@ -1553,10 +1988,51 @@ function drawSkyAndShore(ctx, canvas, state, nearLighting, reflectionStrength, s
1553
1988
  }
1554
1989
  }
1555
1990
 
1556
- 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
+ ) {
1557
2028
  triangles.sort((left, right) => right.depth - left.depth);
1558
2029
  for (const triangle of triangles) {
1559
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
+ };
1560
2036
  const shaded = shadeColor(
1561
2037
  triangle.baseColor,
1562
2038
  surfaceNormal,
@@ -1564,19 +2040,42 @@ function drawTriangles(ctx, triangles, lightDir, reflectionStrength, camera, sha
1564
2040
  clamp((triangle.worldCenter.y + 3) / 10, 0, 1),
1565
2041
  triangle.accent
1566
2042
  );
1567
- const reflection = triangle.worldCenter.y < 0.8 ? reflectionStrength : 0;
2043
+ const reflection = reflectionStrength * (triangle.reflection ?? 0);
1568
2044
  const viewDir = normalizeVec3(subVec3(camera.eye, triangle.worldCenter));
1569
2045
  const reflectedLight = reflectVec3(scaleVec3(lightDir, -1), surfaceNormal);
1570
- const gloss = triangle.worldCenter.y < 0.9 ? 1 : triangle.accent > 0.05 ? 0.55 : 0.3;
1571
- const specular = Math.pow(clamp(dotVec3(reflectedLight, viewDir), 0, 1), triangle.worldCenter.y < 0.9 ? 18 : 12) * gloss;
1572
- const occlusion = triangle.worldCenter.y < 0.9 ? shadowStrength * 0.035 : 0;
1573
- 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(
1574
2054
  {
1575
- r: clamp(shaded.r + reflection * 0.08 + specular * 0.14 - occlusion, 0, 1),
1576
- g: clamp(shaded.g + reflection * 0.08 + specular * 0.15 - occlusion, 0, 1),
1577
- 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
+ ),
1578
2070
  },
1579
- 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
1580
2079
  );
1581
2080
  ctx.fillStyle = fill;
1582
2081
  ctx.beginPath();
@@ -1615,78 +2114,111 @@ function renderProjectedShadow(ctx, worldPoints, camera, viewport, lightDir, opt
1615
2114
  ctx.restore();
1616
2115
  }
1617
2116
 
1618
- function pushHarborGeometry(camera, viewport, triangles, visuals) {
1619
- const harborObjects = [
1620
- {
1621
- position: vec3(-8.2, 1.1, -0.9),
1622
- rotationY: -0.16,
1623
- scale: { x: 5.4, y: 2.4, z: 4.2 },
1624
- color: visuals.harborWall,
1625
- accent: 0.06,
1626
- },
1627
- {
1628
- position: vec3(-5.7, 0.45, 1.4),
1629
- rotationY: -0.08,
1630
- scale: { x: 6.8, y: 0.3, z: 2.1 },
1631
- color: visuals.harborDeck,
1632
- accent: 0.04,
1633
- },
1634
- {
1635
- position: vec3(-10.4, 0.28, 0.8),
1636
- rotationY: 0.22,
1637
- scale: { x: 1.2, y: 0.9, z: 1.2 },
1638
- color: visuals.harborTower,
1639
- accent: 0.02,
1640
- },
1641
- ];
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
+ }
1642
2147
 
1643
- for (const object of harborObjects) {
1644
2148
  buildTrianglesFromMesh(
1645
- UNIT_BOX_MESH,
2149
+ mesh,
1646
2150
  {
1647
- position: object.position,
1648
- rotationY: object.rotationY,
1649
- scale: object.scale,
2151
+ position: vec3(placement.position.x, placement.position.y, placement.position.z),
2152
+ rotationY: placement.rotationY,
2153
+ scale: placement.scale,
1650
2154
  },
1651
- object.color,
2155
+ null,
1652
2156
  camera,
1653
2157
  viewport,
1654
2158
  triangles,
1655
- object.accent
2159
+ {
2160
+ accent: placement.accent,
2161
+ reflection: 0,
2162
+ surfaceType: "structure",
2163
+ }
1656
2164
  );
1657
2165
  }
1658
2166
  }
1659
2167
 
1660
2168
  function renderShipRigging(ctx, ship, camera, viewport) {
1661
2169
  const transform = { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE };
1662
- const mastBase = transformPoint(vec3(0, 0.38, -0.4), transform);
1663
- const mastTop = transformPoint(vec3(0, 3.8, -0.2), transform);
1664
- const aftBase = transformPoint(vec3(-0.25, 0.32, -1.9), transform);
1665
- const aftTop = transformPoint(vec3(-0.15, 2.7, -1.75), transform);
1666
- const sailA = transformPoint(vec3(0.08, 3.2, -0.2), transform);
1667
- const sailB = transformPoint(vec3(0.12, 1.2, -0.5), transform);
1668
- const sailC = transformPoint(vec3(2.25, 2.25, 0.15), transform);
1669
- const projected = [mastBase, mastTop, aftBase, aftTop, sailA, sailB, sailC].map((point) =>
1670
- projectPoint(point, camera, viewport)
1671
- );
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));
1672
2203
  if (projected.some((value) => value === null)) {
1673
2204
  return;
1674
2205
  }
1675
2206
 
1676
- ctx.strokeStyle = "rgba(73, 54, 45, 0.94)";
1677
- ctx.lineWidth = 3.5;
2207
+ ctx.strokeStyle = layout.lineColor;
2208
+ ctx.lineWidth = ship.modelKey === "cutter" ? 2.2 : 3.5;
1678
2209
  ctx.beginPath();
1679
- ctx.moveTo(projected[0].x, projected[0].y);
1680
- ctx.lineTo(projected[1].x, projected[1].y);
1681
- ctx.moveTo(projected[2].x, projected[2].y);
1682
- 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
+ }
1683
2214
  ctx.stroke();
1684
2215
 
1685
- ctx.fillStyle = "rgba(238, 232, 214, 0.88)";
2216
+ const [a, b, c] = layout.sailTriangle;
2217
+ ctx.fillStyle = layout.sailColor;
1686
2218
  ctx.beginPath();
1687
- ctx.moveTo(projected[4].x, projected[4].y);
1688
- ctx.lineTo(projected[5].x, projected[5].y);
1689
- 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);
1690
2222
  ctx.closePath();
1691
2223
  ctx.fill();
1692
2224
  }
@@ -1941,10 +2473,10 @@ function resolveBoundaryCollision(ship, state, shipModel) {
1941
2473
  }
1942
2474
  }
1943
2475
 
1944
- function resolveShipCollision(state, a, b, shipModel) {
2476
+ function resolveShipCollision(state, a, b, shipModelA, shipModelB) {
1945
2477
  const delta = subVec3(b.position, a.position);
1946
- const radiusA = getShipCollisionRadius(a, shipModel);
1947
- const radiusB = getShipCollisionRadius(b, shipModel);
2478
+ const radiusA = getShipCollisionRadius(a, shipModelA);
2479
+ const radiusB = getShipCollisionRadius(b, shipModelB);
1948
2480
  const distance = Math.hypot(delta.x, delta.z);
1949
2481
  const minDistance = radiusA + radiusB;
1950
2482
  if (distance >= minDistance) {
@@ -1957,8 +2489,8 @@ function resolveShipCollision(state, a, b, shipModel) {
1957
2489
  : normalizeVec3(vec3(Math.cos(state.time * 5.2), 0, Math.sin(state.time * 4.8)));
1958
2490
  const tangent = vec3(-normal.z, 0, normal.x);
1959
2491
  const penetration = minDistance - distance;
1960
- const invMassA = getShipInverseMass(a, shipModel);
1961
- const invMassB = getShipInverseMass(b, shipModel);
2492
+ const invMassA = getShipInverseMass(a, shipModelA);
2493
+ const invMassB = getShipInverseMass(b, shipModelB);
1962
2494
  const invMassSum = invMassA + invMassB;
1963
2495
  const correction = scaleVec3(normal, (penetration / Math.max(0.0001, invMassSum)) * 0.72);
1964
2496
  a.position = subVec3(a.position, scaleVec3(correction, invMassA));
@@ -1966,7 +2498,11 @@ function resolveShipCollision(state, a, b, shipModel) {
1966
2498
 
1967
2499
  const relativeVelocity = subVec3(b.velocity, a.velocity);
1968
2500
  const velocityAlongNormal = dotVec3(relativeVelocity, normal);
1969
- 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;
1970
2506
  if (velocityAlongNormal < 0) {
1971
2507
  const impulseMagnitude =
1972
2508
  (-(1 + restitution) * velocityAlongNormal) / Math.max(0.0001, invMassSum);
@@ -1985,10 +2521,10 @@ function resolveShipCollision(state, a, b, shipModel) {
1985
2521
  b.velocity = addVec3(b.velocity, scaleVec3(frictionImpulse, invMassB));
1986
2522
 
1987
2523
  a.angularVelocity -=
1988
- tangentSpeed * radiusA * getShipInverseInertia(a, shipModel) * 0.2 +
2524
+ tangentSpeed * radiusA * getShipInverseInertia(a, shipModelA) * 0.2 +
1989
2525
  impulseMagnitude * 0.00024;
1990
2526
  b.angularVelocity +=
1991
- tangentSpeed * radiusB * getShipInverseInertia(b, shipModel) * 0.2 +
2527
+ tangentSpeed * radiusB * getShipInverseInertia(b, shipModelB) * 0.2 +
1992
2528
  impulseMagnitude * 0.00024;
1993
2529
 
1994
2530
  const impactSpeed = Math.abs(velocityAlongNormal);
@@ -2028,14 +2564,19 @@ function updateShips(state, dt, shipModel) {
2028
2564
  state.contactCount = 0;
2029
2565
 
2030
2566
  for (const ship of state.ships) {
2031
- updateShipMotion(state, ship, dt, shipModel);
2032
- resolveBoundaryCollision(ship, state, shipModel);
2567
+ const activeShipModel = resolveShipModel(state, ship, shipModel);
2568
+ updateShipMotion(state, ship, dt, activeShipModel);
2569
+ resolveBoundaryCollision(ship, state, activeShipModel);
2033
2570
  }
2034
2571
 
2035
2572
  for (let index = 0; index < state.ships.length; index += 1) {
2036
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);
2037
2578
  collided =
2038
- resolveShipCollision(state, state.ships[index], state.ships[otherIndex], shipModel) ||
2579
+ resolveShipCollision(state, shipA, shipB, shipModelA, shipModelB) ||
2039
2580
  collided;
2040
2581
  }
2041
2582
  }
@@ -2327,6 +2868,108 @@ function renderWaterLightReflection(ctx, source, state, camera, viewport) {
2327
2868
  ctx.restore();
2328
2869
  }
2329
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
+
2330
2973
  function renderWaterMotionEffects(ctx, effects, camera, viewport) {
2331
2974
  ctx.save();
2332
2975
  ctx.globalCompositeOperation = "screen";
@@ -2459,6 +3102,15 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2459
3102
  normal,
2460
3103
  baseColor: bandMesh.color,
2461
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",
2462
3114
  });
2463
3115
  }
2464
3116
  }
@@ -2466,7 +3118,7 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2466
3118
  const waterMotionEffects = buildWaterMotionEffects(state);
2467
3119
  const lightSources = collectSceneLightSources(state, visuals);
2468
3120
 
2469
- pushHarborGeometry(camera, viewport, sceneTriangles, visuals);
3121
+ pushHarborGeometry(camera, viewport, sceneTriangles, state);
2470
3122
  const cloth = buildClothSurface(
2471
3123
  state,
2472
3124
  state,
@@ -2489,24 +3141,47 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2489
3141
  normal,
2490
3142
  baseColor: cloth.color,
2491
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",
2492
3153
  });
2493
3154
  }
2494
3155
 
2495
3156
  for (const ship of state.ships) {
3157
+ const activeShipModel = resolveShipModel(state, ship, shipModel);
2496
3158
  buildTrianglesFromMesh(
2497
- shipModel,
3159
+ activeShipModel,
2498
3160
  { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE },
2499
3161
  ship.tint,
2500
3162
  camera,
2501
3163
  viewport,
2502
3164
  sceneTriangles,
2503
- 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
+ }
2504
3170
  );
2505
3171
  }
2506
3172
 
2507
3173
  drawTriangles(ctx, waterTriangles, lightDir, reflectionStrength, camera, shadowStrength);
2508
3174
  for (const ship of state.ships) {
2509
- 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
+ );
2510
3185
  }
2511
3186
  renderFlagShadow(ctx, cloth, camera, viewport, lightDir, shadowStrength);
2512
3187
  for (const source of lightSources.reflectionLights) {
@@ -2514,9 +3189,18 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2514
3189
  }
2515
3190
  renderWaterMotionEffects(ctx, waterMotionEffects, camera, viewport);
2516
3191
  renderWaterHighlights(ctx, water.bandMeshes, camera, viewport);
2517
- 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
+ );
2518
3201
  renderFlagPole(ctx, camera, viewport);
2519
3202
  renderClothAccent(ctx, cloth, camera, viewport);
3203
+ renderLighthouseBeam(ctx, state, camera, viewport, visuals);
2520
3204
  for (const source of lightSources.directLights) {
2521
3205
  renderDirectLightGlow(ctx, source, camera, viewport);
2522
3206
  }
@@ -2524,6 +3208,7 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2524
3208
  renderShipRigging(ctx, ship, camera, viewport);
2525
3209
  }
2526
3210
  renderSprays(ctx, state.sprays, camera, viewport);
3211
+ renderAtmosphericGrade(ctx, canvas, state, visuals);
2527
3212
 
2528
3213
  const debugSnapshot = state.debugSession.getSnapshot();
2529
3214
  const quality = {
@@ -2534,11 +3219,11 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2534
3219
 
2535
3220
  const sceneMetrics = [
2536
3221
  `focus: ${state.focus}`,
2537
- `ships: ${state.ships.length} active GLTF hulls`,
2538
- `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`,
2539
3224
  `physics snapshot: ${state.physics.snapshot.stage} (${state.physics.snapshot.stability})`,
2540
3225
  `physics contacts: ${state.contactCount}`,
2541
- `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(" · ")}`,
2542
3227
  `cloth band: ${cloth.band} -> ${cloth.representation.output}`,
2543
3228
  `fluid near band: ${water.bandMeshes[0].representation.output}`,
2544
3229
  `lighting profile: ${lightingPlan.profile} (${lightingDistanceBands.length} bands)`,
@@ -2561,12 +3246,8 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2561
3246
  ];
2562
3247
  const sceneNotes =
2563
3248
  state.focus === "physics"
2564
- ? [
2565
- "Stable world snapshots are taken after the authoritative rigid-body commit and before visual follow-up work.",
2566
- "The ships collide with mass-weighted impulses and positional correction, so the heavier hull keeps more of its line.",
2567
- "Moonlight keeps the overall read legible while lanterns and torches make collision moments easy to track against the water.",
2568
- ]
2569
- : SCENE_NOTES;
3249
+ ? PHYSICS_SCENE_NOTE_KEYS.map((key) => state.translate(key))
3250
+ : SCENE_NOTE_KEYS.map((key) => state.translate(key));
2570
3251
  const custom = state.demoDescription ?? null;
2571
3252
 
2572
3253
  setListContent(
@@ -2586,13 +3267,23 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2586
3267
  dom.status.textContent =
2587
3268
  typeof custom?.status === "string"
2588
3269
  ? custom.status
2589
- : `3D scene live · ${state.lastDecision.metrics.fps.toFixed(1)} FPS`;
3270
+ : state.translate(gpuSharedTranslationKeys.statusLive, {
3271
+ fps: state.lastDecision.metrics.fps.toFixed(1),
3272
+ });
2590
3273
  dom.details.textContent =
2591
3274
  typeof custom?.details === "string"
2592
3275
  ? custom.details
2593
3276
  : state.focus === "physics"
2594
- ? `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.`
2595
- : `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
+ });
2596
3287
  }
2597
3288
 
2598
3289
  function updateSceneState(state, dt, shipModel) {
@@ -2624,15 +3315,18 @@ function syncTextState(state, shipModel) {
2624
3315
  stress: state.stress,
2625
3316
  ships: state.ships.map((ship) => ({
2626
3317
  id: ship.id,
3318
+ modelKey: ship.modelKey ?? "brigantine",
2627
3319
  x: Number(ship.position.x.toFixed(2)),
2628
3320
  y: Number(ship.position.y.toFixed(2)),
2629
3321
  z: Number(ship.position.z.toFixed(2)),
2630
3322
  vx: Number(ship.velocity.x.toFixed(2)),
2631
3323
  vz: Number(ship.velocity.z.toFixed(2)),
2632
- massKg: Math.round(getShipMass(ship, shipModel)),
3324
+ massKg: Math.round(getShipMass(ship, resolveShipModel(state, ship, shipModel))),
2633
3325
  lanterns: Array.isArray(ship.lanterns) ? ship.lanterns.length : 0,
2634
3326
  })),
2635
- shipPhysics: shipModel.physics,
3327
+ shipPhysics: Object.fromEntries(
3328
+ state.ships.map((ship) => [ship.id, resolveShipModel(state, ship, shipModel)?.physics ?? null])
3329
+ ),
2636
3330
  sprays: state.sprays.length,
2637
3331
  waveImpulses: state.waveImpulses.length,
2638
3332
  pressure: state.lastDecision?.pressureLevel ?? "stable",
@@ -2656,23 +3350,39 @@ function syncTextState(state, shipModel) {
2656
3350
  };
2657
3351
  }
2658
3352
 
2659
- export async function mountGpuShowcase(options = {}) {
3353
+ export async function mountGpuShowcase(options = {}, featureFlags = null) {
2660
3354
  injectStyles();
2661
3355
  const root = options.root ?? document.body;
2662
3356
  root.classList?.add?.(ROOT_CLASS);
3357
+ const captureSettings = resolveCaptureSettings(options);
3358
+ if (captureSettings.captureMode) {
3359
+ root.classList?.add?.(CAPTURE_CLASS);
3360
+ }
2663
3361
  const previousMarkup = root.innerHTML;
2664
3362
  const previousRenderGameToText = window.render_game_to_text;
2665
3363
  const previousAdvanceTime = window.advanceTime;
2666
3364
  const focus = options.focus ?? new URLSearchParams(window.location.search).get("focus") ?? "integrated";
3365
+ const translate = createGpuSharedTranslator(options.translate);
2667
3366
  const dom = buildDemoDom(root, {
2668
3367
  packageName: options.packageName ?? "@plasius/gpu-demo-viewer",
2669
- title: options.title ?? DEFAULT_TITLE,
2670
- subtitle: options.subtitle ?? DEFAULT_SUBTITLE,
3368
+ title: options.title ?? translate(gpuSharedTranslationKeys.showcaseTitle),
3369
+ subtitle: options.subtitle ?? translate(gpuSharedTranslationKeys.showcaseSubtitle),
3370
+ translate,
2671
3371
  });
2672
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];
2673
3384
 
2674
- const state = createSceneState({ focus });
2675
- const shipModel = await loadGltfModel(resolveShowcaseAssetUrl());
3385
+ state.assetCatalog = assetCatalog;
2676
3386
  state.shipModel = shipModel;
2677
3387
  state.packageState =
2678
3388
  typeof options.createState === "function" ? options.createState() : undefined;
@@ -2685,6 +3395,8 @@ export async function mountGpuShowcase(options = {}) {
2685
3395
  if (!ctx) {
2686
3396
  throw new Error("2D canvas context is required for the shared showcase.");
2687
3397
  }
3398
+ ctx.imageSmoothingEnabled = true;
3399
+ ctx.imageSmoothingQuality = "high";
2688
3400
  let animationFrameId = 0;
2689
3401
  let destroyed = false;
2690
3402
  const renderFrame = (nowMs) => {
@@ -2706,6 +3418,7 @@ export async function mountGpuShowcase(options = {}) {
2706
3418
  }
2707
3419
 
2708
3420
  state.demoDescription = resolveSceneDescription(state, options, shipModel).description;
3421
+ resizeCanvasToDisplaySize(dom.canvas, state);
2709
3422
  renderScene(ctx, dom.canvas, state, shipModel, dom);
2710
3423
  syncTextState(state, shipModel);
2711
3424
  animationFrameId = requestAnimationFrame(renderFrame);
@@ -2713,7 +3426,9 @@ export async function mountGpuShowcase(options = {}) {
2713
3426
 
2714
3427
  const handlePauseClick = () => {
2715
3428
  state.paused = !state.paused;
2716
- dom.pauseButton.textContent = state.paused ? "Resume" : "Pause";
3429
+ dom.pauseButton.textContent = state.paused
3430
+ ? state.translate(gpuSharedTranslationKeys.resume)
3431
+ : state.translate(gpuSharedTranslationKeys.pause);
2717
3432
  };
2718
3433
  const handleStressChange = () => {
2719
3434
  state.stress = dom.stressToggle.checked;
@@ -2750,6 +3465,7 @@ export async function mountGpuShowcase(options = {}) {
2750
3465
  state.packageState = undefined;
2751
3466
  }
2752
3467
  root.classList?.remove?.(ROOT_CLASS);
3468
+ root.classList?.remove?.(CAPTURE_CLASS);
2753
3469
  root.innerHTML = previousMarkup;
2754
3470
  if (typeof previousRenderGameToText === "function") {
2755
3471
  window.render_game_to_text = previousRenderGameToText;
@@ -2771,6 +3487,13 @@ export async function mountGpuShowcase(options = {}) {
2771
3487
  }
2772
3488
 
2773
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
+
2774
3497
  state.physics.snapshot = createPhysicsWorldSnapshot({
2775
3498
  frameId: `showcase-${state.frame}`,
2776
3499
  tick: state.frame,
@@ -2787,6 +3510,7 @@ function updatePhysicsSnapshot(state, shipModel) {
2787
3510
  contactCount: state.contactCount,
2788
3511
  snapshotStageId: state.physics.plan.snapshotStageId,
2789
3512
  rigidBodyShape: shipModel.physics.shape ?? "box",
3513
+ rigidBodyShapes,
2790
3514
  },
2791
3515
  });
2792
3516
  }