@plasius/gpu-shared 0.1.20 → 1.0.1

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.
@@ -844,6 +844,13 @@ const LEGACY_HARBOR_LAYOUT = Object.freeze([
844
844
  ]);
845
845
 
846
846
  const SHOWCASE_ENVIRONMENT_LAYOUT = Object.freeze([
847
+ Object.freeze({
848
+ assetKey: "shoreline",
849
+ position: Object.freeze({ x: 1.8, y: -0.04, z: 0.48 }),
850
+ rotationY: -0.03,
851
+ scale: 1.02,
852
+ accent: 0.03,
853
+ }),
847
854
  Object.freeze({
848
855
  assetKey: "harbor-dock",
849
856
  position: Object.freeze({ x: -4.6, y: 0.16, z: 0.7 }),
@@ -876,6 +883,18 @@ const HARBOR_TORCHES = Object.freeze([
876
883
  Object.freeze({ x: -8.6, y: 2.48, z: -0.72, glow: 1 }),
877
884
  Object.freeze({ x: -10.4, y: 1.28, z: 0.82, glow: 0.92 }),
878
885
  ]);
886
+ const SHORELINE_FOAM_ANCHORS = Object.freeze([
887
+ Object.freeze({ x: -7.8, z: 3.0, length: 1.25, angle: -0.12 }),
888
+ Object.freeze({ x: -6.3, z: 2.72, length: 0.92, angle: 0.08 }),
889
+ Object.freeze({ x: -4.9, z: 3.16, length: 1.08, angle: -0.2 }),
890
+ Object.freeze({ x: -3.2, z: 2.42, length: 0.76, angle: 0.16 }),
891
+ Object.freeze({ x: -1.4, z: 2.82, length: 1.18, angle: -0.04 }),
892
+ Object.freeze({ x: 0.4, z: 3.08, length: 0.88, angle: 0.14 }),
893
+ Object.freeze({ x: 2.1, z: 2.56, length: 1.34, angle: -0.18 }),
894
+ Object.freeze({ x: 3.8, z: 3.0, length: 0.94, angle: 0.1 }),
895
+ Object.freeze({ x: 5.5, z: 2.72, length: 1.12, angle: -0.08 }),
896
+ Object.freeze({ x: 7.0, z: 3.22, length: 0.72, angle: 0.18 }),
897
+ ]);
879
898
  const FLAG_LAYOUT = Object.freeze({
880
899
  origin: Object.freeze({ x: -3.5, y: 5.9, z: 2.4 }),
881
900
  width: 4.8,
@@ -918,31 +937,31 @@ function injectStyles() {
918
937
  box-sizing: border-box;
919
938
  }
920
939
  .plasius-demo {
921
- width: min(1560px, calc(100vw - 32px));
922
- margin: 0 auto;
923
- padding: 28px 0 40px;
924
- display: grid;
925
- gap: 20px;
926
- }
927
- .plasius-demo__hero,
928
- .plasius-demo__layout {
929
- display: grid;
930
- gap: 20px;
931
- }
932
- .plasius-demo__hero {
933
- grid-template-columns: minmax(0, 1.5fr) minmax(320px, 0.85fr);
934
- align-items: start;
940
+ position: relative;
941
+ width: 100%;
942
+ min-height: 100dvh;
943
+ overflow: hidden;
935
944
  }
936
945
  .plasius-panel {
937
946
  border: 1px solid var(--plasius-border);
938
- border-radius: 24px;
947
+ border-radius: 8px;
939
948
  background: var(--plasius-panel);
940
949
  box-shadow: var(--plasius-shadow);
941
950
  backdrop-filter: blur(12px);
942
951
  }
943
952
  .plasius-demo__hero-card,
944
953
  .plasius-demo__status {
945
- padding: 20px 22px;
954
+ position: absolute;
955
+ z-index: 3;
956
+ padding: 10px 12px;
957
+ }
958
+ .plasius-demo__hero-card {
959
+ display: none;
960
+ }
961
+ .plasius-demo__status {
962
+ left: 16px;
963
+ bottom: 84px;
964
+ max-width: min(360px, calc(100vw - 32px));
946
965
  }
947
966
  .plasius-demo__eyebrow {
948
967
  margin: 0 0 8px;
@@ -965,31 +984,36 @@ function injectStyles() {
965
984
  .plasius-demo__status-badge {
966
985
  width: fit-content;
967
986
  margin: 0;
968
- padding: 8px 12px;
969
- border-radius: 999px;
987
+ padding: 6px 9px;
988
+ border-radius: 6px;
970
989
  background: rgba(243, 177, 106, 0.14);
971
990
  color: var(--plasius-accent);
972
991
  font-weight: 700;
992
+ font-size: 12px;
973
993
  }
974
994
  .plasius-demo__status-text {
975
995
  margin: 10px 0 0;
976
996
  color: var(--plasius-muted);
977
- line-height: 1.6;
978
- }
979
- .plasius-demo__layout {
980
- grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.68fr);
981
- align-items: start;
997
+ font-size: 12px;
998
+ line-height: 1.45;
982
999
  }
983
1000
  .plasius-demo__canvas-panel {
984
- padding: 18px;
985
- position: relative;
1001
+ position: absolute;
1002
+ inset: 0;
1003
+ padding: 0;
1004
+ border: 0;
1005
+ border-radius: 0;
1006
+ background: transparent;
1007
+ box-shadow: none;
1008
+ backdrop-filter: none;
986
1009
  }
987
1010
  .plasius-demo__canvas {
988
1011
  width: 100%;
989
- aspect-ratio: 16 / 9;
1012
+ height: 100%;
1013
+ min-height: 100dvh;
990
1014
  display: block;
991
- border-radius: 20px;
992
- border: 1px solid rgba(159, 185, 223, 0.12);
1015
+ border: 0;
1016
+ border-radius: 0;
993
1017
  background: linear-gradient(180deg, #071220 0%, #132440 42%, #10344b 42%, #05111d 100%);
994
1018
  }
995
1019
  .${CAPTURE_CLASS} .plasius-demo {
@@ -1002,6 +1026,8 @@ function injectStyles() {
1002
1026
  .${CAPTURE_CLASS} .plasius-demo__toolbar,
1003
1027
  .${CAPTURE_CLASS} .plasius-demo__legend,
1004
1028
  .${CAPTURE_CLASS} .plasius-demo__sidebar,
1029
+ .${CAPTURE_CLASS} .plasius-demo__diagnostics,
1030
+ .${CAPTURE_CLASS} .plasius-demo__status,
1005
1031
  .${CAPTURE_CLASS} .plasius-demo__footer {
1006
1032
  display: none;
1007
1033
  }
@@ -1028,12 +1054,14 @@ function injectStyles() {
1028
1054
  }
1029
1055
  .plasius-demo__toolbar {
1030
1056
  position: absolute;
1031
- top: 26px;
1032
- left: 26px;
1057
+ top: 84px;
1058
+ left: 16px;
1059
+ z-index: 4;
1033
1060
  display: flex;
1034
- gap: 12px;
1061
+ gap: 8px;
1035
1062
  flex-wrap: wrap;
1036
1063
  align-items: center;
1064
+ max-width: min(560px, calc(100vw - 32px));
1037
1065
  }
1038
1066
  .plasius-demo button,
1039
1067
  .plasius-demo label,
@@ -1045,22 +1073,63 @@ function injectStyles() {
1045
1073
  .plasius-demo .plasius-toggle,
1046
1074
  .plasius-demo select {
1047
1075
  border: 1px solid rgba(159, 185, 223, 0.18);
1048
- border-radius: 999px;
1076
+ border-radius: 6px;
1049
1077
  background: rgba(9, 20, 34, 0.84);
1050
1078
  color: var(--plasius-ink);
1051
- padding: 10px 14px;
1079
+ padding: 8px 10px;
1052
1080
  }
1053
1081
  .plasius-toggle {
1054
1082
  display: inline-flex;
1055
1083
  align-items: center;
1056
1084
  gap: 8px;
1057
1085
  }
1086
+ .plasius-demo__diagnostics {
1087
+ position: absolute;
1088
+ right: 16px;
1089
+ bottom: 84px;
1090
+ z-index: 4;
1091
+ max-width: min(420px, calc(100vw - 32px));
1092
+ color: var(--plasius-ink);
1093
+ font-family: "JetBrains Mono", monospace;
1094
+ font-size: 12px;
1095
+ }
1096
+ .plasius-demo__diagnostics summary {
1097
+ width: fit-content;
1098
+ margin-left: auto;
1099
+ border: 1px solid rgba(159, 185, 223, 0.18);
1100
+ border-radius: 6px;
1101
+ padding: 8px 10px;
1102
+ background: rgba(9, 20, 34, 0.84);
1103
+ cursor: pointer;
1104
+ list-style: none;
1105
+ }
1106
+ .plasius-demo__diagnostics summary::-webkit-details-marker {
1107
+ display: none;
1108
+ }
1109
+ .plasius-demo__diagnostics[open] {
1110
+ width: min(420px, calc(100vw - 32px));
1111
+ }
1112
+ .plasius-demo__diagnostics[open] summary {
1113
+ margin-bottom: 8px;
1114
+ background: rgba(243, 177, 106, 0.14);
1115
+ color: var(--plasius-accent);
1116
+ }
1058
1117
  .plasius-demo__sidebar {
1059
1118
  display: grid;
1060
- gap: 18px;
1119
+ gap: 8px;
1120
+ max-height: min(58vh, 520px);
1121
+ overflow: auto;
1061
1122
  }
1062
1123
  .plasius-demo__card {
1063
- padding: 18px;
1124
+ padding: 10px;
1125
+ }
1126
+ .plasius-demo__card h2 {
1127
+ margin: 0;
1128
+ color: rgba(226, 236, 255, 0.72);
1129
+ font-family: "JetBrains Mono", monospace;
1130
+ font-size: 11px;
1131
+ letter-spacing: 0.08em;
1132
+ text-transform: uppercase;
1064
1133
  }
1065
1134
  .plasius-demo__metrics,
1066
1135
  .plasius-demo__metrics li {
@@ -1069,27 +1138,19 @@ function injectStyles() {
1069
1138
  list-style: none;
1070
1139
  }
1071
1140
  .plasius-demo__metrics {
1072
- margin-top: 12px;
1141
+ margin-top: 8px;
1073
1142
  display: grid;
1074
- gap: 8px;
1143
+ gap: 5px;
1075
1144
  color: var(--plasius-muted);
1076
- line-height: 1.55;
1145
+ font-size: 12px;
1146
+ line-height: 1.35;
1077
1147
  }
1078
1148
  .plasius-demo__metrics li {
1079
1149
  border-top: 1px solid rgba(21, 32, 40, 0.08);
1080
- padding-top: 8px;
1150
+ padding-top: 5px;
1081
1151
  }
1082
1152
  .plasius-demo__legend {
1083
- position: absolute;
1084
- right: 24px;
1085
- bottom: 24px;
1086
- padding: 10px 14px;
1087
- border-radius: 16px;
1088
- background: rgba(9, 20, 34, 0.82);
1089
- border: 1px solid rgba(159, 185, 223, 0.16);
1090
- color: var(--plasius-muted);
1091
- font-size: 12px;
1092
- line-height: 1.45;
1153
+ display: none;
1093
1154
  }
1094
1155
  .plasius-demo__legend strong {
1095
1156
  display: block;
@@ -1097,15 +1158,62 @@ function injectStyles() {
1097
1158
  margin-bottom: 4px;
1098
1159
  }
1099
1160
  .plasius-demo__footer {
1100
- margin-top: 4px;
1101
- color: rgba(226, 236, 255, 0.68);
1102
- font-size: 13px;
1103
- line-height: 1.6;
1161
+ display: none;
1104
1162
  }
1105
1163
  @media (max-width: 1200px) {
1106
- .plasius-demo__hero,
1107
- .plasius-demo__layout {
1108
- grid-template-columns: 1fr;
1164
+ .plasius-demo__toolbar {
1165
+ top: 92px;
1166
+ }
1167
+ }
1168
+ @media (max-width: 640px) {
1169
+ .plasius-demo__status {
1170
+ left: 10px;
1171
+ bottom: 10px;
1172
+ max-width: calc(100vw - 126px);
1173
+ padding: 6px 8px;
1174
+ }
1175
+ .plasius-demo__status-text {
1176
+ display: none;
1177
+ }
1178
+ .plasius-demo__status-badge {
1179
+ max-width: calc(100vw - 142px);
1180
+ overflow: hidden;
1181
+ text-overflow: ellipsis;
1182
+ white-space: nowrap;
1183
+ }
1184
+ .plasius-demo__toolbar {
1185
+ top: 10px;
1186
+ left: 10px;
1187
+ right: 10px;
1188
+ max-width: calc(100vw - 20px);
1189
+ flex-wrap: nowrap;
1190
+ overflow-x: auto;
1191
+ padding-bottom: 4px;
1192
+ scrollbar-width: none;
1193
+ }
1194
+ .plasius-demo__toolbar::-webkit-scrollbar {
1195
+ display: none;
1196
+ }
1197
+ .plasius-demo button,
1198
+ .plasius-demo .plasius-toggle,
1199
+ .plasius-demo select {
1200
+ padding: 7px 8px;
1201
+ font-size: 12px;
1202
+ white-space: nowrap;
1203
+ }
1204
+ .plasius-demo__diagnostics {
1205
+ right: 10px;
1206
+ bottom: 10px;
1207
+ }
1208
+ .plasius-demo__diagnostics[open] {
1209
+ bottom: 56px;
1210
+ left: 10px;
1211
+ right: 10px;
1212
+ width: auto;
1213
+ max-width: none;
1214
+ }
1215
+ .plasius-demo__diagnostics[open] .plasius-demo__sidebar {
1216
+ max-height: min(42vh, 340px);
1109
1217
  }
1110
1218
  }
1111
1219
  `;
@@ -1532,38 +1640,74 @@ function buildTrianglesFromMesh(
1532
1640
  }
1533
1641
  }
1534
1642
 
1535
- async function loadShowcaseAssetCatalog() {
1536
- const [brigantine, cutter, lighthouse, harborDock] = await Promise.all([
1643
+ function createShowcaseAssetCatalog({
1644
+ mode,
1645
+ ships,
1646
+ environment,
1647
+ primaryShipKey = "brigantine",
1648
+ fallbackReason = null,
1649
+ }) {
1650
+ return Object.freeze({
1651
+ mode,
1652
+ primaryShipKey,
1653
+ ships: Object.freeze(ships),
1654
+ environment: Object.freeze(environment),
1655
+ fallbackReason,
1656
+ });
1657
+ }
1658
+
1659
+ function normalizeAssetCatalogFailureReason(error) {
1660
+ if (typeof error?.message === "string" && error.message.trim().length > 0) {
1661
+ return error.message;
1662
+ }
1663
+
1664
+ return "showcase asset loading failed";
1665
+ }
1666
+
1667
+ async function loadShowcaseAssetCatalog({ includeSecondaryShip = true } = {}) {
1668
+ const [brigantine, lighthouse, harborDock, shoreline] = await Promise.all([
1537
1669
  loadGltfModel(resolveShowcaseAssetUrl("brigantine")),
1538
- loadGltfModel(resolveShowcaseAssetUrl("cutter")),
1539
1670
  loadGltfModel(resolveShowcaseAssetUrl("lighthouse")),
1540
1671
  loadGltfModel(resolveShowcaseAssetUrl("harbor-dock")),
1672
+ loadGltfModel(resolveShowcaseAssetUrl("shoreline")),
1541
1673
  ]);
1674
+ const ships = {
1675
+ brigantine,
1676
+ };
1542
1677
 
1543
- return Object.freeze({
1544
- primaryShipKey: "brigantine",
1545
- ships: Object.freeze({
1546
- brigantine,
1547
- cutter,
1548
- }),
1549
- environment: Object.freeze({
1678
+ if (includeSecondaryShip) {
1679
+ ships.cutter = await loadGltfModel(resolveShowcaseAssetUrl("cutter"));
1680
+ }
1681
+
1682
+ return createShowcaseAssetCatalog({
1683
+ mode: includeSecondaryShip ? "modeled-rich" : "modeled-baseline",
1684
+ ships,
1685
+ environment: {
1550
1686
  lighthouse,
1551
1687
  "harbor-dock": harborDock,
1552
- }),
1688
+ shoreline,
1689
+ },
1553
1690
  });
1554
1691
  }
1555
1692
 
1556
- function createLegacyShowcaseAssetCatalog() {
1557
- const brigantine = loadGltfModel(resolveShowcaseAssetUrl("brigantine"));
1558
- return Promise.resolve(brigantine).then((primary) =>
1559
- Object.freeze({
1560
- primaryShipKey: "brigantine",
1561
- ships: Object.freeze({
1562
- brigantine: primary,
1563
- }),
1564
- environment: Object.freeze({}),
1565
- })
1566
- );
1693
+ async function createLegacyShowcaseAssetCatalog(error = null) {
1694
+ const brigantine = await loadGltfModel(resolveShowcaseAssetUrl("brigantine"));
1695
+ return createShowcaseAssetCatalog({
1696
+ mode: "legacy-fallback",
1697
+ ships: {
1698
+ brigantine,
1699
+ },
1700
+ environment: {},
1701
+ fallbackReason: normalizeAssetCatalogFailureReason(error),
1702
+ });
1703
+ }
1704
+
1705
+ async function loadShowcaseAssetCatalogWithFallback({ includeSecondaryShip = true } = {}) {
1706
+ try {
1707
+ return await loadShowcaseAssetCatalog({ includeSecondaryShip });
1708
+ } catch (error) {
1709
+ return createLegacyShowcaseAssetCatalog(error);
1710
+ }
1567
1711
  }
1568
1712
 
1569
1713
  function resolveShipModel(state, ship, fallbackModel = null) {
@@ -1574,6 +1718,10 @@ function resolveShipModel(state, ship, fallbackModel = null) {
1574
1718
  );
1575
1719
  }
1576
1720
 
1721
+ function hasModeledHarborEnvironment(state) {
1722
+ return Object.keys(state.assetCatalog?.environment ?? {}).length > 0;
1723
+ }
1724
+
1577
1725
  function createPerformanceGovernor(performanceFeatures) {
1578
1726
  const createQualityLadderAdapter = assertRequiredFunction(
1579
1727
  performanceFeatures,
@@ -1690,24 +1838,27 @@ function buildDemoDom(root, options) {
1690
1838
  ${t(gpuSharedTranslationKeys.legendCollisions)}
1691
1839
  </div>
1692
1840
  </section>
1693
- <aside class="plasius-demo__sidebar">
1694
- <section class="plasius-panel plasius-demo__card">
1695
- <h2>${t(gpuSharedTranslationKeys.sceneState)}</h2>
1696
- <ul id="sceneMetrics" class="plasius-demo__metrics"></ul>
1697
- </section>
1698
- <section class="plasius-panel plasius-demo__card">
1699
- <h2>${t(gpuSharedTranslationKeys.qualityBudgets)}</h2>
1700
- <ul id="qualityMetrics" class="plasius-demo__metrics"></ul>
1701
- </section>
1702
- <section class="plasius-panel plasius-demo__card">
1703
- <h2>${t(gpuSharedTranslationKeys.debugTelemetry)}</h2>
1704
- <ul id="debugMetrics" class="plasius-demo__metrics"></ul>
1705
- </section>
1706
- <section class="plasius-panel plasius-demo__card">
1707
- <h2>${t(gpuSharedTranslationKeys.notes)}</h2>
1708
- <ul id="sceneNotes" class="plasius-demo__metrics"></ul>
1709
- </section>
1710
- </aside>
1841
+ <details class="plasius-demo__diagnostics">
1842
+ <summary>${t(gpuSharedTranslationKeys.debugTelemetry)}</summary>
1843
+ <aside class="plasius-demo__sidebar">
1844
+ <section class="plasius-panel plasius-demo__card">
1845
+ <h2>${t(gpuSharedTranslationKeys.sceneState)}</h2>
1846
+ <ul id="sceneMetrics" class="plasius-demo__metrics"></ul>
1847
+ </section>
1848
+ <section class="plasius-panel plasius-demo__card">
1849
+ <h2>${t(gpuSharedTranslationKeys.qualityBudgets)}</h2>
1850
+ <ul id="qualityMetrics" class="plasius-demo__metrics"></ul>
1851
+ </section>
1852
+ <section class="plasius-panel plasius-demo__card">
1853
+ <h2>${t(gpuSharedTranslationKeys.debugTelemetry)}</h2>
1854
+ <ul id="debugMetrics" class="plasius-demo__metrics"></ul>
1855
+ </section>
1856
+ <section class="plasius-panel plasius-demo__card">
1857
+ <h2>${t(gpuSharedTranslationKeys.notes)}</h2>
1858
+ <ul id="sceneNotes" class="plasius-demo__metrics"></ul>
1859
+ </section>
1860
+ </aside>
1861
+ </details>
1711
1862
  </section>
1712
1863
  <p class="plasius-demo__footer">
1713
1864
  This visual example is shared across the GPU packages to keep manual validation fast and consistent.
@@ -2125,13 +2276,13 @@ function advanceShowcaseClothSimulationState(clothState, options = {}) {
2125
2276
  )
2126
2277
  );
2127
2278
  const windStrength =
2128
- (1.6 + broadMotion * 1.25 + wrinkleLayers * 0.12) *
2279
+ (0.94 + broadMotion * 0.82 + wrinkleLayers * 0.08) *
2129
2280
  flagMotion *
2130
- (0.44 + u * 1.14);
2281
+ (0.36 + u * 0.92);
2131
2282
  const wrinkleForce = vec3(
2132
- Math.sin(wrinklePhase) * 0.22 * wrinkleMotion * flagMotion,
2133
- Math.cos(wrinklePhase * 0.7) * 0.08 * wrinkleMotion,
2134
- Math.cos(wrinklePhase) * 0.14 * broadMotion * flagMotion
2283
+ Math.sin(wrinklePhase) * 0.12 * wrinkleMotion * flagMotion,
2284
+ Math.cos(wrinklePhase * 0.7) * 0.045 * wrinkleMotion,
2285
+ Math.cos(wrinklePhase) * 0.08 * broadMotion * flagMotion
2135
2286
  );
2136
2287
  const acceleration = addVec3(
2137
2288
  vec3(0, -0.48 - u * 0.08, 0),
@@ -2197,25 +2348,25 @@ function resolveVisualConfig(nearLighting, lightingSnapshot, customVisuals = {})
2197
2348
  ambientMist: "rgba(41, 63, 97, 0.16)",
2198
2349
  reflectionStrength: lightingSnapshot.currentLevel.config.reflectionStrength,
2199
2350
  shadowAccent: lightingSnapshot.currentLevel.config.shadowStrength,
2200
- waveAmplitude: 0.94,
2351
+ waveAmplitude: 0.82,
2201
2352
  waveDirection: { x: 0.88, z: 0.28 },
2202
- wavePhaseSpeed: 0.88,
2203
- wakeStrength: 0.31,
2204
- wakeLength: 18,
2205
- collisionRippleStrength: 0.42,
2206
- waterNear: { r: 0.08, g: 0.23, b: 0.33 },
2207
- waterFar: { r: 0.18, g: 0.35, b: 0.49 },
2353
+ wavePhaseSpeed: 0.74,
2354
+ wakeStrength: 0.24,
2355
+ wakeLength: 17,
2356
+ collisionRippleStrength: 0.22,
2357
+ waterNear: { r: 0.05, g: 0.2, b: 0.3 },
2358
+ waterFar: { r: 0.13, g: 0.31, b: 0.45 },
2208
2359
  harborWall: { r: 0.26, g: 0.24, b: 0.28 },
2209
2360
  harborDeck: { r: 0.33, g: 0.22, b: 0.16 },
2210
2361
  harborTower: { r: 0.23, g: 0.24, b: 0.29 },
2211
- flagColor: { r: 0.66, g: 0.16, b: 0.13 },
2212
- flagMotion: 0.92,
2362
+ flagColor: { r: 0.54, g: 0.13, b: 0.11 },
2363
+ flagMotion: 0.58,
2213
2364
  lanternCore: { r: 0.98, g: 0.8, b: 0.48 },
2214
2365
  lanternGlow: { r: 1, g: 0.56, b: 0.2 },
2215
2366
  lanternReflectionStrength: 0.42,
2216
2367
  torchCore: { r: 0.99, g: 0.72, b: 0.36 },
2217
2368
  torchGlow: { r: 0.98, g: 0.38, b: 0.15 },
2218
- collisionFlash: "rgba(255, 212, 168, 0.16)",
2369
+ collisionFlash: "rgba(255, 212, 168, 0.08)",
2219
2370
  };
2220
2371
 
2221
2372
  return {
@@ -2299,6 +2450,11 @@ function buildClothSurface(model, state, meshDetail, visuals, clothFeatures) {
2299
2450
  representation: clothPresentation.representation,
2300
2451
  continuity: clothPresentation.continuity,
2301
2452
  color: visuals.flagColor,
2453
+ material: Object.freeze({
2454
+ weaveAlpha: clothPresentation.band === "near" ? 0.22 : 0.12,
2455
+ foldAlpha: clothPresentation.band === "near" ? 0.3 : 0.18,
2456
+ edgeHighlightAlpha: clothPresentation.band === "near" ? 0.42 : 0.28,
2457
+ }),
2302
2458
  positions: clothState.positions.map((point) => vec3(point.x, point.y, point.z)),
2303
2459
  indices: clothState.indices,
2304
2460
  grid: { rows: clothState.rows, cols: clothState.cols },
@@ -2412,7 +2568,7 @@ function buildWaterMotionEffects(state) {
2412
2568
  impulse.z
2413
2569
  ),
2414
2570
  radius,
2415
- opacity: clamp(impulse.life * 0.28, 0.08, 0.3),
2571
+ opacity: clamp(impulse.life * 0.13, 0.035, 0.15),
2416
2572
  });
2417
2573
  });
2418
2574
 
@@ -2427,7 +2583,7 @@ function buildWaterMotionEffects(state) {
2427
2583
  const lateral = vec3(-direction.z, 0, direction.x);
2428
2584
  const points = [];
2429
2585
  for (let sampleIndex = 0; sampleIndex < 6; sampleIndex += 1) {
2430
- const along = 1 + sampleIndex * 1.45;
2586
+ const along = 1 + sampleIndex * 1.55;
2431
2587
  const lateralOffset =
2432
2588
  Math.sin(state.time * 1.2 + sampleIndex * 0.8 + readVisualNumber(ship.wanderPhase, 0)) * 0.12;
2433
2589
  const worldPoint = addVec3(
@@ -2441,13 +2597,14 @@ function buildWaterMotionEffects(state) {
2441
2597
  sampleWave(state, worldPoint.x, worldPoint.z, state.time) * 0.24 + 0.04,
2442
2598
  worldPoint.z
2443
2599
  ),
2444
- width: 0.34 + sampleIndex * 0.13,
2600
+ width: 0.3 + sampleIndex * 0.11,
2601
+ foam: clamp(0.28 - sampleIndex * 0.028 + speed * 0.025, 0.1, 0.34),
2445
2602
  })
2446
2603
  );
2447
2604
  }
2448
2605
  wakeTrails.push(
2449
2606
  Object.freeze({
2450
- opacity: clamp(0.18 + speed * 0.09, 0.22, 0.46),
2607
+ opacity: clamp(0.1 + speed * 0.048, 0.12, 0.24),
2451
2608
  points: Object.freeze(points),
2452
2609
  })
2453
2610
  );
@@ -2459,6 +2616,28 @@ function buildWaterMotionEffects(state) {
2459
2616
  });
2460
2617
  }
2461
2618
 
2619
+ function buildShorelineFoamSegments(state) {
2620
+ return Object.freeze(
2621
+ SHORELINE_FOAM_ANCHORS.map((anchor, index) => {
2622
+ const pulse = 0.5 + Math.sin(state.time * 0.84 + index * 1.17) * 0.5;
2623
+ const drift = Math.sin(state.time * 0.38 + index * 0.61) * 0.1;
2624
+ const direction = normalizeVec3(vec3(Math.cos(anchor.angle), 0, Math.sin(anchor.angle)));
2625
+ const center = vec3(
2626
+ anchor.x + direction.x * drift,
2627
+ sampleWave(state, anchor.x, anchor.z, state.time) * 0.12 - 0.02,
2628
+ anchor.z + direction.z * drift
2629
+ );
2630
+ return Object.freeze({
2631
+ center,
2632
+ direction,
2633
+ length: anchor.length * (0.78 + pulse * 0.34),
2634
+ width: 0.16 + pulse * 0.12,
2635
+ opacity: 0.07 + pulse * 0.12,
2636
+ });
2637
+ })
2638
+ );
2639
+ }
2640
+
2462
2641
  function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
2463
2642
  const resolvedFluidFeatures = normalizeFluidFeatureAdapters(fluidFeatures);
2464
2643
  const fluidPlan = resolvedFluidFeatures.createPlan({
@@ -2487,14 +2666,14 @@ function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
2487
2666
  const bandContinuity = resolveFluidBandContinuity(continuity, bandSpec.band);
2488
2667
  const bandResolution =
2489
2668
  bandSpec.band === "near"
2490
- ? fluidDetail.nearResolution
2669
+ ? Math.ceil(fluidDetail.nearResolution * 1.28)
2491
2670
  : bandSpec.band === "mid"
2492
- ? fluidDetail.midResolution
2671
+ ? Math.ceil(fluidDetail.midResolution * 1.2)
2493
2672
  : bandSpec.band === "far"
2494
2673
  ? 5
2495
2674
  : 3;
2496
- const cols = Math.max(4, bandResolution * 2);
2497
- const rows = Math.max(4, bandResolution + 2);
2675
+ const cols = Math.max(4, bandResolution * (bandSpec.band === "near" ? 3 : 2));
2676
+ const rows = Math.max(4, bandResolution + (bandSpec.band === "near" ? 5 : 2));
2498
2677
  const positions = [];
2499
2678
  const indices = [];
2500
2679
  const originX = -bandSpec.width * 0.5;
@@ -2505,11 +2684,16 @@ function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
2505
2684
  const v = row / (rows - 1);
2506
2685
  const x = originX + bandSpec.width * u;
2507
2686
  const z = originZ + bandSpec.depth * v;
2508
- const y =
2687
+ const baseHeight =
2509
2688
  bandSpec.y +
2510
2689
  sampleWave(state, x, z, state.time) *
2511
2690
  bandContinuity.amplitudeFloor *
2512
2691
  (bandSpec.band === "near" ? 0.9 : bandSpec.band === "mid" ? 0.55 : 0.3);
2692
+ const detailHeight =
2693
+ bandSpec.band === "near"
2694
+ ? Math.sin(x * 1.25 + z * 0.42 - state.time * 2.4) * 0.035
2695
+ : 0;
2696
+ const y = baseHeight + detailHeight;
2513
2697
  positions.push(vec3(x, y, z));
2514
2698
  }
2515
2699
  }
@@ -2546,7 +2730,12 @@ function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
2546
2730
  r: mix(visuals.waterFar.r, 0.76, 0.2),
2547
2731
  g: mix(visuals.waterFar.g, 0.78, 0.2),
2548
2732
  b: mix(visuals.waterFar.b, 0.82, 0.2),
2549
- },
2733
+ },
2734
+ material: Object.freeze({
2735
+ highlightAlpha: bandSpec.band === "near" ? 0.2 : bandSpec.band === "mid" ? 0.13 : 0.07,
2736
+ foamAlpha: bandSpec.band === "near" ? 0.28 : bandSpec.band === "mid" ? 0.14 : 0.05,
2737
+ microRippleScale: bandSpec.band === "near" ? 1 : bandSpec.band === "mid" ? 0.58 : 0.28,
2738
+ }),
2550
2739
  });
2551
2740
  }
2552
2741
 
@@ -2626,15 +2815,15 @@ function createSceneState(options, featureAdapters) {
2626
2815
  {
2627
2816
  id: "northwind",
2628
2817
  modelKey: "brigantine",
2629
- position: vec3(-5.2, 0, 7.2),
2630
- velocity: vec3(2.35, 0, -1.08),
2631
- rotationY: 0.58,
2632
- angularVelocity: 0.09,
2818
+ position: vec3(-7.8, 0, 11.2),
2819
+ velocity: vec3(1.08, 0, -0.18),
2820
+ rotationY: 1.38,
2821
+ angularVelocity: 0.025,
2633
2822
  tint: { r: 0.62, g: 0.39, b: 0.23 },
2634
2823
  massScale: 1.42,
2635
- cruiseSpeed: 2.25,
2636
- throttleResponse: 0.46,
2637
- rudderResponse: 0.54,
2824
+ cruiseSpeed: 1.22,
2825
+ throttleResponse: 0.36,
2826
+ rudderResponse: 0.4,
2638
2827
  wanderPhase: 0.35,
2639
2828
  lanterns: CUTTER_LANTERNS,
2640
2829
  lanternStrength: 1.06,
@@ -2643,15 +2832,15 @@ function createSceneState(options, featureAdapters) {
2643
2832
  {
2644
2833
  id: "tidecaller",
2645
2834
  modelKey: "cutter",
2646
- position: vec3(4.8, 0, 4.4),
2647
- velocity: vec3(-2.15, 0, 1.74),
2648
- rotationY: -2.48,
2649
- angularVelocity: -0.2,
2835
+ position: vec3(6.8, 0, 5.4),
2836
+ velocity: vec3(-0.82, 0, 0.14),
2837
+ rotationY: -1.34,
2838
+ angularVelocity: -0.035,
2650
2839
  tint: { r: 0.58, g: 0.24, b: 0.16 },
2651
2840
  massScale: 0.84,
2652
- cruiseSpeed: 2.68,
2653
- throttleResponse: 0.7,
2654
- rudderResponse: 0.78,
2841
+ cruiseSpeed: 1.36,
2842
+ throttleResponse: 0.52,
2843
+ rudderResponse: 0.58,
2655
2844
  wanderPhase: 1.6,
2656
2845
  lanterns: SHIP_LANTERNS,
2657
2846
  lanternStrength: 1.18,
@@ -2885,7 +3074,7 @@ function renderProjectedShadow(ctx, worldPoints, camera, viewport, lightDir, opt
2885
3074
  }
2886
3075
 
2887
3076
  function pushHarborGeometry(camera, viewport, triangles, state) {
2888
- if (!state.showcaseRealisticModelsEnabled) {
3077
+ if (!hasModeledHarborEnvironment(state)) {
2889
3078
  for (const object of LEGACY_HARBOR_LAYOUT) {
2890
3079
  buildTrianglesFromMesh(
2891
3080
  { positions: [object], indices: [0], normals: null, colors: null, material: createLegacyMeshPrimitive({})?.material, bounds: null, name: "legacy-structure" },
@@ -2995,13 +3184,14 @@ function renderShipRigging(ctx, ship, camera, viewport) {
2995
3184
 
2996
3185
  function renderClothAccent(ctx, cloth, camera, viewport) {
2997
3186
  const projected = cloth.positions.map((point) => projectPoint(point, camera, viewport));
2998
- ctx.strokeStyle = "rgba(255, 241, 226, 0.92)";
2999
- ctx.lineWidth = 1.7;
3187
+ const material = cloth.material ?? {};
3188
+ ctx.strokeStyle = `rgba(255, 241, 226, ${material.foldAlpha ?? 0.32})`;
3189
+ ctx.lineWidth = 1.8;
3000
3190
 
3001
3191
  for (
3002
3192
  let row = 0;
3003
3193
  row < cloth.grid.rows;
3004
- row += Math.max(1, Math.floor(cloth.grid.rows / 5))
3194
+ row += Math.max(1, Math.floor(cloth.grid.rows / 6))
3005
3195
  ) {
3006
3196
  ctx.beginPath();
3007
3197
  let started = false;
@@ -3022,6 +3212,32 @@ function renderClothAccent(ctx, cloth, camera, viewport) {
3022
3212
  }
3023
3213
  }
3024
3214
 
3215
+ ctx.strokeStyle = `rgba(255, 228, 204, ${material.weaveAlpha ?? 0.22})`;
3216
+ ctx.lineWidth = 0.85;
3217
+ for (
3218
+ let column = 1;
3219
+ column < cloth.grid.cols - 1;
3220
+ column += Math.max(1, Math.floor(cloth.grid.cols / 8))
3221
+ ) {
3222
+ ctx.beginPath();
3223
+ let started = false;
3224
+ for (let row = 0; row < cloth.grid.rows; row += 1) {
3225
+ const point = projected[row * cloth.grid.cols + column];
3226
+ if (!point) {
3227
+ continue;
3228
+ }
3229
+ if (!started) {
3230
+ ctx.moveTo(point.x, point.y);
3231
+ started = true;
3232
+ } else {
3233
+ ctx.lineTo(point.x, point.y);
3234
+ }
3235
+ }
3236
+ if (started) {
3237
+ ctx.stroke();
3238
+ }
3239
+ }
3240
+
3025
3241
  const borderIndices = [
3026
3242
  0,
3027
3243
  cloth.grid.cols - 1,
@@ -3029,6 +3245,26 @@ function renderClothAccent(ctx, cloth, camera, viewport) {
3029
3245
  (cloth.grid.rows - 1) * cloth.grid.cols,
3030
3246
  ];
3031
3247
  ctx.fillStyle = colorToRgba(cloth.color, 0.95);
3248
+ ctx.strokeStyle = `rgba(255, 246, 236, ${material.edgeHighlightAlpha ?? 0.5})`;
3249
+ ctx.lineWidth = 1.4;
3250
+ ctx.beginPath();
3251
+ let borderStarted = false;
3252
+ for (let column = 0; column < cloth.grid.cols; column += 1) {
3253
+ const point = projected[column];
3254
+ if (!point) {
3255
+ continue;
3256
+ }
3257
+ if (!borderStarted) {
3258
+ ctx.moveTo(point.x, point.y);
3259
+ borderStarted = true;
3260
+ } else {
3261
+ ctx.lineTo(point.x, point.y);
3262
+ }
3263
+ }
3264
+ if (borderStarted) {
3265
+ ctx.stroke();
3266
+ }
3267
+
3032
3268
  for (const index of borderIndices) {
3033
3269
  const point = projected[index];
3034
3270
  if (!point) {
@@ -3045,14 +3281,22 @@ function renderWaterHighlights(ctx, waterBands, camera, viewport) {
3045
3281
  if (band.band === "horizon") {
3046
3282
  continue;
3047
3283
  }
3048
- const interval = band.band === "near" ? 2 : 3;
3049
- const alpha = band.band === "near" ? 0.22 : 0.14;
3284
+ const interval = band.band === "near" ? 4 : 5;
3285
+ const alpha = band.material?.highlightAlpha ?? (band.band === "near" ? 0.22 : 0.14);
3050
3286
  ctx.strokeStyle = `rgba(232, 247, 255, ${alpha})`;
3051
- ctx.lineWidth = band.band === "near" ? 1.3 : 0.9;
3287
+ ctx.lineWidth = band.band === "near" ? 0.9 : 0.65;
3052
3288
  for (let row = interval; row < band.rows - 1; row += interval) {
3053
- ctx.beginPath();
3054
3289
  let started = false;
3055
- for (let column = 0; column < band.cols; column += 1) {
3290
+ ctx.beginPath();
3291
+ for (let column = 0; column < band.cols; column += band.band === "near" ? 2 : 3) {
3292
+ if (pseudoRandom(row * 47 + column * 13) < 0.18) {
3293
+ if (started) {
3294
+ ctx.stroke();
3295
+ ctx.beginPath();
3296
+ started = false;
3297
+ }
3298
+ continue;
3299
+ }
3056
3300
  const point = projectPoint(
3057
3301
  band.positions[row * band.cols + column],
3058
3302
  camera,
@@ -3072,9 +3316,63 @@ function renderWaterHighlights(ctx, waterBands, camera, viewport) {
3072
3316
  ctx.stroke();
3073
3317
  }
3074
3318
  }
3319
+
3320
+ if (band.band === "near") {
3321
+ ctx.fillStyle = `rgba(236, 249, 255, ${(band.material?.foamAlpha ?? 0.28) * 0.72})`;
3322
+ for (let column = 3; column < band.cols - 3; column += 10) {
3323
+ const point = projectPoint(
3324
+ band.positions[Math.floor(band.rows * 0.42) * band.cols + column],
3325
+ camera,
3326
+ viewport
3327
+ );
3328
+ if (!point) {
3329
+ continue;
3330
+ }
3331
+ ctx.beginPath();
3332
+ ctx.ellipse(point.x, point.y, 1.8, 0.75, -0.2, 0, Math.PI * 2);
3333
+ ctx.fill();
3334
+ }
3335
+ }
3075
3336
  }
3076
3337
  }
3077
3338
 
3339
+ function renderShorelineFoamSegments(ctx, segments, camera, viewport) {
3340
+ ctx.save();
3341
+ ctx.globalCompositeOperation = "screen";
3342
+ ctx.lineCap = "round";
3343
+ ctx.lineJoin = "round";
3344
+
3345
+ for (const segment of segments) {
3346
+ const half = scaleVec3(segment.direction, segment.length * 0.5);
3347
+ const start = projectPoint(subVec3(segment.center, half), camera, viewport);
3348
+ const end = projectPoint(addVec3(segment.center, half), camera, viewport);
3349
+ const center = projectPoint(segment.center, camera, viewport);
3350
+ if (!start || !end || !center) {
3351
+ continue;
3352
+ }
3353
+
3354
+ const depthScale = clamp(140 / Math.max(12, center.depth), 3, 10);
3355
+ ctx.strokeStyle = `rgba(232, 242, 238, ${segment.opacity})`;
3356
+ ctx.lineWidth = clamp(segment.width * depthScale, 0.8, 2.8);
3357
+ ctx.beginPath();
3358
+ ctx.moveTo(start.x, start.y);
3359
+ ctx.quadraticCurveTo(
3360
+ center.x,
3361
+ center.y + Math.sin(segment.center.x * 1.7) * 2.4,
3362
+ end.x,
3363
+ end.y
3364
+ );
3365
+ ctx.stroke();
3366
+
3367
+ ctx.fillStyle = `rgba(248, 251, 246, ${segment.opacity * 0.68})`;
3368
+ ctx.beginPath();
3369
+ ctx.ellipse(center.x, center.y, depthScale * 0.18, depthScale * 0.08, -0.2, 0, Math.PI * 2);
3370
+ ctx.fill();
3371
+ }
3372
+
3373
+ ctx.restore();
3374
+ }
3375
+
3078
3376
  function readPhysicsNumber(physics, key, fallback) {
3079
3377
  const value = physics?.[key];
3080
3378
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
@@ -3113,14 +3411,17 @@ function getShipInverseInertia(ship, shipModel) {
3113
3411
  }
3114
3412
 
3115
3413
  function spawnSpray(state, point, intensity) {
3116
- const count = state.fluidDetail.getSnapshot().currentLevel.config.splashCount;
3414
+ const count = Math.max(
3415
+ 3,
3416
+ Math.ceil(state.fluidDetail.getSnapshot().currentLevel.config.splashCount * 0.32)
3417
+ );
3117
3418
  for (let index = 0; index < count; index += 1) {
3118
3419
  const angle = (index / count) * Math.PI * 2;
3119
- const speed = 0.9 + Math.random() * intensity * 0.45;
3420
+ const speed = 0.46 + Math.random() * intensity * 0.24;
3120
3421
  state.sprays.push({
3121
3422
  position: vec3(point.x, point.y, point.z),
3122
- velocity: vec3(Math.cos(angle) * speed * 0.35, 1.1 + Math.random() * 0.8, Math.sin(angle) * speed * 0.25),
3123
- life: 1.2 + Math.random() * 0.4,
3423
+ velocity: vec3(Math.cos(angle) * speed * 0.24, 0.46 + Math.random() * 0.34, Math.sin(angle) * speed * 0.18),
3424
+ life: 0.72 + Math.random() * 0.22,
3124
3425
  });
3125
3426
  }
3126
3427
  }
@@ -3140,8 +3441,8 @@ function resolveShipRoute(ship, state, radius) {
3140
3441
  const crossCurrent = Math.cos(state.time * 0.31 + readVisualNumber(ship.wanderPhase, 0));
3141
3442
  const laneCenter =
3142
3443
  ship.id === "northwind"
3143
- ? 10.2 + wander * 2.1 + crossCurrent * 0.6
3144
- : 7 + wander * 3.3 - crossCurrent * 1.1;
3444
+ ? 11.6 + wander * 0.82 + crossCurrent * 0.24
3445
+ : 5.4 + wander * 0.94 - crossCurrent * 0.32;
3145
3446
  const targetX =
3146
3447
  ship.routeDirection > 0
3147
3448
  ? HARBOR_BOUNDS.maxX - radius * 1.7
@@ -3158,7 +3459,7 @@ function updateShipMotion(state, ship, dt, shipModel) {
3158
3459
  const angularDamping = readPhysicsNumber(physics, "angularDamping", 0.08);
3159
3460
  const throttleResponse = readVisualNumber(ship.throttleResponse, 0.58);
3160
3461
  const rudderResponse = readVisualNumber(ship.rudderResponse, 0.62);
3161
- const cruiseSpeed = readVisualNumber(ship.cruiseSpeed, 2.4);
3462
+ const cruiseSpeed = readVisualNumber(ship.cruiseSpeed, 1.25);
3162
3463
 
3163
3464
  ship.collisionCooldown = Math.max(0, readVisualNumber(ship.collisionCooldown, 0) - dt);
3164
3465
 
@@ -3272,7 +3573,7 @@ function resolveShipCollision(state, a, b, shipModelA, shipModelB) {
3272
3573
  ((readPhysicsNumber(shipModelA.physics, "restitution", 0.22) +
3273
3574
  readPhysicsNumber(shipModelB.physics, "restitution", 0.22)) /
3274
3575
  2) *
3275
- 0.88;
3576
+ 0.42;
3276
3577
  if (velocityAlongNormal < 0) {
3277
3578
  const impulseMagnitude =
3278
3579
  (-(1 + restitution) * velocityAlongNormal) / Math.max(0.0001, invMassSum);
@@ -3299,7 +3600,7 @@ function resolveShipCollision(state, a, b, shipModelA, shipModelB) {
3299
3600
 
3300
3601
  const impactSpeed = Math.abs(velocityAlongNormal);
3301
3602
  if (
3302
- impactSpeed > 0.18 &&
3603
+ impactSpeed > 0.36 &&
3303
3604
  Math.max(readVisualNumber(a.collisionCooldown, 0), readVisualNumber(b.collisionCooldown, 0)) <= 0
3304
3605
  ) {
3305
3606
  const contactPoint = vec3(
@@ -3307,21 +3608,21 @@ function resolveShipCollision(state, a, b, shipModelA, shipModelB) {
3307
3608
  (a.position.y + b.position.y) * 0.5 + 0.14,
3308
3609
  (a.position.z + b.position.z) * 0.5
3309
3610
  );
3310
- spawnSpray(state, contactPoint, impactSpeed * 2.4 + penetration * 8);
3611
+ spawnSpray(state, contactPoint, impactSpeed * 0.9 + penetration * 2.4);
3311
3612
  state.waveImpulses.push({
3312
3613
  x: contactPoint.x,
3313
3614
  z: contactPoint.z,
3314
- strength: clamp(0.24 + impactSpeed * 0.46 + penetration * 0.9, 0.2, 1.7),
3315
- radius: 0.9 + penetration * 1.4,
3615
+ strength: clamp(0.1 + impactSpeed * 0.18 + penetration * 0.28, 0.08, 0.52),
3616
+ radius: 0.72 + penetration * 0.72,
3316
3617
  life: 1,
3317
3618
  });
3318
3619
  state.collisionCount += 1;
3319
3620
  state.collisionFlash = Math.max(
3320
3621
  state.collisionFlash,
3321
- clamp(impactSpeed * 0.55 + penetration * 1.8, 0.16, 1)
3622
+ clamp(impactSpeed * 0.14 + penetration * 0.32, 0.04, 0.24)
3322
3623
  );
3323
- a.collisionCooldown = 0.2;
3324
- b.collisionCooldown = 0.2;
3624
+ a.collisionCooldown = 0.72;
3625
+ b.collisionCooldown = 0.72;
3325
3626
  }
3326
3627
  }
3327
3628
 
@@ -3352,8 +3653,8 @@ function updateShips(state, dt, shipModel) {
3352
3653
  }
3353
3654
 
3354
3655
  state.collisionFlash = collided
3355
- ? Math.max(0.12, state.collisionFlash)
3356
- : Math.max(0, state.collisionFlash - dt * 1.3);
3656
+ ? Math.max(0.04, state.collisionFlash)
3657
+ : Math.max(0, state.collisionFlash - dt * 1.7);
3357
3658
  }
3358
3659
 
3359
3660
  function updateWaveImpulses(state, dt) {
@@ -3642,7 +3943,11 @@ function renderLighthouseBeam(ctx, state, camera, viewport, visuals) {
3642
3943
  const lighthousePlacement = SHOWCASE_ENVIRONMENT_LAYOUT.find(
3643
3944
  (placement) => placement.assetKey === "lighthouse"
3644
3945
  );
3645
- if (!lighthousePlacement || !state.showcaseRealisticModelsEnabled) {
3946
+ if (
3947
+ !lighthousePlacement ||
3948
+ !state.showcaseRealisticModelsEnabled ||
3949
+ !hasModeledHarborEnvironment(state)
3950
+ ) {
3646
3951
  return;
3647
3952
  }
3648
3953
 
@@ -3749,6 +4054,7 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
3749
4054
  .map((point) => ({
3750
4055
  projected: projectPoint(point.center, camera, viewport),
3751
4056
  width: point.width,
4057
+ foam: point.foam,
3752
4058
  }))
3753
4059
  .filter((entry) => entry.projected);
3754
4060
  if (projected.length < 2) {
@@ -3760,8 +4066,8 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
3760
4066
  const averageWidth =
3761
4067
  projected.reduce((total, entry) => total + entry.width, 0) / projected.length;
3762
4068
  const baseWidth = clamp((averageWidth / Math.max(0.25, averageDepth)) * 180, 1.6, 5.4);
3763
- ctx.strokeStyle = `rgba(146, 194, 236, ${wake.opacity * 0.52})`;
3764
- ctx.lineWidth = baseWidth * 1.9;
4069
+ ctx.strokeStyle = `rgba(146, 194, 236, ${wake.opacity * 0.34})`;
4070
+ ctx.lineWidth = baseWidth * 1.45;
3765
4071
  ctx.lineCap = "round";
3766
4072
  ctx.lineJoin = "round";
3767
4073
  ctx.beginPath();
@@ -3771,8 +4077,8 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
3771
4077
  }
3772
4078
  ctx.stroke();
3773
4079
 
3774
- ctx.strokeStyle = `rgba(234, 247, 255, ${wake.opacity})`;
3775
- ctx.lineWidth = baseWidth;
4080
+ ctx.strokeStyle = `rgba(234, 247, 255, ${wake.opacity * 0.72})`;
4081
+ ctx.lineWidth = baseWidth * 0.72;
3776
4082
  ctx.lineCap = "round";
3777
4083
  ctx.lineJoin = "round";
3778
4084
  ctx.beginPath();
@@ -3783,13 +4089,14 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
3783
4089
  ctx.stroke();
3784
4090
 
3785
4091
  for (const entry of projected.slice(1, 5)) {
3786
- ctx.fillStyle = `rgba(239, 248, 255, ${wake.opacity * 0.76})`;
4092
+ const foam = entry.foam ?? 0.3;
4093
+ ctx.fillStyle = `rgba(239, 248, 255, ${wake.opacity * foam * 0.92})`;
3787
4094
  ctx.beginPath();
3788
4095
  ctx.ellipse(
3789
4096
  entry.projected.x,
3790
4097
  entry.projected.y,
3791
- baseWidth * 0.72,
3792
- baseWidth * 0.44,
4098
+ baseWidth * 0.54,
4099
+ baseWidth * 0.28,
3793
4100
  0,
3794
4101
  0,
3795
4102
  Math.PI * 2
@@ -3809,15 +4116,25 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
3809
4116
  const radiusX = Math.hypot(xAxis.x - center.x, xAxis.y - center.y);
3810
4117
  const radiusY = Math.hypot(zAxis.x - center.x, zAxis.y - center.y);
3811
4118
  ctx.strokeStyle = `rgba(216, 235, 255, ${ring.opacity})`;
3812
- ctx.lineWidth = clamp((radiusX + radiusY) * 0.02, 1, 3.1);
3813
- ctx.beginPath();
3814
- ctx.ellipse(center.x, center.y, radiusX, radiusY, 0, 0, Math.PI * 2);
3815
- ctx.stroke();
4119
+ ctx.lineWidth = clamp((radiusX + radiusY) * 0.014, 0.65, 1.8);
4120
+ for (let segment = 0; segment < 5; segment += 1) {
4121
+ if (pseudoRandom(segment * 31 + radiusX * 0.7 + radiusY * 0.3) < 0.32) {
4122
+ continue;
4123
+ }
4124
+ const startAngle = segment * 1.22 + stateTimePhase(center.x, center.y) * 0.04;
4125
+ ctx.beginPath();
4126
+ ctx.ellipse(center.x, center.y, radiusX, radiusY, 0, startAngle, startAngle + 0.48);
4127
+ ctx.stroke();
4128
+ }
3816
4129
  }
3817
4130
 
3818
4131
  ctx.restore();
3819
4132
  }
3820
4133
 
4134
+ function stateTimePhase(x, y) {
4135
+ return Math.sin(x * 0.013 + y * 0.017);
4136
+ }
4137
+
3821
4138
  function renderScene(
3822
4139
  ctx,
3823
4140
  canvas,
@@ -3899,6 +4216,7 @@ function renderScene(
3899
4216
  }
3900
4217
 
3901
4218
  const waterMotionEffects = buildWaterMotionEffects(state);
4219
+ const shorelineFoamSegments = buildShorelineFoamSegments(state);
3902
4220
  const lightSources = collectSceneLightSources(state, visuals);
3903
4221
 
3904
4222
  pushHarborGeometry(camera, viewport, sceneTriangles, state);
@@ -3973,6 +4291,7 @@ function renderScene(
3973
4291
  }
3974
4292
  renderWaterMotionEffects(ctx, waterMotionEffects, camera, viewport);
3975
4293
  renderWaterHighlights(ctx, water.bandMeshes, camera, viewport);
4294
+ renderShorelineFoamSegments(ctx, shorelineFoamSegments, camera, viewport);
3976
4295
  drawTriangles(
3977
4296
  ctx,
3978
4297
  sceneTriangles,
@@ -4003,7 +4322,7 @@ function renderScene(
4003
4322
 
4004
4323
  const sceneMetrics = [
4005
4324
  `focus: ${state.focus}`,
4006
- `ships: ${state.ships.length} active GLTF hulls across ${new Set(state.ships.map((ship) => ship.modelKey)).size} model families`,
4325
+ `ships: ${state.ships.length} active GLTF hulls across ${new Set(state.ships.map((ship) => resolveShipModel(state, ship, shipModel)?.name ?? ship.modelKey)).size} model families`,
4007
4326
  `moonlight: cold overhead key + ${HARBOR_TORCHES.length + state.ships.reduce((total, ship) => total + (Array.isArray(ship.lanterns) ? ship.lanterns.length : 0), 0)} warm deck and harbor lights`,
4008
4327
  `physics snapshot: ${state.physics.snapshot.stage} (${state.physics.snapshot.stability})`,
4009
4328
  `physics contacts: ${state.contactCount}`,
@@ -4087,7 +4406,7 @@ function updateSceneState(state, dt, shipModel, featureAdapters) {
4087
4406
  advanceShowcaseClothSimulationState(clothState, {
4088
4407
  dt,
4089
4408
  time: state.time,
4090
- flagMotion: readVisualNumber(state.demoVisuals?.flagMotion, 0.92),
4409
+ flagMotion: readVisualNumber(state.demoVisuals?.flagMotion, 0.58),
4091
4410
  waveInfluence: sampleWave(state, FLAG_LAYOUT.origin.x + FLAG_LAYOUT.width * 0.55, FLAG_LAYOUT.origin.z + FLAG_LAYOUT.width * 0.48, state.time),
4092
4411
  });
4093
4412
  updatePhysicsSnapshot(state, shipModel, featureAdapters.physics);
@@ -4098,17 +4417,28 @@ function syncTextState(state, shipModel, featureAdapters) {
4098
4417
  coordinateSystem: "right-handed world; +x right, +y up, +z forward from the shore",
4099
4418
  focus: state.focus,
4100
4419
  stress: state.stress,
4101
- ships: state.ships.map((ship) => ({
4102
- id: ship.id,
4103
- modelKey: ship.modelKey ?? "brigantine",
4104
- x: Number(ship.position.x.toFixed(2)),
4105
- y: Number(ship.position.y.toFixed(2)),
4106
- z: Number(ship.position.z.toFixed(2)),
4107
- vx: Number(ship.velocity.x.toFixed(2)),
4108
- vz: Number(ship.velocity.z.toFixed(2)),
4109
- massKg: Math.round(getShipMass(ship, resolveShipModel(state, ship, shipModel))),
4110
- lanterns: Array.isArray(ship.lanterns) ? ship.lanterns.length : 0,
4111
- })),
4420
+ ships: state.ships.map((ship) => {
4421
+ const resolvedShipModel = resolveShipModel(state, ship, shipModel);
4422
+ return {
4423
+ id: ship.id,
4424
+ modelKey: ship.modelKey ?? "brigantine",
4425
+ resolvedModelKey: resolvedShipModel?.name ?? ship.modelKey ?? "brigantine",
4426
+ x: Number(ship.position.x.toFixed(2)),
4427
+ y: Number(ship.position.y.toFixed(2)),
4428
+ z: Number(ship.position.z.toFixed(2)),
4429
+ vx: Number(ship.velocity.x.toFixed(2)),
4430
+ vz: Number(ship.velocity.z.toFixed(2)),
4431
+ massKg: Math.round(getShipMass(ship, resolvedShipModel)),
4432
+ lanterns: Array.isArray(ship.lanterns) ? ship.lanterns.length : 0,
4433
+ };
4434
+ }),
4435
+ assetCatalog: {
4436
+ mode: state.assetCatalog?.mode ?? "unknown",
4437
+ shipKeys: Object.keys(state.assetCatalog?.ships ?? {}).sort(),
4438
+ environmentKeys: Object.keys(state.assetCatalog?.environment ?? {}).sort(),
4439
+ fallbackReason: state.assetCatalog?.fallbackReason ?? null,
4440
+ requestedRealisticModels: state.showcaseRealisticModelsEnabled,
4441
+ },
4112
4442
  shipPhysics: Object.fromEntries(
4113
4443
  state.ships.map((ship) => [ship.id, resolveShipModel(state, ship, shipModel)?.physics ?? null])
4114
4444
  ),
@@ -4166,9 +4496,9 @@ export async function mountGpuShowcase(options = {}, featureFlags = null) {
4166
4496
  },
4167
4497
  featureAdapters
4168
4498
  );
4169
- const assetCatalog = await (state.showcaseRealisticModelsEnabled
4170
- ? loadShowcaseAssetCatalog()
4171
- : createLegacyShowcaseAssetCatalog());
4499
+ const assetCatalog = await loadShowcaseAssetCatalogWithFallback({
4500
+ includeSecondaryShip: state.showcaseRealisticModelsEnabled,
4501
+ });
4172
4502
  const shipModel = assetCatalog.ships[assetCatalog.primaryShipKey];
4173
4503
 
4174
4504
  state.assetCatalog = assetCatalog;
@@ -4320,6 +4650,8 @@ function updatePhysicsSnapshot(state, shipModel, physicsFeatures) {
4320
4650
 
4321
4651
  export {
4322
4652
  advanceShowcaseClothSimulationState as __testOnlyAdvanceShowcaseClothSimulationState,
4653
+ buildClothSurface as __testOnlyBuildClothSurface,
4654
+ buildShorelineFoamSegments as __testOnlyBuildShorelineFoamSegments,
4323
4655
  buildWaterBands as __testOnlyBuildWaterBands,
4324
4656
  buildWaterMotionEffects as __testOnlyBuildWaterMotionEffects,
4325
4657
  collectSceneLightSources as __testOnlyCollectSceneLightSources,