@plasius/gpu-shared 0.1.4 → 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.
@@ -35,9 +35,17 @@ import { resolveShowcaseAssetUrl } from "./asset-url.js";
35
35
  import { loadGltfModel } from "./gltf-loader.js";
36
36
 
37
37
  const STYLE_ID = "plasius-shared-3d-showcase-style";
38
+ const ROOT_CLASS = "plasius-showcase-root";
38
39
  const DEFAULT_TITLE = "Flag by the Sea";
39
40
  const DEFAULT_SUBTITLE =
40
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
+ });
41
49
  const CAMERA_PRESETS = Object.freeze({
42
50
  integrated: Object.freeze({ yaw: -0.55, pitch: 0.34, distance: 27, target: [0, 2.2, 0] }),
43
51
  lighting: Object.freeze({ yaw: -0.28, pitch: 0.28, distance: 23, target: [0, 2.8, 0] }),
@@ -50,10 +58,10 @@ const CAMERA_PRESETS = Object.freeze({
50
58
  export const showcaseFocusModes = Object.freeze(Object.keys(CAMERA_PRESETS));
51
59
 
52
60
  const SCENE_NOTES = Object.freeze([
53
- "Ships are loaded from a GLTF asset and carry physics metadata from node extras.",
54
- "Near-field lighting uses the ray-traced-primary shadow and reflection path before stepping down by distance band.",
55
- "Cloth and fluid continuity stay coherent across near, mid, far, and horizon bands.",
56
- "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.",
57
65
  ]);
58
66
 
59
67
  const UNIT_BOX_MESH = Object.freeze({
@@ -77,6 +85,18 @@ const UNIT_BOX_MESH = Object.freeze({
77
85
  ]),
78
86
  });
79
87
 
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
+ ]);
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
+ ]);
80
100
  function injectStyles() {
81
101
  if (document.getElementById(STYLE_ID)) {
82
102
  return;
@@ -85,27 +105,27 @@ function injectStyles() {
85
105
  const style = document.createElement("style");
86
106
  style.id = STYLE_ID;
87
107
  style.textContent = `
88
- :root {
89
- color-scheme: light;
90
- --plasius-paper: #f4f7f8;
91
- --plasius-ink: #152028;
92
- --plasius-muted: #5c6f7b;
93
- --plasius-accent: #8f5634;
94
- --plasius-panel: rgba(255, 255, 255, 0.82);
95
- --plasius-border: rgba(21, 32, 40, 0.12);
96
- --plasius-shadow: 0 20px 48px rgba(15, 24, 31, 0.16);
97
- }
98
- * {
99
- box-sizing: border-box;
100
- }
101
- 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);
102
117
  margin: 0;
103
- min-height: 100vh;
118
+ min-height: 100%;
104
119
  font-family: "Fraunces", "Iowan Old Style", serif;
105
120
  color: var(--plasius-ink);
106
121
  background:
107
- radial-gradient(circle at top left, rgba(255, 247, 238, 0.92), transparent 34%),
108
- 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;
109
129
  }
110
130
  .plasius-demo {
111
131
  width: min(1560px, calc(100vw - 32px));
@@ -139,7 +159,7 @@ function injectStyles() {
139
159
  text-transform: uppercase;
140
160
  letter-spacing: 0.18em;
141
161
  font-size: 12px;
142
- color: rgba(21, 32, 40, 0.56);
162
+ color: rgba(226, 236, 255, 0.58);
143
163
  }
144
164
  .plasius-demo h1,
145
165
  .plasius-demo h2,
@@ -157,7 +177,7 @@ function injectStyles() {
157
177
  margin: 0;
158
178
  padding: 8px 12px;
159
179
  border-radius: 999px;
160
- background: rgba(143, 86, 52, 0.12);
180
+ background: rgba(243, 177, 106, 0.14);
161
181
  color: var(--plasius-accent);
162
182
  font-weight: 700;
163
183
  }
@@ -179,8 +199,8 @@ function injectStyles() {
179
199
  aspect-ratio: 16 / 9;
180
200
  display: block;
181
201
  border-radius: 20px;
182
- border: 1px solid rgba(21, 32, 40, 0.08);
183
- 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%);
184
204
  }
185
205
  .plasius-demo__toolbar {
186
206
  position: absolute;
@@ -200,9 +220,9 @@ function injectStyles() {
200
220
  .plasius-demo button,
201
221
  .plasius-demo .plasius-toggle,
202
222
  .plasius-demo select {
203
- border: 1px solid rgba(21, 32, 40, 0.12);
223
+ border: 1px solid rgba(159, 185, 223, 0.18);
204
224
  border-radius: 999px;
205
- background: rgba(255, 255, 255, 0.84);
225
+ background: rgba(9, 20, 34, 0.84);
206
226
  color: var(--plasius-ink);
207
227
  padding: 10px 14px;
208
228
  }
@@ -241,8 +261,8 @@ function injectStyles() {
241
261
  bottom: 24px;
242
262
  padding: 10px 14px;
243
263
  border-radius: 16px;
244
- background: rgba(255, 255, 255, 0.84);
245
- 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);
246
266
  color: var(--plasius-muted);
247
267
  font-size: 12px;
248
268
  line-height: 1.45;
@@ -254,7 +274,7 @@ function injectStyles() {
254
274
  }
255
275
  .plasius-demo__footer {
256
276
  margin-top: 4px;
257
- color: rgba(21, 32, 40, 0.66);
277
+ color: rgba(226, 236, 255, 0.68);
258
278
  font-size: 13px;
259
279
  line-height: 1.6;
260
280
  }
@@ -276,6 +296,16 @@ function mix(a, b, t) {
276
296
  return a + (b - a) * t;
277
297
  }
278
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
+
279
309
  function vec3(x = 0, y = 0, z = 0) {
280
310
  return { x, y, z };
281
311
  }
@@ -318,6 +348,14 @@ function reflectVec3(vector, normal) {
318
348
  return subVec3(vector, scaleVec3(unitNormal, 2 * dotVec3(vector, unitNormal)));
319
349
  }
320
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
+
321
359
  function rotateY(point, angle) {
322
360
  const cosine = Math.cos(angle);
323
361
  const sine = Math.sin(angle);
@@ -522,7 +560,7 @@ function buildDemoDom(root, options) {
522
560
  <section class="plasius-panel plasius-demo__status">
523
561
  <p id="demoStatus" class="plasius-demo__status-badge">Booting 3D scene…</p>
524
562
  <p id="demoDetails" class="plasius-demo__status-text">
525
- 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.
526
564
  </p>
527
565
  </section>
528
566
  </section>
@@ -550,9 +588,9 @@ function buildDemoDom(root, options) {
550
588
  </div>
551
589
  <div class="plasius-demo__legend">
552
590
  <strong>Scene</strong>
553
- GLTF ships carry collision metadata.<br />
554
- Flag cloth and ocean waves scale by distance band.<br />
555
- 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.
556
594
  </div>
557
595
  </section>
558
596
  <aside class="plasius-demo__sidebar">
@@ -635,6 +673,7 @@ function buildSceneSnapshot(state, shipModel) {
635
673
  })
636
674
  )
637
675
  ),
676
+ shipPhysics: shipModel?.physics ?? null,
638
677
  physics: Object.freeze({
639
678
  profile: state.physics.profile,
640
679
  plan: state.physics.plan,
@@ -686,28 +725,39 @@ function readVisualNumber(value, fallback) {
686
725
  function resolveVisualConfig(nearLighting, lightingSnapshot, customVisuals = {}) {
687
726
  const premiumShadows = nearLighting.primaryShadowSource === "ray-traced-primary";
688
727
  const defaults = {
689
- skyTop: premiumShadows ? "#f0f7fb" : "#e8f1f7",
690
- skyMid: premiumShadows ? "#c7d9e5" : "#b9ceda",
691
- skyBottom: premiumShadows ? "#84a7bd" : "#7b9bb0",
692
- seaTop: premiumShadows ? "#235064" : "#264c5f",
693
- seaMid: premiumShadows ? "#153e53" : "#173d4f",
694
- seaBottom: "#0b2433",
695
- 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)",
696
740
  reflectionStrength: lightingSnapshot.currentLevel.config.reflectionStrength,
697
741
  shadowAccent: lightingSnapshot.currentLevel.config.shadowStrength,
698
- waveAmplitude: 1,
699
- waveDirection: { x: 0.86, z: 0.34 },
700
- wavePhaseSpeed: 1,
701
- wakeStrength: 0.24,
702
- wakeLength: 15,
703
- collisionRippleStrength: 0.34,
704
- waterNear: { r: 0.12, g: 0.36, b: 0.46 },
705
- waterFar: { r: 0.28, g: 0.56, b: 0.68 },
706
- harborWall: { r: 0.48, g: 0.4, b: 0.32 },
707
- harborDeck: { r: 0.5, g: 0.34, b: 0.22 },
708
- harborTower: { r: 0.34, g: 0.32, b: 0.36 },
709
- flagColor: { r: 0.76, g: 0.24, b: 0.18 },
710
- 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)",
711
761
  };
712
762
 
713
763
  return {
@@ -719,8 +769,26 @@ function resolveVisualConfig(nearLighting, lightingSnapshot, customVisuals = {})
719
769
  seaMid: typeof customVisuals.seaMid === "string" ? customVisuals.seaMid : defaults.seaMid,
720
770
  seaBottom:
721
771
  typeof customVisuals.seaBottom === "string" ? customVisuals.seaBottom : defaults.seaBottom,
722
- sunCore:
723
- 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,
724
792
  reflectionStrength: readVisualNumber(
725
793
  customVisuals.reflectionStrength,
726
794
  defaults.reflectionStrength
@@ -747,6 +815,18 @@ function resolveVisualConfig(nearLighting, lightingSnapshot, customVisuals = {})
747
815
  harborTower: normalizeColorOverride(customVisuals.harborTower, defaults.harborTower),
748
816
  flagColor: normalizeColorOverride(customVisuals.flagColor, defaults.flagColor),
749
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,
750
830
  };
751
831
  }
752
832
 
@@ -1052,18 +1132,34 @@ function createSceneState(options) {
1052
1132
  {
1053
1133
  id: "northwind",
1054
1134
  position: vec3(-5.2, 0, 7.2),
1055
- velocity: vec3(2.1, 0, -1.6),
1056
- rotationY: 0.42,
1057
- angularVelocity: 0.18,
1135
+ velocity: vec3(2.35, 0, -1.08),
1136
+ rotationY: 0.58,
1137
+ angularVelocity: 0.09,
1058
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,
1059
1147
  },
1060
1148
  {
1061
1149
  id: "tidecaller",
1062
1150
  position: vec3(4.8, 0, 4.4),
1063
- velocity: vec3(-1.85, 0, 1.25),
1064
- rotationY: -2.62,
1065
- angularVelocity: -0.14,
1151
+ velocity: vec3(-2.15, 0, 1.74),
1152
+ rotationY: -2.48,
1153
+ angularVelocity: -0.2,
1066
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,
1067
1163
  },
1068
1164
  ],
1069
1165
  sprays: [],
@@ -1087,14 +1183,30 @@ function setListContent(element, values) {
1087
1183
  }
1088
1184
 
1089
1185
  function drawSkyAndShore(ctx, canvas, state, nearLighting, reflectionStrength, shadowStrength, visuals) {
1090
- const premiumShadows = nearLighting.primaryShadowSource === "ray-traced-primary";
1091
1186
  const sky = ctx.createLinearGradient(0, 0, 0, canvas.height * 0.5);
1092
1187
  sky.addColorStop(0, visuals.skyTop);
1093
- sky.addColorStop(0.6, visuals.skyMid);
1188
+ sky.addColorStop(0.54, visuals.skyMid);
1094
1189
  sky.addColorStop(1, visuals.skyBottom);
1095
1190
  ctx.fillStyle = sky;
1096
1191
  ctx.fillRect(0, 0, canvas.width, canvas.height);
1097
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
+
1098
1210
  const shoreline = ctx.createLinearGradient(0, canvas.height * 0.45, 0, canvas.height);
1099
1211
  shoreline.addColorStop(0, visuals.seaTop);
1100
1212
  shoreline.addColorStop(0.52, visuals.seaMid);
@@ -1102,30 +1214,48 @@ function drawSkyAndShore(ctx, canvas, state, nearLighting, reflectionStrength, s
1102
1214
  ctx.fillStyle = shoreline;
1103
1215
  ctx.fillRect(0, canvas.height * 0.45, canvas.width, canvas.height * 0.55);
1104
1216
 
1105
- const sunX = mix(canvas.width * 0.16, canvas.width * 0.84, (Math.sin(state.time * 0.12) + 1) * 0.5);
1106
- const sunY = canvas.height * 0.14 + Math.cos(state.time * 0.12) * 22;
1107
- const sun = ctx.createRadialGradient(sunX, sunY, 10, sunX, sunY, 90);
1108
- sun.addColorStop(0, visuals.sunCore);
1109
- sun.addColorStop(1, "rgba(255, 244, 210, 0)");
1110
- 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;
1111
1232
  ctx.beginPath();
1112
- ctx.arc(sunX, sunY, 90, 0, Math.PI * 2);
1233
+ ctx.arc(moonX, moonY, 24, 0, Math.PI * 2);
1113
1234
  ctx.fill();
1114
1235
 
1115
- const track = ctx.createLinearGradient(sunX, canvas.height * 0.46, sunX, canvas.height * 0.96);
1116
- track.addColorStop(0, `rgba(255, 243, 214, ${0.08 + reflectionStrength * 0.18})`);
1117
- track.addColorStop(0.42, `rgba(224, 242, 255, ${0.04 + reflectionStrength * 0.2})`);
1118
- 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)");
1119
1240
  ctx.save();
1120
1241
  ctx.globalCompositeOperation = "screen";
1121
1242
  ctx.fillStyle = track;
1122
1243
  ctx.beginPath();
1123
- 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);
1124
1245
  ctx.fill();
1125
1246
  ctx.restore();
1126
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
+
1127
1254
  if (state.collisionFlash > 0.01) {
1128
- 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
+ );
1129
1259
  ctx.fillRect(0, 0, canvas.width, canvas.height);
1130
1260
  }
1131
1261
  }
@@ -1235,7 +1365,7 @@ function pushHarborGeometry(camera, viewport, triangles, visuals) {
1235
1365
  }
1236
1366
 
1237
1367
  function renderShipRigging(ctx, ship, camera, viewport) {
1238
- const transform = { position: ship.position, rotationY: ship.rotationY, scale: 1.1 };
1368
+ const transform = { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE };
1239
1369
  const mastBase = transformPoint(vec3(0, 0.38, -0.4), transform);
1240
1370
  const mastTop = transformPoint(vec3(0, 3.8, -0.2), transform);
1241
1371
  const aftBase = transformPoint(vec3(-0.25, 0.32, -1.9), transform);
@@ -1350,6 +1480,43 @@ function renderWaterHighlights(ctx, waterBands, camera, viewport) {
1350
1480
  }
1351
1481
  }
1352
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
+
1353
1520
  function spawnSpray(state, point, intensity) {
1354
1521
  const count = state.fluidDetail.getSnapshot().currentLevel.config.splashCount;
1355
1522
  for (let index = 0; index < count; index += 1) {
@@ -1363,58 +1530,226 @@ function spawnSpray(state, point, intensity) {
1363
1530
  }
1364
1531
  }
1365
1532
 
1366
- 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) {
1367
1558
  const physics = shipModel.physics;
1368
- 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) {
1369
1734
  let collided = false;
1370
1735
  state.contactCount = 0;
1736
+
1371
1737
  for (const ship of state.ships) {
1372
- ship.position = addVec3(ship.position, scaleVec3(ship.velocity, dt));
1373
- ship.rotationY += ship.angularVelocity * dt;
1374
- ship.velocity = scaleVec3(ship.velocity, 1 - (physics.linearDamping ?? 0.04) * dt);
1375
- ship.angularVelocity *= 1 - (physics.angularDamping ?? 0.08) * dt;
1376
- ship.position.y =
1377
- sampleWave(state, ship.position.x, ship.position.z, state.time) * 0.22 +
1378
- (physics.waterline ?? 0.42);
1379
- if (Math.abs(ship.position.x) > 10) {
1380
- ship.velocity.x *= -1;
1381
- ship.angularVelocity *= -1;
1382
- }
1383
- if (ship.position.z < 2 || ship.position.z > 16) {
1384
- ship.velocity.z *= -1;
1385
- ship.angularVelocity *= -1;
1386
- }
1738
+ updateShipMotion(state, ship, dt, shipModel);
1739
+ resolveBoundaryCollision(ship, state, shipModel);
1387
1740
  }
1388
1741
 
1389
- const [a, b] = state.ships;
1390
- const dx = b.position.x - a.position.x;
1391
- const dz = b.position.z - a.position.z;
1392
- const overlapX = Math.abs(dx) < halfExtents[0] * 1.7;
1393
- const overlapZ = Math.abs(dz) < halfExtents[2] * 0.8;
1394
- if (overlapX && overlapZ) {
1395
- const restitution = physics.restitution ?? 0.22;
1396
- const swapX = a.velocity.x;
1397
- const swapZ = a.velocity.z;
1398
- a.velocity.x = -b.velocity.x * (0.86 + restitution);
1399
- a.velocity.z = -b.velocity.z * (0.82 + restitution);
1400
- b.velocity.x = -swapX * (0.86 + restitution);
1401
- b.velocity.z = -swapZ * (0.82 + restitution);
1402
- a.angularVelocity += 0.55;
1403
- b.angularVelocity -= 0.55;
1404
- 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);
1405
- spawnSpray(state, contactPoint, Math.abs(dx) + Math.abs(dz));
1406
- state.waveImpulses.push({
1407
- x: contactPoint.x,
1408
- z: contactPoint.z,
1409
- strength: Math.min(1.4, 0.2 + (Math.abs(dx) + Math.abs(dz)) * 0.18),
1410
- radius: 0.8,
1411
- life: 1,
1412
- });
1413
- state.collisionCount += 1;
1414
- state.contactCount = 1;
1415
- 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
+ }
1416
1748
  }
1417
- 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);
1418
1753
  }
1419
1754
 
1420
1755
  function updateWaveImpulses(state, dt) {
@@ -1537,7 +1872,7 @@ function renderFlagPole(ctx, camera, viewport) {
1537
1872
  function renderShipShadow(ctx, shipModel, ship, state, camera, viewport, lightDir, shadowStrength) {
1538
1873
  const bounds = shipModel.bounds;
1539
1874
  const keelY = (shipModel.physics.waterline ?? 0.42) - 0.28;
1540
- const transform = { position: ship.position, rotationY: ship.rotationY, scale: 1.1 };
1875
+ const transform = { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE };
1541
1876
  const hullCorners = [
1542
1877
  vec3(bounds.min[0], keelY, bounds.min[2]),
1543
1878
  vec3(bounds.max[0], keelY, bounds.min[2]),
@@ -1567,6 +1902,113 @@ function renderFlagShadow(ctx, cloth, camera, viewport, lightDir, shadowStrength
1567
1902
  });
1568
1903
  }
1569
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
+
1570
2012
  function renderScene(ctx, canvas, state, shipModel, dom) {
1571
2013
  const viewport = { width: canvas.width, height: canvas.height };
1572
2014
  const camera = buildCamera(state, canvas);
@@ -1576,7 +2018,7 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
1576
2018
  importance: state.focus === "lighting" ? "critical" : "high",
1577
2019
  });
1578
2020
  const nearLighting = lightingPlan.bands.find((entry) => entry.band === "near") ?? lightingPlan.bands[0];
1579
- const lightDir = normalizeVec3(vec3(-0.45, 0.85, -0.24));
2021
+ const lightDir = normalizeVec3(vec3(-0.22, 0.94, -0.31));
1580
2022
  const lightingSnapshot = state.lightingDetail.getSnapshot();
1581
2023
  const visuals = resolveVisualConfig(
1582
2024
  nearLighting,
@@ -1653,7 +2095,7 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
1653
2095
  for (const ship of state.ships) {
1654
2096
  buildTrianglesFromMesh(
1655
2097
  shipModel,
1656
- { position: ship.position, rotationY: ship.rotationY, scale: 1.1 },
2098
+ { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE },
1657
2099
  ship.tint,
1658
2100
  camera,
1659
2101
  viewport,
@@ -1669,10 +2111,12 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
1669
2111
 
1670
2112
  drawTriangles(ctx, triangles, lightDir, reflectionStrength, camera, shadowStrength);
1671
2113
  renderWaterHighlights(ctx, water.bandMeshes, camera, viewport);
2114
+ renderHarborTorches(ctx, state, camera, viewport, visuals);
1672
2115
  renderFlagPole(ctx, camera, viewport);
1673
2116
  renderClothAccent(ctx, cloth, camera, viewport);
1674
2117
  for (const ship of state.ships) {
1675
2118
  renderShipRigging(ctx, ship, camera, viewport);
2119
+ renderShipLanterns(ctx, ship, state, camera, viewport, visuals);
1676
2120
  }
1677
2121
  renderSprays(ctx, state.sprays, camera, viewport);
1678
2122
 
@@ -1686,8 +2130,10 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
1686
2130
  const sceneMetrics = [
1687
2131
  `focus: ${state.focus}`,
1688
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`,
1689
2134
  `physics snapshot: ${state.physics.snapshot.stage} (${state.physics.snapshot.stability})`,
1690
- `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(" · ")}`,
1691
2137
  `cloth band: ${cloth.band} -> ${cloth.representation.output}`,
1692
2138
  `fluid near band: ${water.bandMeshes[0].representation.output}`,
1693
2139
  `lighting profile: ${lightingPlan.profile} (${lightingDistanceBands.length} bands)`,
@@ -1712,8 +2158,8 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
1712
2158
  state.focus === "physics"
1713
2159
  ? [
1714
2160
  "Stable world snapshots are taken after the authoritative rigid-body commit and before visual follow-up work.",
1715
- "The ships collide on GLTF-derived hull volumes while cloth and fluid remain downstream visual consumers.",
1716
- "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.",
1717
2163
  ]
1718
2164
  : SCENE_NOTES;
1719
2165
  const custom = state.demoDescription ?? null;
@@ -1740,8 +2186,8 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
1740
2186
  typeof custom?.details === "string"
1741
2187
  ? custom.details
1742
2188
  : state.focus === "physics"
1743
- ? `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.`
1744
- : `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}.`;
1745
2191
  }
1746
2192
 
1747
2193
  function updateSceneState(state, dt, shipModel) {
@@ -1763,6 +2209,8 @@ function syncTextState(state, shipModel) {
1763
2209
  z: Number(ship.position.z.toFixed(2)),
1764
2210
  vx: Number(ship.velocity.x.toFixed(2)),
1765
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,
1766
2214
  })),
1767
2215
  shipPhysics: shipModel.physics,
1768
2216
  sprays: state.sprays.length,
@@ -1791,6 +2239,7 @@ function syncTextState(state, shipModel) {
1791
2239
  export async function mountGpuShowcase(options = {}) {
1792
2240
  injectStyles();
1793
2241
  const root = options.root ?? document.body;
2242
+ root.classList?.add?.(ROOT_CLASS);
1794
2243
  const previousMarkup = root.innerHTML;
1795
2244
  const previousRenderGameToText = window.render_game_to_text;
1796
2245
  const previousAdvanceTime = window.advanceTime;
@@ -1813,14 +2262,15 @@ export async function mountGpuShowcase(options = {}) {
1813
2262
  syncTextState(state, shipModel);
1814
2263
 
1815
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;
1816
2269
  let destroyed = false;
1817
- let frameHandle = null;
1818
-
1819
2270
  const renderFrame = (nowMs) => {
1820
2271
  if (destroyed) {
1821
2272
  return;
1822
2273
  }
1823
-
1824
2274
  if (!state.paused) {
1825
2275
  if (state.lastTimeMs == null) {
1826
2276
  state.lastTimeMs = nowMs;
@@ -1838,7 +2288,7 @@ export async function mountGpuShowcase(options = {}) {
1838
2288
  state.demoDescription = resolveSceneDescription(state, options, shipModel).description;
1839
2289
  renderScene(ctx, dom.canvas, state, shipModel, dom);
1840
2290
  syncTextState(state, shipModel);
1841
- frameHandle = requestAnimationFrame(renderFrame);
2291
+ animationFrameId = requestAnimationFrame(renderFrame);
1842
2292
  };
1843
2293
 
1844
2294
  const handlePauseClick = () => {
@@ -1860,37 +2310,43 @@ export async function mountGpuShowcase(options = {}) {
1860
2310
  dom.stressToggle.addEventListener("change", handleStressChange);
1861
2311
  dom.focusMode.addEventListener("change", handleFocusChange);
1862
2312
 
1863
- 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
+ };
1864
2345
  return {
1865
2346
  state,
1866
2347
  shipModel,
1867
2348
  canvas: dom.canvas,
1868
- destroy() {
1869
- if (destroyed) {
1870
- return;
1871
- }
1872
-
1873
- destroyed = true;
1874
- if (frameHandle != null) {
1875
- cancelAnimationFrame(frameHandle);
1876
- }
1877
- dom.pauseButton.removeEventListener("click", handlePauseClick);
1878
- dom.stressToggle.removeEventListener("change", handleStressChange);
1879
- dom.focusMode.removeEventListener("change", handleFocusChange);
1880
- root.innerHTML = previousMarkup;
1881
-
1882
- if (typeof previousRenderGameToText === "function") {
1883
- window.render_game_to_text = previousRenderGameToText;
1884
- } else {
1885
- delete window.render_game_to_text;
1886
- }
1887
-
1888
- if (typeof previousAdvanceTime === "function") {
1889
- window.advanceTime = previousAdvanceTime;
1890
- } else {
1891
- delete window.advanceTime;
1892
- }
1893
- },
2349
+ destroy,
1894
2350
  };
1895
2351
  }
1896
2352
 
@@ -1905,7 +2361,7 @@ function updatePhysicsSnapshot(state, shipModel) {
1905
2361
  animationInputRevision: state.frame,
1906
2362
  bodyCount: state.ships.length + 2,
1907
2363
  dynamicBodyCount: state.ships.length,
1908
- contactCount: state.collisionFlash > 0.02 ? 1 : 0,
2364
+ contactCount: state.contactCount,
1909
2365
  metadata: {
1910
2366
  collisionCount: state.collisionCount,
1911
2367
  contactCount: state.contactCount,