@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.
@@ -2,11 +2,11 @@ import {
2
2
  GPU_SHOWCASE_REALISTIC_MODELS_FEATURE,
3
3
  createGpuSharedTranslator,
4
4
  gpuSharedTranslationKeys
5
- } from "./chunk-CH3ZS5TQ.js";
5
+ } from "./chunk-Z6SOXBHL.js";
6
6
  import {
7
7
  loadGltfModel,
8
8
  resolveShowcaseAssetUrl
9
- } from "./chunk-UKCJ2AWJ.js";
9
+ } from "./chunk-KGKLNL4X.js";
10
10
  import "./chunk-2GM64LB6.js";
11
11
  import "./chunk-DGUM43GV.js";
12
12
 
@@ -764,6 +764,13 @@ var LEGACY_HARBOR_LAYOUT = Object.freeze([
764
764
  })
765
765
  ]);
766
766
  var SHOWCASE_ENVIRONMENT_LAYOUT = Object.freeze([
767
+ Object.freeze({
768
+ assetKey: "shoreline",
769
+ position: Object.freeze({ x: 1.8, y: -0.04, z: 0.48 }),
770
+ rotationY: -0.03,
771
+ scale: 1.02,
772
+ accent: 0.03
773
+ }),
767
774
  Object.freeze({
768
775
  assetKey: "harbor-dock",
769
776
  position: Object.freeze({ x: -4.6, y: 0.16, z: 0.7 }),
@@ -794,6 +801,18 @@ var HARBOR_TORCHES = Object.freeze([
794
801
  Object.freeze({ x: -8.6, y: 2.48, z: -0.72, glow: 1 }),
795
802
  Object.freeze({ x: -10.4, y: 1.28, z: 0.82, glow: 0.92 })
796
803
  ]);
804
+ var SHORELINE_FOAM_ANCHORS = Object.freeze([
805
+ Object.freeze({ x: -7.8, z: 3, length: 1.25, angle: -0.12 }),
806
+ Object.freeze({ x: -6.3, z: 2.72, length: 0.92, angle: 0.08 }),
807
+ Object.freeze({ x: -4.9, z: 3.16, length: 1.08, angle: -0.2 }),
808
+ Object.freeze({ x: -3.2, z: 2.42, length: 0.76, angle: 0.16 }),
809
+ Object.freeze({ x: -1.4, z: 2.82, length: 1.18, angle: -0.04 }),
810
+ Object.freeze({ x: 0.4, z: 3.08, length: 0.88, angle: 0.14 }),
811
+ Object.freeze({ x: 2.1, z: 2.56, length: 1.34, angle: -0.18 }),
812
+ Object.freeze({ x: 3.8, z: 3, length: 0.94, angle: 0.1 }),
813
+ Object.freeze({ x: 5.5, z: 2.72, length: 1.12, angle: -0.08 }),
814
+ Object.freeze({ x: 7, z: 3.22, length: 0.72, angle: 0.18 })
815
+ ]);
797
816
  var FLAG_LAYOUT = Object.freeze({
798
817
  origin: Object.freeze({ x: -3.5, y: 5.9, z: 2.4 }),
799
818
  width: 4.8,
@@ -835,31 +854,31 @@ function injectStyles() {
835
854
  box-sizing: border-box;
836
855
  }
837
856
  .plasius-demo {
838
- width: min(1560px, calc(100vw - 32px));
839
- margin: 0 auto;
840
- padding: 28px 0 40px;
841
- display: grid;
842
- gap: 20px;
843
- }
844
- .plasius-demo__hero,
845
- .plasius-demo__layout {
846
- display: grid;
847
- gap: 20px;
848
- }
849
- .plasius-demo__hero {
850
- grid-template-columns: minmax(0, 1.5fr) minmax(320px, 0.85fr);
851
- align-items: start;
857
+ position: relative;
858
+ width: 100%;
859
+ min-height: 100dvh;
860
+ overflow: hidden;
852
861
  }
853
862
  .plasius-panel {
854
863
  border: 1px solid var(--plasius-border);
855
- border-radius: 24px;
864
+ border-radius: 8px;
856
865
  background: var(--plasius-panel);
857
866
  box-shadow: var(--plasius-shadow);
858
867
  backdrop-filter: blur(12px);
859
868
  }
860
869
  .plasius-demo__hero-card,
861
870
  .plasius-demo__status {
862
- padding: 20px 22px;
871
+ position: absolute;
872
+ z-index: 3;
873
+ padding: 10px 12px;
874
+ }
875
+ .plasius-demo__hero-card {
876
+ display: none;
877
+ }
878
+ .plasius-demo__status {
879
+ left: 16px;
880
+ bottom: 84px;
881
+ max-width: min(360px, calc(100vw - 32px));
863
882
  }
864
883
  .plasius-demo__eyebrow {
865
884
  margin: 0 0 8px;
@@ -882,31 +901,36 @@ function injectStyles() {
882
901
  .plasius-demo__status-badge {
883
902
  width: fit-content;
884
903
  margin: 0;
885
- padding: 8px 12px;
886
- border-radius: 999px;
904
+ padding: 6px 9px;
905
+ border-radius: 6px;
887
906
  background: rgba(243, 177, 106, 0.14);
888
907
  color: var(--plasius-accent);
889
908
  font-weight: 700;
909
+ font-size: 12px;
890
910
  }
891
911
  .plasius-demo__status-text {
892
912
  margin: 10px 0 0;
893
913
  color: var(--plasius-muted);
894
- line-height: 1.6;
895
- }
896
- .plasius-demo__layout {
897
- grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.68fr);
898
- align-items: start;
914
+ font-size: 12px;
915
+ line-height: 1.45;
899
916
  }
900
917
  .plasius-demo__canvas-panel {
901
- padding: 18px;
902
- position: relative;
918
+ position: absolute;
919
+ inset: 0;
920
+ padding: 0;
921
+ border: 0;
922
+ border-radius: 0;
923
+ background: transparent;
924
+ box-shadow: none;
925
+ backdrop-filter: none;
903
926
  }
904
927
  .plasius-demo__canvas {
905
928
  width: 100%;
906
- aspect-ratio: 16 / 9;
929
+ height: 100%;
930
+ min-height: 100dvh;
907
931
  display: block;
908
- border-radius: 20px;
909
- border: 1px solid rgba(159, 185, 223, 0.12);
932
+ border: 0;
933
+ border-radius: 0;
910
934
  background: linear-gradient(180deg, #071220 0%, #132440 42%, #10344b 42%, #05111d 100%);
911
935
  }
912
936
  .${CAPTURE_CLASS} .plasius-demo {
@@ -919,6 +943,8 @@ function injectStyles() {
919
943
  .${CAPTURE_CLASS} .plasius-demo__toolbar,
920
944
  .${CAPTURE_CLASS} .plasius-demo__legend,
921
945
  .${CAPTURE_CLASS} .plasius-demo__sidebar,
946
+ .${CAPTURE_CLASS} .plasius-demo__diagnostics,
947
+ .${CAPTURE_CLASS} .plasius-demo__status,
922
948
  .${CAPTURE_CLASS} .plasius-demo__footer {
923
949
  display: none;
924
950
  }
@@ -945,12 +971,14 @@ function injectStyles() {
945
971
  }
946
972
  .plasius-demo__toolbar {
947
973
  position: absolute;
948
- top: 26px;
949
- left: 26px;
974
+ top: 84px;
975
+ left: 16px;
976
+ z-index: 4;
950
977
  display: flex;
951
- gap: 12px;
978
+ gap: 8px;
952
979
  flex-wrap: wrap;
953
980
  align-items: center;
981
+ max-width: min(560px, calc(100vw - 32px));
954
982
  }
955
983
  .plasius-demo button,
956
984
  .plasius-demo label,
@@ -962,22 +990,63 @@ function injectStyles() {
962
990
  .plasius-demo .plasius-toggle,
963
991
  .plasius-demo select {
964
992
  border: 1px solid rgba(159, 185, 223, 0.18);
965
- border-radius: 999px;
993
+ border-radius: 6px;
966
994
  background: rgba(9, 20, 34, 0.84);
967
995
  color: var(--plasius-ink);
968
- padding: 10px 14px;
996
+ padding: 8px 10px;
969
997
  }
970
998
  .plasius-toggle {
971
999
  display: inline-flex;
972
1000
  align-items: center;
973
1001
  gap: 8px;
974
1002
  }
1003
+ .plasius-demo__diagnostics {
1004
+ position: absolute;
1005
+ right: 16px;
1006
+ bottom: 84px;
1007
+ z-index: 4;
1008
+ max-width: min(420px, calc(100vw - 32px));
1009
+ color: var(--plasius-ink);
1010
+ font-family: "JetBrains Mono", monospace;
1011
+ font-size: 12px;
1012
+ }
1013
+ .plasius-demo__diagnostics summary {
1014
+ width: fit-content;
1015
+ margin-left: auto;
1016
+ border: 1px solid rgba(159, 185, 223, 0.18);
1017
+ border-radius: 6px;
1018
+ padding: 8px 10px;
1019
+ background: rgba(9, 20, 34, 0.84);
1020
+ cursor: pointer;
1021
+ list-style: none;
1022
+ }
1023
+ .plasius-demo__diagnostics summary::-webkit-details-marker {
1024
+ display: none;
1025
+ }
1026
+ .plasius-demo__diagnostics[open] {
1027
+ width: min(420px, calc(100vw - 32px));
1028
+ }
1029
+ .plasius-demo__diagnostics[open] summary {
1030
+ margin-bottom: 8px;
1031
+ background: rgba(243, 177, 106, 0.14);
1032
+ color: var(--plasius-accent);
1033
+ }
975
1034
  .plasius-demo__sidebar {
976
1035
  display: grid;
977
- gap: 18px;
1036
+ gap: 8px;
1037
+ max-height: min(58vh, 520px);
1038
+ overflow: auto;
978
1039
  }
979
1040
  .plasius-demo__card {
980
- padding: 18px;
1041
+ padding: 10px;
1042
+ }
1043
+ .plasius-demo__card h2 {
1044
+ margin: 0;
1045
+ color: rgba(226, 236, 255, 0.72);
1046
+ font-family: "JetBrains Mono", monospace;
1047
+ font-size: 11px;
1048
+ letter-spacing: 0.08em;
1049
+ text-transform: uppercase;
981
1050
  }
982
1051
  .plasius-demo__metrics,
983
1052
  .plasius-demo__metrics li {
@@ -986,27 +1055,19 @@ function injectStyles() {
986
1055
  list-style: none;
987
1056
  }
988
1057
  .plasius-demo__metrics {
989
- margin-top: 12px;
1058
+ margin-top: 8px;
990
1059
  display: grid;
991
- gap: 8px;
1060
+ gap: 5px;
992
1061
  color: var(--plasius-muted);
993
- line-height: 1.55;
1062
+ font-size: 12px;
1063
+ line-height: 1.35;
994
1064
  }
995
1065
  .plasius-demo__metrics li {
996
1066
  border-top: 1px solid rgba(21, 32, 40, 0.08);
997
- padding-top: 8px;
1067
+ padding-top: 5px;
998
1068
  }
999
1069
  .plasius-demo__legend {
1000
- position: absolute;
1001
- right: 24px;
1002
- bottom: 24px;
1003
- padding: 10px 14px;
1004
- border-radius: 16px;
1005
- background: rgba(9, 20, 34, 0.82);
1006
- border: 1px solid rgba(159, 185, 223, 0.16);
1007
- color: var(--plasius-muted);
1008
- font-size: 12px;
1009
- line-height: 1.45;
1070
+ display: none;
1010
1071
  }
1011
1072
  .plasius-demo__legend strong {
1012
1073
  display: block;
@@ -1014,15 +1075,62 @@ function injectStyles() {
1014
1075
  margin-bottom: 4px;
1015
1076
  }
1016
1077
  .plasius-demo__footer {
1017
- margin-top: 4px;
1018
- color: rgba(226, 236, 255, 0.68);
1019
- font-size: 13px;
1020
- line-height: 1.6;
1078
+ display: none;
1021
1079
  }
1022
1080
  @media (max-width: 1200px) {
1023
- .plasius-demo__hero,
1024
- .plasius-demo__layout {
1025
- grid-template-columns: 1fr;
1081
+ .plasius-demo__toolbar {
1082
+ top: 92px;
1083
+ }
1084
+ }
1085
+ @media (max-width: 640px) {
1086
+ .plasius-demo__status {
1087
+ left: 10px;
1088
+ bottom: 10px;
1089
+ max-width: calc(100vw - 126px);
1090
+ padding: 6px 8px;
1091
+ }
1092
+ .plasius-demo__status-text {
1093
+ display: none;
1094
+ }
1095
+ .plasius-demo__status-badge {
1096
+ max-width: calc(100vw - 142px);
1097
+ overflow: hidden;
1098
+ text-overflow: ellipsis;
1099
+ white-space: nowrap;
1100
+ }
1101
+ .plasius-demo__toolbar {
1102
+ top: 10px;
1103
+ left: 10px;
1104
+ right: 10px;
1105
+ max-width: calc(100vw - 20px);
1106
+ flex-wrap: nowrap;
1107
+ overflow-x: auto;
1108
+ padding-bottom: 4px;
1109
+ scrollbar-width: none;
1110
+ }
1111
+ .plasius-demo__toolbar::-webkit-scrollbar {
1112
+ display: none;
1113
+ }
1114
+ .plasius-demo button,
1115
+ .plasius-demo .plasius-toggle,
1116
+ .plasius-demo select {
1117
+ padding: 7px 8px;
1118
+ font-size: 12px;
1119
+ white-space: nowrap;
1120
+ }
1121
+ .plasius-demo__diagnostics {
1122
+ right: 10px;
1123
+ bottom: 10px;
1124
+ }
1125
+ .plasius-demo__diagnostics[open] {
1126
+ bottom: 56px;
1127
+ left: 10px;
1128
+ right: 10px;
1129
+ width: auto;
1130
+ max-width: none;
1131
+ }
1132
+ .plasius-demo__diagnostics[open] .plasius-demo__sidebar {
1133
+ max-height: min(42vh, 340px);
1026
1134
  }
1027
1135
  }
1028
1136
  `;
@@ -1363,40 +1471,74 @@ function buildTrianglesFromMesh(mesh, transform, colorOverride, camera, viewport
1363
1471
  }
1364
1472
  }
1365
1473
  }
1366
- async function loadShowcaseAssetCatalog() {
1367
- const [brigantine, cutter, lighthouse, harborDock] = await Promise.all([
1474
+ function createShowcaseAssetCatalog({
1475
+ mode,
1476
+ ships,
1477
+ environment,
1478
+ primaryShipKey = "brigantine",
1479
+ fallbackReason = null
1480
+ }) {
1481
+ return Object.freeze({
1482
+ mode,
1483
+ primaryShipKey,
1484
+ ships: Object.freeze(ships),
1485
+ environment: Object.freeze(environment),
1486
+ fallbackReason
1487
+ });
1488
+ }
1489
+ function normalizeAssetCatalogFailureReason(error) {
1490
+ if (typeof error?.message === "string" && error.message.trim().length > 0) {
1491
+ return error.message;
1492
+ }
1493
+ return "showcase asset loading failed";
1494
+ }
1495
+ async function loadShowcaseAssetCatalog({ includeSecondaryShip = true } = {}) {
1496
+ const [brigantine, lighthouse, harborDock, shoreline] = await Promise.all([
1368
1497
  loadGltfModel(resolveShowcaseAssetUrl("brigantine")),
1369
- loadGltfModel(resolveShowcaseAssetUrl("cutter")),
1370
1498
  loadGltfModel(resolveShowcaseAssetUrl("lighthouse")),
1371
- loadGltfModel(resolveShowcaseAssetUrl("harbor-dock"))
1499
+ loadGltfModel(resolveShowcaseAssetUrl("harbor-dock")),
1500
+ loadGltfModel(resolveShowcaseAssetUrl("shoreline"))
1372
1501
  ]);
1373
- return Object.freeze({
1374
- primaryShipKey: "brigantine",
1375
- ships: Object.freeze({
1376
- brigantine,
1377
- cutter
1378
- }),
1379
- environment: Object.freeze({
1502
+ const ships = {
1503
+ brigantine
1504
+ };
1505
+ if (includeSecondaryShip) {
1506
+ ships.cutter = await loadGltfModel(resolveShowcaseAssetUrl("cutter"));
1507
+ }
1508
+ return createShowcaseAssetCatalog({
1509
+ mode: includeSecondaryShip ? "modeled-rich" : "modeled-baseline",
1510
+ ships,
1511
+ environment: {
1380
1512
  lighthouse,
1381
- "harbor-dock": harborDock
1382
- })
1513
+ "harbor-dock": harborDock,
1514
+ shoreline
1515
+ }
1383
1516
  });
1384
1517
  }
1385
- function createLegacyShowcaseAssetCatalog() {
1386
- const brigantine = loadGltfModel(resolveShowcaseAssetUrl("brigantine"));
1387
- return Promise.resolve(brigantine).then(
1388
- (primary) => Object.freeze({
1389
- primaryShipKey: "brigantine",
1390
- ships: Object.freeze({
1391
- brigantine: primary
1392
- }),
1393
- environment: Object.freeze({})
1394
- })
1395
- );
1518
+ async function createLegacyShowcaseAssetCatalog(error = null) {
1519
+ const brigantine = await loadGltfModel(resolveShowcaseAssetUrl("brigantine"));
1520
+ return createShowcaseAssetCatalog({
1521
+ mode: "legacy-fallback",
1522
+ ships: {
1523
+ brigantine
1524
+ },
1525
+ environment: {},
1526
+ fallbackReason: normalizeAssetCatalogFailureReason(error)
1527
+ });
1528
+ }
1529
+ async function loadShowcaseAssetCatalogWithFallback({ includeSecondaryShip = true } = {}) {
1530
+ try {
1531
+ return await loadShowcaseAssetCatalog({ includeSecondaryShip });
1532
+ } catch (error) {
1533
+ return createLegacyShowcaseAssetCatalog(error);
1534
+ }
1396
1535
  }
1397
1536
  function resolveShipModel(state, ship, fallbackModel = null) {
1398
1537
  return state.assetCatalog?.ships?.[ship.modelKey ?? state.assetCatalog?.primaryShipKey ?? "brigantine"] ?? fallbackModel ?? state.shipModel;
1399
1538
  }
1539
+ function hasModeledHarborEnvironment(state) {
1540
+ return Object.keys(state.assetCatalog?.environment ?? {}).length > 0;
1541
+ }
1400
1542
  function createPerformanceGovernor(performanceFeatures) {
1401
1543
  const createQualityLadderAdapter = assertRequiredFunction(
1402
1544
  performanceFeatures,
@@ -1504,24 +1646,27 @@ function buildDemoDom(root, options) {
1504
1646
  ${t(gpuSharedTranslationKeys.legendCollisions)}
1505
1647
  </div>
1506
1648
  </section>
1507
- <aside class="plasius-demo__sidebar">
1508
- <section class="plasius-panel plasius-demo__card">
1509
- <h2>${t(gpuSharedTranslationKeys.sceneState)}</h2>
1510
- <ul id="sceneMetrics" class="plasius-demo__metrics"></ul>
1511
- </section>
1512
- <section class="plasius-panel plasius-demo__card">
1513
- <h2>${t(gpuSharedTranslationKeys.qualityBudgets)}</h2>
1514
- <ul id="qualityMetrics" class="plasius-demo__metrics"></ul>
1515
- </section>
1516
- <section class="plasius-panel plasius-demo__card">
1517
- <h2>${t(gpuSharedTranslationKeys.debugTelemetry)}</h2>
1518
- <ul id="debugMetrics" class="plasius-demo__metrics"></ul>
1519
- </section>
1520
- <section class="plasius-panel plasius-demo__card">
1521
- <h2>${t(gpuSharedTranslationKeys.notes)}</h2>
1522
- <ul id="sceneNotes" class="plasius-demo__metrics"></ul>
1523
- </section>
1524
- </aside>
1649
+ <details class="plasius-demo__diagnostics">
1650
+ <summary>${t(gpuSharedTranslationKeys.debugTelemetry)}</summary>
1651
+ <aside class="plasius-demo__sidebar">
1652
+ <section class="plasius-panel plasius-demo__card">
1653
+ <h2>${t(gpuSharedTranslationKeys.sceneState)}</h2>
1654
+ <ul id="sceneMetrics" class="plasius-demo__metrics"></ul>
1655
+ </section>
1656
+ <section class="plasius-panel plasius-demo__card">
1657
+ <h2>${t(gpuSharedTranslationKeys.qualityBudgets)}</h2>
1658
+ <ul id="qualityMetrics" class="plasius-demo__metrics"></ul>
1659
+ </section>
1660
+ <section class="plasius-panel plasius-demo__card">
1661
+ <h2>${t(gpuSharedTranslationKeys.debugTelemetry)}</h2>
1662
+ <ul id="debugMetrics" class="plasius-demo__metrics"></ul>
1663
+ </section>
1664
+ <section class="plasius-panel plasius-demo__card">
1665
+ <h2>${t(gpuSharedTranslationKeys.notes)}</h2>
1666
+ <ul id="sceneNotes" class="plasius-demo__metrics"></ul>
1667
+ </section>
1668
+ </aside>
1669
+ </details>
1525
1670
  </section>
1526
1671
  <p class="plasius-demo__footer">
1527
1672
  This visual example is shared across the GPU packages to keep manual validation fast and consistent.
@@ -1885,11 +2030,11 @@ function advanceShowcaseClothSimulationState(clothState, options = {}) {
1885
2030
  1 + Math.sin(gustPhase * 0.74) * 0.18
1886
2031
  )
1887
2032
  );
1888
- const windStrength = (1.6 + broadMotion * 1.25 + wrinkleLayers * 0.12) * flagMotion * (0.44 + u * 1.14);
2033
+ const windStrength = (0.94 + broadMotion * 0.82 + wrinkleLayers * 0.08) * flagMotion * (0.36 + u * 0.92);
1889
2034
  const wrinkleForce = vec3(
1890
- Math.sin(wrinklePhase) * 0.22 * wrinkleMotion * flagMotion,
1891
- Math.cos(wrinklePhase * 0.7) * 0.08 * wrinkleMotion,
1892
- Math.cos(wrinklePhase) * 0.14 * broadMotion * flagMotion
2035
+ Math.sin(wrinklePhase) * 0.12 * wrinkleMotion * flagMotion,
2036
+ Math.cos(wrinklePhase * 0.7) * 0.045 * wrinkleMotion,
2037
+ Math.cos(wrinklePhase) * 0.08 * broadMotion * flagMotion
1893
2038
  );
1894
2039
  const acceleration = addVec3(
1895
2040
  vec3(0, -0.48 - u * 0.08, 0),
@@ -1946,25 +2091,25 @@ function resolveVisualConfig(nearLighting, lightingSnapshot, customVisuals = {})
1946
2091
  ambientMist: "rgba(41, 63, 97, 0.16)",
1947
2092
  reflectionStrength: lightingSnapshot.currentLevel.config.reflectionStrength,
1948
2093
  shadowAccent: lightingSnapshot.currentLevel.config.shadowStrength,
1949
- waveAmplitude: 0.94,
2094
+ waveAmplitude: 0.82,
1950
2095
  waveDirection: { x: 0.88, z: 0.28 },
1951
- wavePhaseSpeed: 0.88,
1952
- wakeStrength: 0.31,
1953
- wakeLength: 18,
1954
- collisionRippleStrength: 0.42,
1955
- waterNear: { r: 0.08, g: 0.23, b: 0.33 },
1956
- waterFar: { r: 0.18, g: 0.35, b: 0.49 },
2096
+ wavePhaseSpeed: 0.74,
2097
+ wakeStrength: 0.24,
2098
+ wakeLength: 17,
2099
+ collisionRippleStrength: 0.22,
2100
+ waterNear: { r: 0.05, g: 0.2, b: 0.3 },
2101
+ waterFar: { r: 0.13, g: 0.31, b: 0.45 },
1957
2102
  harborWall: { r: 0.26, g: 0.24, b: 0.28 },
1958
2103
  harborDeck: { r: 0.33, g: 0.22, b: 0.16 },
1959
2104
  harborTower: { r: 0.23, g: 0.24, b: 0.29 },
1960
- flagColor: { r: 0.66, g: 0.16, b: 0.13 },
1961
- flagMotion: 0.92,
2105
+ flagColor: { r: 0.54, g: 0.13, b: 0.11 },
2106
+ flagMotion: 0.58,
1962
2107
  lanternCore: { r: 0.98, g: 0.8, b: 0.48 },
1963
2108
  lanternGlow: { r: 1, g: 0.56, b: 0.2 },
1964
2109
  lanternReflectionStrength: 0.42,
1965
2110
  torchCore: { r: 0.99, g: 0.72, b: 0.36 },
1966
2111
  torchGlow: { r: 0.98, g: 0.38, b: 0.15 },
1967
- collisionFlash: "rgba(255, 212, 168, 0.16)"
2112
+ collisionFlash: "rgba(255, 212, 168, 0.08)"
1968
2113
  };
1969
2114
  return {
1970
2115
  skyTop: typeof customVisuals.skyTop === "string" ? customVisuals.skyTop : defaults.skyTop,
@@ -2021,6 +2166,11 @@ function buildClothSurface(model, state, meshDetail, visuals, clothFeatures) {
2021
2166
  representation: clothPresentation.representation,
2022
2167
  continuity: clothPresentation.continuity,
2023
2168
  color: visuals.flagColor,
2169
+ material: Object.freeze({
2170
+ weaveAlpha: clothPresentation.band === "near" ? 0.22 : 0.12,
2171
+ foldAlpha: clothPresentation.band === "near" ? 0.3 : 0.18,
2172
+ edgeHighlightAlpha: clothPresentation.band === "near" ? 0.42 : 0.28
2173
+ }),
2024
2174
  positions: clothState.positions.map((point) => vec3(point.x, point.y, point.z)),
2025
2175
  indices: clothState.indices,
2026
2176
  grid: { rows: clothState.rows, cols: clothState.cols }
@@ -2104,7 +2254,7 @@ function buildWaterMotionEffects(state) {
2104
2254
  impulse.z
2105
2255
  ),
2106
2256
  radius,
2107
- opacity: clamp(impulse.life * 0.28, 0.08, 0.3)
2257
+ opacity: clamp(impulse.life * 0.13, 0.035, 0.15)
2108
2258
  });
2109
2259
  });
2110
2260
  for (const ship of state.ships) {
@@ -2117,7 +2267,7 @@ function buildWaterMotionEffects(state) {
2117
2267
  const lateral = vec3(-direction.z, 0, direction.x);
2118
2268
  const points = [];
2119
2269
  for (let sampleIndex = 0; sampleIndex < 6; sampleIndex += 1) {
2120
- const along = 1 + sampleIndex * 1.45;
2270
+ const along = 1 + sampleIndex * 1.55;
2121
2271
  const lateralOffset = Math.sin(state.time * 1.2 + sampleIndex * 0.8 + readVisualNumber(ship.wanderPhase, 0)) * 0.12;
2122
2272
  const worldPoint = addVec3(
2123
2273
  ship.position,
@@ -2130,13 +2280,14 @@ function buildWaterMotionEffects(state) {
2130
2280
  sampleWave(state, worldPoint.x, worldPoint.z, state.time) * 0.24 + 0.04,
2131
2281
  worldPoint.z
2132
2282
  ),
2133
- width: 0.34 + sampleIndex * 0.13
2283
+ width: 0.3 + sampleIndex * 0.11,
2284
+ foam: clamp(0.28 - sampleIndex * 0.028 + speed * 0.025, 0.1, 0.34)
2134
2285
  })
2135
2286
  );
2136
2287
  }
2137
2288
  wakeTrails.push(
2138
2289
  Object.freeze({
2139
- opacity: clamp(0.18 + speed * 0.09, 0.22, 0.46),
2290
+ opacity: clamp(0.1 + speed * 0.048, 0.12, 0.24),
2140
2291
  points: Object.freeze(points)
2141
2292
  })
2142
2293
  );
@@ -2146,6 +2297,27 @@ function buildWaterMotionEffects(state) {
2146
2297
  rippleRings: Object.freeze(rippleRings)
2147
2298
  });
2148
2299
  }
2300
+ function buildShorelineFoamSegments(state) {
2301
+ return Object.freeze(
2302
+ SHORELINE_FOAM_ANCHORS.map((anchor, index) => {
2303
+ const pulse = 0.5 + Math.sin(state.time * 0.84 + index * 1.17) * 0.5;
2304
+ const drift = Math.sin(state.time * 0.38 + index * 0.61) * 0.1;
2305
+ const direction = normalizeVec3(vec3(Math.cos(anchor.angle), 0, Math.sin(anchor.angle)));
2306
+ const center = vec3(
2307
+ anchor.x + direction.x * drift,
2308
+ sampleWave(state, anchor.x, anchor.z, state.time) * 0.12 - 0.02,
2309
+ anchor.z + direction.z * drift
2310
+ );
2311
+ return Object.freeze({
2312
+ center,
2313
+ direction,
2314
+ length: anchor.length * (0.78 + pulse * 0.34),
2315
+ width: 0.16 + pulse * 0.12,
2316
+ opacity: 0.07 + pulse * 0.12
2317
+ });
2318
+ })
2319
+ );
2320
+ }
2149
2321
  function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
2150
2322
  const resolvedFluidFeatures = normalizeFluidFeatureAdapters(fluidFeatures);
2151
2323
  const fluidPlan = resolvedFluidFeatures.createPlan({
@@ -2168,9 +2340,9 @@ function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
2168
2340
  const representation = fluidPlan.representations.find((entry) => entry.band === bandSpec.band) ?? fluidPlan.representations[0];
2169
2341
  const continuity = resolvedFluidFeatures.createContinuityEnvelope({ fluidBodyId: "harbor" });
2170
2342
  const bandContinuity = resolveFluidBandContinuity(continuity, bandSpec.band);
2171
- const bandResolution = bandSpec.band === "near" ? fluidDetail.nearResolution : bandSpec.band === "mid" ? fluidDetail.midResolution : bandSpec.band === "far" ? 5 : 3;
2172
- const cols = Math.max(4, bandResolution * 2);
2173
- const rows = Math.max(4, bandResolution + 2);
2343
+ const bandResolution = bandSpec.band === "near" ? Math.ceil(fluidDetail.nearResolution * 1.28) : bandSpec.band === "mid" ? Math.ceil(fluidDetail.midResolution * 1.2) : bandSpec.band === "far" ? 5 : 3;
2344
+ const cols = Math.max(4, bandResolution * (bandSpec.band === "near" ? 3 : 2));
2345
+ const rows = Math.max(4, bandResolution + (bandSpec.band === "near" ? 5 : 2));
2174
2346
  const positions = [];
2175
2347
  const indices = [];
2176
2348
  const originX = -bandSpec.width * 0.5;
@@ -2181,7 +2353,9 @@ function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
2181
2353
  const v = row / (rows - 1);
2182
2354
  const x = originX + bandSpec.width * u;
2183
2355
  const z = originZ + bandSpec.depth * v;
2184
- const y = bandSpec.y + sampleWave(state, x, z, state.time) * bandContinuity.amplitudeFloor * (bandSpec.band === "near" ? 0.9 : bandSpec.band === "mid" ? 0.55 : 0.3);
2356
+ const baseHeight = bandSpec.y + sampleWave(state, x, z, state.time) * bandContinuity.amplitudeFloor * (bandSpec.band === "near" ? 0.9 : bandSpec.band === "mid" ? 0.55 : 0.3);
2357
+ const detailHeight = bandSpec.band === "near" ? Math.sin(x * 1.25 + z * 0.42 - state.time * 2.4) * 0.035 : 0;
2358
+ const y = baseHeight + detailHeight;
2185
2359
  positions.push(vec3(x, y, z));
2186
2360
  }
2187
2361
  }
@@ -2210,7 +2384,12 @@ function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
2210
2384
  r: mix(visuals.waterFar.r, 0.76, 0.2),
2211
2385
  g: mix(visuals.waterFar.g, 0.78, 0.2),
2212
2386
  b: mix(visuals.waterFar.b, 0.82, 0.2)
2213
- }
2387
+ },
2388
+ material: Object.freeze({
2389
+ highlightAlpha: bandSpec.band === "near" ? 0.2 : bandSpec.band === "mid" ? 0.13 : 0.07,
2390
+ foamAlpha: bandSpec.band === "near" ? 0.28 : bandSpec.band === "mid" ? 0.14 : 0.05,
2391
+ microRippleScale: bandSpec.band === "near" ? 1 : bandSpec.band === "mid" ? 0.58 : 0.28
2392
+ })
2214
2393
  });
2215
2394
  }
2216
2395
  return { fluidPlan, bandMeshes };
@@ -2286,15 +2465,15 @@ function createSceneState(options, featureAdapters) {
2286
2465
  {
2287
2466
  id: "northwind",
2288
2467
  modelKey: "brigantine",
2289
- position: vec3(-5.2, 0, 7.2),
2290
- velocity: vec3(2.35, 0, -1.08),
2291
- rotationY: 0.58,
2292
- angularVelocity: 0.09,
2468
+ position: vec3(-7.8, 0, 11.2),
2469
+ velocity: vec3(1.08, 0, -0.18),
2470
+ rotationY: 1.38,
2471
+ angularVelocity: 0.025,
2293
2472
  tint: { r: 0.62, g: 0.39, b: 0.23 },
2294
2473
  massScale: 1.42,
2295
- cruiseSpeed: 2.25,
2296
- throttleResponse: 0.46,
2297
- rudderResponse: 0.54,
2474
+ cruiseSpeed: 1.22,
2475
+ throttleResponse: 0.36,
2476
+ rudderResponse: 0.4,
2298
2477
  wanderPhase: 0.35,
2299
2478
  lanterns: CUTTER_LANTERNS,
2300
2479
  lanternStrength: 1.06,
@@ -2303,15 +2482,15 @@ function createSceneState(options, featureAdapters) {
2303
2482
  {
2304
2483
  id: "tidecaller",
2305
2484
  modelKey: "cutter",
2306
- position: vec3(4.8, 0, 4.4),
2307
- velocity: vec3(-2.15, 0, 1.74),
2308
- rotationY: -2.48,
2309
- angularVelocity: -0.2,
2485
+ position: vec3(6.8, 0, 5.4),
2486
+ velocity: vec3(-0.82, 0, 0.14),
2487
+ rotationY: -1.34,
2488
+ angularVelocity: -0.035,
2310
2489
  tint: { r: 0.58, g: 0.24, b: 0.16 },
2311
2490
  massScale: 0.84,
2312
- cruiseSpeed: 2.68,
2313
- throttleResponse: 0.7,
2314
- rudderResponse: 0.78,
2491
+ cruiseSpeed: 1.36,
2492
+ throttleResponse: 0.52,
2493
+ rudderResponse: 0.58,
2315
2494
  wanderPhase: 1.6,
2316
2495
  lanterns: SHIP_LANTERNS,
2317
2496
  lanternStrength: 1.18,
@@ -2512,7 +2691,7 @@ function renderProjectedShadow(ctx, worldPoints, camera, viewport, lightDir, opt
2512
2691
  ctx.restore();
2513
2692
  }
2514
2693
  function pushHarborGeometry(camera, viewport, triangles, state) {
2515
- if (!state.showcaseRealisticModelsEnabled) {
2694
+ if (!hasModeledHarborEnvironment(state)) {
2516
2695
  for (const object of LEGACY_HARBOR_LAYOUT) {
2517
2696
  buildTrianglesFromMesh(
2518
2697
  { positions: [object], indices: [0], normals: null, colors: null, material: createLegacyMeshPrimitive({})?.material, bounds: null, name: "legacy-structure" },
@@ -2610,9 +2789,10 @@ function renderShipRigging(ctx, ship, camera, viewport) {
2610
2789
  }
2611
2790
  function renderClothAccent(ctx, cloth, camera, viewport) {
2612
2791
  const projected = cloth.positions.map((point) => projectPoint(point, camera, viewport));
2613
- ctx.strokeStyle = "rgba(255, 241, 226, 0.92)";
2614
- ctx.lineWidth = 1.7;
2615
- for (let row = 0; row < cloth.grid.rows; row += Math.max(1, Math.floor(cloth.grid.rows / 5))) {
2792
+ const material = cloth.material ?? {};
2793
+ ctx.strokeStyle = `rgba(255, 241, 226, ${material.foldAlpha ?? 0.32})`;
2794
+ ctx.lineWidth = 1.8;
2795
+ for (let row = 0; row < cloth.grid.rows; row += Math.max(1, Math.floor(cloth.grid.rows / 6))) {
2616
2796
  ctx.beginPath();
2617
2797
  let started = false;
2618
2798
  for (let column = 0; column < cloth.grid.cols; column += 1) {
@@ -2631,6 +2811,27 @@ function renderClothAccent(ctx, cloth, camera, viewport) {
2631
2811
  ctx.stroke();
2632
2812
  }
2633
2813
  }
2814
+ ctx.strokeStyle = `rgba(255, 228, 204, ${material.weaveAlpha ?? 0.22})`;
2815
+ ctx.lineWidth = 0.85;
2816
+ for (let column = 1; column < cloth.grid.cols - 1; column += Math.max(1, Math.floor(cloth.grid.cols / 8))) {
2817
+ ctx.beginPath();
2818
+ let started = false;
2819
+ for (let row = 0; row < cloth.grid.rows; row += 1) {
2820
+ const point = projected[row * cloth.grid.cols + column];
2821
+ if (!point) {
2822
+ continue;
2823
+ }
2824
+ if (!started) {
2825
+ ctx.moveTo(point.x, point.y);
2826
+ started = true;
2827
+ } else {
2828
+ ctx.lineTo(point.x, point.y);
2829
+ }
2830
+ }
2831
+ if (started) {
2832
+ ctx.stroke();
2833
+ }
2834
+ }
2634
2835
  const borderIndices = [
2635
2836
  0,
2636
2837
  cloth.grid.cols - 1,
@@ -2638,6 +2839,25 @@ function renderClothAccent(ctx, cloth, camera, viewport) {
2638
2839
  (cloth.grid.rows - 1) * cloth.grid.cols
2639
2840
  ];
2640
2841
  ctx.fillStyle = colorToRgba(cloth.color, 0.95);
2842
+ ctx.strokeStyle = `rgba(255, 246, 236, ${material.edgeHighlightAlpha ?? 0.5})`;
2843
+ ctx.lineWidth = 1.4;
2844
+ ctx.beginPath();
2845
+ let borderStarted = false;
2846
+ for (let column = 0; column < cloth.grid.cols; column += 1) {
2847
+ const point = projected[column];
2848
+ if (!point) {
2849
+ continue;
2850
+ }
2851
+ if (!borderStarted) {
2852
+ ctx.moveTo(point.x, point.y);
2853
+ borderStarted = true;
2854
+ } else {
2855
+ ctx.lineTo(point.x, point.y);
2856
+ }
2857
+ }
2858
+ if (borderStarted) {
2859
+ ctx.stroke();
2860
+ }
2641
2861
  for (const index of borderIndices) {
2642
2862
  const point = projected[index];
2643
2863
  if (!point) {
@@ -2653,14 +2873,22 @@ function renderWaterHighlights(ctx, waterBands, camera, viewport) {
2653
2873
  if (band.band === "horizon") {
2654
2874
  continue;
2655
2875
  }
2656
- const interval = band.band === "near" ? 2 : 3;
2657
- const alpha = band.band === "near" ? 0.22 : 0.14;
2876
+ const interval = band.band === "near" ? 4 : 5;
2877
+ const alpha = band.material?.highlightAlpha ?? (band.band === "near" ? 0.22 : 0.14);
2658
2878
  ctx.strokeStyle = `rgba(232, 247, 255, ${alpha})`;
2659
- ctx.lineWidth = band.band === "near" ? 1.3 : 0.9;
2879
+ ctx.lineWidth = band.band === "near" ? 0.9 : 0.65;
2660
2880
  for (let row = interval; row < band.rows - 1; row += interval) {
2661
- ctx.beginPath();
2662
2881
  let started = false;
2663
- for (let column = 0; column < band.cols; column += 1) {
2882
+ ctx.beginPath();
2883
+ for (let column = 0; column < band.cols; column += band.band === "near" ? 2 : 3) {
2884
+ if (pseudoRandom(row * 47 + column * 13) < 0.18) {
2885
+ if (started) {
2886
+ ctx.stroke();
2887
+ ctx.beginPath();
2888
+ started = false;
2889
+ }
2890
+ continue;
2891
+ }
2664
2892
  const point = projectPoint(
2665
2893
  band.positions[row * band.cols + column],
2666
2894
  camera,
@@ -2680,7 +2908,55 @@ function renderWaterHighlights(ctx, waterBands, camera, viewport) {
2680
2908
  ctx.stroke();
2681
2909
  }
2682
2910
  }
2911
+ if (band.band === "near") {
2912
+ ctx.fillStyle = `rgba(236, 249, 255, ${(band.material?.foamAlpha ?? 0.28) * 0.72})`;
2913
+ for (let column = 3; column < band.cols - 3; column += 10) {
2914
+ const point = projectPoint(
2915
+ band.positions[Math.floor(band.rows * 0.42) * band.cols + column],
2916
+ camera,
2917
+ viewport
2918
+ );
2919
+ if (!point) {
2920
+ continue;
2921
+ }
2922
+ ctx.beginPath();
2923
+ ctx.ellipse(point.x, point.y, 1.8, 0.75, -0.2, 0, Math.PI * 2);
2924
+ ctx.fill();
2925
+ }
2926
+ }
2927
+ }
2928
+ }
2929
+ function renderShorelineFoamSegments(ctx, segments, camera, viewport) {
2930
+ ctx.save();
2931
+ ctx.globalCompositeOperation = "screen";
2932
+ ctx.lineCap = "round";
2933
+ ctx.lineJoin = "round";
2934
+ for (const segment of segments) {
2935
+ const half = scaleVec3(segment.direction, segment.length * 0.5);
2936
+ const start = projectPoint(subVec3(segment.center, half), camera, viewport);
2937
+ const end = projectPoint(addVec3(segment.center, half), camera, viewport);
2938
+ const center = projectPoint(segment.center, camera, viewport);
2939
+ if (!start || !end || !center) {
2940
+ continue;
2941
+ }
2942
+ const depthScale = clamp(140 / Math.max(12, center.depth), 3, 10);
2943
+ ctx.strokeStyle = `rgba(232, 242, 238, ${segment.opacity})`;
2944
+ ctx.lineWidth = clamp(segment.width * depthScale, 0.8, 2.8);
2945
+ ctx.beginPath();
2946
+ ctx.moveTo(start.x, start.y);
2947
+ ctx.quadraticCurveTo(
2948
+ center.x,
2949
+ center.y + Math.sin(segment.center.x * 1.7) * 2.4,
2950
+ end.x,
2951
+ end.y
2952
+ );
2953
+ ctx.stroke();
2954
+ ctx.fillStyle = `rgba(248, 251, 246, ${segment.opacity * 0.68})`;
2955
+ ctx.beginPath();
2956
+ ctx.ellipse(center.x, center.y, depthScale * 0.18, depthScale * 0.08, -0.2, 0, Math.PI * 2);
2957
+ ctx.fill();
2683
2958
  }
2959
+ ctx.restore();
2684
2960
  }
2685
2961
  function readPhysicsNumber(physics, key, fallback) {
2686
2962
  const value = physics?.[key];
@@ -2712,14 +2988,17 @@ function getShipInverseInertia(ship, shipModel) {
2712
2988
  return 1 / Math.max(1, inertia);
2713
2989
  }
2714
2990
  function spawnSpray(state, point, intensity) {
2715
- const count = state.fluidDetail.getSnapshot().currentLevel.config.splashCount;
2991
+ const count = Math.max(
2992
+ 3,
2993
+ Math.ceil(state.fluidDetail.getSnapshot().currentLevel.config.splashCount * 0.32)
2994
+ );
2716
2995
  for (let index = 0; index < count; index += 1) {
2717
2996
  const angle = index / count * Math.PI * 2;
2718
- const speed = 0.9 + Math.random() * intensity * 0.45;
2997
+ const speed = 0.46 + Math.random() * intensity * 0.24;
2719
2998
  state.sprays.push({
2720
2999
  position: vec3(point.x, point.y, point.z),
2721
- velocity: vec3(Math.cos(angle) * speed * 0.35, 1.1 + Math.random() * 0.8, Math.sin(angle) * speed * 0.25),
2722
- life: 1.2 + Math.random() * 0.4
3000
+ velocity: vec3(Math.cos(angle) * speed * 0.24, 0.46 + Math.random() * 0.34, Math.sin(angle) * speed * 0.18),
3001
+ life: 0.72 + Math.random() * 0.22
2723
3002
  });
2724
3003
  }
2725
3004
  }
@@ -2734,7 +3013,7 @@ function resolveShipRoute(ship, state, radius) {
2734
3013
  }
2735
3014
  const wander = Math.sin(state.time * 0.22 + readVisualNumber(ship.wanderPhase, 0));
2736
3015
  const crossCurrent = Math.cos(state.time * 0.31 + readVisualNumber(ship.wanderPhase, 0));
2737
- const laneCenter = ship.id === "northwind" ? 10.2 + wander * 2.1 + crossCurrent * 0.6 : 7 + wander * 3.3 - crossCurrent * 1.1;
3016
+ const laneCenter = ship.id === "northwind" ? 11.6 + wander * 0.82 + crossCurrent * 0.24 : 5.4 + wander * 0.94 - crossCurrent * 0.32;
2738
3017
  const targetX = ship.routeDirection > 0 ? HARBOR_BOUNDS.maxX - radius * 1.7 : HARBOR_BOUNDS.minX + radius * 1.7;
2739
3018
  return vec3(targetX, 0, clamp(laneCenter, HARBOR_BOUNDS.minZ + 1.8, HARBOR_BOUNDS.maxZ - 1.8));
2740
3019
  }
@@ -2747,7 +3026,7 @@ function updateShipMotion(state, ship, dt, shipModel) {
2747
3026
  const angularDamping = readPhysicsNumber(physics, "angularDamping", 0.08);
2748
3027
  const throttleResponse = readVisualNumber(ship.throttleResponse, 0.58);
2749
3028
  const rudderResponse = readVisualNumber(ship.rudderResponse, 0.62);
2750
- const cruiseSpeed = readVisualNumber(ship.cruiseSpeed, 2.4);
3029
+ const cruiseSpeed = readVisualNumber(ship.cruiseSpeed, 1.25);
2751
3030
  ship.collisionCooldown = Math.max(0, readVisualNumber(ship.collisionCooldown, 0) - dt);
2752
3031
  const forward = directionFromYaw(ship.rotationY);
2753
3032
  const lateral = perpendicularOnWater(forward);
@@ -2842,7 +3121,7 @@ function resolveShipCollision(state, a, b, shipModelA, shipModelB) {
2842
3121
  b.position = addVec3(b.position, scaleVec3(correction, invMassB));
2843
3122
  const relativeVelocity = subVec3(b.velocity, a.velocity);
2844
3123
  const velocityAlongNormal = dotVec3(relativeVelocity, normal);
2845
- const restitution = (readPhysicsNumber(shipModelA.physics, "restitution", 0.22) + readPhysicsNumber(shipModelB.physics, "restitution", 0.22)) / 2 * 0.88;
3124
+ const restitution = (readPhysicsNumber(shipModelA.physics, "restitution", 0.22) + readPhysicsNumber(shipModelB.physics, "restitution", 0.22)) / 2 * 0.42;
2846
3125
  if (velocityAlongNormal < 0) {
2847
3126
  const impulseMagnitude = -(1 + restitution) * velocityAlongNormal / Math.max(1e-4, invMassSum);
2848
3127
  const impulse = scaleVec3(normal, impulseMagnitude);
@@ -2860,27 +3139,27 @@ function resolveShipCollision(state, a, b, shipModelA, shipModelB) {
2860
3139
  a.angularVelocity -= tangentSpeed * radiusA * getShipInverseInertia(a, shipModelA) * 0.2 + impulseMagnitude * 24e-5;
2861
3140
  b.angularVelocity += tangentSpeed * radiusB * getShipInverseInertia(b, shipModelB) * 0.2 + impulseMagnitude * 24e-5;
2862
3141
  const impactSpeed = Math.abs(velocityAlongNormal);
2863
- if (impactSpeed > 0.18 && Math.max(readVisualNumber(a.collisionCooldown, 0), readVisualNumber(b.collisionCooldown, 0)) <= 0) {
3142
+ if (impactSpeed > 0.36 && Math.max(readVisualNumber(a.collisionCooldown, 0), readVisualNumber(b.collisionCooldown, 0)) <= 0) {
2864
3143
  const contactPoint = vec3(
2865
3144
  (a.position.x + b.position.x) * 0.5,
2866
3145
  (a.position.y + b.position.y) * 0.5 + 0.14,
2867
3146
  (a.position.z + b.position.z) * 0.5
2868
3147
  );
2869
- spawnSpray(state, contactPoint, impactSpeed * 2.4 + penetration * 8);
3148
+ spawnSpray(state, contactPoint, impactSpeed * 0.9 + penetration * 2.4);
2870
3149
  state.waveImpulses.push({
2871
3150
  x: contactPoint.x,
2872
3151
  z: contactPoint.z,
2873
- strength: clamp(0.24 + impactSpeed * 0.46 + penetration * 0.9, 0.2, 1.7),
2874
- radius: 0.9 + penetration * 1.4,
3152
+ strength: clamp(0.1 + impactSpeed * 0.18 + penetration * 0.28, 0.08, 0.52),
3153
+ radius: 0.72 + penetration * 0.72,
2875
3154
  life: 1
2876
3155
  });
2877
3156
  state.collisionCount += 1;
2878
3157
  state.collisionFlash = Math.max(
2879
3158
  state.collisionFlash,
2880
- clamp(impactSpeed * 0.55 + penetration * 1.8, 0.16, 1)
3159
+ clamp(impactSpeed * 0.14 + penetration * 0.32, 0.04, 0.24)
2881
3160
  );
2882
- a.collisionCooldown = 0.2;
2883
- b.collisionCooldown = 0.2;
3161
+ a.collisionCooldown = 0.72;
3162
+ b.collisionCooldown = 0.72;
2884
3163
  }
2885
3164
  }
2886
3165
  state.contactCount += 1;
@@ -2903,7 +3182,7 @@ function updateShips(state, dt, shipModel) {
2903
3182
  collided = resolveShipCollision(state, shipA, shipB, shipModelA, shipModelB) || collided;
2904
3183
  }
2905
3184
  }
2906
- state.collisionFlash = collided ? Math.max(0.12, state.collisionFlash) : Math.max(0, state.collisionFlash - dt * 1.3);
3185
+ state.collisionFlash = collided ? Math.max(0.04, state.collisionFlash) : Math.max(0, state.collisionFlash - dt * 1.7);
2907
3186
  }
2908
3187
  function updateWaveImpulses(state, dt) {
2909
3188
  state.waveImpulses = state.waveImpulses.map((impulse) => ({
@@ -3168,7 +3447,7 @@ function renderLighthouseBeam(ctx, state, camera, viewport, visuals) {
3168
3447
  const lighthousePlacement = SHOWCASE_ENVIRONMENT_LAYOUT.find(
3169
3448
  (placement) => placement.assetKey === "lighthouse"
3170
3449
  );
3171
- if (!lighthousePlacement || !state.showcaseRealisticModelsEnabled) {
3450
+ if (!lighthousePlacement || !state.showcaseRealisticModelsEnabled || !hasModeledHarborEnvironment(state)) {
3172
3451
  return;
3173
3452
  }
3174
3453
  const source = transformPoint(
@@ -3265,7 +3544,8 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
3265
3544
  for (const wake of effects.wakeTrails) {
3266
3545
  const projected = wake.points.map((point) => ({
3267
3546
  projected: projectPoint(point.center, camera, viewport),
3268
- width: point.width
3547
+ width: point.width,
3548
+ foam: point.foam
3269
3549
  })).filter((entry) => entry.projected);
3270
3550
  if (projected.length < 2) {
3271
3551
  continue;
@@ -3273,8 +3553,8 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
3273
3553
  const averageDepth = projected.reduce((total, entry) => total + entry.projected.depth, 0) / projected.length;
3274
3554
  const averageWidth = projected.reduce((total, entry) => total + entry.width, 0) / projected.length;
3275
3555
  const baseWidth = clamp(averageWidth / Math.max(0.25, averageDepth) * 180, 1.6, 5.4);
3276
- ctx.strokeStyle = `rgba(146, 194, 236, ${wake.opacity * 0.52})`;
3277
- ctx.lineWidth = baseWidth * 1.9;
3556
+ ctx.strokeStyle = `rgba(146, 194, 236, ${wake.opacity * 0.34})`;
3557
+ ctx.lineWidth = baseWidth * 1.45;
3278
3558
  ctx.lineCap = "round";
3279
3559
  ctx.lineJoin = "round";
3280
3560
  ctx.beginPath();
@@ -3283,8 +3563,8 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
3283
3563
  ctx.lineTo(projected[index].projected.x, projected[index].projected.y);
3284
3564
  }
3285
3565
  ctx.stroke();
3286
- ctx.strokeStyle = `rgba(234, 247, 255, ${wake.opacity})`;
3287
- ctx.lineWidth = baseWidth;
3566
+ ctx.strokeStyle = `rgba(234, 247, 255, ${wake.opacity * 0.72})`;
3567
+ ctx.lineWidth = baseWidth * 0.72;
3288
3568
  ctx.lineCap = "round";
3289
3569
  ctx.lineJoin = "round";
3290
3570
  ctx.beginPath();
@@ -3294,13 +3574,14 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
3294
3574
  }
3295
3575
  ctx.stroke();
3296
3576
  for (const entry of projected.slice(1, 5)) {
3297
- ctx.fillStyle = `rgba(239, 248, 255, ${wake.opacity * 0.76})`;
3577
+ const foam = entry.foam ?? 0.3;
3578
+ ctx.fillStyle = `rgba(239, 248, 255, ${wake.opacity * foam * 0.92})`;
3298
3579
  ctx.beginPath();
3299
3580
  ctx.ellipse(
3300
3581
  entry.projected.x,
3301
3582
  entry.projected.y,
3302
- baseWidth * 0.72,
3303
- baseWidth * 0.44,
3583
+ baseWidth * 0.54,
3584
+ baseWidth * 0.28,
3304
3585
  0,
3305
3586
  0,
3306
3587
  Math.PI * 2
@@ -3318,13 +3599,22 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
3318
3599
  const radiusX = Math.hypot(xAxis.x - center.x, xAxis.y - center.y);
3319
3600
  const radiusY = Math.hypot(zAxis.x - center.x, zAxis.y - center.y);
3320
3601
  ctx.strokeStyle = `rgba(216, 235, 255, ${ring.opacity})`;
3321
- ctx.lineWidth = clamp((radiusX + radiusY) * 0.02, 1, 3.1);
3322
- ctx.beginPath();
3323
- ctx.ellipse(center.x, center.y, radiusX, radiusY, 0, 0, Math.PI * 2);
3324
- ctx.stroke();
3602
+ ctx.lineWidth = clamp((radiusX + radiusY) * 0.014, 0.65, 1.8);
3603
+ for (let segment = 0; segment < 5; segment += 1) {
3604
+ if (pseudoRandom(segment * 31 + radiusX * 0.7 + radiusY * 0.3) < 0.32) {
3605
+ continue;
3606
+ }
3607
+ const startAngle = segment * 1.22 + stateTimePhase(center.x, center.y) * 0.04;
3608
+ ctx.beginPath();
3609
+ ctx.ellipse(center.x, center.y, radiusX, radiusY, 0, startAngle, startAngle + 0.48);
3610
+ ctx.stroke();
3611
+ }
3325
3612
  }
3326
3613
  ctx.restore();
3327
3614
  }
3615
+ function stateTimePhase(x, y) {
3616
+ return Math.sin(x * 0.013 + y * 0.017);
3617
+ }
3328
3618
  function renderScene(ctx, canvas, state, shipModel, dom, lightingFeatures, fluidFeatures, clothFeatures) {
3329
3619
  const viewport = { width: canvas.width, height: canvas.height };
3330
3620
  const camera = buildCamera(state, canvas);
@@ -3392,6 +3682,7 @@ function renderScene(ctx, canvas, state, shipModel, dom, lightingFeatures, fluid
3392
3682
  }
3393
3683
  }
3394
3684
  const waterMotionEffects = buildWaterMotionEffects(state);
3685
+ const shorelineFoamSegments = buildShorelineFoamSegments(state);
3395
3686
  const lightSources = collectSceneLightSources(state, visuals);
3396
3687
  pushHarborGeometry(camera, viewport, sceneTriangles, state);
3397
3688
  const cloth = buildClothSurface(
@@ -3463,6 +3754,7 @@ function renderScene(ctx, canvas, state, shipModel, dom, lightingFeatures, fluid
3463
3754
  }
3464
3755
  renderWaterMotionEffects(ctx, waterMotionEffects, camera, viewport);
3465
3756
  renderWaterHighlights(ctx, water.bandMeshes, camera, viewport);
3757
+ renderShorelineFoamSegments(ctx, shorelineFoamSegments, camera, viewport);
3466
3758
  drawTriangles(
3467
3759
  ctx,
3468
3760
  sceneTriangles,
@@ -3491,7 +3783,7 @@ function renderScene(ctx, canvas, state, shipModel, dom, lightingFeatures, fluid
3491
3783
  };
3492
3784
  const sceneMetrics = [
3493
3785
  `focus: ${state.focus}`,
3494
- `ships: ${state.ships.length} active GLTF hulls across ${new Set(state.ships.map((ship) => ship.modelKey)).size} model families`,
3786
+ `ships: ${state.ships.length} active GLTF hulls across ${new Set(state.ships.map((ship) => resolveShipModel(state, ship, shipModel)?.name ?? ship.modelKey)).size} model families`,
3495
3787
  `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`,
3496
3788
  `physics snapshot: ${state.physics.snapshot.stage} (${state.physics.snapshot.stability})`,
3497
3789
  `physics contacts: ${state.contactCount}`,
@@ -3559,7 +3851,7 @@ function updateSceneState(state, dt, shipModel, featureAdapters) {
3559
3851
  advanceShowcaseClothSimulationState(clothState, {
3560
3852
  dt,
3561
3853
  time: state.time,
3562
- flagMotion: readVisualNumber(state.demoVisuals?.flagMotion, 0.92),
3854
+ flagMotion: readVisualNumber(state.demoVisuals?.flagMotion, 0.58),
3563
3855
  waveInfluence: sampleWave(state, FLAG_LAYOUT.origin.x + FLAG_LAYOUT.width * 0.55, FLAG_LAYOUT.origin.z + FLAG_LAYOUT.width * 0.48, state.time)
3564
3856
  });
3565
3857
  updatePhysicsSnapshot(state, shipModel, featureAdapters.physics);
@@ -3569,17 +3861,28 @@ function syncTextState(state, shipModel, featureAdapters) {
3569
3861
  coordinateSystem: "right-handed world; +x right, +y up, +z forward from the shore",
3570
3862
  focus: state.focus,
3571
3863
  stress: state.stress,
3572
- ships: state.ships.map((ship) => ({
3573
- id: ship.id,
3574
- modelKey: ship.modelKey ?? "brigantine",
3575
- x: Number(ship.position.x.toFixed(2)),
3576
- y: Number(ship.position.y.toFixed(2)),
3577
- z: Number(ship.position.z.toFixed(2)),
3578
- vx: Number(ship.velocity.x.toFixed(2)),
3579
- vz: Number(ship.velocity.z.toFixed(2)),
3580
- massKg: Math.round(getShipMass(ship, resolveShipModel(state, ship, shipModel))),
3581
- lanterns: Array.isArray(ship.lanterns) ? ship.lanterns.length : 0
3582
- })),
3864
+ ships: state.ships.map((ship) => {
3865
+ const resolvedShipModel = resolveShipModel(state, ship, shipModel);
3866
+ return {
3867
+ id: ship.id,
3868
+ modelKey: ship.modelKey ?? "brigantine",
3869
+ resolvedModelKey: resolvedShipModel?.name ?? ship.modelKey ?? "brigantine",
3870
+ x: Number(ship.position.x.toFixed(2)),
3871
+ y: Number(ship.position.y.toFixed(2)),
3872
+ z: Number(ship.position.z.toFixed(2)),
3873
+ vx: Number(ship.velocity.x.toFixed(2)),
3874
+ vz: Number(ship.velocity.z.toFixed(2)),
3875
+ massKg: Math.round(getShipMass(ship, resolvedShipModel)),
3876
+ lanterns: Array.isArray(ship.lanterns) ? ship.lanterns.length : 0
3877
+ };
3878
+ }),
3879
+ assetCatalog: {
3880
+ mode: state.assetCatalog?.mode ?? "unknown",
3881
+ shipKeys: Object.keys(state.assetCatalog?.ships ?? {}).sort(),
3882
+ environmentKeys: Object.keys(state.assetCatalog?.environment ?? {}).sort(),
3883
+ fallbackReason: state.assetCatalog?.fallbackReason ?? null,
3884
+ requestedRealisticModels: state.showcaseRealisticModelsEnabled
3885
+ },
3583
3886
  shipPhysics: Object.fromEntries(
3584
3887
  state.ships.map((ship) => [ship.id, resolveShipModel(state, ship, shipModel)?.physics ?? null])
3585
3888
  ),
@@ -3636,7 +3939,9 @@ async function mountGpuShowcase(options = {}, featureFlags = null) {
3636
3939
  },
3637
3940
  featureAdapters
3638
3941
  );
3639
- const assetCatalog = await (state.showcaseRealisticModelsEnabled ? loadShowcaseAssetCatalog() : createLegacyShowcaseAssetCatalog());
3942
+ const assetCatalog = await loadShowcaseAssetCatalogWithFallback({
3943
+ includeSecondaryShip: state.showcaseRealisticModelsEnabled
3944
+ });
3640
3945
  const shipModel = assetCatalog.ships[assetCatalog.primaryShipKey];
3641
3946
  state.assetCatalog = assetCatalog;
3642
3947
  state.shipModel = shipModel;
@@ -3776,6 +4081,8 @@ function updatePhysicsSnapshot(state, shipModel, physicsFeatures) {
3776
4081
  }
3777
4082
  export {
3778
4083
  advanceShowcaseClothSimulationState as __testOnlyAdvanceShowcaseClothSimulationState,
4084
+ buildClothSurface as __testOnlyBuildClothSurface,
4085
+ buildShorelineFoamSegments as __testOnlyBuildShorelineFoamSegments,
3779
4086
  buildWaterBands as __testOnlyBuildWaterBands,
3780
4087
  buildWaterMotionEffects as __testOnlyBuildWaterMotionEffects,
3781
4088
  collectSceneLightSources as __testOnlyCollectSceneLightSources,
@@ -3783,4 +4090,4 @@ export {
3783
4090
  mountGpuShowcase,
3784
4091
  showcaseFocusModes
3785
4092
  };
3786
- //# sourceMappingURL=showcase-runtime-OH3H6ZW2.js.map
4093
+ //# sourceMappingURL=showcase-runtime-B544T6AM.js.map