@plasius/gpu-shared 0.1.20 → 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.
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
  });
@@ -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
  `;
@@ -3985,11 +4081,12 @@ function buildTrianglesFromMesh(mesh, transform, colorOverride, camera, viewport
3985
4081
  }
3986
4082
  }
3987
4083
  async function loadShowcaseAssetCatalog() {
3988
- const [brigantine, cutter, lighthouse, harborDock] = await Promise.all([
4084
+ const [brigantine, cutter, lighthouse, harborDock, shoreline] = await Promise.all([
3989
4085
  loadGltfModel(resolveShowcaseAssetUrl("brigantine")),
3990
4086
  loadGltfModel(resolveShowcaseAssetUrl("cutter")),
3991
4087
  loadGltfModel(resolveShowcaseAssetUrl("lighthouse")),
3992
- loadGltfModel(resolveShowcaseAssetUrl("harbor-dock"))
4088
+ loadGltfModel(resolveShowcaseAssetUrl("harbor-dock")),
4089
+ loadGltfModel(resolveShowcaseAssetUrl("shoreline"))
3993
4090
  ]);
3994
4091
  return Object.freeze({
3995
4092
  primaryShipKey: "brigantine",
@@ -3999,7 +4096,8 @@ async function loadShowcaseAssetCatalog() {
3999
4096
  }),
4000
4097
  environment: Object.freeze({
4001
4098
  lighthouse,
4002
- "harbor-dock": harborDock
4099
+ "harbor-dock": harborDock,
4100
+ shoreline
4003
4101
  })
4004
4102
  });
4005
4103
  }
@@ -4125,24 +4223,27 @@ function buildDemoDom(root, options) {
4125
4223
  ${t(gpuSharedTranslationKeys.legendCollisions)}
4126
4224
  </div>
4127
4225
  </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>
4226
+ <details class="plasius-demo__diagnostics">
4227
+ <summary>${t(gpuSharedTranslationKeys.debugTelemetry)}</summary>
4228
+ <aside class="plasius-demo__sidebar">
4229
+ <section class="plasius-panel plasius-demo__card">
4230
+ <h2>${t(gpuSharedTranslationKeys.sceneState)}</h2>
4231
+ <ul id="sceneMetrics" class="plasius-demo__metrics"></ul>
4232
+ </section>
4233
+ <section class="plasius-panel plasius-demo__card">
4234
+ <h2>${t(gpuSharedTranslationKeys.qualityBudgets)}</h2>
4235
+ <ul id="qualityMetrics" class="plasius-demo__metrics"></ul>
4236
+ </section>
4237
+ <section class="plasius-panel plasius-demo__card">
4238
+ <h2>${t(gpuSharedTranslationKeys.debugTelemetry)}</h2>
4239
+ <ul id="debugMetrics" class="plasius-demo__metrics"></ul>
4240
+ </section>
4241
+ <section class="plasius-panel plasius-demo__card">
4242
+ <h2>${t(gpuSharedTranslationKeys.notes)}</h2>
4243
+ <ul id="sceneNotes" class="plasius-demo__metrics"></ul>
4244
+ </section>
4245
+ </aside>
4246
+ </details>
4146
4247
  </section>
4147
4248
  <p class="plasius-demo__footer">
4148
4249
  This visual example is shared across the GPU packages to keep manual validation fast and consistent.
@@ -4506,11 +4607,11 @@ function advanceShowcaseClothSimulationState(clothState, options = {}) {
4506
4607
  1 + Math.sin(gustPhase * 0.74) * 0.18
4507
4608
  )
4508
4609
  );
4509
- const windStrength = (1.6 + broadMotion * 1.25 + wrinkleLayers * 0.12) * flagMotion * (0.44 + u * 1.14);
4610
+ const windStrength = (0.94 + broadMotion * 0.82 + wrinkleLayers * 0.08) * flagMotion * (0.36 + u * 0.92);
4510
4611
  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
4612
+ Math.sin(wrinklePhase) * 0.12 * wrinkleMotion * flagMotion,
4613
+ Math.cos(wrinklePhase * 0.7) * 0.045 * wrinkleMotion,
4614
+ Math.cos(wrinklePhase) * 0.08 * broadMotion * flagMotion
4514
4615
  );
4515
4616
  const acceleration = addVec3(
4516
4617
  vec3(0, -0.48 - u * 0.08, 0),
@@ -4567,25 +4668,25 @@ function resolveVisualConfig(nearLighting, lightingSnapshot, customVisuals = {})
4567
4668
  ambientMist: "rgba(41, 63, 97, 0.16)",
4568
4669
  reflectionStrength: lightingSnapshot.currentLevel.config.reflectionStrength,
4569
4670
  shadowAccent: lightingSnapshot.currentLevel.config.shadowStrength,
4570
- waveAmplitude: 0.94,
4671
+ waveAmplitude: 0.82,
4571
4672
  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 },
4673
+ wavePhaseSpeed: 0.74,
4674
+ wakeStrength: 0.24,
4675
+ wakeLength: 17,
4676
+ collisionRippleStrength: 0.22,
4677
+ waterNear: { r: 0.05, g: 0.2, b: 0.3 },
4678
+ waterFar: { r: 0.13, g: 0.31, b: 0.45 },
4578
4679
  harborWall: { r: 0.26, g: 0.24, b: 0.28 },
4579
4680
  harborDeck: { r: 0.33, g: 0.22, b: 0.16 },
4580
4681
  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,
4682
+ flagColor: { r: 0.54, g: 0.13, b: 0.11 },
4683
+ flagMotion: 0.58,
4583
4684
  lanternCore: { r: 0.98, g: 0.8, b: 0.48 },
4584
4685
  lanternGlow: { r: 1, g: 0.56, b: 0.2 },
4585
4686
  lanternReflectionStrength: 0.42,
4586
4687
  torchCore: { r: 0.99, g: 0.72, b: 0.36 },
4587
4688
  torchGlow: { r: 0.98, g: 0.38, b: 0.15 },
4588
- collisionFlash: "rgba(255, 212, 168, 0.16)"
4689
+ collisionFlash: "rgba(255, 212, 168, 0.08)"
4589
4690
  };
4590
4691
  return {
4591
4692
  skyTop: typeof customVisuals.skyTop === "string" ? customVisuals.skyTop : defaults.skyTop,
@@ -4642,6 +4743,11 @@ function buildClothSurface(model, state, meshDetail, visuals, clothFeatures) {
4642
4743
  representation: clothPresentation.representation,
4643
4744
  continuity: clothPresentation.continuity,
4644
4745
  color: visuals.flagColor,
4746
+ material: Object.freeze({
4747
+ weaveAlpha: clothPresentation.band === "near" ? 0.22 : 0.12,
4748
+ foldAlpha: clothPresentation.band === "near" ? 0.3 : 0.18,
4749
+ edgeHighlightAlpha: clothPresentation.band === "near" ? 0.42 : 0.28
4750
+ }),
4645
4751
  positions: clothState.positions.map((point) => vec3(point.x, point.y, point.z)),
4646
4752
  indices: clothState.indices,
4647
4753
  grid: { rows: clothState.rows, cols: clothState.cols }
@@ -4725,7 +4831,7 @@ function buildWaterMotionEffects(state) {
4725
4831
  impulse.z
4726
4832
  ),
4727
4833
  radius,
4728
- opacity: clamp(impulse.life * 0.28, 0.08, 0.3)
4834
+ opacity: clamp(impulse.life * 0.13, 0.035, 0.15)
4729
4835
  });
4730
4836
  });
4731
4837
  for (const ship of state.ships) {
@@ -4738,7 +4844,7 @@ function buildWaterMotionEffects(state) {
4738
4844
  const lateral = vec3(-direction.z, 0, direction.x);
4739
4845
  const points = [];
4740
4846
  for (let sampleIndex = 0; sampleIndex < 6; sampleIndex += 1) {
4741
- const along = 1 + sampleIndex * 1.45;
4847
+ const along = 1 + sampleIndex * 1.55;
4742
4848
  const lateralOffset = Math.sin(state.time * 1.2 + sampleIndex * 0.8 + readVisualNumber(ship.wanderPhase, 0)) * 0.12;
4743
4849
  const worldPoint = addVec3(
4744
4850
  ship.position,
@@ -4751,13 +4857,14 @@ function buildWaterMotionEffects(state) {
4751
4857
  sampleWave(state, worldPoint.x, worldPoint.z, state.time) * 0.24 + 0.04,
4752
4858
  worldPoint.z
4753
4859
  ),
4754
- width: 0.34 + sampleIndex * 0.13
4860
+ width: 0.3 + sampleIndex * 0.11,
4861
+ foam: clamp(0.28 - sampleIndex * 0.028 + speed * 0.025, 0.1, 0.34)
4755
4862
  })
4756
4863
  );
4757
4864
  }
4758
4865
  wakeTrails.push(
4759
4866
  Object.freeze({
4760
- opacity: clamp(0.18 + speed * 0.09, 0.22, 0.46),
4867
+ opacity: clamp(0.1 + speed * 0.048, 0.12, 0.24),
4761
4868
  points: Object.freeze(points)
4762
4869
  })
4763
4870
  );
@@ -4767,6 +4874,27 @@ function buildWaterMotionEffects(state) {
4767
4874
  rippleRings: Object.freeze(rippleRings)
4768
4875
  });
4769
4876
  }
4877
+ function buildShorelineFoamSegments(state) {
4878
+ return Object.freeze(
4879
+ SHORELINE_FOAM_ANCHORS.map((anchor, index) => {
4880
+ const pulse = 0.5 + Math.sin(state.time * 0.84 + index * 1.17) * 0.5;
4881
+ const drift = Math.sin(state.time * 0.38 + index * 0.61) * 0.1;
4882
+ const direction = normalizeVec3(vec3(Math.cos(anchor.angle), 0, Math.sin(anchor.angle)));
4883
+ const center = vec3(
4884
+ anchor.x + direction.x * drift,
4885
+ sampleWave(state, anchor.x, anchor.z, state.time) * 0.12 - 0.02,
4886
+ anchor.z + direction.z * drift
4887
+ );
4888
+ return Object.freeze({
4889
+ center,
4890
+ direction,
4891
+ length: anchor.length * (0.78 + pulse * 0.34),
4892
+ width: 0.16 + pulse * 0.12,
4893
+ opacity: 0.07 + pulse * 0.12
4894
+ });
4895
+ })
4896
+ );
4897
+ }
4770
4898
  function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
4771
4899
  const resolvedFluidFeatures = normalizeFluidFeatureAdapters(fluidFeatures);
4772
4900
  const fluidPlan = resolvedFluidFeatures.createPlan({
@@ -4789,9 +4917,9 @@ function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
4789
4917
  const representation = fluidPlan.representations.find((entry) => entry.band === bandSpec.band) ?? fluidPlan.representations[0];
4790
4918
  const continuity = resolvedFluidFeatures.createContinuityEnvelope({ fluidBodyId: "harbor" });
4791
4919
  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);
4920
+ 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;
4921
+ const cols = Math.max(4, bandResolution * (bandSpec.band === "near" ? 3 : 2));
4922
+ const rows = Math.max(4, bandResolution + (bandSpec.band === "near" ? 5 : 2));
4795
4923
  const positions = [];
4796
4924
  const indices = [];
4797
4925
  const originX = -bandSpec.width * 0.5;
@@ -4802,7 +4930,9 @@ function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
4802
4930
  const v = row / (rows - 1);
4803
4931
  const x = originX + bandSpec.width * u;
4804
4932
  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);
4933
+ const baseHeight = bandSpec.y + sampleWave(state, x, z, state.time) * bandContinuity.amplitudeFloor * (bandSpec.band === "near" ? 0.9 : bandSpec.band === "mid" ? 0.55 : 0.3);
4934
+ const detailHeight = bandSpec.band === "near" ? Math.sin(x * 1.25 + z * 0.42 - state.time * 2.4) * 0.035 : 0;
4935
+ const y = baseHeight + detailHeight;
4806
4936
  positions.push(vec3(x, y, z));
4807
4937
  }
4808
4938
  }
@@ -4831,7 +4961,12 @@ function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
4831
4961
  r: mix(visuals.waterFar.r, 0.76, 0.2),
4832
4962
  g: mix(visuals.waterFar.g, 0.78, 0.2),
4833
4963
  b: mix(visuals.waterFar.b, 0.82, 0.2)
4834
- }
4964
+ },
4965
+ material: Object.freeze({
4966
+ highlightAlpha: bandSpec.band === "near" ? 0.2 : bandSpec.band === "mid" ? 0.13 : 0.07,
4967
+ foamAlpha: bandSpec.band === "near" ? 0.28 : bandSpec.band === "mid" ? 0.14 : 0.05,
4968
+ microRippleScale: bandSpec.band === "near" ? 1 : bandSpec.band === "mid" ? 0.58 : 0.28
4969
+ })
4835
4970
  });
4836
4971
  }
4837
4972
  return { fluidPlan, bandMeshes };
@@ -4907,15 +5042,15 @@ function createSceneState(options, featureAdapters) {
4907
5042
  {
4908
5043
  id: "northwind",
4909
5044
  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,
5045
+ position: vec3(-7.8, 0, 11.2),
5046
+ velocity: vec3(1.08, 0, -0.18),
5047
+ rotationY: 1.38,
5048
+ angularVelocity: 0.025,
4914
5049
  tint: { r: 0.62, g: 0.39, b: 0.23 },
4915
5050
  massScale: 1.42,
4916
- cruiseSpeed: 2.25,
4917
- throttleResponse: 0.46,
4918
- rudderResponse: 0.54,
5051
+ cruiseSpeed: 1.22,
5052
+ throttleResponse: 0.36,
5053
+ rudderResponse: 0.4,
4919
5054
  wanderPhase: 0.35,
4920
5055
  lanterns: CUTTER_LANTERNS,
4921
5056
  lanternStrength: 1.06,
@@ -4924,15 +5059,15 @@ function createSceneState(options, featureAdapters) {
4924
5059
  {
4925
5060
  id: "tidecaller",
4926
5061
  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,
5062
+ position: vec3(6.8, 0, 5.4),
5063
+ velocity: vec3(-0.82, 0, 0.14),
5064
+ rotationY: -1.34,
5065
+ angularVelocity: -0.035,
4931
5066
  tint: { r: 0.58, g: 0.24, b: 0.16 },
4932
5067
  massScale: 0.84,
4933
- cruiseSpeed: 2.68,
4934
- throttleResponse: 0.7,
4935
- rudderResponse: 0.78,
5068
+ cruiseSpeed: 1.36,
5069
+ throttleResponse: 0.52,
5070
+ rudderResponse: 0.58,
4936
5071
  wanderPhase: 1.6,
4937
5072
  lanterns: SHIP_LANTERNS,
4938
5073
  lanternStrength: 1.18,
@@ -5231,9 +5366,10 @@ function renderShipRigging(ctx, ship, camera, viewport) {
5231
5366
  }
5232
5367
  function renderClothAccent(ctx, cloth, camera, viewport) {
5233
5368
  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))) {
5369
+ const material = cloth.material ?? {};
5370
+ ctx.strokeStyle = `rgba(255, 241, 226, ${material.foldAlpha ?? 0.32})`;
5371
+ ctx.lineWidth = 1.8;
5372
+ for (let row = 0; row < cloth.grid.rows; row += Math.max(1, Math.floor(cloth.grid.rows / 6))) {
5237
5373
  ctx.beginPath();
5238
5374
  let started = false;
5239
5375
  for (let column = 0; column < cloth.grid.cols; column += 1) {
@@ -5252,6 +5388,27 @@ function renderClothAccent(ctx, cloth, camera, viewport) {
5252
5388
  ctx.stroke();
5253
5389
  }
5254
5390
  }
5391
+ ctx.strokeStyle = `rgba(255, 228, 204, ${material.weaveAlpha ?? 0.22})`;
5392
+ ctx.lineWidth = 0.85;
5393
+ for (let column = 1; column < cloth.grid.cols - 1; column += Math.max(1, Math.floor(cloth.grid.cols / 8))) {
5394
+ ctx.beginPath();
5395
+ let started = false;
5396
+ for (let row = 0; row < cloth.grid.rows; row += 1) {
5397
+ const point = projected[row * cloth.grid.cols + column];
5398
+ if (!point) {
5399
+ continue;
5400
+ }
5401
+ if (!started) {
5402
+ ctx.moveTo(point.x, point.y);
5403
+ started = true;
5404
+ } else {
5405
+ ctx.lineTo(point.x, point.y);
5406
+ }
5407
+ }
5408
+ if (started) {
5409
+ ctx.stroke();
5410
+ }
5411
+ }
5255
5412
  const borderIndices = [
5256
5413
  0,
5257
5414
  cloth.grid.cols - 1,
@@ -5259,6 +5416,25 @@ function renderClothAccent(ctx, cloth, camera, viewport) {
5259
5416
  (cloth.grid.rows - 1) * cloth.grid.cols
5260
5417
  ];
5261
5418
  ctx.fillStyle = colorToRgba(cloth.color, 0.95);
5419
+ ctx.strokeStyle = `rgba(255, 246, 236, ${material.edgeHighlightAlpha ?? 0.5})`;
5420
+ ctx.lineWidth = 1.4;
5421
+ ctx.beginPath();
5422
+ let borderStarted = false;
5423
+ for (let column = 0; column < cloth.grid.cols; column += 1) {
5424
+ const point = projected[column];
5425
+ if (!point) {
5426
+ continue;
5427
+ }
5428
+ if (!borderStarted) {
5429
+ ctx.moveTo(point.x, point.y);
5430
+ borderStarted = true;
5431
+ } else {
5432
+ ctx.lineTo(point.x, point.y);
5433
+ }
5434
+ }
5435
+ if (borderStarted) {
5436
+ ctx.stroke();
5437
+ }
5262
5438
  for (const index of borderIndices) {
5263
5439
  const point = projected[index];
5264
5440
  if (!point) {
@@ -5274,14 +5450,22 @@ function renderWaterHighlights(ctx, waterBands, camera, viewport) {
5274
5450
  if (band.band === "horizon") {
5275
5451
  continue;
5276
5452
  }
5277
- const interval = band.band === "near" ? 2 : 3;
5278
- const alpha = band.band === "near" ? 0.22 : 0.14;
5453
+ const interval = band.band === "near" ? 4 : 5;
5454
+ const alpha = band.material?.highlightAlpha ?? (band.band === "near" ? 0.22 : 0.14);
5279
5455
  ctx.strokeStyle = `rgba(232, 247, 255, ${alpha})`;
5280
- ctx.lineWidth = band.band === "near" ? 1.3 : 0.9;
5456
+ ctx.lineWidth = band.band === "near" ? 0.9 : 0.65;
5281
5457
  for (let row = interval; row < band.rows - 1; row += interval) {
5282
- ctx.beginPath();
5283
5458
  let started = false;
5284
- for (let column = 0; column < band.cols; column += 1) {
5459
+ ctx.beginPath();
5460
+ for (let column = 0; column < band.cols; column += band.band === "near" ? 2 : 3) {
5461
+ if (pseudoRandom(row * 47 + column * 13) < 0.18) {
5462
+ if (started) {
5463
+ ctx.stroke();
5464
+ ctx.beginPath();
5465
+ started = false;
5466
+ }
5467
+ continue;
5468
+ }
5285
5469
  const point = projectPoint(
5286
5470
  band.positions[row * band.cols + column],
5287
5471
  camera,
@@ -5301,8 +5485,56 @@ function renderWaterHighlights(ctx, waterBands, camera, viewport) {
5301
5485
  ctx.stroke();
5302
5486
  }
5303
5487
  }
5488
+ if (band.band === "near") {
5489
+ ctx.fillStyle = `rgba(236, 249, 255, ${(band.material?.foamAlpha ?? 0.28) * 0.72})`;
5490
+ for (let column = 3; column < band.cols - 3; column += 10) {
5491
+ const point = projectPoint(
5492
+ band.positions[Math.floor(band.rows * 0.42) * band.cols + column],
5493
+ camera,
5494
+ viewport
5495
+ );
5496
+ if (!point) {
5497
+ continue;
5498
+ }
5499
+ ctx.beginPath();
5500
+ ctx.ellipse(point.x, point.y, 1.8, 0.75, -0.2, 0, Math.PI * 2);
5501
+ ctx.fill();
5502
+ }
5503
+ }
5304
5504
  }
5305
5505
  }
5506
+ function renderShorelineFoamSegments(ctx, segments, camera, viewport) {
5507
+ ctx.save();
5508
+ ctx.globalCompositeOperation = "screen";
5509
+ ctx.lineCap = "round";
5510
+ ctx.lineJoin = "round";
5511
+ for (const segment of segments) {
5512
+ const half = scaleVec3(segment.direction, segment.length * 0.5);
5513
+ const start = projectPoint(subVec3(segment.center, half), camera, viewport);
5514
+ const end = projectPoint(addVec3(segment.center, half), camera, viewport);
5515
+ const center = projectPoint(segment.center, camera, viewport);
5516
+ if (!start || !end || !center) {
5517
+ continue;
5518
+ }
5519
+ const depthScale = clamp(140 / Math.max(12, center.depth), 3, 10);
5520
+ ctx.strokeStyle = `rgba(232, 242, 238, ${segment.opacity})`;
5521
+ ctx.lineWidth = clamp(segment.width * depthScale, 0.8, 2.8);
5522
+ ctx.beginPath();
5523
+ ctx.moveTo(start.x, start.y);
5524
+ ctx.quadraticCurveTo(
5525
+ center.x,
5526
+ center.y + Math.sin(segment.center.x * 1.7) * 2.4,
5527
+ end.x,
5528
+ end.y
5529
+ );
5530
+ ctx.stroke();
5531
+ ctx.fillStyle = `rgba(248, 251, 246, ${segment.opacity * 0.68})`;
5532
+ ctx.beginPath();
5533
+ ctx.ellipse(center.x, center.y, depthScale * 0.18, depthScale * 0.08, -0.2, 0, Math.PI * 2);
5534
+ ctx.fill();
5535
+ }
5536
+ ctx.restore();
5537
+ }
5306
5538
  function readPhysicsNumber(physics, key, fallback) {
5307
5539
  const value = physics?.[key];
5308
5540
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
@@ -5333,14 +5565,17 @@ function getShipInverseInertia(ship, shipModel) {
5333
5565
  return 1 / Math.max(1, inertia);
5334
5566
  }
5335
5567
  function spawnSpray(state, point, intensity) {
5336
- const count = state.fluidDetail.getSnapshot().currentLevel.config.splashCount;
5568
+ const count = Math.max(
5569
+ 3,
5570
+ Math.ceil(state.fluidDetail.getSnapshot().currentLevel.config.splashCount * 0.32)
5571
+ );
5337
5572
  for (let index = 0; index < count; index += 1) {
5338
5573
  const angle = index / count * Math.PI * 2;
5339
- const speed = 0.9 + Math.random() * intensity * 0.45;
5574
+ const speed = 0.46 + Math.random() * intensity * 0.24;
5340
5575
  state.sprays.push({
5341
5576
  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
5577
+ velocity: vec3(Math.cos(angle) * speed * 0.24, 0.46 + Math.random() * 0.34, Math.sin(angle) * speed * 0.18),
5578
+ life: 0.72 + Math.random() * 0.22
5344
5579
  });
5345
5580
  }
5346
5581
  }
@@ -5355,7 +5590,7 @@ function resolveShipRoute(ship, state, radius) {
5355
5590
  }
5356
5591
  const wander = Math.sin(state.time * 0.22 + readVisualNumber(ship.wanderPhase, 0));
5357
5592
  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;
5593
+ const laneCenter = ship.id === "northwind" ? 11.6 + wander * 0.82 + crossCurrent * 0.24 : 5.4 + wander * 0.94 - crossCurrent * 0.32;
5359
5594
  const targetX = ship.routeDirection > 0 ? HARBOR_BOUNDS.maxX - radius * 1.7 : HARBOR_BOUNDS.minX + radius * 1.7;
5360
5595
  return vec3(targetX, 0, clamp(laneCenter, HARBOR_BOUNDS.minZ + 1.8, HARBOR_BOUNDS.maxZ - 1.8));
5361
5596
  }
@@ -5368,7 +5603,7 @@ function updateShipMotion(state, ship, dt, shipModel) {
5368
5603
  const angularDamping = readPhysicsNumber(physics, "angularDamping", 0.08);
5369
5604
  const throttleResponse = readVisualNumber(ship.throttleResponse, 0.58);
5370
5605
  const rudderResponse = readVisualNumber(ship.rudderResponse, 0.62);
5371
- const cruiseSpeed = readVisualNumber(ship.cruiseSpeed, 2.4);
5606
+ const cruiseSpeed = readVisualNumber(ship.cruiseSpeed, 1.25);
5372
5607
  ship.collisionCooldown = Math.max(0, readVisualNumber(ship.collisionCooldown, 0) - dt);
5373
5608
  const forward = directionFromYaw(ship.rotationY);
5374
5609
  const lateral = perpendicularOnWater(forward);
@@ -5463,7 +5698,7 @@ function resolveShipCollision(state, a, b, shipModelA, shipModelB) {
5463
5698
  b.position = addVec3(b.position, scaleVec3(correction, invMassB));
5464
5699
  const relativeVelocity = subVec3(b.velocity, a.velocity);
5465
5700
  const velocityAlongNormal = dotVec3(relativeVelocity, normal);
5466
- const restitution = (readPhysicsNumber(shipModelA.physics, "restitution", 0.22) + readPhysicsNumber(shipModelB.physics, "restitution", 0.22)) / 2 * 0.88;
5701
+ const restitution = (readPhysicsNumber(shipModelA.physics, "restitution", 0.22) + readPhysicsNumber(shipModelB.physics, "restitution", 0.22)) / 2 * 0.42;
5467
5702
  if (velocityAlongNormal < 0) {
5468
5703
  const impulseMagnitude = -(1 + restitution) * velocityAlongNormal / Math.max(1e-4, invMassSum);
5469
5704
  const impulse = scaleVec3(normal, impulseMagnitude);
@@ -5481,27 +5716,27 @@ function resolveShipCollision(state, a, b, shipModelA, shipModelB) {
5481
5716
  a.angularVelocity -= tangentSpeed * radiusA * getShipInverseInertia(a, shipModelA) * 0.2 + impulseMagnitude * 24e-5;
5482
5717
  b.angularVelocity += tangentSpeed * radiusB * getShipInverseInertia(b, shipModelB) * 0.2 + impulseMagnitude * 24e-5;
5483
5718
  const impactSpeed = Math.abs(velocityAlongNormal);
5484
- if (impactSpeed > 0.18 && Math.max(readVisualNumber(a.collisionCooldown, 0), readVisualNumber(b.collisionCooldown, 0)) <= 0) {
5719
+ if (impactSpeed > 0.36 && Math.max(readVisualNumber(a.collisionCooldown, 0), readVisualNumber(b.collisionCooldown, 0)) <= 0) {
5485
5720
  const contactPoint = vec3(
5486
5721
  (a.position.x + b.position.x) * 0.5,
5487
5722
  (a.position.y + b.position.y) * 0.5 + 0.14,
5488
5723
  (a.position.z + b.position.z) * 0.5
5489
5724
  );
5490
- spawnSpray(state, contactPoint, impactSpeed * 2.4 + penetration * 8);
5725
+ spawnSpray(state, contactPoint, impactSpeed * 0.9 + penetration * 2.4);
5491
5726
  state.waveImpulses.push({
5492
5727
  x: contactPoint.x,
5493
5728
  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,
5729
+ strength: clamp(0.1 + impactSpeed * 0.18 + penetration * 0.28, 0.08, 0.52),
5730
+ radius: 0.72 + penetration * 0.72,
5496
5731
  life: 1
5497
5732
  });
5498
5733
  state.collisionCount += 1;
5499
5734
  state.collisionFlash = Math.max(
5500
5735
  state.collisionFlash,
5501
- clamp(impactSpeed * 0.55 + penetration * 1.8, 0.16, 1)
5736
+ clamp(impactSpeed * 0.14 + penetration * 0.32, 0.04, 0.24)
5502
5737
  );
5503
- a.collisionCooldown = 0.2;
5504
- b.collisionCooldown = 0.2;
5738
+ a.collisionCooldown = 0.72;
5739
+ b.collisionCooldown = 0.72;
5505
5740
  }
5506
5741
  }
5507
5742
  state.contactCount += 1;
@@ -5524,7 +5759,7 @@ function updateShips(state, dt, shipModel) {
5524
5759
  collided = resolveShipCollision(state, shipA, shipB, shipModelA, shipModelB) || collided;
5525
5760
  }
5526
5761
  }
5527
- state.collisionFlash = collided ? Math.max(0.12, state.collisionFlash) : Math.max(0, state.collisionFlash - dt * 1.3);
5762
+ state.collisionFlash = collided ? Math.max(0.04, state.collisionFlash) : Math.max(0, state.collisionFlash - dt * 1.7);
5528
5763
  }
5529
5764
  function updateWaveImpulses(state, dt) {
5530
5765
  state.waveImpulses = state.waveImpulses.map((impulse) => ({
@@ -5886,7 +6121,8 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
5886
6121
  for (const wake of effects.wakeTrails) {
5887
6122
  const projected = wake.points.map((point) => ({
5888
6123
  projected: projectPoint(point.center, camera, viewport),
5889
- width: point.width
6124
+ width: point.width,
6125
+ foam: point.foam
5890
6126
  })).filter((entry) => entry.projected);
5891
6127
  if (projected.length < 2) {
5892
6128
  continue;
@@ -5894,8 +6130,8 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
5894
6130
  const averageDepth = projected.reduce((total, entry) => total + entry.projected.depth, 0) / projected.length;
5895
6131
  const averageWidth = projected.reduce((total, entry) => total + entry.width, 0) / projected.length;
5896
6132
  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;
6133
+ ctx.strokeStyle = `rgba(146, 194, 236, ${wake.opacity * 0.34})`;
6134
+ ctx.lineWidth = baseWidth * 1.45;
5899
6135
  ctx.lineCap = "round";
5900
6136
  ctx.lineJoin = "round";
5901
6137
  ctx.beginPath();
@@ -5904,8 +6140,8 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
5904
6140
  ctx.lineTo(projected[index].projected.x, projected[index].projected.y);
5905
6141
  }
5906
6142
  ctx.stroke();
5907
- ctx.strokeStyle = `rgba(234, 247, 255, ${wake.opacity})`;
5908
- ctx.lineWidth = baseWidth;
6143
+ ctx.strokeStyle = `rgba(234, 247, 255, ${wake.opacity * 0.72})`;
6144
+ ctx.lineWidth = baseWidth * 0.72;
5909
6145
  ctx.lineCap = "round";
5910
6146
  ctx.lineJoin = "round";
5911
6147
  ctx.beginPath();
@@ -5915,13 +6151,14 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
5915
6151
  }
5916
6152
  ctx.stroke();
5917
6153
  for (const entry of projected.slice(1, 5)) {
5918
- ctx.fillStyle = `rgba(239, 248, 255, ${wake.opacity * 0.76})`;
6154
+ const foam = entry.foam ?? 0.3;
6155
+ ctx.fillStyle = `rgba(239, 248, 255, ${wake.opacity * foam * 0.92})`;
5919
6156
  ctx.beginPath();
5920
6157
  ctx.ellipse(
5921
6158
  entry.projected.x,
5922
6159
  entry.projected.y,
5923
- baseWidth * 0.72,
5924
- baseWidth * 0.44,
6160
+ baseWidth * 0.54,
6161
+ baseWidth * 0.28,
5925
6162
  0,
5926
6163
  0,
5927
6164
  Math.PI * 2
@@ -5939,13 +6176,22 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
5939
6176
  const radiusX = Math.hypot(xAxis.x - center.x, xAxis.y - center.y);
5940
6177
  const radiusY = Math.hypot(zAxis.x - center.x, zAxis.y - center.y);
5941
6178
  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();
6179
+ ctx.lineWidth = clamp((radiusX + radiusY) * 0.014, 0.65, 1.8);
6180
+ for (let segment = 0; segment < 5; segment += 1) {
6181
+ if (pseudoRandom(segment * 31 + radiusX * 0.7 + radiusY * 0.3) < 0.32) {
6182
+ continue;
6183
+ }
6184
+ const startAngle = segment * 1.22 + stateTimePhase(center.x, center.y) * 0.04;
6185
+ ctx.beginPath();
6186
+ ctx.ellipse(center.x, center.y, radiusX, radiusY, 0, startAngle, startAngle + 0.48);
6187
+ ctx.stroke();
6188
+ }
5946
6189
  }
5947
6190
  ctx.restore();
5948
6191
  }
6192
+ function stateTimePhase(x, y) {
6193
+ return Math.sin(x * 0.013 + y * 0.017);
6194
+ }
5949
6195
  function renderScene(ctx, canvas, state, shipModel, dom, lightingFeatures, fluidFeatures, clothFeatures) {
5950
6196
  const viewport = { width: canvas.width, height: canvas.height };
5951
6197
  const camera = buildCamera(state, canvas);
@@ -6013,6 +6259,7 @@ function renderScene(ctx, canvas, state, shipModel, dom, lightingFeatures, fluid
6013
6259
  }
6014
6260
  }
6015
6261
  const waterMotionEffects = buildWaterMotionEffects(state);
6262
+ const shorelineFoamSegments = buildShorelineFoamSegments(state);
6016
6263
  const lightSources = collectSceneLightSources(state, visuals);
6017
6264
  pushHarborGeometry(camera, viewport, sceneTriangles, state);
6018
6265
  const cloth = buildClothSurface(
@@ -6084,6 +6331,7 @@ function renderScene(ctx, canvas, state, shipModel, dom, lightingFeatures, fluid
6084
6331
  }
6085
6332
  renderWaterMotionEffects(ctx, waterMotionEffects, camera, viewport);
6086
6333
  renderWaterHighlights(ctx, water.bandMeshes, camera, viewport);
6334
+ renderShorelineFoamSegments(ctx, shorelineFoamSegments, camera, viewport);
6087
6335
  drawTriangles(
6088
6336
  ctx,
6089
6337
  sceneTriangles,
@@ -6180,7 +6428,7 @@ function updateSceneState(state, dt, shipModel, featureAdapters) {
6180
6428
  advanceShowcaseClothSimulationState(clothState, {
6181
6429
  dt,
6182
6430
  time: state.time,
6183
- flagMotion: readVisualNumber(state.demoVisuals?.flagMotion, 0.92),
6431
+ flagMotion: readVisualNumber(state.demoVisuals?.flagMotion, 0.58),
6184
6432
  waveInfluence: sampleWave(state, FLAG_LAYOUT.origin.x + FLAG_LAYOUT.width * 0.55, FLAG_LAYOUT.origin.z + FLAG_LAYOUT.width * 0.48, state.time)
6185
6433
  });
6186
6434
  updatePhysicsSnapshot(state, shipModel, featureAdapters.physics);
@@ -6395,7 +6643,7 @@ function updatePhysicsSnapshot(state, shipModel, physicsFeatures) {
6395
6643
  }
6396
6644
  });
6397
6645
  }
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;
6646
+ 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
6647
  var init_showcase_runtime = __esm({
6400
6648
  "src/showcase-runtime.js"() {
6401
6649
  init_asset_url();
@@ -6586,6 +6834,13 @@ var init_showcase_runtime = __esm({
6586
6834
  })
6587
6835
  ]);
6588
6836
  SHOWCASE_ENVIRONMENT_LAYOUT = Object.freeze([
6837
+ Object.freeze({
6838
+ assetKey: "shoreline",
6839
+ position: Object.freeze({ x: 1.8, y: -0.04, z: 0.48 }),
6840
+ rotationY: -0.03,
6841
+ scale: 1.02,
6842
+ accent: 0.03
6843
+ }),
6589
6844
  Object.freeze({
6590
6845
  assetKey: "harbor-dock",
6591
6846
  position: Object.freeze({ x: -4.6, y: 0.16, z: 0.7 }),
@@ -6616,6 +6871,18 @@ var init_showcase_runtime = __esm({
6616
6871
  Object.freeze({ x: -8.6, y: 2.48, z: -0.72, glow: 1 }),
6617
6872
  Object.freeze({ x: -10.4, y: 1.28, z: 0.82, glow: 0.92 })
6618
6873
  ]);
6874
+ SHORELINE_FOAM_ANCHORS = Object.freeze([
6875
+ Object.freeze({ x: -7.8, z: 3, length: 1.25, angle: -0.12 }),
6876
+ Object.freeze({ x: -6.3, z: 2.72, length: 0.92, angle: 0.08 }),
6877
+ Object.freeze({ x: -4.9, z: 3.16, length: 1.08, angle: -0.2 }),
6878
+ Object.freeze({ x: -3.2, z: 2.42, length: 0.76, angle: 0.16 }),
6879
+ Object.freeze({ x: -1.4, z: 2.82, length: 1.18, angle: -0.04 }),
6880
+ Object.freeze({ x: 0.4, z: 3.08, length: 0.88, angle: 0.14 }),
6881
+ Object.freeze({ x: 2.1, z: 2.56, length: 1.34, angle: -0.18 }),
6882
+ Object.freeze({ x: 3.8, z: 3, length: 0.94, angle: 0.1 }),
6883
+ Object.freeze({ x: 5.5, z: 2.72, length: 1.12, angle: -0.08 }),
6884
+ Object.freeze({ x: 7, z: 3.22, length: 0.72, angle: 0.18 })
6885
+ ]);
6619
6886
  FLAG_LAYOUT = Object.freeze({
6620
6887
  origin: Object.freeze({ x: -3.5, y: 5.9, z: 2.4 }),
6621
6888
  width: 4.8,
@@ -6630,6 +6897,7 @@ var index_exports = {};
6630
6897
  __export(index_exports, {
6631
6898
  GPU_SHOWCASE_PRODUCT_STUDIO_FEATURE: () => GPU_SHOWCASE_PRODUCT_STUDIO_FEATURE,
6632
6899
  GPU_SHOWCASE_REALISTIC_MODELS_FEATURE: () => GPU_SHOWCASE_REALISTIC_MODELS_FEATURE,
6900
+ buildProductStudioSceneObjects: () => buildProductStudioSceneObjects,
6633
6901
  createGpuSharedTranslator: () => createGpuSharedTranslator,
6634
6902
  createProductStudioMeshes: () => createProductStudioMeshes,
6635
6903
  gpuSharedEnGbTranslations: () => gpuSharedEnGbTranslations,
@@ -6715,6 +6983,7 @@ async function mountGpuShowcase2(options = {}) {
6715
6983
  0 && (module.exports = {
6716
6984
  GPU_SHOWCASE_PRODUCT_STUDIO_FEATURE,
6717
6985
  GPU_SHOWCASE_REALISTIC_MODELS_FEATURE,
6986
+ buildProductStudioSceneObjects,
6718
6987
  createGpuSharedTranslator,
6719
6988
  createProductStudioMeshes,
6720
6989
  gpuSharedEnGbTranslations,