@plasius/gpu-shared 0.1.19 → 1.0.0

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
  `;
@@ -1533,11 +1641,12 @@ function buildTrianglesFromMesh(
1533
1641
  }
1534
1642
 
1535
1643
  async function loadShowcaseAssetCatalog() {
1536
- const [brigantine, cutter, lighthouse, harborDock] = await Promise.all([
1644
+ const [brigantine, cutter, lighthouse, harborDock, shoreline] = await Promise.all([
1537
1645
  loadGltfModel(resolveShowcaseAssetUrl("brigantine")),
1538
1646
  loadGltfModel(resolveShowcaseAssetUrl("cutter")),
1539
1647
  loadGltfModel(resolveShowcaseAssetUrl("lighthouse")),
1540
1648
  loadGltfModel(resolveShowcaseAssetUrl("harbor-dock")),
1649
+ loadGltfModel(resolveShowcaseAssetUrl("shoreline")),
1541
1650
  ]);
1542
1651
 
1543
1652
  return Object.freeze({
@@ -1549,6 +1658,7 @@ async function loadShowcaseAssetCatalog() {
1549
1658
  environment: Object.freeze({
1550
1659
  lighthouse,
1551
1660
  "harbor-dock": harborDock,
1661
+ shoreline,
1552
1662
  }),
1553
1663
  });
1554
1664
  }
@@ -1690,24 +1800,27 @@ function buildDemoDom(root, options) {
1690
1800
  ${t(gpuSharedTranslationKeys.legendCollisions)}
1691
1801
  </div>
1692
1802
  </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>
1803
+ <details class="plasius-demo__diagnostics">
1804
+ <summary>${t(gpuSharedTranslationKeys.debugTelemetry)}</summary>
1805
+ <aside class="plasius-demo__sidebar">
1806
+ <section class="plasius-panel plasius-demo__card">
1807
+ <h2>${t(gpuSharedTranslationKeys.sceneState)}</h2>
1808
+ <ul id="sceneMetrics" class="plasius-demo__metrics"></ul>
1809
+ </section>
1810
+ <section class="plasius-panel plasius-demo__card">
1811
+ <h2>${t(gpuSharedTranslationKeys.qualityBudgets)}</h2>
1812
+ <ul id="qualityMetrics" class="plasius-demo__metrics"></ul>
1813
+ </section>
1814
+ <section class="plasius-panel plasius-demo__card">
1815
+ <h2>${t(gpuSharedTranslationKeys.debugTelemetry)}</h2>
1816
+ <ul id="debugMetrics" class="plasius-demo__metrics"></ul>
1817
+ </section>
1818
+ <section class="plasius-panel plasius-demo__card">
1819
+ <h2>${t(gpuSharedTranslationKeys.notes)}</h2>
1820
+ <ul id="sceneNotes" class="plasius-demo__metrics"></ul>
1821
+ </section>
1822
+ </aside>
1823
+ </details>
1711
1824
  </section>
1712
1825
  <p class="plasius-demo__footer">
1713
1826
  This visual example is shared across the GPU packages to keep manual validation fast and consistent.
@@ -2125,13 +2238,13 @@ function advanceShowcaseClothSimulationState(clothState, options = {}) {
2125
2238
  )
2126
2239
  );
2127
2240
  const windStrength =
2128
- (1.6 + broadMotion * 1.25 + wrinkleLayers * 0.12) *
2241
+ (0.94 + broadMotion * 0.82 + wrinkleLayers * 0.08) *
2129
2242
  flagMotion *
2130
- (0.44 + u * 1.14);
2243
+ (0.36 + u * 0.92);
2131
2244
  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
2245
+ Math.sin(wrinklePhase) * 0.12 * wrinkleMotion * flagMotion,
2246
+ Math.cos(wrinklePhase * 0.7) * 0.045 * wrinkleMotion,
2247
+ Math.cos(wrinklePhase) * 0.08 * broadMotion * flagMotion
2135
2248
  );
2136
2249
  const acceleration = addVec3(
2137
2250
  vec3(0, -0.48 - u * 0.08, 0),
@@ -2197,25 +2310,25 @@ function resolveVisualConfig(nearLighting, lightingSnapshot, customVisuals = {})
2197
2310
  ambientMist: "rgba(41, 63, 97, 0.16)",
2198
2311
  reflectionStrength: lightingSnapshot.currentLevel.config.reflectionStrength,
2199
2312
  shadowAccent: lightingSnapshot.currentLevel.config.shadowStrength,
2200
- waveAmplitude: 0.94,
2313
+ waveAmplitude: 0.82,
2201
2314
  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 },
2315
+ wavePhaseSpeed: 0.74,
2316
+ wakeStrength: 0.24,
2317
+ wakeLength: 17,
2318
+ collisionRippleStrength: 0.22,
2319
+ waterNear: { r: 0.05, g: 0.2, b: 0.3 },
2320
+ waterFar: { r: 0.13, g: 0.31, b: 0.45 },
2208
2321
  harborWall: { r: 0.26, g: 0.24, b: 0.28 },
2209
2322
  harborDeck: { r: 0.33, g: 0.22, b: 0.16 },
2210
2323
  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,
2324
+ flagColor: { r: 0.54, g: 0.13, b: 0.11 },
2325
+ flagMotion: 0.58,
2213
2326
  lanternCore: { r: 0.98, g: 0.8, b: 0.48 },
2214
2327
  lanternGlow: { r: 1, g: 0.56, b: 0.2 },
2215
2328
  lanternReflectionStrength: 0.42,
2216
2329
  torchCore: { r: 0.99, g: 0.72, b: 0.36 },
2217
2330
  torchGlow: { r: 0.98, g: 0.38, b: 0.15 },
2218
- collisionFlash: "rgba(255, 212, 168, 0.16)",
2331
+ collisionFlash: "rgba(255, 212, 168, 0.08)",
2219
2332
  };
2220
2333
 
2221
2334
  return {
@@ -2299,6 +2412,11 @@ function buildClothSurface(model, state, meshDetail, visuals, clothFeatures) {
2299
2412
  representation: clothPresentation.representation,
2300
2413
  continuity: clothPresentation.continuity,
2301
2414
  color: visuals.flagColor,
2415
+ material: Object.freeze({
2416
+ weaveAlpha: clothPresentation.band === "near" ? 0.22 : 0.12,
2417
+ foldAlpha: clothPresentation.band === "near" ? 0.3 : 0.18,
2418
+ edgeHighlightAlpha: clothPresentation.band === "near" ? 0.42 : 0.28,
2419
+ }),
2302
2420
  positions: clothState.positions.map((point) => vec3(point.x, point.y, point.z)),
2303
2421
  indices: clothState.indices,
2304
2422
  grid: { rows: clothState.rows, cols: clothState.cols },
@@ -2412,7 +2530,7 @@ function buildWaterMotionEffects(state) {
2412
2530
  impulse.z
2413
2531
  ),
2414
2532
  radius,
2415
- opacity: clamp(impulse.life * 0.28, 0.08, 0.3),
2533
+ opacity: clamp(impulse.life * 0.13, 0.035, 0.15),
2416
2534
  });
2417
2535
  });
2418
2536
 
@@ -2427,7 +2545,7 @@ function buildWaterMotionEffects(state) {
2427
2545
  const lateral = vec3(-direction.z, 0, direction.x);
2428
2546
  const points = [];
2429
2547
  for (let sampleIndex = 0; sampleIndex < 6; sampleIndex += 1) {
2430
- const along = 1 + sampleIndex * 1.45;
2548
+ const along = 1 + sampleIndex * 1.55;
2431
2549
  const lateralOffset =
2432
2550
  Math.sin(state.time * 1.2 + sampleIndex * 0.8 + readVisualNumber(ship.wanderPhase, 0)) * 0.12;
2433
2551
  const worldPoint = addVec3(
@@ -2441,13 +2559,14 @@ function buildWaterMotionEffects(state) {
2441
2559
  sampleWave(state, worldPoint.x, worldPoint.z, state.time) * 0.24 + 0.04,
2442
2560
  worldPoint.z
2443
2561
  ),
2444
- width: 0.34 + sampleIndex * 0.13,
2562
+ width: 0.3 + sampleIndex * 0.11,
2563
+ foam: clamp(0.28 - sampleIndex * 0.028 + speed * 0.025, 0.1, 0.34),
2445
2564
  })
2446
2565
  );
2447
2566
  }
2448
2567
  wakeTrails.push(
2449
2568
  Object.freeze({
2450
- opacity: clamp(0.18 + speed * 0.09, 0.22, 0.46),
2569
+ opacity: clamp(0.1 + speed * 0.048, 0.12, 0.24),
2451
2570
  points: Object.freeze(points),
2452
2571
  })
2453
2572
  );
@@ -2459,6 +2578,28 @@ function buildWaterMotionEffects(state) {
2459
2578
  });
2460
2579
  }
2461
2580
 
2581
+ function buildShorelineFoamSegments(state) {
2582
+ return Object.freeze(
2583
+ SHORELINE_FOAM_ANCHORS.map((anchor, index) => {
2584
+ const pulse = 0.5 + Math.sin(state.time * 0.84 + index * 1.17) * 0.5;
2585
+ const drift = Math.sin(state.time * 0.38 + index * 0.61) * 0.1;
2586
+ const direction = normalizeVec3(vec3(Math.cos(anchor.angle), 0, Math.sin(anchor.angle)));
2587
+ const center = vec3(
2588
+ anchor.x + direction.x * drift,
2589
+ sampleWave(state, anchor.x, anchor.z, state.time) * 0.12 - 0.02,
2590
+ anchor.z + direction.z * drift
2591
+ );
2592
+ return Object.freeze({
2593
+ center,
2594
+ direction,
2595
+ length: anchor.length * (0.78 + pulse * 0.34),
2596
+ width: 0.16 + pulse * 0.12,
2597
+ opacity: 0.07 + pulse * 0.12,
2598
+ });
2599
+ })
2600
+ );
2601
+ }
2602
+
2462
2603
  function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
2463
2604
  const resolvedFluidFeatures = normalizeFluidFeatureAdapters(fluidFeatures);
2464
2605
  const fluidPlan = resolvedFluidFeatures.createPlan({
@@ -2487,14 +2628,14 @@ function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
2487
2628
  const bandContinuity = resolveFluidBandContinuity(continuity, bandSpec.band);
2488
2629
  const bandResolution =
2489
2630
  bandSpec.band === "near"
2490
- ? fluidDetail.nearResolution
2631
+ ? Math.ceil(fluidDetail.nearResolution * 1.28)
2491
2632
  : bandSpec.band === "mid"
2492
- ? fluidDetail.midResolution
2633
+ ? Math.ceil(fluidDetail.midResolution * 1.2)
2493
2634
  : bandSpec.band === "far"
2494
2635
  ? 5
2495
2636
  : 3;
2496
- const cols = Math.max(4, bandResolution * 2);
2497
- const rows = Math.max(4, bandResolution + 2);
2637
+ const cols = Math.max(4, bandResolution * (bandSpec.band === "near" ? 3 : 2));
2638
+ const rows = Math.max(4, bandResolution + (bandSpec.band === "near" ? 5 : 2));
2498
2639
  const positions = [];
2499
2640
  const indices = [];
2500
2641
  const originX = -bandSpec.width * 0.5;
@@ -2505,11 +2646,16 @@ function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
2505
2646
  const v = row / (rows - 1);
2506
2647
  const x = originX + bandSpec.width * u;
2507
2648
  const z = originZ + bandSpec.depth * v;
2508
- const y =
2649
+ const baseHeight =
2509
2650
  bandSpec.y +
2510
2651
  sampleWave(state, x, z, state.time) *
2511
2652
  bandContinuity.amplitudeFloor *
2512
2653
  (bandSpec.band === "near" ? 0.9 : bandSpec.band === "mid" ? 0.55 : 0.3);
2654
+ const detailHeight =
2655
+ bandSpec.band === "near"
2656
+ ? Math.sin(x * 1.25 + z * 0.42 - state.time * 2.4) * 0.035
2657
+ : 0;
2658
+ const y = baseHeight + detailHeight;
2513
2659
  positions.push(vec3(x, y, z));
2514
2660
  }
2515
2661
  }
@@ -2546,7 +2692,12 @@ function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
2546
2692
  r: mix(visuals.waterFar.r, 0.76, 0.2),
2547
2693
  g: mix(visuals.waterFar.g, 0.78, 0.2),
2548
2694
  b: mix(visuals.waterFar.b, 0.82, 0.2),
2549
- },
2695
+ },
2696
+ material: Object.freeze({
2697
+ highlightAlpha: bandSpec.band === "near" ? 0.2 : bandSpec.band === "mid" ? 0.13 : 0.07,
2698
+ foamAlpha: bandSpec.band === "near" ? 0.28 : bandSpec.band === "mid" ? 0.14 : 0.05,
2699
+ microRippleScale: bandSpec.band === "near" ? 1 : bandSpec.band === "mid" ? 0.58 : 0.28,
2700
+ }),
2550
2701
  });
2551
2702
  }
2552
2703
 
@@ -2626,15 +2777,15 @@ function createSceneState(options, featureAdapters) {
2626
2777
  {
2627
2778
  id: "northwind",
2628
2779
  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,
2780
+ position: vec3(-7.8, 0, 11.2),
2781
+ velocity: vec3(1.08, 0, -0.18),
2782
+ rotationY: 1.38,
2783
+ angularVelocity: 0.025,
2633
2784
  tint: { r: 0.62, g: 0.39, b: 0.23 },
2634
2785
  massScale: 1.42,
2635
- cruiseSpeed: 2.25,
2636
- throttleResponse: 0.46,
2637
- rudderResponse: 0.54,
2786
+ cruiseSpeed: 1.22,
2787
+ throttleResponse: 0.36,
2788
+ rudderResponse: 0.4,
2638
2789
  wanderPhase: 0.35,
2639
2790
  lanterns: CUTTER_LANTERNS,
2640
2791
  lanternStrength: 1.06,
@@ -2643,15 +2794,15 @@ function createSceneState(options, featureAdapters) {
2643
2794
  {
2644
2795
  id: "tidecaller",
2645
2796
  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,
2797
+ position: vec3(6.8, 0, 5.4),
2798
+ velocity: vec3(-0.82, 0, 0.14),
2799
+ rotationY: -1.34,
2800
+ angularVelocity: -0.035,
2650
2801
  tint: { r: 0.58, g: 0.24, b: 0.16 },
2651
2802
  massScale: 0.84,
2652
- cruiseSpeed: 2.68,
2653
- throttleResponse: 0.7,
2654
- rudderResponse: 0.78,
2803
+ cruiseSpeed: 1.36,
2804
+ throttleResponse: 0.52,
2805
+ rudderResponse: 0.58,
2655
2806
  wanderPhase: 1.6,
2656
2807
  lanterns: SHIP_LANTERNS,
2657
2808
  lanternStrength: 1.18,
@@ -2995,13 +3146,14 @@ function renderShipRigging(ctx, ship, camera, viewport) {
2995
3146
 
2996
3147
  function renderClothAccent(ctx, cloth, camera, viewport) {
2997
3148
  const projected = cloth.positions.map((point) => projectPoint(point, camera, viewport));
2998
- ctx.strokeStyle = "rgba(255, 241, 226, 0.92)";
2999
- ctx.lineWidth = 1.7;
3149
+ const material = cloth.material ?? {};
3150
+ ctx.strokeStyle = `rgba(255, 241, 226, ${material.foldAlpha ?? 0.32})`;
3151
+ ctx.lineWidth = 1.8;
3000
3152
 
3001
3153
  for (
3002
3154
  let row = 0;
3003
3155
  row < cloth.grid.rows;
3004
- row += Math.max(1, Math.floor(cloth.grid.rows / 5))
3156
+ row += Math.max(1, Math.floor(cloth.grid.rows / 6))
3005
3157
  ) {
3006
3158
  ctx.beginPath();
3007
3159
  let started = false;
@@ -3022,6 +3174,32 @@ function renderClothAccent(ctx, cloth, camera, viewport) {
3022
3174
  }
3023
3175
  }
3024
3176
 
3177
+ ctx.strokeStyle = `rgba(255, 228, 204, ${material.weaveAlpha ?? 0.22})`;
3178
+ ctx.lineWidth = 0.85;
3179
+ for (
3180
+ let column = 1;
3181
+ column < cloth.grid.cols - 1;
3182
+ column += Math.max(1, Math.floor(cloth.grid.cols / 8))
3183
+ ) {
3184
+ ctx.beginPath();
3185
+ let started = false;
3186
+ for (let row = 0; row < cloth.grid.rows; row += 1) {
3187
+ const point = projected[row * cloth.grid.cols + column];
3188
+ if (!point) {
3189
+ continue;
3190
+ }
3191
+ if (!started) {
3192
+ ctx.moveTo(point.x, point.y);
3193
+ started = true;
3194
+ } else {
3195
+ ctx.lineTo(point.x, point.y);
3196
+ }
3197
+ }
3198
+ if (started) {
3199
+ ctx.stroke();
3200
+ }
3201
+ }
3202
+
3025
3203
  const borderIndices = [
3026
3204
  0,
3027
3205
  cloth.grid.cols - 1,
@@ -3029,6 +3207,26 @@ function renderClothAccent(ctx, cloth, camera, viewport) {
3029
3207
  (cloth.grid.rows - 1) * cloth.grid.cols,
3030
3208
  ];
3031
3209
  ctx.fillStyle = colorToRgba(cloth.color, 0.95);
3210
+ ctx.strokeStyle = `rgba(255, 246, 236, ${material.edgeHighlightAlpha ?? 0.5})`;
3211
+ ctx.lineWidth = 1.4;
3212
+ ctx.beginPath();
3213
+ let borderStarted = false;
3214
+ for (let column = 0; column < cloth.grid.cols; column += 1) {
3215
+ const point = projected[column];
3216
+ if (!point) {
3217
+ continue;
3218
+ }
3219
+ if (!borderStarted) {
3220
+ ctx.moveTo(point.x, point.y);
3221
+ borderStarted = true;
3222
+ } else {
3223
+ ctx.lineTo(point.x, point.y);
3224
+ }
3225
+ }
3226
+ if (borderStarted) {
3227
+ ctx.stroke();
3228
+ }
3229
+
3032
3230
  for (const index of borderIndices) {
3033
3231
  const point = projected[index];
3034
3232
  if (!point) {
@@ -3045,14 +3243,22 @@ function renderWaterHighlights(ctx, waterBands, camera, viewport) {
3045
3243
  if (band.band === "horizon") {
3046
3244
  continue;
3047
3245
  }
3048
- const interval = band.band === "near" ? 2 : 3;
3049
- const alpha = band.band === "near" ? 0.22 : 0.14;
3246
+ const interval = band.band === "near" ? 4 : 5;
3247
+ const alpha = band.material?.highlightAlpha ?? (band.band === "near" ? 0.22 : 0.14);
3050
3248
  ctx.strokeStyle = `rgba(232, 247, 255, ${alpha})`;
3051
- ctx.lineWidth = band.band === "near" ? 1.3 : 0.9;
3249
+ ctx.lineWidth = band.band === "near" ? 0.9 : 0.65;
3052
3250
  for (let row = interval; row < band.rows - 1; row += interval) {
3053
- ctx.beginPath();
3054
3251
  let started = false;
3055
- for (let column = 0; column < band.cols; column += 1) {
3252
+ ctx.beginPath();
3253
+ for (let column = 0; column < band.cols; column += band.band === "near" ? 2 : 3) {
3254
+ if (pseudoRandom(row * 47 + column * 13) < 0.18) {
3255
+ if (started) {
3256
+ ctx.stroke();
3257
+ ctx.beginPath();
3258
+ started = false;
3259
+ }
3260
+ continue;
3261
+ }
3056
3262
  const point = projectPoint(
3057
3263
  band.positions[row * band.cols + column],
3058
3264
  camera,
@@ -3072,7 +3278,61 @@ function renderWaterHighlights(ctx, waterBands, camera, viewport) {
3072
3278
  ctx.stroke();
3073
3279
  }
3074
3280
  }
3281
+
3282
+ if (band.band === "near") {
3283
+ ctx.fillStyle = `rgba(236, 249, 255, ${(band.material?.foamAlpha ?? 0.28) * 0.72})`;
3284
+ for (let column = 3; column < band.cols - 3; column += 10) {
3285
+ const point = projectPoint(
3286
+ band.positions[Math.floor(band.rows * 0.42) * band.cols + column],
3287
+ camera,
3288
+ viewport
3289
+ );
3290
+ if (!point) {
3291
+ continue;
3292
+ }
3293
+ ctx.beginPath();
3294
+ ctx.ellipse(point.x, point.y, 1.8, 0.75, -0.2, 0, Math.PI * 2);
3295
+ ctx.fill();
3296
+ }
3297
+ }
3298
+ }
3299
+ }
3300
+
3301
+ function renderShorelineFoamSegments(ctx, segments, camera, viewport) {
3302
+ ctx.save();
3303
+ ctx.globalCompositeOperation = "screen";
3304
+ ctx.lineCap = "round";
3305
+ ctx.lineJoin = "round";
3306
+
3307
+ for (const segment of segments) {
3308
+ const half = scaleVec3(segment.direction, segment.length * 0.5);
3309
+ const start = projectPoint(subVec3(segment.center, half), camera, viewport);
3310
+ const end = projectPoint(addVec3(segment.center, half), camera, viewport);
3311
+ const center = projectPoint(segment.center, camera, viewport);
3312
+ if (!start || !end || !center) {
3313
+ continue;
3314
+ }
3315
+
3316
+ const depthScale = clamp(140 / Math.max(12, center.depth), 3, 10);
3317
+ ctx.strokeStyle = `rgba(232, 242, 238, ${segment.opacity})`;
3318
+ ctx.lineWidth = clamp(segment.width * depthScale, 0.8, 2.8);
3319
+ ctx.beginPath();
3320
+ ctx.moveTo(start.x, start.y);
3321
+ ctx.quadraticCurveTo(
3322
+ center.x,
3323
+ center.y + Math.sin(segment.center.x * 1.7) * 2.4,
3324
+ end.x,
3325
+ end.y
3326
+ );
3327
+ ctx.stroke();
3328
+
3329
+ ctx.fillStyle = `rgba(248, 251, 246, ${segment.opacity * 0.68})`;
3330
+ ctx.beginPath();
3331
+ ctx.ellipse(center.x, center.y, depthScale * 0.18, depthScale * 0.08, -0.2, 0, Math.PI * 2);
3332
+ ctx.fill();
3075
3333
  }
3334
+
3335
+ ctx.restore();
3076
3336
  }
3077
3337
 
3078
3338
  function readPhysicsNumber(physics, key, fallback) {
@@ -3113,14 +3373,17 @@ function getShipInverseInertia(ship, shipModel) {
3113
3373
  }
3114
3374
 
3115
3375
  function spawnSpray(state, point, intensity) {
3116
- const count = state.fluidDetail.getSnapshot().currentLevel.config.splashCount;
3376
+ const count = Math.max(
3377
+ 3,
3378
+ Math.ceil(state.fluidDetail.getSnapshot().currentLevel.config.splashCount * 0.32)
3379
+ );
3117
3380
  for (let index = 0; index < count; index += 1) {
3118
3381
  const angle = (index / count) * Math.PI * 2;
3119
- const speed = 0.9 + Math.random() * intensity * 0.45;
3382
+ const speed = 0.46 + Math.random() * intensity * 0.24;
3120
3383
  state.sprays.push({
3121
3384
  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,
3385
+ velocity: vec3(Math.cos(angle) * speed * 0.24, 0.46 + Math.random() * 0.34, Math.sin(angle) * speed * 0.18),
3386
+ life: 0.72 + Math.random() * 0.22,
3124
3387
  });
3125
3388
  }
3126
3389
  }
@@ -3140,8 +3403,8 @@ function resolveShipRoute(ship, state, radius) {
3140
3403
  const crossCurrent = Math.cos(state.time * 0.31 + readVisualNumber(ship.wanderPhase, 0));
3141
3404
  const laneCenter =
3142
3405
  ship.id === "northwind"
3143
- ? 10.2 + wander * 2.1 + crossCurrent * 0.6
3144
- : 7 + wander * 3.3 - crossCurrent * 1.1;
3406
+ ? 11.6 + wander * 0.82 + crossCurrent * 0.24
3407
+ : 5.4 + wander * 0.94 - crossCurrent * 0.32;
3145
3408
  const targetX =
3146
3409
  ship.routeDirection > 0
3147
3410
  ? HARBOR_BOUNDS.maxX - radius * 1.7
@@ -3158,7 +3421,7 @@ function updateShipMotion(state, ship, dt, shipModel) {
3158
3421
  const angularDamping = readPhysicsNumber(physics, "angularDamping", 0.08);
3159
3422
  const throttleResponse = readVisualNumber(ship.throttleResponse, 0.58);
3160
3423
  const rudderResponse = readVisualNumber(ship.rudderResponse, 0.62);
3161
- const cruiseSpeed = readVisualNumber(ship.cruiseSpeed, 2.4);
3424
+ const cruiseSpeed = readVisualNumber(ship.cruiseSpeed, 1.25);
3162
3425
 
3163
3426
  ship.collisionCooldown = Math.max(0, readVisualNumber(ship.collisionCooldown, 0) - dt);
3164
3427
 
@@ -3272,7 +3535,7 @@ function resolveShipCollision(state, a, b, shipModelA, shipModelB) {
3272
3535
  ((readPhysicsNumber(shipModelA.physics, "restitution", 0.22) +
3273
3536
  readPhysicsNumber(shipModelB.physics, "restitution", 0.22)) /
3274
3537
  2) *
3275
- 0.88;
3538
+ 0.42;
3276
3539
  if (velocityAlongNormal < 0) {
3277
3540
  const impulseMagnitude =
3278
3541
  (-(1 + restitution) * velocityAlongNormal) / Math.max(0.0001, invMassSum);
@@ -3299,7 +3562,7 @@ function resolveShipCollision(state, a, b, shipModelA, shipModelB) {
3299
3562
 
3300
3563
  const impactSpeed = Math.abs(velocityAlongNormal);
3301
3564
  if (
3302
- impactSpeed > 0.18 &&
3565
+ impactSpeed > 0.36 &&
3303
3566
  Math.max(readVisualNumber(a.collisionCooldown, 0), readVisualNumber(b.collisionCooldown, 0)) <= 0
3304
3567
  ) {
3305
3568
  const contactPoint = vec3(
@@ -3307,21 +3570,21 @@ function resolveShipCollision(state, a, b, shipModelA, shipModelB) {
3307
3570
  (a.position.y + b.position.y) * 0.5 + 0.14,
3308
3571
  (a.position.z + b.position.z) * 0.5
3309
3572
  );
3310
- spawnSpray(state, contactPoint, impactSpeed * 2.4 + penetration * 8);
3573
+ spawnSpray(state, contactPoint, impactSpeed * 0.9 + penetration * 2.4);
3311
3574
  state.waveImpulses.push({
3312
3575
  x: contactPoint.x,
3313
3576
  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,
3577
+ strength: clamp(0.1 + impactSpeed * 0.18 + penetration * 0.28, 0.08, 0.52),
3578
+ radius: 0.72 + penetration * 0.72,
3316
3579
  life: 1,
3317
3580
  });
3318
3581
  state.collisionCount += 1;
3319
3582
  state.collisionFlash = Math.max(
3320
3583
  state.collisionFlash,
3321
- clamp(impactSpeed * 0.55 + penetration * 1.8, 0.16, 1)
3584
+ clamp(impactSpeed * 0.14 + penetration * 0.32, 0.04, 0.24)
3322
3585
  );
3323
- a.collisionCooldown = 0.2;
3324
- b.collisionCooldown = 0.2;
3586
+ a.collisionCooldown = 0.72;
3587
+ b.collisionCooldown = 0.72;
3325
3588
  }
3326
3589
  }
3327
3590
 
@@ -3352,8 +3615,8 @@ function updateShips(state, dt, shipModel) {
3352
3615
  }
3353
3616
 
3354
3617
  state.collisionFlash = collided
3355
- ? Math.max(0.12, state.collisionFlash)
3356
- : Math.max(0, state.collisionFlash - dt * 1.3);
3618
+ ? Math.max(0.04, state.collisionFlash)
3619
+ : Math.max(0, state.collisionFlash - dt * 1.7);
3357
3620
  }
3358
3621
 
3359
3622
  function updateWaveImpulses(state, dt) {
@@ -3749,6 +4012,7 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
3749
4012
  .map((point) => ({
3750
4013
  projected: projectPoint(point.center, camera, viewport),
3751
4014
  width: point.width,
4015
+ foam: point.foam,
3752
4016
  }))
3753
4017
  .filter((entry) => entry.projected);
3754
4018
  if (projected.length < 2) {
@@ -3760,8 +4024,8 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
3760
4024
  const averageWidth =
3761
4025
  projected.reduce((total, entry) => total + entry.width, 0) / projected.length;
3762
4026
  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;
4027
+ ctx.strokeStyle = `rgba(146, 194, 236, ${wake.opacity * 0.34})`;
4028
+ ctx.lineWidth = baseWidth * 1.45;
3765
4029
  ctx.lineCap = "round";
3766
4030
  ctx.lineJoin = "round";
3767
4031
  ctx.beginPath();
@@ -3771,8 +4035,8 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
3771
4035
  }
3772
4036
  ctx.stroke();
3773
4037
 
3774
- ctx.strokeStyle = `rgba(234, 247, 255, ${wake.opacity})`;
3775
- ctx.lineWidth = baseWidth;
4038
+ ctx.strokeStyle = `rgba(234, 247, 255, ${wake.opacity * 0.72})`;
4039
+ ctx.lineWidth = baseWidth * 0.72;
3776
4040
  ctx.lineCap = "round";
3777
4041
  ctx.lineJoin = "round";
3778
4042
  ctx.beginPath();
@@ -3783,13 +4047,14 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
3783
4047
  ctx.stroke();
3784
4048
 
3785
4049
  for (const entry of projected.slice(1, 5)) {
3786
- ctx.fillStyle = `rgba(239, 248, 255, ${wake.opacity * 0.76})`;
4050
+ const foam = entry.foam ?? 0.3;
4051
+ ctx.fillStyle = `rgba(239, 248, 255, ${wake.opacity * foam * 0.92})`;
3787
4052
  ctx.beginPath();
3788
4053
  ctx.ellipse(
3789
4054
  entry.projected.x,
3790
4055
  entry.projected.y,
3791
- baseWidth * 0.72,
3792
- baseWidth * 0.44,
4056
+ baseWidth * 0.54,
4057
+ baseWidth * 0.28,
3793
4058
  0,
3794
4059
  0,
3795
4060
  Math.PI * 2
@@ -3809,15 +4074,25 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
3809
4074
  const radiusX = Math.hypot(xAxis.x - center.x, xAxis.y - center.y);
3810
4075
  const radiusY = Math.hypot(zAxis.x - center.x, zAxis.y - center.y);
3811
4076
  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();
4077
+ ctx.lineWidth = clamp((radiusX + radiusY) * 0.014, 0.65, 1.8);
4078
+ for (let segment = 0; segment < 5; segment += 1) {
4079
+ if (pseudoRandom(segment * 31 + radiusX * 0.7 + radiusY * 0.3) < 0.32) {
4080
+ continue;
4081
+ }
4082
+ const startAngle = segment * 1.22 + stateTimePhase(center.x, center.y) * 0.04;
4083
+ ctx.beginPath();
4084
+ ctx.ellipse(center.x, center.y, radiusX, radiusY, 0, startAngle, startAngle + 0.48);
4085
+ ctx.stroke();
4086
+ }
3816
4087
  }
3817
4088
 
3818
4089
  ctx.restore();
3819
4090
  }
3820
4091
 
4092
+ function stateTimePhase(x, y) {
4093
+ return Math.sin(x * 0.013 + y * 0.017);
4094
+ }
4095
+
3821
4096
  function renderScene(
3822
4097
  ctx,
3823
4098
  canvas,
@@ -3899,6 +4174,7 @@ function renderScene(
3899
4174
  }
3900
4175
 
3901
4176
  const waterMotionEffects = buildWaterMotionEffects(state);
4177
+ const shorelineFoamSegments = buildShorelineFoamSegments(state);
3902
4178
  const lightSources = collectSceneLightSources(state, visuals);
3903
4179
 
3904
4180
  pushHarborGeometry(camera, viewport, sceneTriangles, state);
@@ -3973,6 +4249,7 @@ function renderScene(
3973
4249
  }
3974
4250
  renderWaterMotionEffects(ctx, waterMotionEffects, camera, viewport);
3975
4251
  renderWaterHighlights(ctx, water.bandMeshes, camera, viewport);
4252
+ renderShorelineFoamSegments(ctx, shorelineFoamSegments, camera, viewport);
3976
4253
  drawTriangles(
3977
4254
  ctx,
3978
4255
  sceneTriangles,
@@ -4087,7 +4364,7 @@ function updateSceneState(state, dt, shipModel, featureAdapters) {
4087
4364
  advanceShowcaseClothSimulationState(clothState, {
4088
4365
  dt,
4089
4366
  time: state.time,
4090
- flagMotion: readVisualNumber(state.demoVisuals?.flagMotion, 0.92),
4367
+ flagMotion: readVisualNumber(state.demoVisuals?.flagMotion, 0.58),
4091
4368
  waveInfluence: sampleWave(state, FLAG_LAYOUT.origin.x + FLAG_LAYOUT.width * 0.55, FLAG_LAYOUT.origin.z + FLAG_LAYOUT.width * 0.48, state.time),
4092
4369
  });
4093
4370
  updatePhysicsSnapshot(state, shipModel, featureAdapters.physics);
@@ -4320,6 +4597,8 @@ function updatePhysicsSnapshot(state, shipModel, physicsFeatures) {
4320
4597
 
4321
4598
  export {
4322
4599
  advanceShowcaseClothSimulationState as __testOnlyAdvanceShowcaseClothSimulationState,
4600
+ buildClothSurface as __testOnlyBuildClothSurface,
4601
+ buildShorelineFoamSegments as __testOnlyBuildShorelineFoamSegments,
4323
4602
  buildWaterBands as __testOnlyBuildWaterBands,
4324
4603
  buildWaterMotionEffects as __testOnlyBuildWaterMotionEffects,
4325
4604
  collectSceneLightSources as __testOnlyCollectSceneLightSources,