@plasius/gpu-shared 0.1.3 → 0.1.6

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.
@@ -31,12 +31,21 @@ import {
31
31
  getPhysicsWorkerManifest,
32
32
  } from "@plasius/gpu-physics/browser";
33
33
 
34
+ import { resolveShowcaseAssetUrl } from "./asset-url.js";
34
35
  import { loadGltfModel } from "./gltf-loader.js";
35
36
 
36
37
  const STYLE_ID = "plasius-shared-3d-showcase-style";
38
+ const ROOT_CLASS = "plasius-showcase-root";
37
39
  const DEFAULT_TITLE = "Flag by the Sea";
38
40
  const DEFAULT_SUBTITLE =
39
41
  "Shared 3D validation scene using GLTF ships, cloth, fluid continuity, adaptive performance, and telemetry.";
42
+ const SHIP_SCALE = 1.1;
43
+ const HARBOR_BOUNDS = Object.freeze({
44
+ minX: -11.2,
45
+ maxX: 11.2,
46
+ minZ: 1.8,
47
+ maxZ: 17.2,
48
+ });
40
49
  const CAMERA_PRESETS = Object.freeze({
41
50
  integrated: Object.freeze({ yaw: -0.55, pitch: 0.34, distance: 27, target: [0, 2.2, 0] }),
42
51
  lighting: Object.freeze({ yaw: -0.28, pitch: 0.28, distance: 23, target: [0, 2.8, 0] }),
@@ -49,10 +58,10 @@ const CAMERA_PRESETS = Object.freeze({
49
58
  export const showcaseFocusModes = Object.freeze(Object.keys(CAMERA_PRESETS));
50
59
 
51
60
  const SCENE_NOTES = Object.freeze([
52
- "Ships are loaded from a GLTF asset and carry physics metadata from node extras.",
53
- "Near-field lighting uses the ray-traced-primary shadow and reflection path before stepping down by distance band.",
54
- "Cloth and fluid continuity stay coherent across near, mid, far, and horizon bands.",
55
- "Performance pressure reduces visual detail before authoritative collision motion is touched.",
61
+ "Ships are loaded from a GLTF asset and carry mass, damping, restitution, and hull extents from node extras.",
62
+ "Moonlight sets the cold ambient read while deck lanterns and harbor torches provide warm local contrast.",
63
+ "Cloth and fluid continuity stay coherent across near, mid, far, and horizon bands even in the darker night palette.",
64
+ "Performance pressure reduces visual detail before mass-weighted authoritative collision motion is touched.",
56
65
  ]);
57
66
 
58
67
  const UNIT_BOX_MESH = Object.freeze({
@@ -76,10 +85,18 @@ const UNIT_BOX_MESH = Object.freeze({
76
85
  ]),
77
86
  });
78
87
 
79
- export function resolveShowcaseAssetUrl(baseUrl = import.meta.url) {
80
- return new URL("../assets/brigantine.gltf", baseUrl);
81
- }
88
+ const SHIP_LANTERNS = Object.freeze([
89
+ Object.freeze({ x: 0.94, y: 1.54, z: 2.52, glow: 1 }),
90
+ Object.freeze({ x: -0.9, y: 1.58, z: 2.44, glow: 0.92 }),
91
+ Object.freeze({ x: 0.62, y: 1.42, z: -2.18, glow: 0.88 }),
92
+ Object.freeze({ x: -0.58, y: 1.46, z: -2.04, glow: 0.84 }),
93
+ ]);
82
94
 
95
+ const HARBOR_TORCHES = Object.freeze([
96
+ Object.freeze({ x: -5.2, y: 1.25, z: 1.36, glow: 1.1 }),
97
+ Object.freeze({ x: -8.6, y: 2.48, z: -0.72, glow: 1 }),
98
+ Object.freeze({ x: -10.4, y: 1.28, z: 0.82, glow: 0.92 }),
99
+ ]);
83
100
  function injectStyles() {
84
101
  if (document.getElementById(STYLE_ID)) {
85
102
  return;
@@ -88,27 +105,27 @@ function injectStyles() {
88
105
  const style = document.createElement("style");
89
106
  style.id = STYLE_ID;
90
107
  style.textContent = `
91
- :root {
92
- color-scheme: light;
93
- --plasius-paper: #f4f7f8;
94
- --plasius-ink: #152028;
95
- --plasius-muted: #5c6f7b;
96
- --plasius-accent: #8f5634;
97
- --plasius-panel: rgba(255, 255, 255, 0.82);
98
- --plasius-border: rgba(21, 32, 40, 0.12);
99
- --plasius-shadow: 0 20px 48px rgba(15, 24, 31, 0.16);
100
- }
101
- * {
102
- box-sizing: border-box;
103
- }
104
- body {
108
+ .${ROOT_CLASS} {
109
+ color-scheme: dark;
110
+ --plasius-paper: #081321;
111
+ --plasius-ink: #edf4ff;
112
+ --plasius-muted: #b6c5dd;
113
+ --plasius-accent: #f3b16a;
114
+ --plasius-panel: rgba(8, 19, 33, 0.72);
115
+ --plasius-border: rgba(159, 185, 223, 0.18);
116
+ --plasius-shadow: 0 24px 56px rgba(1, 6, 14, 0.44);
105
117
  margin: 0;
106
- min-height: 100vh;
118
+ min-height: 100%;
107
119
  font-family: "Fraunces", "Iowan Old Style", serif;
108
120
  color: var(--plasius-ink);
109
121
  background:
110
- radial-gradient(circle at top left, rgba(255, 247, 238, 0.92), transparent 34%),
111
- linear-gradient(180deg, #f6f8fb 0%, #d2dee6 48%, #b6c4ce 100%);
122
+ radial-gradient(circle at 18% 12%, rgba(73, 101, 170, 0.28), transparent 30%),
123
+ radial-gradient(circle at 82% 18%, rgba(240, 188, 103, 0.08), transparent 18%),
124
+ linear-gradient(180deg, #04101d 0%, #0b1930 42%, #081321 100%);
125
+ }
126
+ .${ROOT_CLASS},
127
+ .${ROOT_CLASS} * {
128
+ box-sizing: border-box;
112
129
  }
113
130
  .plasius-demo {
114
131
  width: min(1560px, calc(100vw - 32px));
@@ -142,7 +159,7 @@ function injectStyles() {
142
159
  text-transform: uppercase;
143
160
  letter-spacing: 0.18em;
144
161
  font-size: 12px;
145
- color: rgba(21, 32, 40, 0.56);
162
+ color: rgba(226, 236, 255, 0.58);
146
163
  }
147
164
  .plasius-demo h1,
148
165
  .plasius-demo h2,
@@ -160,7 +177,7 @@ function injectStyles() {
160
177
  margin: 0;
161
178
  padding: 8px 12px;
162
179
  border-radius: 999px;
163
- background: rgba(143, 86, 52, 0.12);
180
+ background: rgba(243, 177, 106, 0.14);
164
181
  color: var(--plasius-accent);
165
182
  font-weight: 700;
166
183
  }
@@ -182,8 +199,8 @@ function injectStyles() {
182
199
  aspect-ratio: 16 / 9;
183
200
  display: block;
184
201
  border-radius: 20px;
185
- border: 1px solid rgba(21, 32, 40, 0.08);
186
- background: linear-gradient(180deg, #dce8ef 0%, #a9bfd0 42%, #0f5168 42%, #092433 100%);
202
+ border: 1px solid rgba(159, 185, 223, 0.12);
203
+ background: linear-gradient(180deg, #071220 0%, #132440 42%, #10344b 42%, #05111d 100%);
187
204
  }
188
205
  .plasius-demo__toolbar {
189
206
  position: absolute;
@@ -203,9 +220,9 @@ function injectStyles() {
203
220
  .plasius-demo button,
204
221
  .plasius-demo .plasius-toggle,
205
222
  .plasius-demo select {
206
- border: 1px solid rgba(21, 32, 40, 0.12);
223
+ border: 1px solid rgba(159, 185, 223, 0.18);
207
224
  border-radius: 999px;
208
- background: rgba(255, 255, 255, 0.84);
225
+ background: rgba(9, 20, 34, 0.84);
209
226
  color: var(--plasius-ink);
210
227
  padding: 10px 14px;
211
228
  }
@@ -244,8 +261,8 @@ function injectStyles() {
244
261
  bottom: 24px;
245
262
  padding: 10px 14px;
246
263
  border-radius: 16px;
247
- background: rgba(255, 255, 255, 0.84);
248
- border: 1px solid rgba(21, 32, 40, 0.1);
264
+ background: rgba(9, 20, 34, 0.82);
265
+ border: 1px solid rgba(159, 185, 223, 0.16);
249
266
  color: var(--plasius-muted);
250
267
  font-size: 12px;
251
268
  line-height: 1.45;
@@ -257,7 +274,7 @@ function injectStyles() {
257
274
  }
258
275
  .plasius-demo__footer {
259
276
  margin-top: 4px;
260
- color: rgba(21, 32, 40, 0.66);
277
+ color: rgba(226, 236, 255, 0.68);
261
278
  font-size: 13px;
262
279
  line-height: 1.6;
263
280
  }
@@ -279,6 +296,16 @@ function mix(a, b, t) {
279
296
  return a + (b - a) * t;
280
297
  }
281
298
 
299
+ function smoothstep(min, max, value) {
300
+ const t = clamp((value - min) / Math.max(0.0001, max - min), 0, 1);
301
+ return t * t * (3 - 2 * t);
302
+ }
303
+
304
+ function pseudoRandom(seed) {
305
+ const value = Math.sin(seed * 12.9898 + seed * seed * 0.0017) * 43758.5453;
306
+ return value - Math.floor(value);
307
+ }
308
+
282
309
  function vec3(x = 0, y = 0, z = 0) {
283
310
  return { x, y, z };
284
311
  }
@@ -321,6 +348,14 @@ function reflectVec3(vector, normal) {
321
348
  return subVec3(vector, scaleVec3(unitNormal, 2 * dotVec3(vector, unitNormal)));
322
349
  }
323
350
 
351
+ function directionFromYaw(yaw) {
352
+ return normalizeVec3(vec3(Math.sin(yaw), 0, Math.cos(yaw)));
353
+ }
354
+
355
+ function perpendicularOnWater(direction) {
356
+ return vec3(-direction.z, 0, direction.x);
357
+ }
358
+
324
359
  function rotateY(point, angle) {
325
360
  const cosine = Math.cos(angle);
326
361
  const sine = Math.sin(angle);
@@ -525,7 +560,7 @@ function buildDemoDom(root, options) {
525
560
  <section class="plasius-panel plasius-demo__status">
526
561
  <p id="demoStatus" class="plasius-demo__status-badge">Booting 3D scene…</p>
527
562
  <p id="demoDetails" class="plasius-demo__status-text">
528
- Preparing GLTF assets, cloth and fluid continuity plans, and adaptive quality metadata.
563
+ Preparing a moonlit harbor scene, GLTF hull data, cloth and fluid continuity plans, and adaptive quality metadata.
529
564
  </p>
530
565
  </section>
531
566
  </section>
@@ -553,9 +588,9 @@ function buildDemoDom(root, options) {
553
588
  </div>
554
589
  <div class="plasius-demo__legend">
555
590
  <strong>Scene</strong>
556
- GLTF ships carry collision metadata.<br />
557
- Flag cloth and ocean waves scale by distance band.<br />
558
- Ray-traced shadow and reflection style is preserved near the camera.
591
+ GLTF ships carry hull mass and damping metadata.<br />
592
+ Lanterns and torches warm the moonlit harbor.<br />
593
+ Mass-aware collisions stay authoritative near the camera.
559
594
  </div>
560
595
  </section>
561
596
  <aside class="plasius-demo__sidebar">
@@ -638,6 +673,7 @@ function buildSceneSnapshot(state, shipModel) {
638
673
  })
639
674
  )
640
675
  ),
676
+ shipPhysics: shipModel?.physics ?? null,
641
677
  physics: Object.freeze({
642
678
  profile: state.physics.profile,
643
679
  plan: state.physics.plan,
@@ -689,28 +725,39 @@ function readVisualNumber(value, fallback) {
689
725
  function resolveVisualConfig(nearLighting, lightingSnapshot, customVisuals = {}) {
690
726
  const premiumShadows = nearLighting.primaryShadowSource === "ray-traced-primary";
691
727
  const defaults = {
692
- skyTop: premiumShadows ? "#f0f7fb" : "#e8f1f7",
693
- skyMid: premiumShadows ? "#c7d9e5" : "#b9ceda",
694
- skyBottom: premiumShadows ? "#84a7bd" : "#7b9bb0",
695
- seaTop: premiumShadows ? "#235064" : "#264c5f",
696
- seaMid: premiumShadows ? "#153e53" : "#173d4f",
697
- seaBottom: "#0b2433",
698
- sunCore: "rgba(255, 244, 210, 0.9)",
728
+ skyTop: premiumShadows ? "#040c1a" : "#06101f",
729
+ skyMid: premiumShadows ? "#11203b" : "#152643",
730
+ skyBottom: premiumShadows ? "#2f4468" : "#364d73",
731
+ duskGlow: premiumShadows ? "rgba(116, 142, 201, 0.26)" : "rgba(104, 128, 188, 0.22)",
732
+ seaTop: premiumShadows ? "#102946" : "#153050",
733
+ seaMid: premiumShadows ? "#0a1d33" : "#0d2138",
734
+ seaBottom: "#04101d",
735
+ moonCore: "rgba(241, 246, 255, 0.98)",
736
+ moonHalo: "rgba(167, 191, 255, 0.24)",
737
+ moonReflection: "rgba(192, 214, 255, 0.22)",
738
+ starColor: "rgba(232, 239, 255, 0.82)",
739
+ ambientMist: "rgba(41, 63, 97, 0.16)",
699
740
  reflectionStrength: lightingSnapshot.currentLevel.config.reflectionStrength,
700
741
  shadowAccent: lightingSnapshot.currentLevel.config.shadowStrength,
701
- waveAmplitude: 1,
702
- waveDirection: { x: 0.86, z: 0.34 },
703
- wavePhaseSpeed: 1,
704
- wakeStrength: 0.24,
705
- wakeLength: 15,
706
- collisionRippleStrength: 0.34,
707
- waterNear: { r: 0.12, g: 0.36, b: 0.46 },
708
- waterFar: { r: 0.28, g: 0.56, b: 0.68 },
709
- harborWall: { r: 0.48, g: 0.4, b: 0.32 },
710
- harborDeck: { r: 0.5, g: 0.34, b: 0.22 },
711
- harborTower: { r: 0.34, g: 0.32, b: 0.36 },
712
- flagColor: { r: 0.76, g: 0.24, b: 0.18 },
713
- flagMotion: 1,
742
+ waveAmplitude: 0.94,
743
+ waveDirection: { x: 0.88, z: 0.28 },
744
+ wavePhaseSpeed: 0.88,
745
+ wakeStrength: 0.31,
746
+ wakeLength: 18,
747
+ collisionRippleStrength: 0.42,
748
+ waterNear: { r: 0.08, g: 0.23, b: 0.33 },
749
+ waterFar: { r: 0.18, g: 0.35, b: 0.49 },
750
+ harborWall: { r: 0.26, g: 0.24, b: 0.28 },
751
+ harborDeck: { r: 0.33, g: 0.22, b: 0.16 },
752
+ harborTower: { r: 0.23, g: 0.24, b: 0.29 },
753
+ flagColor: { r: 0.66, g: 0.16, b: 0.13 },
754
+ flagMotion: 0.92,
755
+ lanternCore: { r: 0.98, g: 0.8, b: 0.48 },
756
+ lanternGlow: { r: 1, g: 0.56, b: 0.2 },
757
+ lanternReflectionStrength: 0.42,
758
+ torchCore: { r: 0.99, g: 0.72, b: 0.36 },
759
+ torchGlow: { r: 0.98, g: 0.38, b: 0.15 },
760
+ collisionFlash: "rgba(255, 212, 168, 0.16)",
714
761
  };
715
762
 
716
763
  return {
@@ -722,8 +769,26 @@ function resolveVisualConfig(nearLighting, lightingSnapshot, customVisuals = {})
722
769
  seaMid: typeof customVisuals.seaMid === "string" ? customVisuals.seaMid : defaults.seaMid,
723
770
  seaBottom:
724
771
  typeof customVisuals.seaBottom === "string" ? customVisuals.seaBottom : defaults.seaBottom,
725
- sunCore:
726
- typeof customVisuals.sunCore === "string" ? customVisuals.sunCore : defaults.sunCore,
772
+ duskGlow:
773
+ typeof customVisuals.duskGlow === "string" ? customVisuals.duskGlow : defaults.duskGlow,
774
+ moonCore:
775
+ typeof customVisuals.moonCore === "string"
776
+ ? customVisuals.moonCore
777
+ : typeof customVisuals.sunCore === "string"
778
+ ? customVisuals.sunCore
779
+ : defaults.moonCore,
780
+ moonHalo:
781
+ typeof customVisuals.moonHalo === "string" ? customVisuals.moonHalo : defaults.moonHalo,
782
+ moonReflection:
783
+ typeof customVisuals.moonReflection === "string"
784
+ ? customVisuals.moonReflection
785
+ : defaults.moonReflection,
786
+ starColor:
787
+ typeof customVisuals.starColor === "string" ? customVisuals.starColor : defaults.starColor,
788
+ ambientMist:
789
+ typeof customVisuals.ambientMist === "string"
790
+ ? customVisuals.ambientMist
791
+ : defaults.ambientMist,
727
792
  reflectionStrength: readVisualNumber(
728
793
  customVisuals.reflectionStrength,
729
794
  defaults.reflectionStrength
@@ -750,6 +815,18 @@ function resolveVisualConfig(nearLighting, lightingSnapshot, customVisuals = {})
750
815
  harborTower: normalizeColorOverride(customVisuals.harborTower, defaults.harborTower),
751
816
  flagColor: normalizeColorOverride(customVisuals.flagColor, defaults.flagColor),
752
817
  flagMotion: readVisualNumber(customVisuals.flagMotion, defaults.flagMotion),
818
+ lanternCore: normalizeColorOverride(customVisuals.lanternCore, defaults.lanternCore),
819
+ lanternGlow: normalizeColorOverride(customVisuals.lanternGlow, defaults.lanternGlow),
820
+ lanternReflectionStrength: readVisualNumber(
821
+ customVisuals.lanternReflectionStrength,
822
+ defaults.lanternReflectionStrength
823
+ ),
824
+ torchCore: normalizeColorOverride(customVisuals.torchCore, defaults.torchCore),
825
+ torchGlow: normalizeColorOverride(customVisuals.torchGlow, defaults.torchGlow),
826
+ collisionFlash:
827
+ typeof customVisuals.collisionFlash === "string"
828
+ ? customVisuals.collisionFlash
829
+ : defaults.collisionFlash,
753
830
  };
754
831
  }
755
832
 
@@ -1055,18 +1132,34 @@ function createSceneState(options) {
1055
1132
  {
1056
1133
  id: "northwind",
1057
1134
  position: vec3(-5.2, 0, 7.2),
1058
- velocity: vec3(2.1, 0, -1.6),
1059
- rotationY: 0.42,
1060
- angularVelocity: 0.18,
1135
+ velocity: vec3(2.35, 0, -1.08),
1136
+ rotationY: 0.58,
1137
+ angularVelocity: 0.09,
1061
1138
  tint: { r: 0.62, g: 0.39, b: 0.23 },
1139
+ massScale: 1.42,
1140
+ cruiseSpeed: 2.25,
1141
+ throttleResponse: 0.46,
1142
+ rudderResponse: 0.54,
1143
+ wanderPhase: 0.35,
1144
+ lanterns: SHIP_LANTERNS,
1145
+ lanternStrength: 1.06,
1146
+ collisionRadiusScale: 1.04,
1062
1147
  },
1063
1148
  {
1064
1149
  id: "tidecaller",
1065
1150
  position: vec3(4.8, 0, 4.4),
1066
- velocity: vec3(-1.85, 0, 1.25),
1067
- rotationY: -2.62,
1068
- angularVelocity: -0.14,
1151
+ velocity: vec3(-2.15, 0, 1.74),
1152
+ rotationY: -2.48,
1153
+ angularVelocity: -0.2,
1069
1154
  tint: { r: 0.48, g: 0.28, b: 0.19 },
1155
+ massScale: 0.84,
1156
+ cruiseSpeed: 2.68,
1157
+ throttleResponse: 0.7,
1158
+ rudderResponse: 0.78,
1159
+ wanderPhase: 1.6,
1160
+ lanterns: SHIP_LANTERNS,
1161
+ lanternStrength: 1.18,
1162
+ collisionRadiusScale: 0.94,
1070
1163
  },
1071
1164
  ],
1072
1165
  sprays: [],
@@ -1090,14 +1183,30 @@ function setListContent(element, values) {
1090
1183
  }
1091
1184
 
1092
1185
  function drawSkyAndShore(ctx, canvas, state, nearLighting, reflectionStrength, shadowStrength, visuals) {
1093
- const premiumShadows = nearLighting.primaryShadowSource === "ray-traced-primary";
1094
1186
  const sky = ctx.createLinearGradient(0, 0, 0, canvas.height * 0.5);
1095
1187
  sky.addColorStop(0, visuals.skyTop);
1096
- sky.addColorStop(0.6, visuals.skyMid);
1188
+ sky.addColorStop(0.54, visuals.skyMid);
1097
1189
  sky.addColorStop(1, visuals.skyBottom);
1098
1190
  ctx.fillStyle = sky;
1099
1191
  ctx.fillRect(0, 0, canvas.width, canvas.height);
1100
1192
 
1193
+ for (let index = 0; index < 70; index += 1) {
1194
+ const x = pseudoRandom(index + 13) * canvas.width;
1195
+ const y = pseudoRandom(index * 7 + 5) * canvas.height * 0.42;
1196
+ const twinkle = 0.45 + Math.sin(state.time * 1.4 + index * 0.73) * 0.25;
1197
+ const radius = 0.6 + pseudoRandom(index * 11 + 2) * 1.9;
1198
+ ctx.fillStyle = visuals.starColor.replace(/[\d.]+\)$/u, `${clamp(twinkle, 0.16, 0.92)})`);
1199
+ ctx.beginPath();
1200
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
1201
+ ctx.fill();
1202
+ }
1203
+
1204
+ const horizonGlow = ctx.createLinearGradient(0, canvas.height * 0.22, 0, canvas.height * 0.62);
1205
+ horizonGlow.addColorStop(0, "rgba(0, 0, 0, 0)");
1206
+ horizonGlow.addColorStop(1, visuals.duskGlow);
1207
+ ctx.fillStyle = horizonGlow;
1208
+ ctx.fillRect(0, canvas.height * 0.2, canvas.width, canvas.height * 0.45);
1209
+
1101
1210
  const shoreline = ctx.createLinearGradient(0, canvas.height * 0.45, 0, canvas.height);
1102
1211
  shoreline.addColorStop(0, visuals.seaTop);
1103
1212
  shoreline.addColorStop(0.52, visuals.seaMid);
@@ -1105,30 +1214,48 @@ function drawSkyAndShore(ctx, canvas, state, nearLighting, reflectionStrength, s
1105
1214
  ctx.fillStyle = shoreline;
1106
1215
  ctx.fillRect(0, canvas.height * 0.45, canvas.width, canvas.height * 0.55);
1107
1216
 
1108
- const sunX = mix(canvas.width * 0.16, canvas.width * 0.84, (Math.sin(state.time * 0.12) + 1) * 0.5);
1109
- const sunY = canvas.height * 0.14 + Math.cos(state.time * 0.12) * 22;
1110
- const sun = ctx.createRadialGradient(sunX, sunY, 10, sunX, sunY, 90);
1111
- sun.addColorStop(0, visuals.sunCore);
1112
- sun.addColorStop(1, "rgba(255, 244, 210, 0)");
1113
- ctx.fillStyle = sun;
1217
+ const moonX = canvas.width * 0.76 + Math.sin(state.time * 0.045) * 18;
1218
+ const moonY = canvas.height * 0.17 + Math.cos(state.time * 0.05) * 10;
1219
+ const moon = ctx.createRadialGradient(moonX, moonY, 14, moonX, moonY, 126);
1220
+ moon.addColorStop(0, visuals.moonCore);
1221
+ moon.addColorStop(0.46, visuals.moonHalo);
1222
+ moon.addColorStop(1, "rgba(167, 191, 255, 0)");
1223
+ ctx.fillStyle = moon;
1224
+ ctx.beginPath();
1225
+ ctx.arc(moonX, moonY, 94, 0, Math.PI * 2);
1226
+ ctx.fill();
1227
+
1228
+ const moonCore = ctx.createRadialGradient(moonX, moonY, 4, moonX, moonY, 28);
1229
+ moonCore.addColorStop(0, "rgba(255, 255, 255, 0.98)");
1230
+ moonCore.addColorStop(1, visuals.moonCore);
1231
+ ctx.fillStyle = moonCore;
1114
1232
  ctx.beginPath();
1115
- ctx.arc(sunX, sunY, 90, 0, Math.PI * 2);
1233
+ ctx.arc(moonX, moonY, 24, 0, Math.PI * 2);
1116
1234
  ctx.fill();
1117
1235
 
1118
- const track = ctx.createLinearGradient(sunX, canvas.height * 0.46, sunX, canvas.height * 0.96);
1119
- track.addColorStop(0, `rgba(255, 243, 214, ${0.08 + reflectionStrength * 0.18})`);
1120
- track.addColorStop(0.42, `rgba(224, 242, 255, ${0.04 + reflectionStrength * 0.2})`);
1121
- track.addColorStop(1, "rgba(224, 242, 255, 0)");
1236
+ const track = ctx.createLinearGradient(moonX, canvas.height * 0.44, moonX, canvas.height * 0.98);
1237
+ track.addColorStop(0, visuals.moonReflection.replace(/[\d.]+\)$/u, `${0.08 + reflectionStrength * 0.12})`));
1238
+ track.addColorStop(0.42, visuals.moonReflection.replace(/[\d.]+\)$/u, `${0.04 + reflectionStrength * 0.18})`));
1239
+ track.addColorStop(1, "rgba(192, 214, 255, 0)");
1122
1240
  ctx.save();
1123
1241
  ctx.globalCompositeOperation = "screen";
1124
1242
  ctx.fillStyle = track;
1125
1243
  ctx.beginPath();
1126
- ctx.ellipse(sunX, canvas.height * 0.72, 46 + shadowStrength * 60, canvas.height * 0.26, 0, 0, Math.PI * 2);
1244
+ ctx.ellipse(moonX, canvas.height * 0.75, 38 + shadowStrength * 42, canvas.height * 0.24, 0, 0, Math.PI * 2);
1127
1245
  ctx.fill();
1128
1246
  ctx.restore();
1129
1247
 
1248
+ const mist = ctx.createLinearGradient(0, canvas.height * 0.5, 0, canvas.height);
1249
+ mist.addColorStop(0, "rgba(0, 0, 0, 0)");
1250
+ mist.addColorStop(1, visuals.ambientMist);
1251
+ ctx.fillStyle = mist;
1252
+ ctx.fillRect(0, canvas.height * 0.45, canvas.width, canvas.height * 0.55);
1253
+
1130
1254
  if (state.collisionFlash > 0.01) {
1131
- ctx.fillStyle = `rgba(255, 243, 228, ${state.collisionFlash * 0.14})`;
1255
+ ctx.fillStyle = visuals.collisionFlash.replace(
1256
+ /[\d.]+\)$/u,
1257
+ `${clamp(state.collisionFlash * 0.22, 0, 0.26)})`
1258
+ );
1132
1259
  ctx.fillRect(0, 0, canvas.width, canvas.height);
1133
1260
  }
1134
1261
  }
@@ -1238,7 +1365,7 @@ function pushHarborGeometry(camera, viewport, triangles, visuals) {
1238
1365
  }
1239
1366
 
1240
1367
  function renderShipRigging(ctx, ship, camera, viewport) {
1241
- const transform = { position: ship.position, rotationY: ship.rotationY, scale: 1.1 };
1368
+ const transform = { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE };
1242
1369
  const mastBase = transformPoint(vec3(0, 0.38, -0.4), transform);
1243
1370
  const mastTop = transformPoint(vec3(0, 3.8, -0.2), transform);
1244
1371
  const aftBase = transformPoint(vec3(-0.25, 0.32, -1.9), transform);
@@ -1353,6 +1480,43 @@ function renderWaterHighlights(ctx, waterBands, camera, viewport) {
1353
1480
  }
1354
1481
  }
1355
1482
 
1483
+ function readPhysicsNumber(physics, key, fallback) {
1484
+ const value = physics?.[key];
1485
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
1486
+ }
1487
+
1488
+ function getShipMass(ship, shipModel) {
1489
+ const baseMass = readPhysicsNumber(shipModel.physics, "mass", 3200);
1490
+ return baseMass * readVisualNumber(ship.massScale, 1);
1491
+ }
1492
+
1493
+ function getShipHalfExtents(ship, shipModel) {
1494
+ const physicsHalfExtents = Array.isArray(shipModel.physics.halfExtents)
1495
+ ? shipModel.physics.halfExtents
1496
+ : [1.35, 0.95, 3.9];
1497
+ const scale = SHIP_SCALE * readVisualNumber(ship.collisionRadiusScale, 1);
1498
+ return {
1499
+ x: physicsHalfExtents[0] * scale,
1500
+ y: physicsHalfExtents[1] * scale,
1501
+ z: physicsHalfExtents[2] * scale,
1502
+ };
1503
+ }
1504
+
1505
+ function getShipCollisionRadius(ship, shipModel) {
1506
+ const halfExtents = getShipHalfExtents(ship, shipModel);
1507
+ return Math.max(halfExtents.x * 1.08, halfExtents.z * 0.62);
1508
+ }
1509
+
1510
+ function getShipInverseMass(ship, shipModel) {
1511
+ return 1 / Math.max(1, getShipMass(ship, shipModel));
1512
+ }
1513
+
1514
+ function getShipInverseInertia(ship, shipModel) {
1515
+ const radius = getShipCollisionRadius(ship, shipModel);
1516
+ const inertia = getShipMass(ship, shipModel) * radius * radius * 0.72;
1517
+ return 1 / Math.max(1, inertia);
1518
+ }
1519
+
1356
1520
  function spawnSpray(state, point, intensity) {
1357
1521
  const count = state.fluidDetail.getSnapshot().currentLevel.config.splashCount;
1358
1522
  for (let index = 0; index < count; index += 1) {
@@ -1366,58 +1530,226 @@ function spawnSpray(state, point, intensity) {
1366
1530
  }
1367
1531
  }
1368
1532
 
1369
- function updateShips(state, dt, shipModel) {
1533
+ function resolveShipRoute(ship, state, radius) {
1534
+ if (typeof ship.routeDirection !== "number") {
1535
+ ship.routeDirection = ship.velocity.x >= 0 ? 1 : -1;
1536
+ }
1537
+
1538
+ if (ship.position.x > HARBOR_BOUNDS.maxX - radius * 1.1) {
1539
+ ship.routeDirection = -1;
1540
+ } else if (ship.position.x < HARBOR_BOUNDS.minX + radius * 1.1) {
1541
+ ship.routeDirection = 1;
1542
+ }
1543
+
1544
+ const wander = Math.sin(state.time * 0.22 + readVisualNumber(ship.wanderPhase, 0));
1545
+ const crossCurrent = Math.cos(state.time * 0.31 + readVisualNumber(ship.wanderPhase, 0));
1546
+ const laneCenter =
1547
+ ship.id === "northwind"
1548
+ ? 10.2 + wander * 2.1 + crossCurrent * 0.6
1549
+ : 7 + wander * 3.3 - crossCurrent * 1.1;
1550
+ const targetX =
1551
+ ship.routeDirection > 0
1552
+ ? HARBOR_BOUNDS.maxX - radius * 1.7
1553
+ : HARBOR_BOUNDS.minX + radius * 1.7;
1554
+ return vec3(targetX, 0, clamp(laneCenter, HARBOR_BOUNDS.minZ + 1.8, HARBOR_BOUNDS.maxZ - 1.8));
1555
+ }
1556
+
1557
+ function updateShipMotion(state, ship, dt, shipModel) {
1370
1558
  const physics = shipModel.physics;
1371
- const halfExtents = physics.halfExtents ?? [1.35, 0.95, 3.9];
1559
+ const massScale = Math.max(0.55, readVisualNumber(ship.massScale, 1));
1560
+ const radius = getShipCollisionRadius(ship, shipModel);
1561
+ const waterline = readPhysicsNumber(physics, "waterline", 0.42);
1562
+ const linearDamping = readPhysicsNumber(physics, "linearDamping", 0.04);
1563
+ const angularDamping = readPhysicsNumber(physics, "angularDamping", 0.08);
1564
+ const throttleResponse = readVisualNumber(ship.throttleResponse, 0.58);
1565
+ const rudderResponse = readVisualNumber(ship.rudderResponse, 0.62);
1566
+ const cruiseSpeed = readVisualNumber(ship.cruiseSpeed, 2.4);
1567
+
1568
+ ship.collisionCooldown = Math.max(0, readVisualNumber(ship.collisionCooldown, 0) - dt);
1569
+
1570
+ const forward = directionFromYaw(ship.rotationY);
1571
+ const lateral = perpendicularOnWater(forward);
1572
+ const routeTarget = resolveShipRoute(ship, state, radius);
1573
+ const desiredHeading = Math.atan2(routeTarget.x - ship.position.x, routeTarget.z - ship.position.z);
1574
+ const headingError = Math.atan2(
1575
+ Math.sin(desiredHeading - ship.rotationY),
1576
+ Math.cos(desiredHeading - ship.rotationY)
1577
+ );
1578
+ ship.angularVelocity +=
1579
+ headingError * rudderResponse * dt * (1.18 / Math.sqrt(massScale)) +
1580
+ Math.sin(state.time * 0.9 + readVisualNumber(ship.wanderPhase, 0)) * dt * 0.04;
1581
+
1582
+ const waveDirection = resolveWaveDirection(state);
1583
+ const forwardSpeed = dotVec3(ship.velocity, forward);
1584
+ const lateralSpeed = dotVec3(ship.velocity, lateral);
1585
+ const thrust = (cruiseSpeed - forwardSpeed) * throttleResponse;
1586
+ const currentDrift = sampleWave(state, ship.position.x, ship.position.z, state.time) * 0.016;
1587
+ const acceleration = addVec3(
1588
+ scaleVec3(forward, thrust),
1589
+ addVec3(
1590
+ scaleVec3(lateral, -lateralSpeed * (1.28 + rudderResponse * 0.4)),
1591
+ scaleVec3(waveDirection, currentDrift / Math.sqrt(massScale))
1592
+ )
1593
+ );
1594
+
1595
+ ship.velocity = addVec3(ship.velocity, scaleVec3(acceleration, dt));
1596
+ ship.velocity = scaleVec3(
1597
+ ship.velocity,
1598
+ Math.max(0, 1 - (linearDamping / Math.pow(massScale, 0.22)) * dt)
1599
+ );
1600
+ ship.angularVelocity *= Math.max(
1601
+ 0,
1602
+ 1 - (angularDamping / Math.pow(massScale, 0.15)) * dt
1603
+ );
1604
+ ship.rotationY += ship.angularVelocity * dt;
1605
+ ship.position = addVec3(ship.position, scaleVec3(ship.velocity, dt));
1606
+ ship.position.y =
1607
+ sampleWave(state, ship.position.x, ship.position.z, state.time) * 0.24 + waterline;
1608
+ }
1609
+
1610
+ function resolveBoundaryCollision(ship, state, shipModel) {
1611
+ const restitution = readPhysicsNumber(shipModel.physics, "restitution", 0.22) * 0.56;
1612
+ const radius = getShipCollisionRadius(ship, shipModel);
1613
+ const boundaries = [
1614
+ { axis: "x", min: HARBOR_BOUNDS.minX + radius, max: HARBOR_BOUNDS.maxX - radius, normalMin: vec3(1, 0, 0), normalMax: vec3(-1, 0, 0) },
1615
+ { axis: "z", min: HARBOR_BOUNDS.minZ + radius, max: HARBOR_BOUNDS.maxZ - radius, normalMin: vec3(0, 0, 1), normalMax: vec3(0, 0, -1) },
1616
+ ];
1617
+
1618
+ for (const boundary of boundaries) {
1619
+ if (ship.position[boundary.axis] < boundary.min) {
1620
+ ship.position[boundary.axis] = boundary.min;
1621
+ const normal = boundary.normalMin;
1622
+ const speedIntoWall = dotVec3(ship.velocity, normal);
1623
+ if (speedIntoWall < 0) {
1624
+ ship.velocity = subVec3(
1625
+ ship.velocity,
1626
+ scaleVec3(normal, (1 + restitution) * speedIntoWall)
1627
+ );
1628
+ const tangent = vec3(-normal.z, 0, normal.x);
1629
+ const tangentSpeed = dotVec3(ship.velocity, tangent);
1630
+ ship.velocity = subVec3(ship.velocity, scaleVec3(tangent, tangentSpeed * 0.12));
1631
+ ship.angularVelocity += tangentSpeed * 0.004;
1632
+ }
1633
+ } else if (ship.position[boundary.axis] > boundary.max) {
1634
+ ship.position[boundary.axis] = boundary.max;
1635
+ const normal = boundary.normalMax;
1636
+ const speedIntoWall = dotVec3(ship.velocity, normal);
1637
+ if (speedIntoWall < 0) {
1638
+ ship.velocity = subVec3(
1639
+ ship.velocity,
1640
+ scaleVec3(normal, (1 + restitution) * speedIntoWall)
1641
+ );
1642
+ const tangent = vec3(-normal.z, 0, normal.x);
1643
+ const tangentSpeed = dotVec3(ship.velocity, tangent);
1644
+ ship.velocity = subVec3(ship.velocity, scaleVec3(tangent, tangentSpeed * 0.12));
1645
+ ship.angularVelocity += tangentSpeed * 0.004;
1646
+ }
1647
+ }
1648
+ }
1649
+ }
1650
+
1651
+ function resolveShipCollision(state, a, b, shipModel) {
1652
+ const delta = subVec3(b.position, a.position);
1653
+ const radiusA = getShipCollisionRadius(a, shipModel);
1654
+ const radiusB = getShipCollisionRadius(b, shipModel);
1655
+ const distance = Math.hypot(delta.x, delta.z);
1656
+ const minDistance = radiusA + radiusB;
1657
+ if (distance >= minDistance) {
1658
+ return false;
1659
+ }
1660
+
1661
+ const normal =
1662
+ distance > 0.0001
1663
+ ? normalizeVec3(vec3(delta.x / distance, 0, delta.z / distance))
1664
+ : normalizeVec3(vec3(Math.cos(state.time * 5.2), 0, Math.sin(state.time * 4.8)));
1665
+ const tangent = vec3(-normal.z, 0, normal.x);
1666
+ const penetration = minDistance - distance;
1667
+ const invMassA = getShipInverseMass(a, shipModel);
1668
+ const invMassB = getShipInverseMass(b, shipModel);
1669
+ const invMassSum = invMassA + invMassB;
1670
+ const correction = scaleVec3(normal, (penetration / Math.max(0.0001, invMassSum)) * 0.72);
1671
+ a.position = subVec3(a.position, scaleVec3(correction, invMassA));
1672
+ b.position = addVec3(b.position, scaleVec3(correction, invMassB));
1673
+
1674
+ const relativeVelocity = subVec3(b.velocity, a.velocity);
1675
+ const velocityAlongNormal = dotVec3(relativeVelocity, normal);
1676
+ const restitution = readPhysicsNumber(shipModel.physics, "restitution", 0.22) * 0.88;
1677
+ if (velocityAlongNormal < 0) {
1678
+ const impulseMagnitude =
1679
+ (-(1 + restitution) * velocityAlongNormal) / Math.max(0.0001, invMassSum);
1680
+ const impulse = scaleVec3(normal, impulseMagnitude);
1681
+ a.velocity = subVec3(a.velocity, scaleVec3(impulse, invMassA));
1682
+ b.velocity = addVec3(b.velocity, scaleVec3(impulse, invMassB));
1683
+
1684
+ const tangentSpeed = dotVec3(relativeVelocity, tangent);
1685
+ const frictionMagnitude = clamp(
1686
+ (-tangentSpeed / Math.max(0.0001, invMassSum)),
1687
+ -impulseMagnitude * 0.16,
1688
+ impulseMagnitude * 0.16
1689
+ );
1690
+ const frictionImpulse = scaleVec3(tangent, frictionMagnitude);
1691
+ a.velocity = subVec3(a.velocity, scaleVec3(frictionImpulse, invMassA));
1692
+ b.velocity = addVec3(b.velocity, scaleVec3(frictionImpulse, invMassB));
1693
+
1694
+ a.angularVelocity -=
1695
+ tangentSpeed * radiusA * getShipInverseInertia(a, shipModel) * 0.2 +
1696
+ impulseMagnitude * 0.00024;
1697
+ b.angularVelocity +=
1698
+ tangentSpeed * radiusB * getShipInverseInertia(b, shipModel) * 0.2 +
1699
+ impulseMagnitude * 0.00024;
1700
+
1701
+ const impactSpeed = Math.abs(velocityAlongNormal);
1702
+ if (
1703
+ impactSpeed > 0.18 &&
1704
+ Math.max(readVisualNumber(a.collisionCooldown, 0), readVisualNumber(b.collisionCooldown, 0)) <= 0
1705
+ ) {
1706
+ const contactPoint = vec3(
1707
+ (a.position.x + b.position.x) * 0.5,
1708
+ (a.position.y + b.position.y) * 0.5 + 0.14,
1709
+ (a.position.z + b.position.z) * 0.5
1710
+ );
1711
+ spawnSpray(state, contactPoint, impactSpeed * 2.4 + penetration * 8);
1712
+ state.waveImpulses.push({
1713
+ x: contactPoint.x,
1714
+ z: contactPoint.z,
1715
+ strength: clamp(0.24 + impactSpeed * 0.46 + penetration * 0.9, 0.2, 1.7),
1716
+ radius: 0.9 + penetration * 1.4,
1717
+ life: 1,
1718
+ });
1719
+ state.collisionCount += 1;
1720
+ state.collisionFlash = Math.max(
1721
+ state.collisionFlash,
1722
+ clamp(impactSpeed * 0.55 + penetration * 1.8, 0.16, 1)
1723
+ );
1724
+ a.collisionCooldown = 0.2;
1725
+ b.collisionCooldown = 0.2;
1726
+ }
1727
+ }
1728
+
1729
+ state.contactCount += 1;
1730
+ return true;
1731
+ }
1732
+
1733
+ function updateShips(state, dt, shipModel) {
1372
1734
  let collided = false;
1373
1735
  state.contactCount = 0;
1736
+
1374
1737
  for (const ship of state.ships) {
1375
- ship.position = addVec3(ship.position, scaleVec3(ship.velocity, dt));
1376
- ship.rotationY += ship.angularVelocity * dt;
1377
- ship.velocity = scaleVec3(ship.velocity, 1 - (physics.linearDamping ?? 0.04) * dt);
1378
- ship.angularVelocity *= 1 - (physics.angularDamping ?? 0.08) * dt;
1379
- ship.position.y =
1380
- sampleWave(state, ship.position.x, ship.position.z, state.time) * 0.22 +
1381
- (physics.waterline ?? 0.42);
1382
- if (Math.abs(ship.position.x) > 10) {
1383
- ship.velocity.x *= -1;
1384
- ship.angularVelocity *= -1;
1385
- }
1386
- if (ship.position.z < 2 || ship.position.z > 16) {
1387
- ship.velocity.z *= -1;
1388
- ship.angularVelocity *= -1;
1389
- }
1738
+ updateShipMotion(state, ship, dt, shipModel);
1739
+ resolveBoundaryCollision(ship, state, shipModel);
1390
1740
  }
1391
1741
 
1392
- const [a, b] = state.ships;
1393
- const dx = b.position.x - a.position.x;
1394
- const dz = b.position.z - a.position.z;
1395
- const overlapX = Math.abs(dx) < halfExtents[0] * 1.7;
1396
- const overlapZ = Math.abs(dz) < halfExtents[2] * 0.8;
1397
- if (overlapX && overlapZ) {
1398
- const restitution = physics.restitution ?? 0.22;
1399
- const swapX = a.velocity.x;
1400
- const swapZ = a.velocity.z;
1401
- a.velocity.x = -b.velocity.x * (0.86 + restitution);
1402
- a.velocity.z = -b.velocity.z * (0.82 + restitution);
1403
- b.velocity.x = -swapX * (0.86 + restitution);
1404
- b.velocity.z = -swapZ * (0.82 + restitution);
1405
- a.angularVelocity += 0.55;
1406
- b.angularVelocity -= 0.55;
1407
- const contactPoint = vec3((a.position.x + b.position.x) * 0.5, (a.position.y + b.position.y) * 0.5 + 0.1, (a.position.z + b.position.z) * 0.5);
1408
- spawnSpray(state, contactPoint, Math.abs(dx) + Math.abs(dz));
1409
- state.waveImpulses.push({
1410
- x: contactPoint.x,
1411
- z: contactPoint.z,
1412
- strength: Math.min(1.4, 0.2 + (Math.abs(dx) + Math.abs(dz)) * 0.18),
1413
- radius: 0.8,
1414
- life: 1,
1415
- });
1416
- state.collisionCount += 1;
1417
- state.contactCount = 1;
1418
- collided = true;
1742
+ for (let index = 0; index < state.ships.length; index += 1) {
1743
+ for (let otherIndex = index + 1; otherIndex < state.ships.length; otherIndex += 1) {
1744
+ collided =
1745
+ resolveShipCollision(state, state.ships[index], state.ships[otherIndex], shipModel) ||
1746
+ collided;
1747
+ }
1419
1748
  }
1420
- state.collisionFlash = collided ? 1 : Math.max(0, state.collisionFlash - dt * 1.8);
1749
+
1750
+ state.collisionFlash = collided
1751
+ ? Math.max(0.12, state.collisionFlash)
1752
+ : Math.max(0, state.collisionFlash - dt * 1.3);
1421
1753
  }
1422
1754
 
1423
1755
  function updateWaveImpulses(state, dt) {
@@ -1540,7 +1872,7 @@ function renderFlagPole(ctx, camera, viewport) {
1540
1872
  function renderShipShadow(ctx, shipModel, ship, state, camera, viewport, lightDir, shadowStrength) {
1541
1873
  const bounds = shipModel.bounds;
1542
1874
  const keelY = (shipModel.physics.waterline ?? 0.42) - 0.28;
1543
- const transform = { position: ship.position, rotationY: ship.rotationY, scale: 1.1 };
1875
+ const transform = { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE };
1544
1876
  const hullCorners = [
1545
1877
  vec3(bounds.min[0], keelY, bounds.min[2]),
1546
1878
  vec3(bounds.max[0], keelY, bounds.min[2]),
@@ -1570,6 +1902,113 @@ function renderFlagShadow(ctx, cloth, camera, viewport, lightDir, shadowStrength
1570
1902
  });
1571
1903
  }
1572
1904
 
1905
+ function renderGlowLight(
1906
+ ctx,
1907
+ point,
1908
+ camera,
1909
+ viewport,
1910
+ coreColor,
1911
+ glowColor,
1912
+ glowScale,
1913
+ reflectionStrength = 0,
1914
+ state = null
1915
+ ) {
1916
+ const projected = projectPoint(point, camera, viewport);
1917
+ if (!projected) {
1918
+ return;
1919
+ }
1920
+
1921
+ const radius = clamp((1 / projected.depth) * 420 * glowScale, 4, 34);
1922
+ const halo = ctx.createRadialGradient(projected.x, projected.y, radius * 0.12, projected.x, projected.y, radius);
1923
+ halo.addColorStop(0, colorToRgba(coreColor, 0.98));
1924
+ halo.addColorStop(0.5, colorToRgba(glowColor, 0.42));
1925
+ halo.addColorStop(1, colorToRgba(glowColor, 0));
1926
+ ctx.save();
1927
+ ctx.globalCompositeOperation = "screen";
1928
+ ctx.fillStyle = halo;
1929
+ ctx.beginPath();
1930
+ ctx.arc(projected.x, projected.y, radius, 0, Math.PI * 2);
1931
+ ctx.fill();
1932
+ ctx.restore();
1933
+
1934
+ ctx.fillStyle = colorToRgba(coreColor, 0.98);
1935
+ ctx.beginPath();
1936
+ ctx.arc(projected.x, projected.y, Math.max(1.2, radius * 0.16), 0, Math.PI * 2);
1937
+ ctx.fill();
1938
+
1939
+ if (state && reflectionStrength > 0) {
1940
+ const waterline = sampleWave(state, point.x, point.z, state.time) * 0.22;
1941
+ const reflectedPoint = vec3(point.x, waterline - (point.y - waterline) * 0.58, point.z + 0.08);
1942
+ const reflected = projectPoint(reflectedPoint, camera, viewport);
1943
+ if (reflected) {
1944
+ const reflectionRadius = radius * 0.72;
1945
+ const glow = ctx.createRadialGradient(
1946
+ reflected.x,
1947
+ reflected.y,
1948
+ reflectionRadius * 0.1,
1949
+ reflected.x,
1950
+ reflected.y,
1951
+ reflectionRadius
1952
+ );
1953
+ glow.addColorStop(0, colorToRgba(coreColor, reflectionStrength * 0.34));
1954
+ glow.addColorStop(1, colorToRgba(glowColor, 0));
1955
+ ctx.save();
1956
+ ctx.globalCompositeOperation = "screen";
1957
+ ctx.fillStyle = glow;
1958
+ ctx.beginPath();
1959
+ ctx.ellipse(
1960
+ reflected.x,
1961
+ reflected.y,
1962
+ reflectionRadius * 0.34,
1963
+ reflectionRadius,
1964
+ 0,
1965
+ 0,
1966
+ Math.PI * 2
1967
+ );
1968
+ ctx.fill();
1969
+ ctx.restore();
1970
+ }
1971
+ }
1972
+ }
1973
+
1974
+ function renderShipLanterns(ctx, ship, state, camera, viewport, visuals) {
1975
+ const lanterns = Array.isArray(ship.lanterns) ? ship.lanterns : SHIP_LANTERNS;
1976
+ const strength = readVisualNumber(ship.lanternStrength, 1);
1977
+ for (const lantern of lanterns) {
1978
+ const position = transformPoint(
1979
+ vec3(lantern.x, lantern.y, lantern.z),
1980
+ { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE }
1981
+ );
1982
+ renderGlowLight(
1983
+ ctx,
1984
+ position,
1985
+ camera,
1986
+ viewport,
1987
+ visuals.lanternCore,
1988
+ visuals.lanternGlow,
1989
+ lantern.glow * strength,
1990
+ visuals.lanternReflectionStrength,
1991
+ state
1992
+ );
1993
+ }
1994
+ }
1995
+
1996
+ function renderHarborTorches(ctx, state, camera, viewport, visuals) {
1997
+ for (const torch of HARBOR_TORCHES) {
1998
+ renderGlowLight(
1999
+ ctx,
2000
+ vec3(torch.x, torch.y, torch.z),
2001
+ camera,
2002
+ viewport,
2003
+ visuals.torchCore,
2004
+ visuals.torchGlow,
2005
+ torch.glow,
2006
+ visuals.lanternReflectionStrength * 0.55,
2007
+ state
2008
+ );
2009
+ }
2010
+ }
2011
+
1573
2012
  function renderScene(ctx, canvas, state, shipModel, dom) {
1574
2013
  const viewport = { width: canvas.width, height: canvas.height };
1575
2014
  const camera = buildCamera(state, canvas);
@@ -1579,7 +2018,7 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
1579
2018
  importance: state.focus === "lighting" ? "critical" : "high",
1580
2019
  });
1581
2020
  const nearLighting = lightingPlan.bands.find((entry) => entry.band === "near") ?? lightingPlan.bands[0];
1582
- const lightDir = normalizeVec3(vec3(-0.45, 0.85, -0.24));
2021
+ const lightDir = normalizeVec3(vec3(-0.22, 0.94, -0.31));
1583
2022
  const lightingSnapshot = state.lightingDetail.getSnapshot();
1584
2023
  const visuals = resolveVisualConfig(
1585
2024
  nearLighting,
@@ -1656,7 +2095,7 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
1656
2095
  for (const ship of state.ships) {
1657
2096
  buildTrianglesFromMesh(
1658
2097
  shipModel,
1659
- { position: ship.position, rotationY: ship.rotationY, scale: 1.1 },
2098
+ { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE },
1660
2099
  ship.tint,
1661
2100
  camera,
1662
2101
  viewport,
@@ -1672,10 +2111,12 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
1672
2111
 
1673
2112
  drawTriangles(ctx, triangles, lightDir, reflectionStrength, camera, shadowStrength);
1674
2113
  renderWaterHighlights(ctx, water.bandMeshes, camera, viewport);
2114
+ renderHarborTorches(ctx, state, camera, viewport, visuals);
1675
2115
  renderFlagPole(ctx, camera, viewport);
1676
2116
  renderClothAccent(ctx, cloth, camera, viewport);
1677
2117
  for (const ship of state.ships) {
1678
2118
  renderShipRigging(ctx, ship, camera, viewport);
2119
+ renderShipLanterns(ctx, ship, state, camera, viewport, visuals);
1679
2120
  }
1680
2121
  renderSprays(ctx, state.sprays, camera, viewport);
1681
2122
 
@@ -1689,8 +2130,10 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
1689
2130
  const sceneMetrics = [
1690
2131
  `focus: ${state.focus}`,
1691
2132
  `ships: ${state.ships.length} active GLTF hulls`,
2133
+ `moonlight: cold overhead key + ${HARBOR_TORCHES.length + state.ships.length * SHIP_LANTERNS.length} warm deck and harbor lights`,
1692
2134
  `physics snapshot: ${state.physics.snapshot.stage} (${state.physics.snapshot.stability})`,
1693
- `physics contacts: ${state.physics.snapshot.contactCount ?? 0}`,
2135
+ `physics contacts: ${state.contactCount}`,
2136
+ `mass split: ${state.ships.map((ship) => `${ship.id} ${(getShipMass(ship, shipModel) / 1000).toFixed(1)}t`).join(" · ")}`,
1694
2137
  `cloth band: ${cloth.band} -> ${cloth.representation.output}`,
1695
2138
  `fluid near band: ${water.bandMeshes[0].representation.output}`,
1696
2139
  `lighting profile: ${lightingPlan.profile} (${lightingDistanceBands.length} bands)`,
@@ -1715,8 +2158,8 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
1715
2158
  state.focus === "physics"
1716
2159
  ? [
1717
2160
  "Stable world snapshots are taken after the authoritative rigid-body commit and before visual follow-up work.",
1718
- "The ships collide on GLTF-derived hull volumes while cloth and fluid remain downstream visual consumers.",
1719
- "Near-field lighting keeps the ray-traced-primary shadow impression so the collision read stays crisp.",
2161
+ "The ships collide with mass-weighted impulses and positional correction, so the heavier hull keeps more of its line.",
2162
+ "Moonlight keeps the overall read legible while lanterns and torches make collision moments easy to track against the water.",
1720
2163
  ]
1721
2164
  : SCENE_NOTES;
1722
2165
  const custom = state.demoDescription ?? null;
@@ -1743,8 +2186,8 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
1743
2186
  typeof custom?.details === "string"
1744
2187
  ? custom.details
1745
2188
  : state.focus === "physics"
1746
- ? `Stable world snapshots are emitted from ${state.physics.plan.snapshotStageId} after the authoritative solver; GLTF ships collide on ${shipModel.physics.shape ?? "box"} volumes while visual follow-up remains downstream.`
1747
- : `GLTF ships are colliding with ${shipModel.physics.shape ?? "box"} physics volumes; cloth and fluid remain continuous while the governor pressure is ${state.lastDecision.pressureLevel}.`;
2189
+ ? `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.`
2190
+ : `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}.`;
1748
2191
  }
1749
2192
 
1750
2193
  function updateSceneState(state, dt, shipModel) {
@@ -1766,6 +2209,8 @@ function syncTextState(state, shipModel) {
1766
2209
  z: Number(ship.position.z.toFixed(2)),
1767
2210
  vx: Number(ship.velocity.x.toFixed(2)),
1768
2211
  vz: Number(ship.velocity.z.toFixed(2)),
2212
+ massKg: Math.round(getShipMass(ship, shipModel)),
2213
+ lanterns: Array.isArray(ship.lanterns) ? ship.lanterns.length : 0,
1769
2214
  })),
1770
2215
  shipPhysics: shipModel.physics,
1771
2216
  sprays: state.sprays.length,
@@ -1794,6 +2239,7 @@ function syncTextState(state, shipModel) {
1794
2239
  export async function mountGpuShowcase(options = {}) {
1795
2240
  injectStyles();
1796
2241
  const root = options.root ?? document.body;
2242
+ root.classList?.add?.(ROOT_CLASS);
1797
2243
  const previousMarkup = root.innerHTML;
1798
2244
  const previousRenderGameToText = window.render_game_to_text;
1799
2245
  const previousAdvanceTime = window.advanceTime;
@@ -1816,14 +2262,15 @@ export async function mountGpuShowcase(options = {}) {
1816
2262
  syncTextState(state, shipModel);
1817
2263
 
1818
2264
  const ctx = dom.canvas.getContext("2d");
2265
+ if (!ctx) {
2266
+ throw new Error("2D canvas context is required for the shared showcase.");
2267
+ }
2268
+ let animationFrameId = 0;
1819
2269
  let destroyed = false;
1820
- let frameHandle = null;
1821
-
1822
2270
  const renderFrame = (nowMs) => {
1823
2271
  if (destroyed) {
1824
2272
  return;
1825
2273
  }
1826
-
1827
2274
  if (!state.paused) {
1828
2275
  if (state.lastTimeMs == null) {
1829
2276
  state.lastTimeMs = nowMs;
@@ -1841,7 +2288,7 @@ export async function mountGpuShowcase(options = {}) {
1841
2288
  state.demoDescription = resolveSceneDescription(state, options, shipModel).description;
1842
2289
  renderScene(ctx, dom.canvas, state, shipModel, dom);
1843
2290
  syncTextState(state, shipModel);
1844
- frameHandle = requestAnimationFrame(renderFrame);
2291
+ animationFrameId = requestAnimationFrame(renderFrame);
1845
2292
  };
1846
2293
 
1847
2294
  const handlePauseClick = () => {
@@ -1863,37 +2310,43 @@ export async function mountGpuShowcase(options = {}) {
1863
2310
  dom.stressToggle.addEventListener("change", handleStressChange);
1864
2311
  dom.focusMode.addEventListener("change", handleFocusChange);
1865
2312
 
1866
- frameHandle = requestAnimationFrame(renderFrame);
2313
+ animationFrameId = requestAnimationFrame(renderFrame);
2314
+ const destroy = () => {
2315
+ if (destroyed) {
2316
+ return;
2317
+ }
2318
+ destroyed = true;
2319
+ if (animationFrameId) {
2320
+ cancelAnimationFrame(animationFrameId);
2321
+ }
2322
+ dom.pauseButton.removeEventListener("click", handlePauseClick);
2323
+ dom.stressToggle.removeEventListener("change", handleStressChange);
2324
+ dom.focusMode.removeEventListener("change", handleFocusChange);
2325
+ try {
2326
+ if (typeof options.destroyState === "function") {
2327
+ options.destroyState(state.packageState);
2328
+ }
2329
+ } finally {
2330
+ state.packageState = undefined;
2331
+ }
2332
+ root.classList?.remove?.(ROOT_CLASS);
2333
+ root.innerHTML = previousMarkup;
2334
+ if (typeof previousRenderGameToText === "function") {
2335
+ window.render_game_to_text = previousRenderGameToText;
2336
+ } else {
2337
+ delete window.render_game_to_text;
2338
+ }
2339
+ if (typeof previousAdvanceTime === "function") {
2340
+ window.advanceTime = previousAdvanceTime;
2341
+ } else {
2342
+ delete window.advanceTime;
2343
+ }
2344
+ };
1867
2345
  return {
1868
2346
  state,
1869
2347
  shipModel,
1870
2348
  canvas: dom.canvas,
1871
- destroy() {
1872
- if (destroyed) {
1873
- return;
1874
- }
1875
-
1876
- destroyed = true;
1877
- if (frameHandle != null) {
1878
- cancelAnimationFrame(frameHandle);
1879
- }
1880
- dom.pauseButton.removeEventListener("click", handlePauseClick);
1881
- dom.stressToggle.removeEventListener("change", handleStressChange);
1882
- dom.focusMode.removeEventListener("change", handleFocusChange);
1883
- root.innerHTML = previousMarkup;
1884
-
1885
- if (typeof previousRenderGameToText === "function") {
1886
- window.render_game_to_text = previousRenderGameToText;
1887
- } else {
1888
- delete window.render_game_to_text;
1889
- }
1890
-
1891
- if (typeof previousAdvanceTime === "function") {
1892
- window.advanceTime = previousAdvanceTime;
1893
- } else {
1894
- delete window.advanceTime;
1895
- }
1896
- },
2349
+ destroy,
1897
2350
  };
1898
2351
  }
1899
2352
 
@@ -1908,7 +2361,7 @@ function updatePhysicsSnapshot(state, shipModel) {
1908
2361
  animationInputRevision: state.frame,
1909
2362
  bodyCount: state.ships.length + 2,
1910
2363
  dynamicBodyCount: state.ships.length,
1911
- contactCount: state.collisionFlash > 0.02 ? 1 : 0,
2364
+ contactCount: state.contactCount,
1912
2365
  metadata: {
1913
2366
  collisionCount: state.collisionCount,
1914
2367
  contactCount: state.contactCount,