@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.
package/dist/index.cjs CHANGED
@@ -119,7 +119,8 @@ var init_asset_url = __esm({
119
119
  brigantine: "brigantine.gltf",
120
120
  cutter: "cutter.gltf",
121
121
  lighthouse: "lighthouse.gltf",
122
- "harbor-dock": "harbor-dock.gltf"
122
+ "harbor-dock": "harbor-dock.gltf",
123
+ shoreline: "shoreline.gltf"
123
124
  });
124
125
  }
125
126
  });
@@ -136,7 +137,7 @@ var init_en_GB = __esm({
136
137
  "gpuShared.showcase.details.booting": "Preparing a moonlit harbor scene, GLTF hull data, cloth and fluid continuity plans, and adaptive quality metadata.",
137
138
  "gpuShared.showcase.details.physics": "Stable world snapshots are emitted from {snapshotStageId} after the authoritative solver; the heavier hull now carries momentum through mass-aware collision impulses while cloth and fluid remain downstream.",
138
139
  "gpuShared.showcase.details.realistic": "Moonlit GLTF ships now mix a brigantine and a cutter against modeled harbor assets; cloth, fluid, and ship-local lighting stay continuous while the governor pressure is {pressureLevel}.",
139
- "gpuShared.showcase.details.legacy": "Moonlit GLTF ships use the legacy brigantine and placeholder harbor blocks while cloth, fluid, and ship-local lighting stay continuous while the governor pressure is {pressureLevel}.",
140
+ "gpuShared.showcase.details.legacy": "Showcase fallback keeps the brigantine-only harbor path active while cloth, fluid, and ship-local lighting stay continuous under {pressureLevel} governor pressure.",
140
141
  "gpuShared.showcase.action.pause": "Pause",
141
142
  "gpuShared.showcase.action.resume": "Resume",
142
143
  "gpuShared.showcase.control.stressMode": "Stress mode",
@@ -2407,6 +2408,7 @@ var init_dist = __esm({
2407
2408
  // src/product-studio-runtime.js
2408
2409
  var product_studio_runtime_exports = {};
2409
2410
  __export(product_studio_runtime_exports, {
2411
+ buildProductStudioSceneObjects: () => buildProductStudioSceneObjects,
2410
2412
  createProductStudioMeshes: () => createProductStudioMeshes,
2411
2413
  mountGpuProductStudio: () => mountGpuProductStudio
2412
2414
  });
@@ -2665,6 +2667,9 @@ function createProductStudioMeshes(model, options = {}) {
2665
2667
  const modelMeshes = primitives.map((primitive, index) => createProductStudioMeshFromPrimitive(primitive, index, transform)).filter(Boolean);
2666
2668
  return Object.freeze([...createProductStudioEnvironmentMeshes(), ...modelMeshes]);
2667
2669
  }
2670
+ function buildProductStudioSceneObjects(model, options = {}) {
2671
+ return createProductStudioMeshes(model, options);
2672
+ }
2668
2673
  function ensureStyles(documentRef) {
2669
2674
  if (documentRef.getElementById?.(STYLE_ID)) {
2670
2675
  return;
@@ -2845,6 +2850,8 @@ var init_product_studio_runtime = __esm({
2845
2850
  var showcase_runtime_exports = {};
2846
2851
  __export(showcase_runtime_exports, {
2847
2852
  __testOnlyAdvanceShowcaseClothSimulationState: () => advanceShowcaseClothSimulationState,
2853
+ __testOnlyBuildClothSurface: () => buildClothSurface,
2854
+ __testOnlyBuildShorelineFoamSegments: () => buildShorelineFoamSegments,
2848
2855
  __testOnlyBuildWaterBands: () => buildWaterBands,
2849
2856
  __testOnlyBuildWaterMotionEffects: () => buildWaterMotionEffects,
2850
2857
  __testOnlyCollectSceneLightSources: () => collectSceneLightSources,
@@ -3456,31 +3463,31 @@ function injectStyles() {
3456
3463
  box-sizing: border-box;
3457
3464
  }
3458
3465
  .plasius-demo {
3459
- width: min(1560px, calc(100vw - 32px));
3460
- margin: 0 auto;
3461
- padding: 28px 0 40px;
3462
- display: grid;
3463
- gap: 20px;
3464
- }
3465
- .plasius-demo__hero,
3466
- .plasius-demo__layout {
3467
- display: grid;
3468
- gap: 20px;
3469
- }
3470
- .plasius-demo__hero {
3471
- grid-template-columns: minmax(0, 1.5fr) minmax(320px, 0.85fr);
3472
- align-items: start;
3466
+ position: relative;
3467
+ width: 100%;
3468
+ min-height: 100dvh;
3469
+ overflow: hidden;
3473
3470
  }
3474
3471
  .plasius-panel {
3475
3472
  border: 1px solid var(--plasius-border);
3476
- border-radius: 24px;
3473
+ border-radius: 8px;
3477
3474
  background: var(--plasius-panel);
3478
3475
  box-shadow: var(--plasius-shadow);
3479
3476
  backdrop-filter: blur(12px);
3480
3477
  }
3481
3478
  .plasius-demo__hero-card,
3482
3479
  .plasius-demo__status {
3483
- padding: 20px 22px;
3480
+ position: absolute;
3481
+ z-index: 3;
3482
+ padding: 10px 12px;
3483
+ }
3484
+ .plasius-demo__hero-card {
3485
+ display: none;
3486
+ }
3487
+ .plasius-demo__status {
3488
+ left: 16px;
3489
+ bottom: 84px;
3490
+ max-width: min(360px, calc(100vw - 32px));
3484
3491
  }
3485
3492
  .plasius-demo__eyebrow {
3486
3493
  margin: 0 0 8px;
@@ -3503,31 +3510,36 @@ function injectStyles() {
3503
3510
  .plasius-demo__status-badge {
3504
3511
  width: fit-content;
3505
3512
  margin: 0;
3506
- padding: 8px 12px;
3507
- border-radius: 999px;
3513
+ padding: 6px 9px;
3514
+ border-radius: 6px;
3508
3515
  background: rgba(243, 177, 106, 0.14);
3509
3516
  color: var(--plasius-accent);
3510
3517
  font-weight: 700;
3518
+ font-size: 12px;
3511
3519
  }
3512
3520
  .plasius-demo__status-text {
3513
3521
  margin: 10px 0 0;
3514
3522
  color: var(--plasius-muted);
3515
- line-height: 1.6;
3516
- }
3517
- .plasius-demo__layout {
3518
- grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.68fr);
3519
- align-items: start;
3523
+ font-size: 12px;
3524
+ line-height: 1.45;
3520
3525
  }
3521
3526
  .plasius-demo__canvas-panel {
3522
- padding: 18px;
3523
- position: relative;
3527
+ position: absolute;
3528
+ inset: 0;
3529
+ padding: 0;
3530
+ border: 0;
3531
+ border-radius: 0;
3532
+ background: transparent;
3533
+ box-shadow: none;
3534
+ backdrop-filter: none;
3524
3535
  }
3525
3536
  .plasius-demo__canvas {
3526
3537
  width: 100%;
3527
- aspect-ratio: 16 / 9;
3538
+ height: 100%;
3539
+ min-height: 100dvh;
3528
3540
  display: block;
3529
- border-radius: 20px;
3530
- border: 1px solid rgba(159, 185, 223, 0.12);
3541
+ border: 0;
3542
+ border-radius: 0;
3531
3543
  background: linear-gradient(180deg, #071220 0%, #132440 42%, #10344b 42%, #05111d 100%);
3532
3544
  }
3533
3545
  .${CAPTURE_CLASS} .plasius-demo {
@@ -3540,6 +3552,8 @@ function injectStyles() {
3540
3552
  .${CAPTURE_CLASS} .plasius-demo__toolbar,
3541
3553
  .${CAPTURE_CLASS} .plasius-demo__legend,
3542
3554
  .${CAPTURE_CLASS} .plasius-demo__sidebar,
3555
+ .${CAPTURE_CLASS} .plasius-demo__diagnostics,
3556
+ .${CAPTURE_CLASS} .plasius-demo__status,
3543
3557
  .${CAPTURE_CLASS} .plasius-demo__footer {
3544
3558
  display: none;
3545
3559
  }
@@ -3566,12 +3580,14 @@ function injectStyles() {
3566
3580
  }
3567
3581
  .plasius-demo__toolbar {
3568
3582
  position: absolute;
3569
- top: 26px;
3570
- left: 26px;
3583
+ top: 84px;
3584
+ left: 16px;
3585
+ z-index: 4;
3571
3586
  display: flex;
3572
- gap: 12px;
3587
+ gap: 8px;
3573
3588
  flex-wrap: wrap;
3574
3589
  align-items: center;
3590
+ max-width: min(560px, calc(100vw - 32px));
3575
3591
  }
3576
3592
  .plasius-demo button,
3577
3593
  .plasius-demo label,
@@ -3583,22 +3599,63 @@ function injectStyles() {
3583
3599
  .plasius-demo .plasius-toggle,
3584
3600
  .plasius-demo select {
3585
3601
  border: 1px solid rgba(159, 185, 223, 0.18);
3586
- border-radius: 999px;
3602
+ border-radius: 6px;
3587
3603
  background: rgba(9, 20, 34, 0.84);
3588
3604
  color: var(--plasius-ink);
3589
- padding: 10px 14px;
3605
+ padding: 8px 10px;
3590
3606
  }
3591
3607
  .plasius-toggle {
3592
3608
  display: inline-flex;
3593
3609
  align-items: center;
3594
3610
  gap: 8px;
3595
3611
  }
3612
+ .plasius-demo__diagnostics {
3613
+ position: absolute;
3614
+ right: 16px;
3615
+ bottom: 84px;
3616
+ z-index: 4;
3617
+ max-width: min(420px, calc(100vw - 32px));
3618
+ color: var(--plasius-ink);
3619
+ font-family: "JetBrains Mono", monospace;
3620
+ font-size: 12px;
3621
+ }
3622
+ .plasius-demo__diagnostics summary {
3623
+ width: fit-content;
3624
+ margin-left: auto;
3625
+ border: 1px solid rgba(159, 185, 223, 0.18);
3626
+ border-radius: 6px;
3627
+ padding: 8px 10px;
3628
+ background: rgba(9, 20, 34, 0.84);
3629
+ cursor: pointer;
3630
+ list-style: none;
3631
+ }
3632
+ .plasius-demo__diagnostics summary::-webkit-details-marker {
3633
+ display: none;
3634
+ }
3635
+ .plasius-demo__diagnostics[open] {
3636
+ width: min(420px, calc(100vw - 32px));
3637
+ }
3638
+ .plasius-demo__diagnostics[open] summary {
3639
+ margin-bottom: 8px;
3640
+ background: rgba(243, 177, 106, 0.14);
3641
+ color: var(--plasius-accent);
3642
+ }
3596
3643
  .plasius-demo__sidebar {
3597
3644
  display: grid;
3598
- gap: 18px;
3645
+ gap: 8px;
3646
+ max-height: min(58vh, 520px);
3647
+ overflow: auto;
3599
3648
  }
3600
3649
  .plasius-demo__card {
3601
- padding: 18px;
3650
+ padding: 10px;
3651
+ }
3652
+ .plasius-demo__card h2 {
3653
+ margin: 0;
3654
+ color: rgba(226, 236, 255, 0.72);
3655
+ font-family: "JetBrains Mono", monospace;
3656
+ font-size: 11px;
3657
+ letter-spacing: 0.08em;
3658
+ text-transform: uppercase;
3602
3659
  }
3603
3660
  .plasius-demo__metrics,
3604
3661
  .plasius-demo__metrics li {
@@ -3607,27 +3664,19 @@ function injectStyles() {
3607
3664
  list-style: none;
3608
3665
  }
3609
3666
  .plasius-demo__metrics {
3610
- margin-top: 12px;
3667
+ margin-top: 8px;
3611
3668
  display: grid;
3612
- gap: 8px;
3669
+ gap: 5px;
3613
3670
  color: var(--plasius-muted);
3614
- line-height: 1.55;
3671
+ font-size: 12px;
3672
+ line-height: 1.35;
3615
3673
  }
3616
3674
  .plasius-demo__metrics li {
3617
3675
  border-top: 1px solid rgba(21, 32, 40, 0.08);
3618
- padding-top: 8px;
3676
+ padding-top: 5px;
3619
3677
  }
3620
3678
  .plasius-demo__legend {
3621
- position: absolute;
3622
- right: 24px;
3623
- bottom: 24px;
3624
- padding: 10px 14px;
3625
- border-radius: 16px;
3626
- background: rgba(9, 20, 34, 0.82);
3627
- border: 1px solid rgba(159, 185, 223, 0.16);
3628
- color: var(--plasius-muted);
3629
- font-size: 12px;
3630
- line-height: 1.45;
3679
+ display: none;
3631
3680
  }
3632
3681
  .plasius-demo__legend strong {
3633
3682
  display: block;
@@ -3635,15 +3684,62 @@ function injectStyles() {
3635
3684
  margin-bottom: 4px;
3636
3685
  }
3637
3686
  .plasius-demo__footer {
3638
- margin-top: 4px;
3639
- color: rgba(226, 236, 255, 0.68);
3640
- font-size: 13px;
3641
- line-height: 1.6;
3687
+ display: none;
3642
3688
  }
3643
3689
  @media (max-width: 1200px) {
3644
- .plasius-demo__hero,
3645
- .plasius-demo__layout {
3646
- grid-template-columns: 1fr;
3690
+ .plasius-demo__toolbar {
3691
+ top: 92px;
3692
+ }
3693
+ }
3694
+ @media (max-width: 640px) {
3695
+ .plasius-demo__status {
3696
+ left: 10px;
3697
+ bottom: 10px;
3698
+ max-width: calc(100vw - 126px);
3699
+ padding: 6px 8px;
3700
+ }
3701
+ .plasius-demo__status-text {
3702
+ display: none;
3703
+ }
3704
+ .plasius-demo__status-badge {
3705
+ max-width: calc(100vw - 142px);
3706
+ overflow: hidden;
3707
+ text-overflow: ellipsis;
3708
+ white-space: nowrap;
3709
+ }
3710
+ .plasius-demo__toolbar {
3711
+ top: 10px;
3712
+ left: 10px;
3713
+ right: 10px;
3714
+ max-width: calc(100vw - 20px);
3715
+ flex-wrap: nowrap;
3716
+ overflow-x: auto;
3717
+ padding-bottom: 4px;
3718
+ scrollbar-width: none;
3719
+ }
3720
+ .plasius-demo__toolbar::-webkit-scrollbar {
3721
+ display: none;
3722
+ }
3723
+ .plasius-demo button,
3724
+ .plasius-demo .plasius-toggle,
3725
+ .plasius-demo select {
3726
+ padding: 7px 8px;
3727
+ font-size: 12px;
3728
+ white-space: nowrap;
3729
+ }
3730
+ .plasius-demo__diagnostics {
3731
+ right: 10px;
3732
+ bottom: 10px;
3733
+ }
3734
+ .plasius-demo__diagnostics[open] {
3735
+ bottom: 56px;
3736
+ left: 10px;
3737
+ right: 10px;
3738
+ width: auto;
3739
+ max-width: none;
3740
+ }
3741
+ .plasius-demo__diagnostics[open] .plasius-demo__sidebar {
3742
+ max-height: min(42vh, 340px);
3647
3743
  }
3648
3744
  }
3649
3745
  `;
@@ -3984,40 +4080,74 @@ function buildTrianglesFromMesh(mesh, transform, colorOverride, camera, viewport
3984
4080
  }
3985
4081
  }
3986
4082
  }
3987
- async function loadShowcaseAssetCatalog() {
3988
- const [brigantine, cutter, lighthouse, harborDock] = await Promise.all([
4083
+ function createShowcaseAssetCatalog({
4084
+ mode,
4085
+ ships,
4086
+ environment,
4087
+ primaryShipKey = "brigantine",
4088
+ fallbackReason = null
4089
+ }) {
4090
+ return Object.freeze({
4091
+ mode,
4092
+ primaryShipKey,
4093
+ ships: Object.freeze(ships),
4094
+ environment: Object.freeze(environment),
4095
+ fallbackReason
4096
+ });
4097
+ }
4098
+ function normalizeAssetCatalogFailureReason(error) {
4099
+ if (typeof error?.message === "string" && error.message.trim().length > 0) {
4100
+ return error.message;
4101
+ }
4102
+ return "showcase asset loading failed";
4103
+ }
4104
+ async function loadShowcaseAssetCatalog({ includeSecondaryShip = true } = {}) {
4105
+ const [brigantine, lighthouse, harborDock, shoreline] = await Promise.all([
3989
4106
  loadGltfModel(resolveShowcaseAssetUrl("brigantine")),
3990
- loadGltfModel(resolveShowcaseAssetUrl("cutter")),
3991
4107
  loadGltfModel(resolveShowcaseAssetUrl("lighthouse")),
3992
- loadGltfModel(resolveShowcaseAssetUrl("harbor-dock"))
4108
+ loadGltfModel(resolveShowcaseAssetUrl("harbor-dock")),
4109
+ loadGltfModel(resolveShowcaseAssetUrl("shoreline"))
3993
4110
  ]);
3994
- return Object.freeze({
3995
- primaryShipKey: "brigantine",
3996
- ships: Object.freeze({
3997
- brigantine,
3998
- cutter
3999
- }),
4000
- environment: Object.freeze({
4111
+ const ships = {
4112
+ brigantine
4113
+ };
4114
+ if (includeSecondaryShip) {
4115
+ ships.cutter = await loadGltfModel(resolveShowcaseAssetUrl("cutter"));
4116
+ }
4117
+ return createShowcaseAssetCatalog({
4118
+ mode: includeSecondaryShip ? "modeled-rich" : "modeled-baseline",
4119
+ ships,
4120
+ environment: {
4001
4121
  lighthouse,
4002
- "harbor-dock": harborDock
4003
- })
4122
+ "harbor-dock": harborDock,
4123
+ shoreline
4124
+ }
4004
4125
  });
4005
4126
  }
4006
- function createLegacyShowcaseAssetCatalog() {
4007
- const brigantine = loadGltfModel(resolveShowcaseAssetUrl("brigantine"));
4008
- return Promise.resolve(brigantine).then(
4009
- (primary) => Object.freeze({
4010
- primaryShipKey: "brigantine",
4011
- ships: Object.freeze({
4012
- brigantine: primary
4013
- }),
4014
- environment: Object.freeze({})
4015
- })
4016
- );
4127
+ async function createLegacyShowcaseAssetCatalog(error = null) {
4128
+ const brigantine = await loadGltfModel(resolveShowcaseAssetUrl("brigantine"));
4129
+ return createShowcaseAssetCatalog({
4130
+ mode: "legacy-fallback",
4131
+ ships: {
4132
+ brigantine
4133
+ },
4134
+ environment: {},
4135
+ fallbackReason: normalizeAssetCatalogFailureReason(error)
4136
+ });
4137
+ }
4138
+ async function loadShowcaseAssetCatalogWithFallback({ includeSecondaryShip = true } = {}) {
4139
+ try {
4140
+ return await loadShowcaseAssetCatalog({ includeSecondaryShip });
4141
+ } catch (error) {
4142
+ return createLegacyShowcaseAssetCatalog(error);
4143
+ }
4017
4144
  }
4018
4145
  function resolveShipModel(state, ship, fallbackModel = null) {
4019
4146
  return state.assetCatalog?.ships?.[ship.modelKey ?? state.assetCatalog?.primaryShipKey ?? "brigantine"] ?? fallbackModel ?? state.shipModel;
4020
4147
  }
4148
+ function hasModeledHarborEnvironment(state) {
4149
+ return Object.keys(state.assetCatalog?.environment ?? {}).length > 0;
4150
+ }
4021
4151
  function createPerformanceGovernor(performanceFeatures) {
4022
4152
  const createQualityLadderAdapter = assertRequiredFunction(
4023
4153
  performanceFeatures,
@@ -4125,24 +4255,27 @@ function buildDemoDom(root, options) {
4125
4255
  ${t(gpuSharedTranslationKeys.legendCollisions)}
4126
4256
  </div>
4127
4257
  </section>
4128
- <aside class="plasius-demo__sidebar">
4129
- <section class="plasius-panel plasius-demo__card">
4130
- <h2>${t(gpuSharedTranslationKeys.sceneState)}</h2>
4131
- <ul id="sceneMetrics" class="plasius-demo__metrics"></ul>
4132
- </section>
4133
- <section class="plasius-panel plasius-demo__card">
4134
- <h2>${t(gpuSharedTranslationKeys.qualityBudgets)}</h2>
4135
- <ul id="qualityMetrics" class="plasius-demo__metrics"></ul>
4136
- </section>
4137
- <section class="plasius-panel plasius-demo__card">
4138
- <h2>${t(gpuSharedTranslationKeys.debugTelemetry)}</h2>
4139
- <ul id="debugMetrics" class="plasius-demo__metrics"></ul>
4140
- </section>
4141
- <section class="plasius-panel plasius-demo__card">
4142
- <h2>${t(gpuSharedTranslationKeys.notes)}</h2>
4143
- <ul id="sceneNotes" class="plasius-demo__metrics"></ul>
4144
- </section>
4145
- </aside>
4258
+ <details class="plasius-demo__diagnostics">
4259
+ <summary>${t(gpuSharedTranslationKeys.debugTelemetry)}</summary>
4260
+ <aside class="plasius-demo__sidebar">
4261
+ <section class="plasius-panel plasius-demo__card">
4262
+ <h2>${t(gpuSharedTranslationKeys.sceneState)}</h2>
4263
+ <ul id="sceneMetrics" class="plasius-demo__metrics"></ul>
4264
+ </section>
4265
+ <section class="plasius-panel plasius-demo__card">
4266
+ <h2>${t(gpuSharedTranslationKeys.qualityBudgets)}</h2>
4267
+ <ul id="qualityMetrics" class="plasius-demo__metrics"></ul>
4268
+ </section>
4269
+ <section class="plasius-panel plasius-demo__card">
4270
+ <h2>${t(gpuSharedTranslationKeys.debugTelemetry)}</h2>
4271
+ <ul id="debugMetrics" class="plasius-demo__metrics"></ul>
4272
+ </section>
4273
+ <section class="plasius-panel plasius-demo__card">
4274
+ <h2>${t(gpuSharedTranslationKeys.notes)}</h2>
4275
+ <ul id="sceneNotes" class="plasius-demo__metrics"></ul>
4276
+ </section>
4277
+ </aside>
4278
+ </details>
4146
4279
  </section>
4147
4280
  <p class="plasius-demo__footer">
4148
4281
  This visual example is shared across the GPU packages to keep manual validation fast and consistent.
@@ -4506,11 +4639,11 @@ function advanceShowcaseClothSimulationState(clothState, options = {}) {
4506
4639
  1 + Math.sin(gustPhase * 0.74) * 0.18
4507
4640
  )
4508
4641
  );
4509
- const windStrength = (1.6 + broadMotion * 1.25 + wrinkleLayers * 0.12) * flagMotion * (0.44 + u * 1.14);
4642
+ const windStrength = (0.94 + broadMotion * 0.82 + wrinkleLayers * 0.08) * flagMotion * (0.36 + u * 0.92);
4510
4643
  const wrinkleForce = vec3(
4511
- Math.sin(wrinklePhase) * 0.22 * wrinkleMotion * flagMotion,
4512
- Math.cos(wrinklePhase * 0.7) * 0.08 * wrinkleMotion,
4513
- Math.cos(wrinklePhase) * 0.14 * broadMotion * flagMotion
4644
+ Math.sin(wrinklePhase) * 0.12 * wrinkleMotion * flagMotion,
4645
+ Math.cos(wrinklePhase * 0.7) * 0.045 * wrinkleMotion,
4646
+ Math.cos(wrinklePhase) * 0.08 * broadMotion * flagMotion
4514
4647
  );
4515
4648
  const acceleration = addVec3(
4516
4649
  vec3(0, -0.48 - u * 0.08, 0),
@@ -4567,25 +4700,25 @@ function resolveVisualConfig(nearLighting, lightingSnapshot, customVisuals = {})
4567
4700
  ambientMist: "rgba(41, 63, 97, 0.16)",
4568
4701
  reflectionStrength: lightingSnapshot.currentLevel.config.reflectionStrength,
4569
4702
  shadowAccent: lightingSnapshot.currentLevel.config.shadowStrength,
4570
- waveAmplitude: 0.94,
4703
+ waveAmplitude: 0.82,
4571
4704
  waveDirection: { x: 0.88, z: 0.28 },
4572
- wavePhaseSpeed: 0.88,
4573
- wakeStrength: 0.31,
4574
- wakeLength: 18,
4575
- collisionRippleStrength: 0.42,
4576
- waterNear: { r: 0.08, g: 0.23, b: 0.33 },
4577
- waterFar: { r: 0.18, g: 0.35, b: 0.49 },
4705
+ wavePhaseSpeed: 0.74,
4706
+ wakeStrength: 0.24,
4707
+ wakeLength: 17,
4708
+ collisionRippleStrength: 0.22,
4709
+ waterNear: { r: 0.05, g: 0.2, b: 0.3 },
4710
+ waterFar: { r: 0.13, g: 0.31, b: 0.45 },
4578
4711
  harborWall: { r: 0.26, g: 0.24, b: 0.28 },
4579
4712
  harborDeck: { r: 0.33, g: 0.22, b: 0.16 },
4580
4713
  harborTower: { r: 0.23, g: 0.24, b: 0.29 },
4581
- flagColor: { r: 0.66, g: 0.16, b: 0.13 },
4582
- flagMotion: 0.92,
4714
+ flagColor: { r: 0.54, g: 0.13, b: 0.11 },
4715
+ flagMotion: 0.58,
4583
4716
  lanternCore: { r: 0.98, g: 0.8, b: 0.48 },
4584
4717
  lanternGlow: { r: 1, g: 0.56, b: 0.2 },
4585
4718
  lanternReflectionStrength: 0.42,
4586
4719
  torchCore: { r: 0.99, g: 0.72, b: 0.36 },
4587
4720
  torchGlow: { r: 0.98, g: 0.38, b: 0.15 },
4588
- collisionFlash: "rgba(255, 212, 168, 0.16)"
4721
+ collisionFlash: "rgba(255, 212, 168, 0.08)"
4589
4722
  };
4590
4723
  return {
4591
4724
  skyTop: typeof customVisuals.skyTop === "string" ? customVisuals.skyTop : defaults.skyTop,
@@ -4642,6 +4775,11 @@ function buildClothSurface(model, state, meshDetail, visuals, clothFeatures) {
4642
4775
  representation: clothPresentation.representation,
4643
4776
  continuity: clothPresentation.continuity,
4644
4777
  color: visuals.flagColor,
4778
+ material: Object.freeze({
4779
+ weaveAlpha: clothPresentation.band === "near" ? 0.22 : 0.12,
4780
+ foldAlpha: clothPresentation.band === "near" ? 0.3 : 0.18,
4781
+ edgeHighlightAlpha: clothPresentation.band === "near" ? 0.42 : 0.28
4782
+ }),
4645
4783
  positions: clothState.positions.map((point) => vec3(point.x, point.y, point.z)),
4646
4784
  indices: clothState.indices,
4647
4785
  grid: { rows: clothState.rows, cols: clothState.cols }
@@ -4725,7 +4863,7 @@ function buildWaterMotionEffects(state) {
4725
4863
  impulse.z
4726
4864
  ),
4727
4865
  radius,
4728
- opacity: clamp(impulse.life * 0.28, 0.08, 0.3)
4866
+ opacity: clamp(impulse.life * 0.13, 0.035, 0.15)
4729
4867
  });
4730
4868
  });
4731
4869
  for (const ship of state.ships) {
@@ -4738,7 +4876,7 @@ function buildWaterMotionEffects(state) {
4738
4876
  const lateral = vec3(-direction.z, 0, direction.x);
4739
4877
  const points = [];
4740
4878
  for (let sampleIndex = 0; sampleIndex < 6; sampleIndex += 1) {
4741
- const along = 1 + sampleIndex * 1.45;
4879
+ const along = 1 + sampleIndex * 1.55;
4742
4880
  const lateralOffset = Math.sin(state.time * 1.2 + sampleIndex * 0.8 + readVisualNumber(ship.wanderPhase, 0)) * 0.12;
4743
4881
  const worldPoint = addVec3(
4744
4882
  ship.position,
@@ -4751,13 +4889,14 @@ function buildWaterMotionEffects(state) {
4751
4889
  sampleWave(state, worldPoint.x, worldPoint.z, state.time) * 0.24 + 0.04,
4752
4890
  worldPoint.z
4753
4891
  ),
4754
- width: 0.34 + sampleIndex * 0.13
4892
+ width: 0.3 + sampleIndex * 0.11,
4893
+ foam: clamp(0.28 - sampleIndex * 0.028 + speed * 0.025, 0.1, 0.34)
4755
4894
  })
4756
4895
  );
4757
4896
  }
4758
4897
  wakeTrails.push(
4759
4898
  Object.freeze({
4760
- opacity: clamp(0.18 + speed * 0.09, 0.22, 0.46),
4899
+ opacity: clamp(0.1 + speed * 0.048, 0.12, 0.24),
4761
4900
  points: Object.freeze(points)
4762
4901
  })
4763
4902
  );
@@ -4767,6 +4906,27 @@ function buildWaterMotionEffects(state) {
4767
4906
  rippleRings: Object.freeze(rippleRings)
4768
4907
  });
4769
4908
  }
4909
+ function buildShorelineFoamSegments(state) {
4910
+ return Object.freeze(
4911
+ SHORELINE_FOAM_ANCHORS.map((anchor, index) => {
4912
+ const pulse = 0.5 + Math.sin(state.time * 0.84 + index * 1.17) * 0.5;
4913
+ const drift = Math.sin(state.time * 0.38 + index * 0.61) * 0.1;
4914
+ const direction = normalizeVec3(vec3(Math.cos(anchor.angle), 0, Math.sin(anchor.angle)));
4915
+ const center = vec3(
4916
+ anchor.x + direction.x * drift,
4917
+ sampleWave(state, anchor.x, anchor.z, state.time) * 0.12 - 0.02,
4918
+ anchor.z + direction.z * drift
4919
+ );
4920
+ return Object.freeze({
4921
+ center,
4922
+ direction,
4923
+ length: anchor.length * (0.78 + pulse * 0.34),
4924
+ width: 0.16 + pulse * 0.12,
4925
+ opacity: 0.07 + pulse * 0.12
4926
+ });
4927
+ })
4928
+ );
4929
+ }
4770
4930
  function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
4771
4931
  const resolvedFluidFeatures = normalizeFluidFeatureAdapters(fluidFeatures);
4772
4932
  const fluidPlan = resolvedFluidFeatures.createPlan({
@@ -4789,9 +4949,9 @@ function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
4789
4949
  const representation = fluidPlan.representations.find((entry) => entry.band === bandSpec.band) ?? fluidPlan.representations[0];
4790
4950
  const continuity = resolvedFluidFeatures.createContinuityEnvelope({ fluidBodyId: "harbor" });
4791
4951
  const bandContinuity = resolveFluidBandContinuity(continuity, bandSpec.band);
4792
- const bandResolution = bandSpec.band === "near" ? fluidDetail.nearResolution : bandSpec.band === "mid" ? fluidDetail.midResolution : bandSpec.band === "far" ? 5 : 3;
4793
- const cols = Math.max(4, bandResolution * 2);
4794
- const rows = Math.max(4, bandResolution + 2);
4952
+ 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;
4953
+ const cols = Math.max(4, bandResolution * (bandSpec.band === "near" ? 3 : 2));
4954
+ const rows = Math.max(4, bandResolution + (bandSpec.band === "near" ? 5 : 2));
4795
4955
  const positions = [];
4796
4956
  const indices = [];
4797
4957
  const originX = -bandSpec.width * 0.5;
@@ -4802,7 +4962,9 @@ function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
4802
4962
  const v = row / (rows - 1);
4803
4963
  const x = originX + bandSpec.width * u;
4804
4964
  const z = originZ + bandSpec.depth * v;
4805
- const y = bandSpec.y + sampleWave(state, x, z, state.time) * bandContinuity.amplitudeFloor * (bandSpec.band === "near" ? 0.9 : bandSpec.band === "mid" ? 0.55 : 0.3);
4965
+ const baseHeight = bandSpec.y + sampleWave(state, x, z, state.time) * bandContinuity.amplitudeFloor * (bandSpec.band === "near" ? 0.9 : bandSpec.band === "mid" ? 0.55 : 0.3);
4966
+ const detailHeight = bandSpec.band === "near" ? Math.sin(x * 1.25 + z * 0.42 - state.time * 2.4) * 0.035 : 0;
4967
+ const y = baseHeight + detailHeight;
4806
4968
  positions.push(vec3(x, y, z));
4807
4969
  }
4808
4970
  }
@@ -4831,7 +4993,12 @@ function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
4831
4993
  r: mix(visuals.waterFar.r, 0.76, 0.2),
4832
4994
  g: mix(visuals.waterFar.g, 0.78, 0.2),
4833
4995
  b: mix(visuals.waterFar.b, 0.82, 0.2)
4834
- }
4996
+ },
4997
+ material: Object.freeze({
4998
+ highlightAlpha: bandSpec.band === "near" ? 0.2 : bandSpec.band === "mid" ? 0.13 : 0.07,
4999
+ foamAlpha: bandSpec.band === "near" ? 0.28 : bandSpec.band === "mid" ? 0.14 : 0.05,
5000
+ microRippleScale: bandSpec.band === "near" ? 1 : bandSpec.band === "mid" ? 0.58 : 0.28
5001
+ })
4835
5002
  });
4836
5003
  }
4837
5004
  return { fluidPlan, bandMeshes };
@@ -4907,15 +5074,15 @@ function createSceneState(options, featureAdapters) {
4907
5074
  {
4908
5075
  id: "northwind",
4909
5076
  modelKey: "brigantine",
4910
- position: vec3(-5.2, 0, 7.2),
4911
- velocity: vec3(2.35, 0, -1.08),
4912
- rotationY: 0.58,
4913
- angularVelocity: 0.09,
5077
+ position: vec3(-7.8, 0, 11.2),
5078
+ velocity: vec3(1.08, 0, -0.18),
5079
+ rotationY: 1.38,
5080
+ angularVelocity: 0.025,
4914
5081
  tint: { r: 0.62, g: 0.39, b: 0.23 },
4915
5082
  massScale: 1.42,
4916
- cruiseSpeed: 2.25,
4917
- throttleResponse: 0.46,
4918
- rudderResponse: 0.54,
5083
+ cruiseSpeed: 1.22,
5084
+ throttleResponse: 0.36,
5085
+ rudderResponse: 0.4,
4919
5086
  wanderPhase: 0.35,
4920
5087
  lanterns: CUTTER_LANTERNS,
4921
5088
  lanternStrength: 1.06,
@@ -4924,15 +5091,15 @@ function createSceneState(options, featureAdapters) {
4924
5091
  {
4925
5092
  id: "tidecaller",
4926
5093
  modelKey: "cutter",
4927
- position: vec3(4.8, 0, 4.4),
4928
- velocity: vec3(-2.15, 0, 1.74),
4929
- rotationY: -2.48,
4930
- angularVelocity: -0.2,
5094
+ position: vec3(6.8, 0, 5.4),
5095
+ velocity: vec3(-0.82, 0, 0.14),
5096
+ rotationY: -1.34,
5097
+ angularVelocity: -0.035,
4931
5098
  tint: { r: 0.58, g: 0.24, b: 0.16 },
4932
5099
  massScale: 0.84,
4933
- cruiseSpeed: 2.68,
4934
- throttleResponse: 0.7,
4935
- rudderResponse: 0.78,
5100
+ cruiseSpeed: 1.36,
5101
+ throttleResponse: 0.52,
5102
+ rudderResponse: 0.58,
4936
5103
  wanderPhase: 1.6,
4937
5104
  lanterns: SHIP_LANTERNS,
4938
5105
  lanternStrength: 1.18,
@@ -5133,7 +5300,7 @@ function renderProjectedShadow(ctx, worldPoints, camera, viewport, lightDir, opt
5133
5300
  ctx.restore();
5134
5301
  }
5135
5302
  function pushHarborGeometry(camera, viewport, triangles, state) {
5136
- if (!state.showcaseRealisticModelsEnabled) {
5303
+ if (!hasModeledHarborEnvironment(state)) {
5137
5304
  for (const object of LEGACY_HARBOR_LAYOUT) {
5138
5305
  buildTrianglesFromMesh(
5139
5306
  { positions: [object], indices: [0], normals: null, colors: null, material: createLegacyMeshPrimitive({})?.material, bounds: null, name: "legacy-structure" },
@@ -5231,9 +5398,10 @@ function renderShipRigging(ctx, ship, camera, viewport) {
5231
5398
  }
5232
5399
  function renderClothAccent(ctx, cloth, camera, viewport) {
5233
5400
  const projected = cloth.positions.map((point) => projectPoint(point, camera, viewport));
5234
- ctx.strokeStyle = "rgba(255, 241, 226, 0.92)";
5235
- ctx.lineWidth = 1.7;
5236
- for (let row = 0; row < cloth.grid.rows; row += Math.max(1, Math.floor(cloth.grid.rows / 5))) {
5401
+ const material = cloth.material ?? {};
5402
+ ctx.strokeStyle = `rgba(255, 241, 226, ${material.foldAlpha ?? 0.32})`;
5403
+ ctx.lineWidth = 1.8;
5404
+ for (let row = 0; row < cloth.grid.rows; row += Math.max(1, Math.floor(cloth.grid.rows / 6))) {
5237
5405
  ctx.beginPath();
5238
5406
  let started = false;
5239
5407
  for (let column = 0; column < cloth.grid.cols; column += 1) {
@@ -5252,6 +5420,27 @@ function renderClothAccent(ctx, cloth, camera, viewport) {
5252
5420
  ctx.stroke();
5253
5421
  }
5254
5422
  }
5423
+ ctx.strokeStyle = `rgba(255, 228, 204, ${material.weaveAlpha ?? 0.22})`;
5424
+ ctx.lineWidth = 0.85;
5425
+ for (let column = 1; column < cloth.grid.cols - 1; column += Math.max(1, Math.floor(cloth.grid.cols / 8))) {
5426
+ ctx.beginPath();
5427
+ let started = false;
5428
+ for (let row = 0; row < cloth.grid.rows; row += 1) {
5429
+ const point = projected[row * cloth.grid.cols + column];
5430
+ if (!point) {
5431
+ continue;
5432
+ }
5433
+ if (!started) {
5434
+ ctx.moveTo(point.x, point.y);
5435
+ started = true;
5436
+ } else {
5437
+ ctx.lineTo(point.x, point.y);
5438
+ }
5439
+ }
5440
+ if (started) {
5441
+ ctx.stroke();
5442
+ }
5443
+ }
5255
5444
  const borderIndices = [
5256
5445
  0,
5257
5446
  cloth.grid.cols - 1,
@@ -5259,6 +5448,25 @@ function renderClothAccent(ctx, cloth, camera, viewport) {
5259
5448
  (cloth.grid.rows - 1) * cloth.grid.cols
5260
5449
  ];
5261
5450
  ctx.fillStyle = colorToRgba(cloth.color, 0.95);
5451
+ ctx.strokeStyle = `rgba(255, 246, 236, ${material.edgeHighlightAlpha ?? 0.5})`;
5452
+ ctx.lineWidth = 1.4;
5453
+ ctx.beginPath();
5454
+ let borderStarted = false;
5455
+ for (let column = 0; column < cloth.grid.cols; column += 1) {
5456
+ const point = projected[column];
5457
+ if (!point) {
5458
+ continue;
5459
+ }
5460
+ if (!borderStarted) {
5461
+ ctx.moveTo(point.x, point.y);
5462
+ borderStarted = true;
5463
+ } else {
5464
+ ctx.lineTo(point.x, point.y);
5465
+ }
5466
+ }
5467
+ if (borderStarted) {
5468
+ ctx.stroke();
5469
+ }
5262
5470
  for (const index of borderIndices) {
5263
5471
  const point = projected[index];
5264
5472
  if (!point) {
@@ -5274,14 +5482,22 @@ function renderWaterHighlights(ctx, waterBands, camera, viewport) {
5274
5482
  if (band.band === "horizon") {
5275
5483
  continue;
5276
5484
  }
5277
- const interval = band.band === "near" ? 2 : 3;
5278
- const alpha = band.band === "near" ? 0.22 : 0.14;
5485
+ const interval = band.band === "near" ? 4 : 5;
5486
+ const alpha = band.material?.highlightAlpha ?? (band.band === "near" ? 0.22 : 0.14);
5279
5487
  ctx.strokeStyle = `rgba(232, 247, 255, ${alpha})`;
5280
- ctx.lineWidth = band.band === "near" ? 1.3 : 0.9;
5488
+ ctx.lineWidth = band.band === "near" ? 0.9 : 0.65;
5281
5489
  for (let row = interval; row < band.rows - 1; row += interval) {
5282
- ctx.beginPath();
5283
5490
  let started = false;
5284
- for (let column = 0; column < band.cols; column += 1) {
5491
+ ctx.beginPath();
5492
+ for (let column = 0; column < band.cols; column += band.band === "near" ? 2 : 3) {
5493
+ if (pseudoRandom(row * 47 + column * 13) < 0.18) {
5494
+ if (started) {
5495
+ ctx.stroke();
5496
+ ctx.beginPath();
5497
+ started = false;
5498
+ }
5499
+ continue;
5500
+ }
5285
5501
  const point = projectPoint(
5286
5502
  band.positions[row * band.cols + column],
5287
5503
  camera,
@@ -5301,8 +5517,56 @@ function renderWaterHighlights(ctx, waterBands, camera, viewport) {
5301
5517
  ctx.stroke();
5302
5518
  }
5303
5519
  }
5520
+ if (band.band === "near") {
5521
+ ctx.fillStyle = `rgba(236, 249, 255, ${(band.material?.foamAlpha ?? 0.28) * 0.72})`;
5522
+ for (let column = 3; column < band.cols - 3; column += 10) {
5523
+ const point = projectPoint(
5524
+ band.positions[Math.floor(band.rows * 0.42) * band.cols + column],
5525
+ camera,
5526
+ viewport
5527
+ );
5528
+ if (!point) {
5529
+ continue;
5530
+ }
5531
+ ctx.beginPath();
5532
+ ctx.ellipse(point.x, point.y, 1.8, 0.75, -0.2, 0, Math.PI * 2);
5533
+ ctx.fill();
5534
+ }
5535
+ }
5304
5536
  }
5305
5537
  }
5538
+ function renderShorelineFoamSegments(ctx, segments, camera, viewport) {
5539
+ ctx.save();
5540
+ ctx.globalCompositeOperation = "screen";
5541
+ ctx.lineCap = "round";
5542
+ ctx.lineJoin = "round";
5543
+ for (const segment of segments) {
5544
+ const half = scaleVec3(segment.direction, segment.length * 0.5);
5545
+ const start = projectPoint(subVec3(segment.center, half), camera, viewport);
5546
+ const end = projectPoint(addVec3(segment.center, half), camera, viewport);
5547
+ const center = projectPoint(segment.center, camera, viewport);
5548
+ if (!start || !end || !center) {
5549
+ continue;
5550
+ }
5551
+ const depthScale = clamp(140 / Math.max(12, center.depth), 3, 10);
5552
+ ctx.strokeStyle = `rgba(232, 242, 238, ${segment.opacity})`;
5553
+ ctx.lineWidth = clamp(segment.width * depthScale, 0.8, 2.8);
5554
+ ctx.beginPath();
5555
+ ctx.moveTo(start.x, start.y);
5556
+ ctx.quadraticCurveTo(
5557
+ center.x,
5558
+ center.y + Math.sin(segment.center.x * 1.7) * 2.4,
5559
+ end.x,
5560
+ end.y
5561
+ );
5562
+ ctx.stroke();
5563
+ ctx.fillStyle = `rgba(248, 251, 246, ${segment.opacity * 0.68})`;
5564
+ ctx.beginPath();
5565
+ ctx.ellipse(center.x, center.y, depthScale * 0.18, depthScale * 0.08, -0.2, 0, Math.PI * 2);
5566
+ ctx.fill();
5567
+ }
5568
+ ctx.restore();
5569
+ }
5306
5570
  function readPhysicsNumber(physics, key, fallback) {
5307
5571
  const value = physics?.[key];
5308
5572
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
@@ -5333,14 +5597,17 @@ function getShipInverseInertia(ship, shipModel) {
5333
5597
  return 1 / Math.max(1, inertia);
5334
5598
  }
5335
5599
  function spawnSpray(state, point, intensity) {
5336
- const count = state.fluidDetail.getSnapshot().currentLevel.config.splashCount;
5600
+ const count = Math.max(
5601
+ 3,
5602
+ Math.ceil(state.fluidDetail.getSnapshot().currentLevel.config.splashCount * 0.32)
5603
+ );
5337
5604
  for (let index = 0; index < count; index += 1) {
5338
5605
  const angle = index / count * Math.PI * 2;
5339
- const speed = 0.9 + Math.random() * intensity * 0.45;
5606
+ const speed = 0.46 + Math.random() * intensity * 0.24;
5340
5607
  state.sprays.push({
5341
5608
  position: vec3(point.x, point.y, point.z),
5342
- velocity: vec3(Math.cos(angle) * speed * 0.35, 1.1 + Math.random() * 0.8, Math.sin(angle) * speed * 0.25),
5343
- life: 1.2 + Math.random() * 0.4
5609
+ velocity: vec3(Math.cos(angle) * speed * 0.24, 0.46 + Math.random() * 0.34, Math.sin(angle) * speed * 0.18),
5610
+ life: 0.72 + Math.random() * 0.22
5344
5611
  });
5345
5612
  }
5346
5613
  }
@@ -5355,7 +5622,7 @@ function resolveShipRoute(ship, state, radius) {
5355
5622
  }
5356
5623
  const wander = Math.sin(state.time * 0.22 + readVisualNumber(ship.wanderPhase, 0));
5357
5624
  const crossCurrent = Math.cos(state.time * 0.31 + readVisualNumber(ship.wanderPhase, 0));
5358
- const laneCenter = ship.id === "northwind" ? 10.2 + wander * 2.1 + crossCurrent * 0.6 : 7 + wander * 3.3 - crossCurrent * 1.1;
5625
+ const laneCenter = ship.id === "northwind" ? 11.6 + wander * 0.82 + crossCurrent * 0.24 : 5.4 + wander * 0.94 - crossCurrent * 0.32;
5359
5626
  const targetX = ship.routeDirection > 0 ? HARBOR_BOUNDS.maxX - radius * 1.7 : HARBOR_BOUNDS.minX + radius * 1.7;
5360
5627
  return vec3(targetX, 0, clamp(laneCenter, HARBOR_BOUNDS.minZ + 1.8, HARBOR_BOUNDS.maxZ - 1.8));
5361
5628
  }
@@ -5368,7 +5635,7 @@ function updateShipMotion(state, ship, dt, shipModel) {
5368
5635
  const angularDamping = readPhysicsNumber(physics, "angularDamping", 0.08);
5369
5636
  const throttleResponse = readVisualNumber(ship.throttleResponse, 0.58);
5370
5637
  const rudderResponse = readVisualNumber(ship.rudderResponse, 0.62);
5371
- const cruiseSpeed = readVisualNumber(ship.cruiseSpeed, 2.4);
5638
+ const cruiseSpeed = readVisualNumber(ship.cruiseSpeed, 1.25);
5372
5639
  ship.collisionCooldown = Math.max(0, readVisualNumber(ship.collisionCooldown, 0) - dt);
5373
5640
  const forward = directionFromYaw(ship.rotationY);
5374
5641
  const lateral = perpendicularOnWater(forward);
@@ -5463,7 +5730,7 @@ function resolveShipCollision(state, a, b, shipModelA, shipModelB) {
5463
5730
  b.position = addVec3(b.position, scaleVec3(correction, invMassB));
5464
5731
  const relativeVelocity = subVec3(b.velocity, a.velocity);
5465
5732
  const velocityAlongNormal = dotVec3(relativeVelocity, normal);
5466
- const restitution = (readPhysicsNumber(shipModelA.physics, "restitution", 0.22) + readPhysicsNumber(shipModelB.physics, "restitution", 0.22)) / 2 * 0.88;
5733
+ const restitution = (readPhysicsNumber(shipModelA.physics, "restitution", 0.22) + readPhysicsNumber(shipModelB.physics, "restitution", 0.22)) / 2 * 0.42;
5467
5734
  if (velocityAlongNormal < 0) {
5468
5735
  const impulseMagnitude = -(1 + restitution) * velocityAlongNormal / Math.max(1e-4, invMassSum);
5469
5736
  const impulse = scaleVec3(normal, impulseMagnitude);
@@ -5481,27 +5748,27 @@ function resolveShipCollision(state, a, b, shipModelA, shipModelB) {
5481
5748
  a.angularVelocity -= tangentSpeed * radiusA * getShipInverseInertia(a, shipModelA) * 0.2 + impulseMagnitude * 24e-5;
5482
5749
  b.angularVelocity += tangentSpeed * radiusB * getShipInverseInertia(b, shipModelB) * 0.2 + impulseMagnitude * 24e-5;
5483
5750
  const impactSpeed = Math.abs(velocityAlongNormal);
5484
- if (impactSpeed > 0.18 && Math.max(readVisualNumber(a.collisionCooldown, 0), readVisualNumber(b.collisionCooldown, 0)) <= 0) {
5751
+ if (impactSpeed > 0.36 && Math.max(readVisualNumber(a.collisionCooldown, 0), readVisualNumber(b.collisionCooldown, 0)) <= 0) {
5485
5752
  const contactPoint = vec3(
5486
5753
  (a.position.x + b.position.x) * 0.5,
5487
5754
  (a.position.y + b.position.y) * 0.5 + 0.14,
5488
5755
  (a.position.z + b.position.z) * 0.5
5489
5756
  );
5490
- spawnSpray(state, contactPoint, impactSpeed * 2.4 + penetration * 8);
5757
+ spawnSpray(state, contactPoint, impactSpeed * 0.9 + penetration * 2.4);
5491
5758
  state.waveImpulses.push({
5492
5759
  x: contactPoint.x,
5493
5760
  z: contactPoint.z,
5494
- strength: clamp(0.24 + impactSpeed * 0.46 + penetration * 0.9, 0.2, 1.7),
5495
- radius: 0.9 + penetration * 1.4,
5761
+ strength: clamp(0.1 + impactSpeed * 0.18 + penetration * 0.28, 0.08, 0.52),
5762
+ radius: 0.72 + penetration * 0.72,
5496
5763
  life: 1
5497
5764
  });
5498
5765
  state.collisionCount += 1;
5499
5766
  state.collisionFlash = Math.max(
5500
5767
  state.collisionFlash,
5501
- clamp(impactSpeed * 0.55 + penetration * 1.8, 0.16, 1)
5768
+ clamp(impactSpeed * 0.14 + penetration * 0.32, 0.04, 0.24)
5502
5769
  );
5503
- a.collisionCooldown = 0.2;
5504
- b.collisionCooldown = 0.2;
5770
+ a.collisionCooldown = 0.72;
5771
+ b.collisionCooldown = 0.72;
5505
5772
  }
5506
5773
  }
5507
5774
  state.contactCount += 1;
@@ -5524,7 +5791,7 @@ function updateShips(state, dt, shipModel) {
5524
5791
  collided = resolveShipCollision(state, shipA, shipB, shipModelA, shipModelB) || collided;
5525
5792
  }
5526
5793
  }
5527
- state.collisionFlash = collided ? Math.max(0.12, state.collisionFlash) : Math.max(0, state.collisionFlash - dt * 1.3);
5794
+ state.collisionFlash = collided ? Math.max(0.04, state.collisionFlash) : Math.max(0, state.collisionFlash - dt * 1.7);
5528
5795
  }
5529
5796
  function updateWaveImpulses(state, dt) {
5530
5797
  state.waveImpulses = state.waveImpulses.map((impulse) => ({
@@ -5789,7 +6056,7 @@ function renderLighthouseBeam(ctx, state, camera, viewport, visuals) {
5789
6056
  const lighthousePlacement = SHOWCASE_ENVIRONMENT_LAYOUT.find(
5790
6057
  (placement) => placement.assetKey === "lighthouse"
5791
6058
  );
5792
- if (!lighthousePlacement || !state.showcaseRealisticModelsEnabled) {
6059
+ if (!lighthousePlacement || !state.showcaseRealisticModelsEnabled || !hasModeledHarborEnvironment(state)) {
5793
6060
  return;
5794
6061
  }
5795
6062
  const source = transformPoint2(
@@ -5886,7 +6153,8 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
5886
6153
  for (const wake of effects.wakeTrails) {
5887
6154
  const projected = wake.points.map((point) => ({
5888
6155
  projected: projectPoint(point.center, camera, viewport),
5889
- width: point.width
6156
+ width: point.width,
6157
+ foam: point.foam
5890
6158
  })).filter((entry) => entry.projected);
5891
6159
  if (projected.length < 2) {
5892
6160
  continue;
@@ -5894,8 +6162,8 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
5894
6162
  const averageDepth = projected.reduce((total, entry) => total + entry.projected.depth, 0) / projected.length;
5895
6163
  const averageWidth = projected.reduce((total, entry) => total + entry.width, 0) / projected.length;
5896
6164
  const baseWidth = clamp(averageWidth / Math.max(0.25, averageDepth) * 180, 1.6, 5.4);
5897
- ctx.strokeStyle = `rgba(146, 194, 236, ${wake.opacity * 0.52})`;
5898
- ctx.lineWidth = baseWidth * 1.9;
6165
+ ctx.strokeStyle = `rgba(146, 194, 236, ${wake.opacity * 0.34})`;
6166
+ ctx.lineWidth = baseWidth * 1.45;
5899
6167
  ctx.lineCap = "round";
5900
6168
  ctx.lineJoin = "round";
5901
6169
  ctx.beginPath();
@@ -5904,8 +6172,8 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
5904
6172
  ctx.lineTo(projected[index].projected.x, projected[index].projected.y);
5905
6173
  }
5906
6174
  ctx.stroke();
5907
- ctx.strokeStyle = `rgba(234, 247, 255, ${wake.opacity})`;
5908
- ctx.lineWidth = baseWidth;
6175
+ ctx.strokeStyle = `rgba(234, 247, 255, ${wake.opacity * 0.72})`;
6176
+ ctx.lineWidth = baseWidth * 0.72;
5909
6177
  ctx.lineCap = "round";
5910
6178
  ctx.lineJoin = "round";
5911
6179
  ctx.beginPath();
@@ -5915,13 +6183,14 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
5915
6183
  }
5916
6184
  ctx.stroke();
5917
6185
  for (const entry of projected.slice(1, 5)) {
5918
- ctx.fillStyle = `rgba(239, 248, 255, ${wake.opacity * 0.76})`;
6186
+ const foam = entry.foam ?? 0.3;
6187
+ ctx.fillStyle = `rgba(239, 248, 255, ${wake.opacity * foam * 0.92})`;
5919
6188
  ctx.beginPath();
5920
6189
  ctx.ellipse(
5921
6190
  entry.projected.x,
5922
6191
  entry.projected.y,
5923
- baseWidth * 0.72,
5924
- baseWidth * 0.44,
6192
+ baseWidth * 0.54,
6193
+ baseWidth * 0.28,
5925
6194
  0,
5926
6195
  0,
5927
6196
  Math.PI * 2
@@ -5939,13 +6208,22 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
5939
6208
  const radiusX = Math.hypot(xAxis.x - center.x, xAxis.y - center.y);
5940
6209
  const radiusY = Math.hypot(zAxis.x - center.x, zAxis.y - center.y);
5941
6210
  ctx.strokeStyle = `rgba(216, 235, 255, ${ring.opacity})`;
5942
- ctx.lineWidth = clamp((radiusX + radiusY) * 0.02, 1, 3.1);
5943
- ctx.beginPath();
5944
- ctx.ellipse(center.x, center.y, radiusX, radiusY, 0, 0, Math.PI * 2);
5945
- ctx.stroke();
6211
+ ctx.lineWidth = clamp((radiusX + radiusY) * 0.014, 0.65, 1.8);
6212
+ for (let segment = 0; segment < 5; segment += 1) {
6213
+ if (pseudoRandom(segment * 31 + radiusX * 0.7 + radiusY * 0.3) < 0.32) {
6214
+ continue;
6215
+ }
6216
+ const startAngle = segment * 1.22 + stateTimePhase(center.x, center.y) * 0.04;
6217
+ ctx.beginPath();
6218
+ ctx.ellipse(center.x, center.y, radiusX, radiusY, 0, startAngle, startAngle + 0.48);
6219
+ ctx.stroke();
6220
+ }
5946
6221
  }
5947
6222
  ctx.restore();
5948
6223
  }
6224
+ function stateTimePhase(x, y) {
6225
+ return Math.sin(x * 0.013 + y * 0.017);
6226
+ }
5949
6227
  function renderScene(ctx, canvas, state, shipModel, dom, lightingFeatures, fluidFeatures, clothFeatures) {
5950
6228
  const viewport = { width: canvas.width, height: canvas.height };
5951
6229
  const camera = buildCamera(state, canvas);
@@ -6013,6 +6291,7 @@ function renderScene(ctx, canvas, state, shipModel, dom, lightingFeatures, fluid
6013
6291
  }
6014
6292
  }
6015
6293
  const waterMotionEffects = buildWaterMotionEffects(state);
6294
+ const shorelineFoamSegments = buildShorelineFoamSegments(state);
6016
6295
  const lightSources = collectSceneLightSources(state, visuals);
6017
6296
  pushHarborGeometry(camera, viewport, sceneTriangles, state);
6018
6297
  const cloth = buildClothSurface(
@@ -6084,6 +6363,7 @@ function renderScene(ctx, canvas, state, shipModel, dom, lightingFeatures, fluid
6084
6363
  }
6085
6364
  renderWaterMotionEffects(ctx, waterMotionEffects, camera, viewport);
6086
6365
  renderWaterHighlights(ctx, water.bandMeshes, camera, viewport);
6366
+ renderShorelineFoamSegments(ctx, shorelineFoamSegments, camera, viewport);
6087
6367
  drawTriangles(
6088
6368
  ctx,
6089
6369
  sceneTriangles,
@@ -6112,7 +6392,7 @@ function renderScene(ctx, canvas, state, shipModel, dom, lightingFeatures, fluid
6112
6392
  };
6113
6393
  const sceneMetrics = [
6114
6394
  `focus: ${state.focus}`,
6115
- `ships: ${state.ships.length} active GLTF hulls across ${new Set(state.ships.map((ship) => ship.modelKey)).size} model families`,
6395
+ `ships: ${state.ships.length} active GLTF hulls across ${new Set(state.ships.map((ship) => resolveShipModel(state, ship, shipModel)?.name ?? ship.modelKey)).size} model families`,
6116
6396
  `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`,
6117
6397
  `physics snapshot: ${state.physics.snapshot.stage} (${state.physics.snapshot.stability})`,
6118
6398
  `physics contacts: ${state.contactCount}`,
@@ -6180,7 +6460,7 @@ function updateSceneState(state, dt, shipModel, featureAdapters) {
6180
6460
  advanceShowcaseClothSimulationState(clothState, {
6181
6461
  dt,
6182
6462
  time: state.time,
6183
- flagMotion: readVisualNumber(state.demoVisuals?.flagMotion, 0.92),
6463
+ flagMotion: readVisualNumber(state.demoVisuals?.flagMotion, 0.58),
6184
6464
  waveInfluence: sampleWave(state, FLAG_LAYOUT.origin.x + FLAG_LAYOUT.width * 0.55, FLAG_LAYOUT.origin.z + FLAG_LAYOUT.width * 0.48, state.time)
6185
6465
  });
6186
6466
  updatePhysicsSnapshot(state, shipModel, featureAdapters.physics);
@@ -6190,17 +6470,28 @@ function syncTextState(state, shipModel, featureAdapters) {
6190
6470
  coordinateSystem: "right-handed world; +x right, +y up, +z forward from the shore",
6191
6471
  focus: state.focus,
6192
6472
  stress: state.stress,
6193
- ships: state.ships.map((ship) => ({
6194
- id: ship.id,
6195
- modelKey: ship.modelKey ?? "brigantine",
6196
- x: Number(ship.position.x.toFixed(2)),
6197
- y: Number(ship.position.y.toFixed(2)),
6198
- z: Number(ship.position.z.toFixed(2)),
6199
- vx: Number(ship.velocity.x.toFixed(2)),
6200
- vz: Number(ship.velocity.z.toFixed(2)),
6201
- massKg: Math.round(getShipMass(ship, resolveShipModel(state, ship, shipModel))),
6202
- lanterns: Array.isArray(ship.lanterns) ? ship.lanterns.length : 0
6203
- })),
6473
+ ships: state.ships.map((ship) => {
6474
+ const resolvedShipModel = resolveShipModel(state, ship, shipModel);
6475
+ return {
6476
+ id: ship.id,
6477
+ modelKey: ship.modelKey ?? "brigantine",
6478
+ resolvedModelKey: resolvedShipModel?.name ?? ship.modelKey ?? "brigantine",
6479
+ x: Number(ship.position.x.toFixed(2)),
6480
+ y: Number(ship.position.y.toFixed(2)),
6481
+ z: Number(ship.position.z.toFixed(2)),
6482
+ vx: Number(ship.velocity.x.toFixed(2)),
6483
+ vz: Number(ship.velocity.z.toFixed(2)),
6484
+ massKg: Math.round(getShipMass(ship, resolvedShipModel)),
6485
+ lanterns: Array.isArray(ship.lanterns) ? ship.lanterns.length : 0
6486
+ };
6487
+ }),
6488
+ assetCatalog: {
6489
+ mode: state.assetCatalog?.mode ?? "unknown",
6490
+ shipKeys: Object.keys(state.assetCatalog?.ships ?? {}).sort(),
6491
+ environmentKeys: Object.keys(state.assetCatalog?.environment ?? {}).sort(),
6492
+ fallbackReason: state.assetCatalog?.fallbackReason ?? null,
6493
+ requestedRealisticModels: state.showcaseRealisticModelsEnabled
6494
+ },
6204
6495
  shipPhysics: Object.fromEntries(
6205
6496
  state.ships.map((ship) => [ship.id, resolveShipModel(state, ship, shipModel)?.physics ?? null])
6206
6497
  ),
@@ -6257,7 +6548,9 @@ async function mountGpuShowcase(options = {}, featureFlags = null) {
6257
6548
  },
6258
6549
  featureAdapters
6259
6550
  );
6260
- const assetCatalog = await (state.showcaseRealisticModelsEnabled ? loadShowcaseAssetCatalog() : createLegacyShowcaseAssetCatalog());
6551
+ const assetCatalog = await loadShowcaseAssetCatalogWithFallback({
6552
+ includeSecondaryShip: state.showcaseRealisticModelsEnabled
6553
+ });
6261
6554
  const shipModel = assetCatalog.ships[assetCatalog.primaryShipKey];
6262
6555
  state.assetCatalog = assetCatalog;
6263
6556
  state.shipModel = shipModel;
@@ -6395,7 +6688,7 @@ function updatePhysicsSnapshot(state, shipModel, physicsFeatures) {
6395
6688
  }
6396
6689
  });
6397
6690
  }
6398
- var STYLE_ID2, ROOT_CLASS, CAPTURE_CLASS, DEFAULT_CANVAS_WIDTH, DEFAULT_CANVAS_HEIGHT, CAPTURE_CANVAS_PIXEL_BUDGET, SHIP_SCALE, HARBOR_BOUNDS, CAMERA_PRESETS, FALLBACK_LIGHTING_DISTANCE_BANDS, FALLBACK_LIGHTING_PROFILE, FALLBACK_PHYSICS_PROFILE, FALLBACK_PERFORMANCE_LEVELS, SHOWCASE_FEATURE_LOADERS, DEFAULT_FLUID_BAND_THRESHOLDS, DEFAULT_CLOTH_BAND_THRESHOLDS, showcaseFocusModes, FOCUS_MODE_TRANSLATION_KEYS, SCENE_NOTE_KEYS, PHYSICS_SCENE_NOTE_KEYS, LEGACY_HARBOR_LAYOUT, SHOWCASE_ENVIRONMENT_LAYOUT, SHIP_LANTERNS, CUTTER_LANTERNS, HARBOR_TORCHES, FLAG_LAYOUT;
6691
+ var STYLE_ID2, ROOT_CLASS, CAPTURE_CLASS, DEFAULT_CANVAS_WIDTH, DEFAULT_CANVAS_HEIGHT, CAPTURE_CANVAS_PIXEL_BUDGET, SHIP_SCALE, HARBOR_BOUNDS, CAMERA_PRESETS, FALLBACK_LIGHTING_DISTANCE_BANDS, FALLBACK_LIGHTING_PROFILE, FALLBACK_PHYSICS_PROFILE, FALLBACK_PERFORMANCE_LEVELS, SHOWCASE_FEATURE_LOADERS, DEFAULT_FLUID_BAND_THRESHOLDS, DEFAULT_CLOTH_BAND_THRESHOLDS, showcaseFocusModes, FOCUS_MODE_TRANSLATION_KEYS, SCENE_NOTE_KEYS, PHYSICS_SCENE_NOTE_KEYS, LEGACY_HARBOR_LAYOUT, SHOWCASE_ENVIRONMENT_LAYOUT, SHIP_LANTERNS, CUTTER_LANTERNS, HARBOR_TORCHES, SHORELINE_FOAM_ANCHORS, FLAG_LAYOUT;
6399
6692
  var init_showcase_runtime = __esm({
6400
6693
  "src/showcase-runtime.js"() {
6401
6694
  init_asset_url();
@@ -6586,6 +6879,13 @@ var init_showcase_runtime = __esm({
6586
6879
  })
6587
6880
  ]);
6588
6881
  SHOWCASE_ENVIRONMENT_LAYOUT = Object.freeze([
6882
+ Object.freeze({
6883
+ assetKey: "shoreline",
6884
+ position: Object.freeze({ x: 1.8, y: -0.04, z: 0.48 }),
6885
+ rotationY: -0.03,
6886
+ scale: 1.02,
6887
+ accent: 0.03
6888
+ }),
6589
6889
  Object.freeze({
6590
6890
  assetKey: "harbor-dock",
6591
6891
  position: Object.freeze({ x: -4.6, y: 0.16, z: 0.7 }),
@@ -6616,6 +6916,18 @@ var init_showcase_runtime = __esm({
6616
6916
  Object.freeze({ x: -8.6, y: 2.48, z: -0.72, glow: 1 }),
6617
6917
  Object.freeze({ x: -10.4, y: 1.28, z: 0.82, glow: 0.92 })
6618
6918
  ]);
6919
+ SHORELINE_FOAM_ANCHORS = Object.freeze([
6920
+ Object.freeze({ x: -7.8, z: 3, length: 1.25, angle: -0.12 }),
6921
+ Object.freeze({ x: -6.3, z: 2.72, length: 0.92, angle: 0.08 }),
6922
+ Object.freeze({ x: -4.9, z: 3.16, length: 1.08, angle: -0.2 }),
6923
+ Object.freeze({ x: -3.2, z: 2.42, length: 0.76, angle: 0.16 }),
6924
+ Object.freeze({ x: -1.4, z: 2.82, length: 1.18, angle: -0.04 }),
6925
+ Object.freeze({ x: 0.4, z: 3.08, length: 0.88, angle: 0.14 }),
6926
+ Object.freeze({ x: 2.1, z: 2.56, length: 1.34, angle: -0.18 }),
6927
+ Object.freeze({ x: 3.8, z: 3, length: 0.94, angle: 0.1 }),
6928
+ Object.freeze({ x: 5.5, z: 2.72, length: 1.12, angle: -0.08 }),
6929
+ Object.freeze({ x: 7, z: 3.22, length: 0.72, angle: 0.18 })
6930
+ ]);
6619
6931
  FLAG_LAYOUT = Object.freeze({
6620
6932
  origin: Object.freeze({ x: -3.5, y: 5.9, z: 2.4 }),
6621
6933
  width: 4.8,
@@ -6630,6 +6942,7 @@ var index_exports = {};
6630
6942
  __export(index_exports, {
6631
6943
  GPU_SHOWCASE_PRODUCT_STUDIO_FEATURE: () => GPU_SHOWCASE_PRODUCT_STUDIO_FEATURE,
6632
6944
  GPU_SHOWCASE_REALISTIC_MODELS_FEATURE: () => GPU_SHOWCASE_REALISTIC_MODELS_FEATURE,
6945
+ buildProductStudioSceneObjects: () => buildProductStudioSceneObjects,
6633
6946
  createGpuSharedTranslator: () => createGpuSharedTranslator,
6634
6947
  createProductStudioMeshes: () => createProductStudioMeshes,
6635
6948
  gpuSharedEnGbTranslations: () => gpuSharedEnGbTranslations,
@@ -6715,6 +7028,7 @@ async function mountGpuShowcase2(options = {}) {
6715
7028
  0 && (module.exports = {
6716
7029
  GPU_SHOWCASE_PRODUCT_STUDIO_FEATURE,
6717
7030
  GPU_SHOWCASE_REALISTIC_MODELS_FEATURE,
7031
+ buildProductStudioSceneObjects,
6718
7032
  createGpuSharedTranslator,
6719
7033
  createProductStudioMeshes,
6720
7034
  gpuSharedEnGbTranslations,