@plasius/gpu-shared 0.1.1 → 0.1.3

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.
@@ -597,7 +597,163 @@ function buildDemoDom(root, options) {
597
597
  };
598
598
  }
599
599
 
600
- function buildClothSurface(model, state, meshDetail) {
600
+ function buildSceneSnapshot(state, shipModel) {
601
+ return Object.freeze({
602
+ focus: state.focus,
603
+ frame: state.frame,
604
+ time: state.time,
605
+ stress: state.stress,
606
+ collisions: state.contactCount,
607
+ collisionCount: state.collisionCount,
608
+ collisionFlash: state.collisionFlash,
609
+ sprays: Object.freeze(
610
+ state.sprays.map((spray) =>
611
+ Object.freeze({
612
+ life: spray.life,
613
+ position: Object.freeze({ ...spray.position }),
614
+ velocity: Object.freeze({ ...spray.velocity }),
615
+ })
616
+ )
617
+ ),
618
+ ships: Object.freeze(
619
+ state.ships.map((ship) =>
620
+ Object.freeze({
621
+ id: ship.id,
622
+ position: Object.freeze({ ...ship.position }),
623
+ velocity: Object.freeze({ ...ship.velocity }),
624
+ rotationY: ship.rotationY,
625
+ angularVelocity: ship.angularVelocity,
626
+ tint: Object.freeze({ ...ship.tint }),
627
+ })
628
+ )
629
+ ),
630
+ waveImpulses: Object.freeze(
631
+ state.waveImpulses.map((impulse) =>
632
+ Object.freeze({
633
+ x: impulse.x,
634
+ z: impulse.z,
635
+ strength: impulse.strength,
636
+ radius: impulse.radius,
637
+ life: impulse.life,
638
+ })
639
+ )
640
+ ),
641
+ physics: Object.freeze({
642
+ profile: state.physics.profile,
643
+ plan: state.physics.plan,
644
+ manifest: state.physics.manifest,
645
+ snapshot: state.physics.snapshot,
646
+ shipPhysics: shipModel?.physics ?? null,
647
+ }),
648
+ });
649
+ }
650
+
651
+ function resolveSceneDescription(state, options, shipModel) {
652
+ const scene = buildSceneSnapshot(state, shipModel);
653
+ if (typeof options.describeState !== "function") {
654
+ return { scene, description: null };
655
+ }
656
+
657
+ const description = options.describeState(state.packageState, scene) ?? null;
658
+ return { scene, description };
659
+ }
660
+
661
+ function updatePackageState(state, options, shipModel, dt) {
662
+ if (typeof options.updateState !== "function") {
663
+ return;
664
+ }
665
+
666
+ const scene = buildSceneSnapshot(state, shipModel);
667
+ const nextState = options.updateState(state.packageState, scene, dt);
668
+ if (typeof nextState !== "undefined") {
669
+ state.packageState = nextState;
670
+ }
671
+ }
672
+
673
+ function normalizeColorOverride(color, fallback) {
674
+ if (!color || typeof color !== "object") {
675
+ return fallback;
676
+ }
677
+
678
+ return {
679
+ r: typeof color.r === "number" ? color.r : fallback.r,
680
+ g: typeof color.g === "number" ? color.g : fallback.g,
681
+ b: typeof color.b === "number" ? color.b : fallback.b,
682
+ };
683
+ }
684
+
685
+ function readVisualNumber(value, fallback) {
686
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
687
+ }
688
+
689
+ function resolveVisualConfig(nearLighting, lightingSnapshot, customVisuals = {}) {
690
+ const premiumShadows = nearLighting.primaryShadowSource === "ray-traced-primary";
691
+ const defaults = {
692
+ skyTop: premiumShadows ? "#f0f7fb" : "#e8f1f7",
693
+ skyMid: premiumShadows ? "#c7d9e5" : "#b9ceda",
694
+ skyBottom: premiumShadows ? "#84a7bd" : "#7b9bb0",
695
+ seaTop: premiumShadows ? "#235064" : "#264c5f",
696
+ seaMid: premiumShadows ? "#153e53" : "#173d4f",
697
+ seaBottom: "#0b2433",
698
+ sunCore: "rgba(255, 244, 210, 0.9)",
699
+ reflectionStrength: lightingSnapshot.currentLevel.config.reflectionStrength,
700
+ shadowAccent: lightingSnapshot.currentLevel.config.shadowStrength,
701
+ waveAmplitude: 1,
702
+ waveDirection: { x: 0.86, z: 0.34 },
703
+ wavePhaseSpeed: 1,
704
+ wakeStrength: 0.24,
705
+ wakeLength: 15,
706
+ collisionRippleStrength: 0.34,
707
+ waterNear: { r: 0.12, g: 0.36, b: 0.46 },
708
+ waterFar: { r: 0.28, g: 0.56, b: 0.68 },
709
+ harborWall: { r: 0.48, g: 0.4, b: 0.32 },
710
+ harborDeck: { r: 0.5, g: 0.34, b: 0.22 },
711
+ harborTower: { r: 0.34, g: 0.32, b: 0.36 },
712
+ flagColor: { r: 0.76, g: 0.24, b: 0.18 },
713
+ flagMotion: 1,
714
+ };
715
+
716
+ return {
717
+ skyTop: typeof customVisuals.skyTop === "string" ? customVisuals.skyTop : defaults.skyTop,
718
+ skyMid: typeof customVisuals.skyMid === "string" ? customVisuals.skyMid : defaults.skyMid,
719
+ skyBottom:
720
+ typeof customVisuals.skyBottom === "string" ? customVisuals.skyBottom : defaults.skyBottom,
721
+ seaTop: typeof customVisuals.seaTop === "string" ? customVisuals.seaTop : defaults.seaTop,
722
+ seaMid: typeof customVisuals.seaMid === "string" ? customVisuals.seaMid : defaults.seaMid,
723
+ seaBottom:
724
+ typeof customVisuals.seaBottom === "string" ? customVisuals.seaBottom : defaults.seaBottom,
725
+ sunCore:
726
+ typeof customVisuals.sunCore === "string" ? customVisuals.sunCore : defaults.sunCore,
727
+ reflectionStrength: readVisualNumber(
728
+ customVisuals.reflectionStrength,
729
+ defaults.reflectionStrength
730
+ ),
731
+ shadowAccent: readVisualNumber(customVisuals.shadowAccent, defaults.shadowAccent),
732
+ waveAmplitude: readVisualNumber(customVisuals.waveAmplitude, defaults.waveAmplitude),
733
+ waveDirection:
734
+ customVisuals.waveDirection &&
735
+ typeof customVisuals.waveDirection.x === "number" &&
736
+ typeof customVisuals.waveDirection.z === "number"
737
+ ? { x: customVisuals.waveDirection.x, z: customVisuals.waveDirection.z }
738
+ : defaults.waveDirection,
739
+ wavePhaseSpeed: readVisualNumber(customVisuals.wavePhaseSpeed, defaults.wavePhaseSpeed),
740
+ wakeStrength: readVisualNumber(customVisuals.wakeStrength, defaults.wakeStrength),
741
+ wakeLength: readVisualNumber(customVisuals.wakeLength, defaults.wakeLength),
742
+ collisionRippleStrength: readVisualNumber(
743
+ customVisuals.collisionRippleStrength,
744
+ defaults.collisionRippleStrength
745
+ ),
746
+ waterNear: normalizeColorOverride(customVisuals.waterNear, defaults.waterNear),
747
+ waterFar: normalizeColorOverride(customVisuals.waterFar, defaults.waterFar),
748
+ harborWall: normalizeColorOverride(customVisuals.harborWall, defaults.harborWall),
749
+ harborDeck: normalizeColorOverride(customVisuals.harborDeck, defaults.harborDeck),
750
+ harborTower: normalizeColorOverride(customVisuals.harborTower, defaults.harborTower),
751
+ flagColor: normalizeColorOverride(customVisuals.flagColor, defaults.flagColor),
752
+ flagMotion: readVisualNumber(customVisuals.flagMotion, defaults.flagMotion),
753
+ };
754
+ }
755
+
756
+ function buildClothSurface(model, state, meshDetail, visuals) {
601
757
  const clothPlan = createClothRepresentationPlan({
602
758
  garmentId: "shore-flag",
603
759
  kind: state.focus === "cloth" ? "flag" : clothGarmentKinds[0],
@@ -626,12 +782,23 @@ function buildClothSurface(model, state, meshDetail) {
626
782
  for (let column = 0; column < cols; column += 1) {
627
783
  const u = column / (cols - 1);
628
784
  const v = row / (rows - 1);
629
- const gust = Math.sin(time * 1.9 + v * 3.2 + u * 2.1) * continuity.broadMotionFloor;
630
- const wrinkle = Math.sin(time * 4.4 + u * 9.2 + v * 5.6) * continuity.wrinkleFloor * 0.22;
785
+ const gust =
786
+ Math.sin(time * 1.9 + v * 3.2 + u * 2.1) *
787
+ continuity.broadMotionFloor *
788
+ visuals.flagMotion;
789
+ const wrinkle =
790
+ Math.sin(time * 4.4 + u * 9.2 + v * 5.6) *
791
+ continuity.wrinkleFloor *
792
+ 0.22 *
793
+ Math.max(0.55, visuals.flagMotion);
631
794
  const x = origin.x + u * 1.8 + gust * 0.55 * (u * 0.9);
632
795
  const y = origin.y - height * v + wrinkle * 0.2;
633
796
  const z = origin.z + width * u + gust * 0.72 * (u * 0.85);
634
- const flap = Math.cos(time * 2.7 + u * 7.4 + v * 3.8) * continuity.broadMotionFloor * 0.28;
797
+ const flap =
798
+ Math.cos(time * 2.7 + u * 7.4 + v * 3.8) *
799
+ continuity.broadMotionFloor *
800
+ 0.28 *
801
+ visuals.flagMotion;
635
802
  positions.push(vec3(x + flap, y, z));
636
803
  }
637
804
  }
@@ -651,21 +818,102 @@ function buildClothSurface(model, state, meshDetail) {
651
818
  band,
652
819
  representation,
653
820
  continuity,
821
+ color: visuals.flagColor,
654
822
  positions,
655
823
  indices,
656
824
  grid: { rows, cols },
657
825
  };
658
826
  }
659
827
 
660
- function sampleWave(x, z, time) {
828
+ function resolveWaveDirection(state) {
829
+ const direction = state.demoVisuals?.waveDirection;
830
+ if (
831
+ direction &&
832
+ typeof direction === "object" &&
833
+ typeof direction.x === "number" &&
834
+ typeof direction.z === "number"
835
+ ) {
836
+ return normalizeVec3(vec3(direction.x, 0, direction.z));
837
+ }
838
+
839
+ return normalizeVec3(vec3(0.86, 0, 0.34));
840
+ }
841
+
842
+ function sampleShipWake(state, x, z, time) {
843
+ const wakeStrength = readVisualNumber(state.demoVisuals?.wakeStrength, 0.24);
844
+ const wakeLength = readVisualNumber(state.demoVisuals?.wakeLength, 15);
845
+ let total = 0;
846
+
847
+ for (const ship of state.ships) {
848
+ const speed = Math.hypot(ship.velocity.x, ship.velocity.z);
849
+ if (speed <= 0.05) {
850
+ continue;
851
+ }
852
+
853
+ const direction = normalizeVec3(vec3(ship.velocity.x, 0, ship.velocity.z));
854
+ const behind = scaleVec3(direction, -1);
855
+ const lateral = vec3(-direction.z, 0, direction.x);
856
+ const delta = vec3(x - ship.position.x, 0, z - ship.position.z);
857
+ const along = dotVec3(delta, behind);
858
+ if (along < 0 || along > wakeLength) {
859
+ continue;
860
+ }
861
+
862
+ const cross = Math.abs(dotVec3(delta, lateral));
863
+ const width = 0.9 + along * 0.2;
864
+ if (cross > width * 3.2) {
865
+ continue;
866
+ }
867
+
868
+ const envelope =
869
+ Math.exp(-along * 0.14) * Math.exp(-((cross * cross) / Math.max(0.4, width * width * 2.4)));
870
+ total += Math.sin(along * 1.6 - time * 4.2) * speed * wakeStrength * envelope;
871
+ }
872
+
873
+ return total;
874
+ }
875
+
876
+ function sampleWaveImpulses(state, x, z, time) {
877
+ const rippleStrength = readVisualNumber(state.demoVisuals?.collisionRippleStrength, 0.34);
878
+ let total = 0;
879
+
880
+ for (const impulse of state.waveImpulses) {
881
+ const dx = x - impulse.x;
882
+ const dz = z - impulse.z;
883
+ const distance = Math.hypot(dx, dz);
884
+ const radius = impulse.radius + (1 - impulse.life) * 4.8;
885
+ if (distance > radius * 2.8) {
886
+ continue;
887
+ }
888
+
889
+ const phase = distance * 1.8 - (1 - impulse.life) * 10 - time * 0.4;
890
+ const envelope = Math.exp(-distance / Math.max(0.1, radius)) * impulse.life;
891
+ total += Math.sin(phase) * impulse.strength * rippleStrength * envelope * 0.18;
892
+ }
893
+
894
+ return total;
895
+ }
896
+
897
+ function sampleWave(state, x, z, time) {
898
+ const direction = resolveWaveDirection(state);
899
+ const lateral = vec3(-direction.z, 0, direction.x);
900
+ const along = x * direction.x + z * direction.z;
901
+ const cross = x * lateral.x + z * lateral.z;
902
+ const phaseSpeed = readVisualNumber(state.demoVisuals?.wavePhaseSpeed, 1);
903
+ const amplitude = readVisualNumber(state.demoVisuals?.waveAmplitude, 1);
904
+ const base =
905
+ Math.sin(along * 0.22 - time * 1.12 * phaseSpeed) * 0.42 +
906
+ Math.cos(along * 0.11 + cross * 0.07 - time * 0.78 * phaseSpeed) * 0.26 +
907
+ Math.sin(cross * 0.19 - time * 1.34 * phaseSpeed) * 0.16;
908
+
661
909
  return (
662
- Math.sin(x * 0.18 + time * 1.2) * 0.55 +
663
- Math.cos(z * 0.12 + time * 0.9) * 0.35 +
664
- Math.sin((x + z) * 0.08 + time * 1.6) * 0.22
910
+ base * amplitude +
911
+ sampleShipWake(state, x, z, time) +
912
+ sampleWaveImpulses(state, x, z, time)
665
913
  );
666
914
  }
667
915
 
668
- function buildWaterBands(state, fluidDetail) {
916
+ function buildWaterBands(state, fluidDetail, visuals) {
669
917
  const fluidPlan = createFluidRepresentationPlan({
670
918
  fluidBodyId: "harbor",
671
919
  kind: state.focus === "fluid" ? "ocean" : fluidBodyKinds[0],
@@ -711,7 +959,7 @@ function buildWaterBands(state, fluidDetail) {
711
959
  const z = originZ + bandSpec.depth * v;
712
960
  const y =
713
961
  bandSpec.y +
714
- sampleWave(x, z, state.time) *
962
+ sampleWave(state, x, z, state.time) *
715
963
  continuity.amplitudeFloor *
716
964
  (bandSpec.band === "near" ? 0.9 : bandSpec.band === "mid" ? 0.55 : 0.3);
717
965
  positions.push(vec3(x, y, z));
@@ -737,12 +985,20 @@ function buildWaterBands(state, fluidDetail) {
737
985
  indices,
738
986
  color:
739
987
  bandSpec.band === "near"
740
- ? { r: 0.12, g: 0.36, b: 0.46 }
988
+ ? visuals.waterNear
741
989
  : bandSpec.band === "mid"
742
- ? { r: 0.15, g: 0.42, b: 0.54 }
990
+ ? {
991
+ r: mix(visuals.waterNear.r, visuals.waterFar.r, 0.4),
992
+ g: mix(visuals.waterNear.g, visuals.waterFar.g, 0.4),
993
+ b: mix(visuals.waterNear.b, visuals.waterFar.b, 0.4),
994
+ }
743
995
  : bandSpec.band === "far"
744
- ? { r: 0.22, g: 0.48, b: 0.6 }
745
- : { r: 0.34, g: 0.58, b: 0.7 },
996
+ ? visuals.waterFar
997
+ : {
998
+ r: mix(visuals.waterFar.r, 0.76, 0.2),
999
+ g: mix(visuals.waterFar.g, 0.78, 0.2),
1000
+ b: mix(visuals.waterFar.b, 0.82, 0.2),
1001
+ },
746
1002
  });
747
1003
  }
748
1004
 
@@ -784,6 +1040,9 @@ function createSceneState(options) {
784
1040
  clothDetail,
785
1041
  lightingDetail,
786
1042
  debugSession,
1043
+ packageState: undefined,
1044
+ demoDescription: null,
1045
+ demoVisuals: null,
787
1046
  time: 0,
788
1047
  lastTimeMs: null,
789
1048
  paused: false,
@@ -811,7 +1070,9 @@ function createSceneState(options) {
811
1070
  },
812
1071
  ],
813
1072
  sprays: [],
1073
+ waveImpulses: [],
814
1074
  frame: 0,
1075
+ contactCount: 0,
815
1076
  collisionCount: 0,
816
1077
  collisionFlash: 0,
817
1078
  physics: {
@@ -828,26 +1089,26 @@ function setListContent(element, values) {
828
1089
  element.innerHTML = values.map((value) => `<li>${value}</li>`).join("");
829
1090
  }
830
1091
 
831
- function drawSkyAndShore(ctx, canvas, state, nearLighting, reflectionStrength, shadowStrength) {
1092
+ function drawSkyAndShore(ctx, canvas, state, nearLighting, reflectionStrength, shadowStrength, visuals) {
832
1093
  const premiumShadows = nearLighting.primaryShadowSource === "ray-traced-primary";
833
1094
  const sky = ctx.createLinearGradient(0, 0, 0, canvas.height * 0.5);
834
- sky.addColorStop(0, premiumShadows ? "#f0f7fb" : "#e8f1f7");
835
- sky.addColorStop(0.6, premiumShadows ? "#c7d9e5" : "#b9ceda");
836
- sky.addColorStop(1, premiumShadows ? "#84a7bd" : "#7b9bb0");
1095
+ sky.addColorStop(0, visuals.skyTop);
1096
+ sky.addColorStop(0.6, visuals.skyMid);
1097
+ sky.addColorStop(1, visuals.skyBottom);
837
1098
  ctx.fillStyle = sky;
838
1099
  ctx.fillRect(0, 0, canvas.width, canvas.height);
839
1100
 
840
1101
  const shoreline = ctx.createLinearGradient(0, canvas.height * 0.45, 0, canvas.height);
841
- shoreline.addColorStop(0, premiumShadows ? "#235064" : "#264c5f");
842
- shoreline.addColorStop(0.52, premiumShadows ? "#153e53" : "#173d4f");
843
- shoreline.addColorStop(1, "#0b2433");
1102
+ shoreline.addColorStop(0, visuals.seaTop);
1103
+ shoreline.addColorStop(0.52, visuals.seaMid);
1104
+ shoreline.addColorStop(1, visuals.seaBottom);
844
1105
  ctx.fillStyle = shoreline;
845
1106
  ctx.fillRect(0, canvas.height * 0.45, canvas.width, canvas.height * 0.55);
846
1107
 
847
1108
  const sunX = mix(canvas.width * 0.16, canvas.width * 0.84, (Math.sin(state.time * 0.12) + 1) * 0.5);
848
1109
  const sunY = canvas.height * 0.14 + Math.cos(state.time * 0.12) * 22;
849
1110
  const sun = ctx.createRadialGradient(sunX, sunY, 10, sunX, sunY, 90);
850
- sun.addColorStop(0, "rgba(255, 244, 210, 0.9)");
1111
+ sun.addColorStop(0, visuals.sunCore);
851
1112
  sun.addColorStop(1, "rgba(255, 244, 210, 0)");
852
1113
  ctx.fillStyle = sun;
853
1114
  ctx.beginPath();
@@ -934,27 +1195,27 @@ function renderProjectedShadow(ctx, worldPoints, camera, viewport, lightDir, opt
934
1195
  ctx.restore();
935
1196
  }
936
1197
 
937
- function pushHarborGeometry(camera, viewport, triangles) {
1198
+ function pushHarborGeometry(camera, viewport, triangles, visuals) {
938
1199
  const harborObjects = [
939
1200
  {
940
1201
  position: vec3(-8.2, 1.1, -0.9),
941
1202
  rotationY: -0.16,
942
1203
  scale: { x: 5.4, y: 2.4, z: 4.2 },
943
- color: { r: 0.48, g: 0.4, b: 0.32 },
1204
+ color: visuals.harborWall,
944
1205
  accent: 0.06,
945
1206
  },
946
1207
  {
947
1208
  position: vec3(-5.7, 0.45, 1.4),
948
1209
  rotationY: -0.08,
949
1210
  scale: { x: 6.8, y: 0.3, z: 2.1 },
950
- color: { r: 0.5, g: 0.34, b: 0.22 },
1211
+ color: visuals.harborDeck,
951
1212
  accent: 0.04,
952
1213
  },
953
1214
  {
954
1215
  position: vec3(-10.4, 0.28, 0.8),
955
1216
  rotationY: 0.22,
956
1217
  scale: { x: 1.2, y: 0.9, z: 1.2 },
957
- color: { r: 0.34, g: 0.32, b: 0.36 },
1218
+ color: visuals.harborTower,
958
1219
  accent: 0.02,
959
1220
  },
960
1221
  ];
@@ -1045,7 +1306,7 @@ function renderClothAccent(ctx, cloth, camera, viewport) {
1045
1306
  cloth.grid.rows * cloth.grid.cols - 1,
1046
1307
  (cloth.grid.rows - 1) * cloth.grid.cols,
1047
1308
  ];
1048
- ctx.fillStyle = "rgba(164, 44, 28, 0.95)";
1309
+ ctx.fillStyle = colorToRgba(cloth.color, 0.95);
1049
1310
  for (const index of borderIndices) {
1050
1311
  const point = projected[index];
1051
1312
  if (!point) {
@@ -1109,12 +1370,15 @@ function updateShips(state, dt, shipModel) {
1109
1370
  const physics = shipModel.physics;
1110
1371
  const halfExtents = physics.halfExtents ?? [1.35, 0.95, 3.9];
1111
1372
  let collided = false;
1373
+ state.contactCount = 0;
1112
1374
  for (const ship of state.ships) {
1113
1375
  ship.position = addVec3(ship.position, scaleVec3(ship.velocity, dt));
1114
1376
  ship.rotationY += ship.angularVelocity * dt;
1115
1377
  ship.velocity = scaleVec3(ship.velocity, 1 - (physics.linearDamping ?? 0.04) * dt);
1116
1378
  ship.angularVelocity *= 1 - (physics.angularDamping ?? 0.08) * dt;
1117
- ship.position.y = sampleWave(ship.position.x, ship.position.z, state.time) * 0.22 + (physics.waterline ?? 0.42);
1379
+ ship.position.y =
1380
+ sampleWave(state, ship.position.x, ship.position.z, state.time) * 0.22 +
1381
+ (physics.waterline ?? 0.42);
1118
1382
  if (Math.abs(ship.position.x) > 10) {
1119
1383
  ship.velocity.x *= -1;
1120
1384
  ship.angularVelocity *= -1;
@@ -1142,12 +1406,29 @@ function updateShips(state, dt, shipModel) {
1142
1406
  b.angularVelocity -= 0.55;
1143
1407
  const contactPoint = vec3((a.position.x + b.position.x) * 0.5, (a.position.y + b.position.y) * 0.5 + 0.1, (a.position.z + b.position.z) * 0.5);
1144
1408
  spawnSpray(state, contactPoint, Math.abs(dx) + Math.abs(dz));
1409
+ state.waveImpulses.push({
1410
+ x: contactPoint.x,
1411
+ z: contactPoint.z,
1412
+ strength: Math.min(1.4, 0.2 + (Math.abs(dx) + Math.abs(dz)) * 0.18),
1413
+ radius: 0.8,
1414
+ life: 1,
1415
+ });
1145
1416
  state.collisionCount += 1;
1417
+ state.contactCount = 1;
1146
1418
  collided = true;
1147
1419
  }
1148
1420
  state.collisionFlash = collided ? 1 : Math.max(0, state.collisionFlash - dt * 1.8);
1149
1421
  }
1150
1422
 
1423
+ function updateWaveImpulses(state, dt) {
1424
+ state.waveImpulses = state.waveImpulses
1425
+ .map((impulse) => ({
1426
+ ...impulse,
1427
+ life: impulse.life - dt * 0.55,
1428
+ }))
1429
+ .filter((impulse) => impulse.life > 0);
1430
+ }
1431
+
1151
1432
  function updateSpray(state, dt) {
1152
1433
  state.sprays = state.sprays
1153
1434
  .map((particle) => {
@@ -1268,7 +1549,7 @@ function renderShipShadow(ctx, shipModel, ship, state, camera, viewport, lightDi
1268
1549
  ].map((point) => transformPoint(point, transform));
1269
1550
 
1270
1551
  renderProjectedShadow(ctx, hullCorners, camera, viewport, lightDir, {
1271
- planeY: sampleWave(ship.position.x, ship.position.z, state.time) * 0.24 - 0.03,
1552
+ planeY: sampleWave(state, ship.position.x, ship.position.z, state.time) * 0.24 - 0.03,
1272
1553
  alpha: 0.08 + shadowStrength * 0.2,
1273
1554
  blur: 14 + shadowStrength * 24,
1274
1555
  });
@@ -1300,13 +1581,31 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
1300
1581
  const nearLighting = lightingPlan.bands.find((entry) => entry.band === "near") ?? lightingPlan.bands[0];
1301
1582
  const lightDir = normalizeVec3(vec3(-0.45, 0.85, -0.24));
1302
1583
  const lightingSnapshot = state.lightingDetail.getSnapshot();
1303
- const reflectionStrength = lightingSnapshot.currentLevel.config.reflectionStrength;
1304
- const shadowStrength = lightingSnapshot.currentLevel.config.shadowStrength;
1305
- drawSkyAndShore(ctx, canvas, state, nearLighting, reflectionStrength, shadowStrength);
1584
+ const visuals = resolveVisualConfig(
1585
+ nearLighting,
1586
+ lightingSnapshot,
1587
+ state.demoDescription?.visuals
1588
+ );
1589
+ state.demoVisuals = visuals;
1590
+ const reflectionStrength = visuals.reflectionStrength;
1591
+ const shadowStrength = visuals.shadowAccent;
1592
+ drawSkyAndShore(
1593
+ ctx,
1594
+ canvas,
1595
+ state,
1596
+ nearLighting,
1597
+ reflectionStrength,
1598
+ shadowStrength,
1599
+ visuals
1600
+ );
1306
1601
 
1307
1602
  const triangles = [];
1308
- pushHarborGeometry(camera, viewport, triangles);
1309
- const water = buildWaterBands(state, state.fluidDetail.getSnapshot().currentLevel.config);
1603
+ pushHarborGeometry(camera, viewport, triangles, visuals);
1604
+ const water = buildWaterBands(
1605
+ state,
1606
+ state.fluidDetail.getSnapshot().currentLevel.config,
1607
+ visuals
1608
+ );
1310
1609
  for (const bandMesh of water.bandMeshes) {
1311
1610
  const bandAccent = bandMesh.band === "near" ? 0.06 : bandMesh.band === "mid" ? 0.04 : 0;
1312
1611
  for (let index = 0; index < bandMesh.indices.length; index += 3) {
@@ -1329,7 +1628,12 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
1329
1628
  }
1330
1629
  }
1331
1630
 
1332
- const cloth = buildClothSurface(state, state, state.clothDetail.getSnapshot().currentLevel.config);
1631
+ const cloth = buildClothSurface(
1632
+ state,
1633
+ state,
1634
+ state.clothDetail.getSnapshot().currentLevel.config,
1635
+ visuals
1636
+ );
1333
1637
  for (let index = 0; index < cloth.indices.length; index += 3) {
1334
1638
  const a = cloth.positions[cloth.indices[index]];
1335
1639
  const b = cloth.positions[cloth.indices[index + 1]];
@@ -1344,7 +1648,7 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
1344
1648
  depth: (projected[0].depth + projected[1].depth + projected[2].depth) / 3,
1345
1649
  worldCenter: scaleVec3(addVec3(addVec3(a, b), c), 1 / 3),
1346
1650
  normal,
1347
- baseColor: { r: 0.76, g: 0.24, b: 0.18 },
1651
+ baseColor: cloth.color,
1348
1652
  accent: cloth.band === "near" ? 0.1 : 0.04,
1349
1653
  });
1350
1654
  }
@@ -1415,21 +1719,37 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
1415
1719
  "Near-field lighting keeps the ray-traced-primary shadow impression so the collision read stays crisp.",
1416
1720
  ]
1417
1721
  : SCENE_NOTES;
1722
+ const custom = state.demoDescription ?? null;
1418
1723
 
1419
- setListContent(dom.sceneMetrics, sceneMetrics);
1420
- setListContent(dom.qualityMetrics, qualityMetrics);
1421
- setListContent(dom.debugMetrics, debugMetrics);
1422
- setListContent(dom.sceneNotes, sceneNotes);
1724
+ setListContent(
1725
+ dom.sceneMetrics,
1726
+ Array.isArray(custom?.sceneMetrics) ? custom.sceneMetrics : sceneMetrics
1727
+ );
1728
+ setListContent(
1729
+ dom.qualityMetrics,
1730
+ Array.isArray(custom?.qualityMetrics) ? custom.qualityMetrics : qualityMetrics
1731
+ );
1732
+ setListContent(
1733
+ dom.debugMetrics,
1734
+ Array.isArray(custom?.debugMetrics) ? custom.debugMetrics : debugMetrics
1735
+ );
1736
+ setListContent(dom.sceneNotes, Array.isArray(custom?.notes) ? custom.notes : sceneNotes);
1423
1737
 
1424
- dom.status.textContent = `3D scene live · ${state.lastDecision.metrics.fps.toFixed(1)} FPS`;
1738
+ dom.status.textContent =
1739
+ typeof custom?.status === "string"
1740
+ ? custom.status
1741
+ : `3D scene live · ${state.lastDecision.metrics.fps.toFixed(1)} FPS`;
1425
1742
  dom.details.textContent =
1426
- state.focus === "physics"
1427
- ? `Stable world snapshots are emitted from ${state.physics.plan.snapshotStageId} after the authoritative solver; GLTF ships collide on ${shipModel.physics.shape ?? "box"} volumes while visual follow-up remains downstream.`
1428
- : `GLTF ships are colliding with ${shipModel.physics.shape ?? "box"} physics volumes; cloth and fluid remain continuous while the governor pressure is ${state.lastDecision.pressureLevel}.`;
1743
+ typeof custom?.details === "string"
1744
+ ? custom.details
1745
+ : state.focus === "physics"
1746
+ ? `Stable world snapshots are emitted from ${state.physics.plan.snapshotStageId} after the authoritative solver; GLTF ships collide on ${shipModel.physics.shape ?? "box"} volumes while visual follow-up remains downstream.`
1747
+ : `GLTF ships are colliding with ${shipModel.physics.shape ?? "box"} physics volumes; cloth and fluid remain continuous while the governor pressure is ${state.lastDecision.pressureLevel}.`;
1429
1748
  }
1430
1749
 
1431
1750
  function updateSceneState(state, dt, shipModel) {
1432
1751
  updateShips(state, dt, shipModel);
1752
+ updateWaveImpulses(state, dt);
1433
1753
  updateSpray(state, dt);
1434
1754
  updatePhysicsSnapshot(state, shipModel);
1435
1755
  }
@@ -1449,6 +1769,7 @@ function syncTextState(state, shipModel) {
1449
1769
  })),
1450
1770
  shipPhysics: shipModel.physics,
1451
1771
  sprays: state.sprays.length,
1772
+ waveImpulses: state.waveImpulses.length,
1452
1773
  pressure: state.lastDecision?.pressureLevel ?? "stable",
1453
1774
  physics: {
1454
1775
  profile: state.physics.profile,
@@ -1456,6 +1777,7 @@ function syncTextState(state, shipModel) {
1456
1777
  workerJobCount: state.physics.manifest.jobs.length,
1457
1778
  snapshot: state.physics.snapshot,
1458
1779
  },
1780
+ package: state.demoDescription?.textState ?? null,
1459
1781
  };
1460
1782
  window.render_game_to_text = () => JSON.stringify(snapshot);
1461
1783
  window.advanceTime = (ms) => {
@@ -1472,6 +1794,9 @@ function syncTextState(state, shipModel) {
1472
1794
  export async function mountGpuShowcase(options = {}) {
1473
1795
  injectStyles();
1474
1796
  const root = options.root ?? document.body;
1797
+ const previousMarkup = root.innerHTML;
1798
+ const previousRenderGameToText = window.render_game_to_text;
1799
+ const previousAdvanceTime = window.advanceTime;
1475
1800
  const focus = options.focus ?? new URLSearchParams(window.location.search).get("focus") ?? "integrated";
1476
1801
  const dom = buildDemoDom(root, {
1477
1802
  packageName: options.packageName ?? "@plasius/gpu-demo-viewer",
@@ -1483,12 +1808,22 @@ export async function mountGpuShowcase(options = {}) {
1483
1808
  const state = createSceneState({ focus });
1484
1809
  const shipModel = await loadGltfModel(resolveShowcaseAssetUrl());
1485
1810
  state.shipModel = shipModel;
1811
+ state.packageState =
1812
+ typeof options.createState === "function" ? options.createState() : undefined;
1486
1813
  updatePhysicsSnapshot(state, shipModel);
1487
1814
  state.lastDecision = recordTelemetry(state, 16.4);
1815
+ state.demoDescription = resolveSceneDescription(state, options, shipModel).description;
1488
1816
  syncTextState(state, shipModel);
1489
1817
 
1490
1818
  const ctx = dom.canvas.getContext("2d");
1819
+ let destroyed = false;
1820
+ let frameHandle = null;
1821
+
1491
1822
  const renderFrame = (nowMs) => {
1823
+ if (destroyed) {
1824
+ return;
1825
+ }
1826
+
1492
1827
  if (!state.paused) {
1493
1828
  if (state.lastTimeMs == null) {
1494
1829
  state.lastTimeMs = nowMs;
@@ -1498,35 +1833,67 @@ export async function mountGpuShowcase(options = {}) {
1498
1833
  state.time += dt;
1499
1834
  state.frame += 1;
1500
1835
  updateSceneState(state, dt, shipModel);
1836
+ updatePackageState(state, options, shipModel, dt);
1501
1837
  const syntheticFrame = 14.2 + state.sprays.length * 0.1 + (state.stress ? 6.4 : 0);
1502
1838
  state.lastDecision = recordTelemetry(state, syntheticFrame);
1503
1839
  }
1504
1840
 
1841
+ state.demoDescription = resolveSceneDescription(state, options, shipModel).description;
1505
1842
  renderScene(ctx, dom.canvas, state, shipModel, dom);
1506
1843
  syncTextState(state, shipModel);
1507
- requestAnimationFrame(renderFrame);
1844
+ frameHandle = requestAnimationFrame(renderFrame);
1508
1845
  };
1509
1846
 
1510
- dom.pauseButton.addEventListener("click", () => {
1847
+ const handlePauseClick = () => {
1511
1848
  state.paused = !state.paused;
1512
1849
  dom.pauseButton.textContent = state.paused ? "Resume" : "Pause";
1513
- });
1514
- dom.stressToggle.addEventListener("change", () => {
1850
+ };
1851
+ const handleStressChange = () => {
1515
1852
  state.stress = dom.stressToggle.checked;
1516
- });
1517
- dom.focusMode.addEventListener("change", () => {
1853
+ };
1854
+ const handleFocusChange = () => {
1518
1855
  state.focus = dom.focusMode.value;
1519
1856
  Object.assign(state.camera, {
1520
1857
  ...CAMERA_PRESETS[state.focus],
1521
1858
  target: vec3(...CAMERA_PRESETS[state.focus].target),
1522
1859
  });
1523
- });
1860
+ };
1861
+
1862
+ dom.pauseButton.addEventListener("click", handlePauseClick);
1863
+ dom.stressToggle.addEventListener("change", handleStressChange);
1864
+ dom.focusMode.addEventListener("change", handleFocusChange);
1524
1865
 
1525
- requestAnimationFrame(renderFrame);
1866
+ frameHandle = requestAnimationFrame(renderFrame);
1526
1867
  return {
1527
1868
  state,
1528
1869
  shipModel,
1529
1870
  canvas: dom.canvas,
1871
+ destroy() {
1872
+ if (destroyed) {
1873
+ return;
1874
+ }
1875
+
1876
+ destroyed = true;
1877
+ if (frameHandle != null) {
1878
+ cancelAnimationFrame(frameHandle);
1879
+ }
1880
+ dom.pauseButton.removeEventListener("click", handlePauseClick);
1881
+ dom.stressToggle.removeEventListener("change", handleStressChange);
1882
+ dom.focusMode.removeEventListener("change", handleFocusChange);
1883
+ root.innerHTML = previousMarkup;
1884
+
1885
+ if (typeof previousRenderGameToText === "function") {
1886
+ window.render_game_to_text = previousRenderGameToText;
1887
+ } else {
1888
+ delete window.render_game_to_text;
1889
+ }
1890
+
1891
+ if (typeof previousAdvanceTime === "function") {
1892
+ window.advanceTime = previousAdvanceTime;
1893
+ } else {
1894
+ delete window.advanceTime;
1895
+ }
1896
+ },
1530
1897
  };
1531
1898
  }
1532
1899
 
@@ -1544,6 +1911,7 @@ function updatePhysicsSnapshot(state, shipModel) {
1544
1911
  contactCount: state.collisionFlash > 0.02 ? 1 : 0,
1545
1912
  metadata: {
1546
1913
  collisionCount: state.collisionCount,
1914
+ contactCount: state.contactCount,
1547
1915
  snapshotStageId: state.physics.plan.snapshotStageId,
1548
1916
  rigidBodyShape: shipModel.physics.shape ?? "box",
1549
1917
  },