@plasius/gpu-shared 0.1.13 → 0.1.14

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +55 -3
  3. package/dist/{chunk-NCPJWLX3.js → chunk-2GM64LB6.js} +1 -9
  4. package/dist/{chunk-NCPJWLX3.js.map → chunk-2GM64LB6.js.map} +1 -1
  5. package/dist/{chunk-DABW627O.js → chunk-3ARPGHCQ.js} +8 -2
  6. package/dist/chunk-3ARPGHCQ.js.map +1 -0
  7. package/dist/chunk-4ZJ24VRS.js +402 -0
  8. package/dist/chunk-4ZJ24VRS.js.map +1 -0
  9. package/dist/{chunk-DQX4DXBR.js → chunk-W5GA3VA6.js} +79 -6
  10. package/dist/chunk-W5GA3VA6.js.map +1 -0
  11. package/dist/gltf-loader-YDPLZS5Q.js +8 -0
  12. package/dist/index.cjs +1230 -6198
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.js +31 -5
  15. package/dist/index.js.map +1 -1
  16. package/dist/product-studio-runtime-HDAUDWYO.js +11 -0
  17. package/dist/showcase-inline-assets-WT4PSNKI.js +7 -0
  18. package/dist/showcase-inline-assets-WT4PSNKI.js.map +1 -0
  19. package/dist/showcase-runtime-SNCUFSSC.js +3785 -0
  20. package/dist/showcase-runtime-SNCUFSSC.js.map +1 -0
  21. package/package.json +6 -8
  22. package/src/feature-flags.js +1 -0
  23. package/src/gltf-loader.js +10 -2
  24. package/src/index.d.ts +72 -1
  25. package/src/index.js +33 -0
  26. package/src/product-studio-runtime.js +465 -0
  27. package/src/showcase-runtime.js +875 -72
  28. package/dist/chunk-2FIFSBB4.js +0 -74
  29. package/dist/chunk-2FIFSBB4.js.map +0 -1
  30. package/dist/chunk-DABW627O.js.map +0 -1
  31. package/dist/chunk-DQX4DXBR.js.map +0 -1
  32. package/dist/gltf-loader-WAM23F37.js +0 -9
  33. package/dist/showcase-inline-assets-B7U7VX5H.js +0 -7
  34. package/dist/showcase-runtime-PN7N3FZY.js +0 -9164
  35. package/dist/showcase-runtime-PN7N3FZY.js.map +0 -1
  36. /package/dist/{gltf-loader-WAM23F37.js.map → gltf-loader-YDPLZS5Q.js.map} +0 -0
  37. /package/dist/{showcase-inline-assets-B7U7VX5H.js.map → product-studio-runtime-HDAUDWYO.js.map} +0 -0
@@ -0,0 +1,3785 @@
1
+ import {
2
+ GPU_SHOWCASE_REALISTIC_MODELS_FEATURE,
3
+ createGpuSharedTranslator,
4
+ gpuSharedTranslationKeys
5
+ } from "./chunk-3ARPGHCQ.js";
6
+ import {
7
+ loadGltfModel,
8
+ resolveShowcaseAssetUrl
9
+ } from "./chunk-W5GA3VA6.js";
10
+ import "./chunk-2GM64LB6.js";
11
+
12
+ // src/showcase-runtime.js
13
+ var STYLE_ID = "plasius-shared-3d-showcase-style";
14
+ var ROOT_CLASS = "plasius-showcase-root";
15
+ var CAPTURE_CLASS = "plasius-showcase-root--capture";
16
+ var DEFAULT_CANVAS_WIDTH = 1280;
17
+ var DEFAULT_CANVAS_HEIGHT = 720;
18
+ var CAPTURE_CANVAS_PIXEL_BUDGET = 1920 * 1080;
19
+ var SHIP_SCALE = 1.1;
20
+ var HARBOR_BOUNDS = Object.freeze({
21
+ minX: -11.2,
22
+ maxX: 11.2,
23
+ minZ: 1.8,
24
+ maxZ: 17.2
25
+ });
26
+ var CAMERA_PRESETS = Object.freeze({
27
+ integrated: Object.freeze({ yaw: -0.55, pitch: 0.34, distance: 27, target: [0, 2.2, 0] }),
28
+ lighting: Object.freeze({ yaw: -0.28, pitch: 0.28, distance: 23, target: [0, 2.8, 0] }),
29
+ cloth: Object.freeze({ yaw: -1.1, pitch: 0.25, distance: 15, target: [-8.4, 5.3, -1.5] }),
30
+ fluid: Object.freeze({ yaw: -0.4, pitch: 0.18, distance: 18, target: [0, 1.2, 6] }),
31
+ physics: Object.freeze({ yaw: -0.12, pitch: 0.27, distance: 16, target: [0, 1.8, 6.8] }),
32
+ performance: Object.freeze({ yaw: -0.65, pitch: 0.36, distance: 24, target: [0, 2.2, 0] }),
33
+ debug: Object.freeze({ yaw: -0.7, pitch: 0.32, distance: 24, target: [0, 2.2, 0] })
34
+ });
35
+ var FALLBACK_LIGHTING_DISTANCE_BANDS = Object.freeze([
36
+ Object.freeze({
37
+ band: "near",
38
+ primaryShadowSource: "ray-traced-primary",
39
+ rtParticipation: Object.freeze({
40
+ reflections: "full",
41
+ globalIllumination: "high",
42
+ directShadows: "premium"
43
+ }),
44
+ updateCadenceDivisor: 1
45
+ }),
46
+ Object.freeze({
47
+ band: "mid",
48
+ primaryShadowSource: "ray-traced-secondary",
49
+ rtParticipation: Object.freeze({
50
+ reflections: "medium",
51
+ globalIllumination: "medium",
52
+ directShadows: "selective"
53
+ }),
54
+ updateCadenceDivisor: 2
55
+ }),
56
+ Object.freeze({
57
+ band: "far",
58
+ primaryShadowSource: "baked",
59
+ rtParticipation: Object.freeze({
60
+ reflections: "low",
61
+ globalIllumination: "low",
62
+ directShadows: "none"
63
+ }),
64
+ updateCadenceDivisor: 4
65
+ }),
66
+ Object.freeze({
67
+ band: "horizon",
68
+ primaryShadowSource: "impression",
69
+ rtParticipation: Object.freeze({
70
+ reflections: "off",
71
+ globalIllumination: "off",
72
+ directShadows: "none"
73
+ }),
74
+ updateCadenceDivisor: 8
75
+ })
76
+ ]);
77
+ var FALLBACK_LIGHTING_PROFILE = "cinematic";
78
+ var FALLBACK_PHYSICS_PROFILE = "cinematic";
79
+ var FALLBACK_PERFORMANCE_LEVELS = Object.freeze({
80
+ fluid: Object.freeze([
81
+ Object.freeze({
82
+ id: "low",
83
+ config: Object.freeze({ nearResolution: 10, midResolution: 6, splashCount: 10 }),
84
+ estimatedCostMs: 0.8
85
+ }),
86
+ Object.freeze({
87
+ id: "medium",
88
+ config: Object.freeze({ nearResolution: 16, midResolution: 8, splashCount: 18 }),
89
+ estimatedCostMs: 1.4
90
+ }),
91
+ Object.freeze({
92
+ id: "high",
93
+ config: Object.freeze({ nearResolution: 24, midResolution: 12, splashCount: 28 }),
94
+ estimatedCostMs: 2.4
95
+ })
96
+ ]),
97
+ cloth: Object.freeze([
98
+ Object.freeze({
99
+ id: "low",
100
+ config: Object.freeze({ cols: 10, rows: 7 }),
101
+ estimatedCostMs: 0.7
102
+ }),
103
+ Object.freeze({
104
+ id: "medium",
105
+ config: Object.freeze({ cols: 16, rows: 11 }),
106
+ estimatedCostMs: 1.3
107
+ }),
108
+ Object.freeze({
109
+ id: "high",
110
+ config: Object.freeze({ cols: 24, rows: 16 }),
111
+ estimatedCostMs: 2.1
112
+ })
113
+ ]),
114
+ lighting: Object.freeze([
115
+ Object.freeze({
116
+ id: "low",
117
+ config: Object.freeze({ shadowStrength: 0.18, reflectionStrength: 0.08 }),
118
+ estimatedCostMs: 0.5
119
+ }),
120
+ Object.freeze({
121
+ id: "medium",
122
+ config: Object.freeze({ shadowStrength: 0.34, reflectionStrength: 0.16 }),
123
+ estimatedCostMs: 1
124
+ }),
125
+ Object.freeze({
126
+ id: "high",
127
+ config: Object.freeze({ shadowStrength: 0.5, reflectionStrength: 0.24 }),
128
+ estimatedCostMs: 1.8
129
+ })
130
+ ])
131
+ });
132
+ function createFallbackClothFeatureModule() {
133
+ const fallback = createFallbackClothFeatureAdapters();
134
+ return {
135
+ clothGarmentKinds: fallback.garmentKinds,
136
+ clothProfileNames: fallback.profileNames,
137
+ createClothRepresentationPlan: fallback.createPlan,
138
+ selectClothRepresentationBand: fallback.selectBand
139
+ };
140
+ }
141
+ function createFallbackFluidFeatureModule() {
142
+ const fallback = createFallbackFluidFeatureAdapters();
143
+ return {
144
+ fluidBodyKinds: fallback.bodyKinds,
145
+ fluidProfileNames: fallback.profileNames,
146
+ createFluidContinuityEnvelope: fallback.createContinuityEnvelope,
147
+ createFluidRepresentationPlan: fallback.createPlan,
148
+ selectFluidRepresentationBand: fallback.selectBand
149
+ };
150
+ }
151
+ function createFallbackLightingFeatureModule() {
152
+ return {
153
+ createLightingBandPlan({ profile = FALLBACK_LIGHTING_PROFILE, importance = "high" } = {}) {
154
+ const defaultPlan = {
155
+ profile,
156
+ importance,
157
+ bands: FALLBACK_LIGHTING_DISTANCE_BANDS
158
+ };
159
+ return defaultPlan;
160
+ },
161
+ defaultLightingProfile: FALLBACK_LIGHTING_PROFILE,
162
+ getLightingProfile(profile = FALLBACK_LIGHTING_PROFILE) {
163
+ return {
164
+ name: profile,
165
+ bands: FALLBACK_LIGHTING_DISTANCE_BANDS
166
+ };
167
+ },
168
+ lightingDistanceBands: FALLBACK_LIGHTING_DISTANCE_BANDS
169
+ };
170
+ }
171
+ function createFallbackPerformanceQualityState(levels = [], initialLevel = "high", profile = "") {
172
+ const fallbackLevels = levels.length ? levels : FALLBACK_PERFORMANCE_LEVELS[profile] ?? [];
173
+ const resolvedLevels = fallbackLevels.map((entry) => ({
174
+ id: String(entry?.id ?? "high"),
175
+ config: entry?.config ?? {},
176
+ estimatedCostMs: Number.isFinite(Number(entry?.estimatedCostMs)) ? Number(entry.estimatedCostMs) : 1
177
+ }));
178
+ if (resolvedLevels.length === 0) {
179
+ return {
180
+ module: {
181
+ id: "high",
182
+ config: Object.freeze({}),
183
+ estimatedCostMs: 1
184
+ },
185
+ getSnapshot() {
186
+ return {
187
+ currentLevel: {
188
+ id: "high",
189
+ config: Object.freeze({}),
190
+ estimatedCostMs: 1
191
+ }
192
+ };
193
+ },
194
+ id: "high"
195
+ };
196
+ }
197
+ const initial = resolvedLevels.find((entry) => entry.id === initialLevel) ?? resolvedLevels[0];
198
+ return {
199
+ id: initial.id,
200
+ getSnapshot() {
201
+ return {
202
+ currentLevel: {
203
+ id: initial.id,
204
+ config: initial.config,
205
+ estimatedCostMs: initial.estimatedCostMs
206
+ }
207
+ };
208
+ }
209
+ };
210
+ }
211
+ function createFallbackPerformanceFeatureModule() {
212
+ const moduleIds = /* @__PURE__ */ new Set(["fluid-detail", "cloth-detail", "lighting-detail"]);
213
+ return {
214
+ createDeviceProfile(profile = {}) {
215
+ return {
216
+ deviceClass: "desktop",
217
+ mode: "flat",
218
+ refreshRateHz: Number.isFinite(profile?.refreshRateHz) ? Number(profile.refreshRateHz) : 60,
219
+ supportedFrameRates: Array.isArray(profile?.supportedFrameRates) ? profile.supportedFrameRates : [60, 90],
220
+ supportsWebGpu: true
221
+ };
222
+ },
223
+ createQualityLadderAdapter({ id, domain, levels, initialLevel }) {
224
+ return {
225
+ id: String(id ?? ""),
226
+ domain,
227
+ ...createFallbackPerformanceQualityState(
228
+ domain === "fluid" ? FALLBACK_PERFORMANCE_LEVELS.fluid : domain === "cloth" ? FALLBACK_PERFORMANCE_LEVELS.cloth : FALLBACK_PERFORMANCE_LEVELS.lighting,
229
+ initialLevel,
230
+ domain
231
+ )
232
+ };
233
+ },
234
+ createGpuPerformanceGovernor({ device, modules = [], adaptation = {} } = {}) {
235
+ let pressureLevel = "stable";
236
+ let frameSamples = 0;
237
+ let averageMs = 16.67;
238
+ const clamp2 = (next = 16.67) => Number.isFinite(next) ? Math.max(1, next) : 16.67;
239
+ const target = Object.freeze({
240
+ targetFrameTimeMs: 16.67,
241
+ downgradeFrameTimeMs: clamp2(adaptation?.degradeThresholdMs ?? 20),
242
+ upgradeFrameTimeMs: clamp2(adaptation?.upgradeThresholdMs ?? 14)
243
+ });
244
+ return {
245
+ recordFrame({ frameTimeMs = averageMs } = {}) {
246
+ const sample = Number.isFinite(Number(frameTimeMs)) ? Number(frameTimeMs) : averageMs;
247
+ frameSamples += 1;
248
+ averageMs = clamp2((averageMs * (frameSamples - 1) + sample) / frameSamples);
249
+ const fps = 1e3 / averageMs;
250
+ pressureLevel = sample > target.downgradeFrameTimeMs ? "degrade" : pressureLevel === "degrade" && sample <= target.upgradeFrameTimeMs ? "stable" : pressureLevel;
251
+ return {
252
+ pressureLevel,
253
+ metrics: {
254
+ fps,
255
+ averageFrameTimeMs: averageMs
256
+ },
257
+ adjustments: pressureLevel === "degrade" ? [{ type: "capability-step-down" }] : []
258
+ };
259
+ },
260
+ getTarget() {
261
+ return target;
262
+ },
263
+ getState() {
264
+ return {
265
+ modules: modules.filter((entry) => entry != null && typeof entry.id === "string" && moduleIds.has(entry.id)).map((entry) => ({
266
+ isAtMaximum: pressureLevel === "stable"
267
+ }))
268
+ };
269
+ }
270
+ };
271
+ }
272
+ };
273
+ }
274
+ function createFallbackDebugFeatureModule() {
275
+ let queueSamples = 0;
276
+ let queuePeakDepth = 0;
277
+ let readyLaneSamples = 0;
278
+ let readyLanePeakDepth = 0;
279
+ let dispatchSamples = 0;
280
+ let dispatchDurationTotal = 0;
281
+ let dependencyUnlockSamples = 0;
282
+ let dependencyUnlockCount = 0;
283
+ let pipelineSamples = 0;
284
+ let pipelineAgeTotal = 0;
285
+ let frameSamples = 0;
286
+ let frameTimeTotal = 0;
287
+ let gpuBusyTotal = 0;
288
+ let frameDroppedSamples = 0;
289
+ let memoryTotalBytes = 0;
290
+ function ensureNumber(value, fallback = 0) {
291
+ const asNumber = Number(value);
292
+ return Number.isFinite(asNumber) ? asNumber : fallback;
293
+ }
294
+ function createDebugSession({ adapter } = {}) {
295
+ const memoryHint = Number.isFinite(Number(adapter?.memoryCapacityHintBytes)) ? Number(adapter.memoryCapacityHintBytes) : 0;
296
+ memoryTotalBytes = Math.max(0, memoryHint);
297
+ return {
298
+ trackAllocation({ sizeBytes = 0 } = {}) {
299
+ memoryTotalBytes += ensureNumber(sizeBytes);
300
+ },
301
+ recordQueue({ depth = 0 } = {}) {
302
+ queueSamples += 1;
303
+ queuePeakDepth = Math.max(queuePeakDepth, ensureNumber(depth, 0));
304
+ },
305
+ recordReadyLane({ depth = 0 } = {}) {
306
+ readyLaneSamples += 1;
307
+ readyLanePeakDepth = Math.max(readyLanePeakDepth, ensureNumber(depth, 0));
308
+ },
309
+ recordDispatch({ durationMs = 0 } = {}) {
310
+ dispatchSamples += 1;
311
+ dispatchDurationTotal += ensureNumber(durationMs);
312
+ },
313
+ recordDependencyUnlock({ unlockCount = 0 } = {}) {
314
+ dependencyUnlockSamples += 1;
315
+ dependencyUnlockCount += ensureNumber(unlockCount);
316
+ },
317
+ recordPipelinePhase({ snapshotAgeMs = 0 } = {}) {
318
+ pipelineSamples += 1;
319
+ pipelineAgeTotal += ensureNumber(snapshotAgeMs);
320
+ },
321
+ recordFrame({
322
+ frameTimeMs = 16.67,
323
+ targetFrameTimeMs = 16.67,
324
+ gpuBusyMs = 0,
325
+ dropped = false
326
+ } = {}) {
327
+ frameSamples += 1;
328
+ frameTimeTotal += ensureNumber(frameTimeMs);
329
+ gpuBusyTotal += ensureNumber(gpuBusyMs);
330
+ frameDroppedSamples += dropped === true ? 1 : 0;
331
+ targetFrameTimeMs;
332
+ },
333
+ getSnapshot() {
334
+ const queueAverageDepth = queueSamples > 0 ? queuePeakDepth / queueSamples : 0;
335
+ return {
336
+ frames: {
337
+ sampleCount: frameSamples,
338
+ averageFrameTimeMs: frameSamples > 0 ? frameTimeTotal / frameSamples : 0,
339
+ averageGpuBusyMs: frameSamples > 0 ? gpuBusyTotal / frameSamples : 0,
340
+ droppedCount: frameDroppedSamples
341
+ },
342
+ queues: {
343
+ sampleCount: queueSamples,
344
+ peakDepth: queuePeakDepth,
345
+ averageDepth: queueAverageDepth
346
+ },
347
+ dispatch: {
348
+ sampleCount: dispatchSamples,
349
+ averageDurationMs: dispatchSamples > 0 ? dispatchDurationTotal / dispatchSamples : 0
350
+ },
351
+ dag: {
352
+ peakReadyLaneDepth: readyLanePeakDepth,
353
+ totalUnlockCount: dependencyUnlockCount,
354
+ unlockSamples: dependencyUnlockSamples
355
+ },
356
+ pipeline: {
357
+ sampleCount: pipelineSamples,
358
+ averageSnapshotAgeMs: pipelineSamples > 0 ? pipelineAgeTotal / pipelineSamples : 0
359
+ },
360
+ memory: {
361
+ totalTrackedBytes: memoryTotalBytes
362
+ }
363
+ };
364
+ }
365
+ };
366
+ }
367
+ return {
368
+ createGpuDebugSession({ adapter } = {}) {
369
+ return createDebugSession({ adapter });
370
+ },
371
+ createSession({ adapter } = {}) {
372
+ return createDebugSession({ adapter });
373
+ }
374
+ };
375
+ }
376
+ function createFallbackPhysicsFeatureModule() {
377
+ const fallbackPlan = Object.freeze({
378
+ snapshotStageId: "baseline",
379
+ stageOrder: Object.freeze(["authoritative"]),
380
+ secondarySimulationStageIds: Object.freeze(["visual"])
381
+ });
382
+ return {
383
+ createPhysicsSimulationPlan() {
384
+ return {
385
+ snapshotStageId: "baseline",
386
+ stageOrder: fallbackPlan.stageOrder,
387
+ secondarySimulationStageIds: fallbackPlan.secondarySimulationStageIds
388
+ };
389
+ },
390
+ createPhysicsWorldSnapshot(input = {}) {
391
+ return {
392
+ stage: "baseline",
393
+ stability: "stable",
394
+ stageId: fallbackPlan.snapshotStageId,
395
+ metadata: input.metadata ?? {},
396
+ bodyCount: input.bodyCount ?? 0,
397
+ dynamicBodyCount: input.dynamicBodyCount ?? 0,
398
+ contactCount: input.contactCount ?? 0,
399
+ snapshotStageId: fallbackPlan.snapshotStageId
400
+ };
401
+ },
402
+ defaultPhysicsWorkerProfile: FALLBACK_PHYSICS_PROFILE,
403
+ getPhysicsWorkerManifest() {
404
+ return {
405
+ jobs: [
406
+ Object.freeze({
407
+ worker: Object.freeze({ authority: "authoritative", jobType: "simulate" })
408
+ })
409
+ ],
410
+ suggestedAllocationIds: Object.freeze(["physics-workspace"])
411
+ };
412
+ }
413
+ };
414
+ }
415
+ var SHOWCASE_FEATURE_LOADERS = Object.freeze({
416
+ cloth: () => Promise.resolve(createFallbackClothFeatureModule()),
417
+ fluid: () => Promise.resolve(createFallbackFluidFeatureModule()),
418
+ lighting: () => Promise.resolve(createFallbackLightingFeatureModule()),
419
+ performance: () => Promise.resolve(createFallbackPerformanceFeatureModule()),
420
+ debug: () => Promise.resolve(createFallbackDebugFeatureModule()),
421
+ physics: () => Promise.resolve(createFallbackPhysicsFeatureModule())
422
+ });
423
+ var DEFAULT_FLUID_BAND_THRESHOLDS = Object.freeze({
424
+ near: Object.freeze({ minDistance: 0, maxDistance: 22 }),
425
+ mid: Object.freeze({ minDistance: 22, maxDistance: 90 }),
426
+ far: Object.freeze({ minDistance: 90, maxDistance: 260 }),
427
+ horizon: Object.freeze({ minDistance: 260, maxDistance: Number.POSITIVE_INFINITY })
428
+ });
429
+ var DEFAULT_CLOTH_BAND_THRESHOLDS = Object.freeze({
430
+ near: Object.freeze({ minDistance: 0, maxDistance: 24 }),
431
+ mid: Object.freeze({ minDistance: 24, maxDistance: 86 }),
432
+ far: Object.freeze({ minDistance: 86, maxDistance: 190 }),
433
+ horizon: Object.freeze({ minDistance: 190, maxDistance: Number.POSITIVE_INFINITY })
434
+ });
435
+ function resolveFluidBandSelection(distance, thresholds = DEFAULT_FLUID_BAND_THRESHOLDS) {
436
+ if (!Number.isFinite(distance)) {
437
+ return "horizon";
438
+ }
439
+ if (distance <= (thresholds.near?.maxDistance ?? DEFAULT_FLUID_BAND_THRESHOLDS.near.maxDistance)) {
440
+ return "near";
441
+ }
442
+ if (distance <= (thresholds.mid?.maxDistance ?? DEFAULT_FLUID_BAND_THRESHOLDS.mid.maxDistance)) {
443
+ return "mid";
444
+ }
445
+ if (distance <= (thresholds.far?.maxDistance ?? DEFAULT_FLUID_BAND_THRESHOLDS.far.maxDistance)) {
446
+ return "far";
447
+ }
448
+ return "horizon";
449
+ }
450
+ function createFallbackFluidFeatureAdapters() {
451
+ const defaultContinuityBand = Object.freeze({
452
+ amplitudeFloor: 0.22,
453
+ frequencyFloor: 0.05,
454
+ blendWindowMeters: 14,
455
+ retainFoamHistory: true,
456
+ retainDirectionality: true
457
+ });
458
+ return {
459
+ bodyKinds: Object.freeze(["ocean"]),
460
+ profileNames: Object.freeze(["cinematic"]),
461
+ createContinuityEnvelope() {
462
+ return Object.freeze({
463
+ bands: Object.freeze({
464
+ near: defaultContinuityBand,
465
+ mid: Object.freeze({
466
+ ...defaultContinuityBand,
467
+ blendWindowMeters: 22,
468
+ amplitudeFloor: 0.17
469
+ }),
470
+ far: Object.freeze({
471
+ ...defaultContinuityBand,
472
+ blendWindowMeters: 34,
473
+ amplitudeFloor: 0.12
474
+ }),
475
+ horizon: Object.freeze({
476
+ ...defaultContinuityBand,
477
+ blendWindowMeters: 42,
478
+ amplitudeFloor: 0.09
479
+ })
480
+ })
481
+ });
482
+ },
483
+ createPlan({ fluidBodyId = "harbor", kind = "ocean", profile = "cinematic" }) {
484
+ return Object.freeze({
485
+ fluidBodyId,
486
+ kind,
487
+ profile,
488
+ thresholds: DEFAULT_FLUID_BAND_THRESHOLDS,
489
+ representations: Object.freeze([
490
+ Object.freeze({
491
+ band: "near",
492
+ output: "raster",
493
+ rtParticipation: "full",
494
+ shading: Object.freeze({ refraction: 0.14, reflectionMode: "full", caustics: true })
495
+ }),
496
+ Object.freeze({
497
+ band: "mid",
498
+ output: "raster",
499
+ rtParticipation: "reduced",
500
+ shading: Object.freeze({ reflectionMode: "partial" })
501
+ }),
502
+ Object.freeze({
503
+ band: "far",
504
+ output: "raster",
505
+ rtParticipation: "low",
506
+ shading: Object.freeze({ reflectionMode: "partial" })
507
+ }),
508
+ Object.freeze({
509
+ band: "horizon",
510
+ output: "coarse",
511
+ rtParticipation: "off",
512
+ shading: Object.freeze({ reflectionMode: "reduced" })
513
+ })
514
+ ])
515
+ });
516
+ },
517
+ selectBand(distance, thresholds = DEFAULT_FLUID_BAND_THRESHOLDS) {
518
+ return resolveFluidBandSelection(distance, thresholds);
519
+ }
520
+ };
521
+ }
522
+ function createFallbackClothFeatureAdapters() {
523
+ const defaultContinuity = Object.freeze({
524
+ amplitudeFloor: 0.22,
525
+ wrinkleFloor: 0.32,
526
+ damping: 0.58,
527
+ creaseBias: 0.14
528
+ });
529
+ return {
530
+ garmentKinds: Object.freeze(["flag"]),
531
+ profileNames: Object.freeze(["cinematic"]),
532
+ createPlan() {
533
+ return Object.freeze({
534
+ thresholds: DEFAULT_CLOTH_BAND_THRESHOLDS,
535
+ representations: Object.freeze([
536
+ Object.freeze({
537
+ band: "near",
538
+ continuity: defaultContinuity
539
+ }),
540
+ Object.freeze({
541
+ band: "mid",
542
+ continuity: defaultContinuity
543
+ }),
544
+ Object.freeze({
545
+ band: "far",
546
+ continuity: defaultContinuity
547
+ }),
548
+ Object.freeze({
549
+ band: "horizon",
550
+ continuity: defaultContinuity
551
+ })
552
+ ])
553
+ });
554
+ },
555
+ selectBand(distance, thresholds = DEFAULT_CLOTH_BAND_THRESHOLDS) {
556
+ if (!Number.isFinite(distance)) {
557
+ return "horizon";
558
+ }
559
+ if (distance <= (thresholds.near?.maxDistance ?? DEFAULT_CLOTH_BAND_THRESHOLDS.near.maxDistance)) {
560
+ return "near";
561
+ }
562
+ if (distance <= (thresholds.mid?.maxDistance ?? DEFAULT_CLOTH_BAND_THRESHOLDS.mid.maxDistance)) {
563
+ return "mid";
564
+ }
565
+ if (distance <= (thresholds.far?.maxDistance ?? DEFAULT_CLOTH_BAND_THRESHOLDS.far.maxDistance)) {
566
+ return "far";
567
+ }
568
+ return "horizon";
569
+ }
570
+ };
571
+ }
572
+ function normalizeClothFeatureAdapters(clothFeatures) {
573
+ const fallback = createFallbackClothFeatureAdapters();
574
+ if (clothFeatures == null || typeof clothFeatures !== "object") {
575
+ return fallback;
576
+ }
577
+ return {
578
+ garmentKinds: Array.isArray(clothFeatures.garmentKinds) && clothFeatures.garmentKinds.length > 0 ? clothFeatures.garmentKinds : fallback.garmentKinds,
579
+ profileNames: Array.isArray(clothFeatures.profileNames) && clothFeatures.profileNames.length > 0 ? clothFeatures.profileNames : fallback.profileNames,
580
+ createPlan: assertRequiredFunction(clothFeatures, "cloth", "createPlan"),
581
+ selectBand: assertRequiredFunction(clothFeatures, "cloth", "selectBand")
582
+ };
583
+ }
584
+ function normalizeFluidFeatureAdapters(fluidFeatures) {
585
+ const fallback = createFallbackFluidFeatureAdapters();
586
+ if (fluidFeatures == null || typeof fluidFeatures !== "object") {
587
+ return fallback;
588
+ }
589
+ return {
590
+ bodyKinds: Array.isArray(fluidFeatures.bodyKinds) && fluidFeatures.bodyKinds.length > 0 ? fluidFeatures.bodyKinds : fallback.bodyKinds,
591
+ profileNames: Array.isArray(fluidFeatures.profileNames) && fluidFeatures.profileNames.length > 0 ? fluidFeatures.profileNames : fallback.profileNames,
592
+ createContinuityEnvelope: assertRequiredFunction(
593
+ fluidFeatures,
594
+ "fluid",
595
+ "createContinuityEnvelope"
596
+ ),
597
+ createPlan: assertRequiredFunction(fluidFeatures, "fluid", "createPlan"),
598
+ selectBand: assertRequiredFunction(fluidFeatures, "fluid", "selectBand")
599
+ };
600
+ }
601
+ function assertRequiredFunction(module, featureLabel, exportName) {
602
+ const value = module?.[exportName];
603
+ if (typeof value !== "function") {
604
+ throw new Error(
605
+ `Showcase ${featureLabel} feature package must export "${exportName}" as a function.`
606
+ );
607
+ }
608
+ return value;
609
+ }
610
+ function assertRequiredArray(module, featureLabel, exportName) {
611
+ const value = module?.[exportName];
612
+ if (!Array.isArray(value)) {
613
+ throw new Error(
614
+ `Showcase ${featureLabel} feature package must export "${exportName}" as an array.`
615
+ );
616
+ }
617
+ return value;
618
+ }
619
+ async function loadShowcaseFeatureModule(featureLabel, loader) {
620
+ try {
621
+ const module = await loader();
622
+ if (module == null || typeof module !== "object") {
623
+ throw new Error("module is missing or not an object.");
624
+ }
625
+ return module;
626
+ } catch (error) {
627
+ const message = error?.message ?? String(error);
628
+ throw new Error(`Unable to load showcase ${featureLabel} feature package: ${message}`, {
629
+ cause: error
630
+ });
631
+ }
632
+ }
633
+ function resolveShowcaseFeatureLoaders(options = {}) {
634
+ const overrides = options.__showcaseFeatureLoaders;
635
+ return {
636
+ cloth: typeof overrides?.cloth === "function" ? overrides.cloth : SHOWCASE_FEATURE_LOADERS.cloth,
637
+ fluid: typeof overrides?.fluid === "function" ? overrides.fluid : SHOWCASE_FEATURE_LOADERS.fluid,
638
+ lighting: typeof overrides?.lighting === "function" ? overrides.lighting : SHOWCASE_FEATURE_LOADERS.lighting,
639
+ performance: typeof overrides?.performance === "function" ? overrides.performance : SHOWCASE_FEATURE_LOADERS.performance,
640
+ debug: typeof overrides?.debug === "function" ? overrides.debug : SHOWCASE_FEATURE_LOADERS.debug,
641
+ physics: typeof overrides?.physics === "function" ? overrides.physics : SHOWCASE_FEATURE_LOADERS.physics
642
+ };
643
+ }
644
+ async function resolveShowcaseFeatureAdapters(options = {}) {
645
+ const loaders = resolveShowcaseFeatureLoaders(options);
646
+ const [
647
+ clothModule,
648
+ fluidModule,
649
+ lightingModule,
650
+ performanceModule,
651
+ debugModule,
652
+ physicsModule
653
+ ] = await Promise.all([
654
+ loadShowcaseFeatureModule("cloth", loaders.cloth),
655
+ loadShowcaseFeatureModule("fluid", loaders.fluid),
656
+ loadShowcaseFeatureModule("lighting", loaders.lighting),
657
+ loadShowcaseFeatureModule("performance", loaders.performance),
658
+ loadShowcaseFeatureModule("debug", loaders.debug),
659
+ loadShowcaseFeatureModule("physics", loaders.physics)
660
+ ]);
661
+ return {
662
+ cloth: {
663
+ garmentKinds: assertRequiredArray(clothModule, "cloth", "clothGarmentKinds"),
664
+ profileNames: assertRequiredArray(clothModule, "cloth", "clothProfileNames"),
665
+ createPlan: assertRequiredFunction(clothModule, "cloth", "createClothRepresentationPlan"),
666
+ selectBand: assertRequiredFunction(clothModule, "cloth", "selectClothRepresentationBand")
667
+ },
668
+ fluid: {
669
+ bodyKinds: assertRequiredArray(fluidModule, "fluid", "fluidBodyKinds"),
670
+ profileNames: assertRequiredArray(fluidModule, "fluid", "fluidProfileNames"),
671
+ createContinuityEnvelope: assertRequiredFunction(
672
+ fluidModule,
673
+ "fluid",
674
+ "createFluidContinuityEnvelope"
675
+ ),
676
+ createPlan: assertRequiredFunction(fluidModule, "fluid", "createFluidRepresentationPlan"),
677
+ selectBand: assertRequiredFunction(fluidModule, "fluid", "selectFluidRepresentationBand")
678
+ },
679
+ lighting: {
680
+ createBandPlan: assertRequiredFunction(lightingModule, "lighting", "createLightingBandPlan"),
681
+ defaultProfile: lightingModule.defaultLightingProfile,
682
+ getProfile: assertRequiredFunction(lightingModule, "lighting", "getLightingProfile"),
683
+ distanceBands: assertRequiredArray(lightingModule, "lighting", "lightingDistanceBands")
684
+ },
685
+ performance: {
686
+ createDeviceProfile: assertRequiredFunction(
687
+ performanceModule,
688
+ "performance",
689
+ "createDeviceProfile"
690
+ ),
691
+ createGovernor: assertRequiredFunction(
692
+ performanceModule,
693
+ "performance",
694
+ "createGpuPerformanceGovernor"
695
+ ),
696
+ createQualityAdapter: assertRequiredFunction(
697
+ performanceModule,
698
+ "performance",
699
+ "createQualityLadderAdapter"
700
+ )
701
+ },
702
+ debug: {
703
+ createSession: assertRequiredFunction(debugModule, "debug", "createGpuDebugSession")
704
+ },
705
+ physics: {
706
+ createSimulationPlan: assertRequiredFunction(
707
+ physicsModule,
708
+ "physics",
709
+ "createPhysicsSimulationPlan"
710
+ ),
711
+ createWorldSnapshot: assertRequiredFunction(
712
+ physicsModule,
713
+ "physics",
714
+ "createPhysicsWorldSnapshot"
715
+ ),
716
+ defaultProfile: physicsModule.defaultPhysicsWorkerProfile,
717
+ getManifest: assertRequiredFunction(physicsModule, "physics", "getPhysicsWorkerManifest")
718
+ }
719
+ };
720
+ }
721
+ var showcaseFocusModes = Object.freeze(Object.keys(CAMERA_PRESETS));
722
+ var FOCUS_MODE_TRANSLATION_KEYS = Object.freeze({
723
+ integrated: gpuSharedTranslationKeys.focusIntegrated,
724
+ lighting: gpuSharedTranslationKeys.focusLighting,
725
+ cloth: gpuSharedTranslationKeys.focusCloth,
726
+ fluid: gpuSharedTranslationKeys.focusFluid,
727
+ physics: gpuSharedTranslationKeys.focusPhysics,
728
+ performance: gpuSharedTranslationKeys.focusPerformance,
729
+ debug: gpuSharedTranslationKeys.focusDebug
730
+ });
731
+ var SCENE_NOTE_KEYS = Object.freeze([
732
+ gpuSharedTranslationKeys.noteAssetLoading,
733
+ gpuSharedTranslationKeys.noteMoonlight,
734
+ gpuSharedTranslationKeys.noteContinuity,
735
+ gpuSharedTranslationKeys.notePerformance
736
+ ]);
737
+ var PHYSICS_SCENE_NOTE_KEYS = Object.freeze([
738
+ gpuSharedTranslationKeys.notePhysicsSnapshots,
739
+ gpuSharedTranslationKeys.notePhysicsCollisions,
740
+ gpuSharedTranslationKeys.notePhysicsLighting
741
+ ]);
742
+ var LEGACY_HARBOR_LAYOUT = Object.freeze([
743
+ Object.freeze({
744
+ position: Object.freeze({ x: -8.2, y: 1.1, z: -0.9 }),
745
+ rotationY: -0.16,
746
+ scale: 5.4,
747
+ color: { r: 0.32, g: 0.27, b: 0.23, a: 1 },
748
+ accent: 0.06
749
+ }),
750
+ Object.freeze({
751
+ position: Object.freeze({ x: -5.7, y: 0.45, z: 1.4 }),
752
+ rotationY: -0.08,
753
+ scale: { x: 6.8, y: 0.3, z: 2.1 },
754
+ color: { r: 0.31, g: 0.31, b: 0.34, a: 1 },
755
+ accent: 0.04
756
+ }),
757
+ Object.freeze({
758
+ position: Object.freeze({ x: -10.4, y: 0.28, z: 0.8 }),
759
+ rotationY: 0.22,
760
+ scale: { x: 1.2, y: 0.9, z: 1.2 },
761
+ color: { r: 0.31, g: 0.35, b: 0.39, a: 1 },
762
+ accent: 0.02
763
+ })
764
+ ]);
765
+ var SHOWCASE_ENVIRONMENT_LAYOUT = Object.freeze([
766
+ Object.freeze({
767
+ assetKey: "harbor-dock",
768
+ position: Object.freeze({ x: -4.6, y: 0.16, z: 0.7 }),
769
+ rotationY: -0.08,
770
+ scale: 0.84,
771
+ accent: 0.04
772
+ }),
773
+ Object.freeze({
774
+ assetKey: "lighthouse",
775
+ position: Object.freeze({ x: -9.8, y: 0, z: -0.58 }),
776
+ rotationY: 0.12,
777
+ scale: 0.56,
778
+ accent: 0.08
779
+ })
780
+ ]);
781
+ var SHIP_LANTERNS = Object.freeze([
782
+ Object.freeze({ x: 0.94, y: 1.54, z: 2.52, glow: 1 }),
783
+ Object.freeze({ x: -0.9, y: 1.58, z: 2.44, glow: 0.92 }),
784
+ Object.freeze({ x: 0.62, y: 1.42, z: -2.18, glow: 0.88 }),
785
+ Object.freeze({ x: -0.58, y: 1.46, z: -2.04, glow: 0.84 })
786
+ ]);
787
+ var CUTTER_LANTERNS = Object.freeze([
788
+ Object.freeze({ x: 0.42, y: 1.04, z: 1.18, glow: 0.94 }),
789
+ Object.freeze({ x: -0.42, y: 1.04, z: 1.12, glow: 0.88 })
790
+ ]);
791
+ var HARBOR_TORCHES = Object.freeze([
792
+ Object.freeze({ x: -5.2, y: 1.25, z: 1.36, glow: 1.1 }),
793
+ Object.freeze({ x: -8.6, y: 2.48, z: -0.72, glow: 1 }),
794
+ Object.freeze({ x: -10.4, y: 1.28, z: 0.82, glow: 0.92 })
795
+ ]);
796
+ var FLAG_LAYOUT = Object.freeze({
797
+ origin: Object.freeze({ x: -3.5, y: 5.9, z: 2.4 }),
798
+ width: 4.8,
799
+ height: 2.7,
800
+ mastOffsetX: 1.8
801
+ });
802
+ function injectStyles() {
803
+ if (document.getElementById(STYLE_ID)) {
804
+ return;
805
+ }
806
+ const style = document.createElement("style");
807
+ style.id = STYLE_ID;
808
+ style.textContent = `
809
+ .${ROOT_CLASS} {
810
+ color-scheme: dark;
811
+ --plasius-paper: #081321;
812
+ --plasius-ink: #edf4ff;
813
+ --plasius-muted: #b6c5dd;
814
+ --plasius-accent: #f3b16a;
815
+ --plasius-panel: rgba(8, 19, 33, 0.72);
816
+ --plasius-border: rgba(159, 185, 223, 0.18);
817
+ --plasius-shadow: 0 24px 56px rgba(1, 6, 14, 0.44);
818
+ margin: 0;
819
+ min-height: 100%;
820
+ font-family: "Fraunces", "Iowan Old Style", serif;
821
+ color: var(--plasius-ink);
822
+ background:
823
+ radial-gradient(circle at 18% 12%, rgba(73, 101, 170, 0.28), transparent 30%),
824
+ radial-gradient(circle at 82% 18%, rgba(240, 188, 103, 0.08), transparent 18%),
825
+ linear-gradient(180deg, #04101d 0%, #0b1930 42%, #081321 100%);
826
+ }
827
+ .${ROOT_CLASS}.${CAPTURE_CLASS} {
828
+ min-height: 100vh;
829
+ overflow: hidden;
830
+ background: #030710;
831
+ }
832
+ .${ROOT_CLASS},
833
+ .${ROOT_CLASS} * {
834
+ box-sizing: border-box;
835
+ }
836
+ .plasius-demo {
837
+ width: min(1560px, calc(100vw - 32px));
838
+ margin: 0 auto;
839
+ padding: 28px 0 40px;
840
+ display: grid;
841
+ gap: 20px;
842
+ }
843
+ .plasius-demo__hero,
844
+ .plasius-demo__layout {
845
+ display: grid;
846
+ gap: 20px;
847
+ }
848
+ .plasius-demo__hero {
849
+ grid-template-columns: minmax(0, 1.5fr) minmax(320px, 0.85fr);
850
+ align-items: start;
851
+ }
852
+ .plasius-panel {
853
+ border: 1px solid var(--plasius-border);
854
+ border-radius: 24px;
855
+ background: var(--plasius-panel);
856
+ box-shadow: var(--plasius-shadow);
857
+ backdrop-filter: blur(12px);
858
+ }
859
+ .plasius-demo__hero-card,
860
+ .plasius-demo__status {
861
+ padding: 20px 22px;
862
+ }
863
+ .plasius-demo__eyebrow {
864
+ margin: 0 0 8px;
865
+ text-transform: uppercase;
866
+ letter-spacing: 0.18em;
867
+ font-size: 12px;
868
+ color: rgba(226, 236, 255, 0.58);
869
+ }
870
+ .plasius-demo h1,
871
+ .plasius-demo h2,
872
+ .plasius-demo h3 {
873
+ margin: 0;
874
+ }
875
+ .plasius-demo__lead {
876
+ margin: 12px 0 0;
877
+ color: var(--plasius-muted);
878
+ line-height: 1.6;
879
+ max-width: 760px;
880
+ }
881
+ .plasius-demo__status-badge {
882
+ width: fit-content;
883
+ margin: 0;
884
+ padding: 8px 12px;
885
+ border-radius: 999px;
886
+ background: rgba(243, 177, 106, 0.14);
887
+ color: var(--plasius-accent);
888
+ font-weight: 700;
889
+ }
890
+ .plasius-demo__status-text {
891
+ margin: 10px 0 0;
892
+ color: var(--plasius-muted);
893
+ line-height: 1.6;
894
+ }
895
+ .plasius-demo__layout {
896
+ grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.68fr);
897
+ align-items: start;
898
+ }
899
+ .plasius-demo__canvas-panel {
900
+ padding: 18px;
901
+ position: relative;
902
+ }
903
+ .plasius-demo__canvas {
904
+ width: 100%;
905
+ aspect-ratio: 16 / 9;
906
+ display: block;
907
+ border-radius: 20px;
908
+ border: 1px solid rgba(159, 185, 223, 0.12);
909
+ background: linear-gradient(180deg, #071220 0%, #132440 42%, #10344b 42%, #05111d 100%);
910
+ }
911
+ .${CAPTURE_CLASS} .plasius-demo {
912
+ width: 100vw;
913
+ height: 100vh;
914
+ padding: 0;
915
+ display: block;
916
+ }
917
+ .${CAPTURE_CLASS} .plasius-demo__hero,
918
+ .${CAPTURE_CLASS} .plasius-demo__toolbar,
919
+ .${CAPTURE_CLASS} .plasius-demo__legend,
920
+ .${CAPTURE_CLASS} .plasius-demo__sidebar,
921
+ .${CAPTURE_CLASS} .plasius-demo__footer {
922
+ display: none;
923
+ }
924
+ .${CAPTURE_CLASS} .plasius-demo__layout {
925
+ display: block;
926
+ height: 100%;
927
+ }
928
+ .${CAPTURE_CLASS} .plasius-demo__canvas-panel {
929
+ height: 100%;
930
+ padding: 0;
931
+ border: 0;
932
+ border-radius: 0;
933
+ background: transparent;
934
+ box-shadow: none;
935
+ backdrop-filter: none;
936
+ }
937
+ .${CAPTURE_CLASS} .plasius-demo__canvas {
938
+ width: 100%;
939
+ height: 100%;
940
+ aspect-ratio: auto;
941
+ border: 0;
942
+ border-radius: 0;
943
+ background: #030710;
944
+ }
945
+ .plasius-demo__toolbar {
946
+ position: absolute;
947
+ top: 26px;
948
+ left: 26px;
949
+ display: flex;
950
+ gap: 12px;
951
+ flex-wrap: wrap;
952
+ align-items: center;
953
+ }
954
+ .plasius-demo button,
955
+ .plasius-demo label,
956
+ .plasius-demo select {
957
+ font-family: "JetBrains Mono", monospace;
958
+ font-size: 13px;
959
+ }
960
+ .plasius-demo button,
961
+ .plasius-demo .plasius-toggle,
962
+ .plasius-demo select {
963
+ border: 1px solid rgba(159, 185, 223, 0.18);
964
+ border-radius: 999px;
965
+ background: rgba(9, 20, 34, 0.84);
966
+ color: var(--plasius-ink);
967
+ padding: 10px 14px;
968
+ }
969
+ .plasius-toggle {
970
+ display: inline-flex;
971
+ align-items: center;
972
+ gap: 8px;
973
+ }
974
+ .plasius-demo__sidebar {
975
+ display: grid;
976
+ gap: 18px;
977
+ }
978
+ .plasius-demo__card {
979
+ padding: 18px;
980
+ }
981
+ .plasius-demo__metrics,
982
+ .plasius-demo__metrics li {
983
+ margin: 0;
984
+ padding: 0;
985
+ list-style: none;
986
+ }
987
+ .plasius-demo__metrics {
988
+ margin-top: 12px;
989
+ display: grid;
990
+ gap: 8px;
991
+ color: var(--plasius-muted);
992
+ line-height: 1.55;
993
+ }
994
+ .plasius-demo__metrics li {
995
+ border-top: 1px solid rgba(21, 32, 40, 0.08);
996
+ padding-top: 8px;
997
+ }
998
+ .plasius-demo__legend {
999
+ position: absolute;
1000
+ right: 24px;
1001
+ bottom: 24px;
1002
+ padding: 10px 14px;
1003
+ border-radius: 16px;
1004
+ background: rgba(9, 20, 34, 0.82);
1005
+ border: 1px solid rgba(159, 185, 223, 0.16);
1006
+ color: var(--plasius-muted);
1007
+ font-size: 12px;
1008
+ line-height: 1.45;
1009
+ }
1010
+ .plasius-demo__legend strong {
1011
+ display: block;
1012
+ color: var(--plasius-ink);
1013
+ margin-bottom: 4px;
1014
+ }
1015
+ .plasius-demo__footer {
1016
+ margin-top: 4px;
1017
+ color: rgba(226, 236, 255, 0.68);
1018
+ font-size: 13px;
1019
+ line-height: 1.6;
1020
+ }
1021
+ @media (max-width: 1200px) {
1022
+ .plasius-demo__hero,
1023
+ .plasius-demo__layout {
1024
+ grid-template-columns: 1fr;
1025
+ }
1026
+ }
1027
+ `;
1028
+ document.head.appendChild(style);
1029
+ }
1030
+ function clamp(value, min, max) {
1031
+ return Math.max(min, Math.min(max, value));
1032
+ }
1033
+ function mix(a, b, t) {
1034
+ return a + (b - a) * t;
1035
+ }
1036
+ function smoothstep(min, max, value) {
1037
+ const t = clamp((value - min) / Math.max(1e-4, max - min), 0, 1);
1038
+ return t * t * (3 - 2 * t);
1039
+ }
1040
+ function pseudoRandom(seed) {
1041
+ const value = Math.sin(seed * 12.9898 + seed * seed * 17e-4) * 43758.5453;
1042
+ return value - Math.floor(value);
1043
+ }
1044
+ function vec3(x = 0, y = 0, z = 0) {
1045
+ return { x, y, z };
1046
+ }
1047
+ function addVec3(a, b) {
1048
+ return vec3(a.x + b.x, a.y + b.y, a.z + b.z);
1049
+ }
1050
+ function subVec3(a, b) {
1051
+ return vec3(a.x - b.x, a.y - b.y, a.z - b.z);
1052
+ }
1053
+ function scaleVec3(a, s) {
1054
+ return vec3(a.x * s, a.y * s, a.z * s);
1055
+ }
1056
+ function dotVec3(a, b) {
1057
+ return a.x * b.x + a.y * b.y + a.z * b.z;
1058
+ }
1059
+ function crossVec3(a, b) {
1060
+ return vec3(
1061
+ a.y * b.z - a.z * b.y,
1062
+ a.z * b.x - a.x * b.z,
1063
+ a.x * b.y - a.y * b.x
1064
+ );
1065
+ }
1066
+ function lengthVec3(a) {
1067
+ return Math.hypot(a.x, a.y, a.z);
1068
+ }
1069
+ function normalizeVec3(a) {
1070
+ const length = lengthVec3(a) || 1;
1071
+ return vec3(a.x / length, a.y / length, a.z / length);
1072
+ }
1073
+ function reflectVec3(vector, normal) {
1074
+ const unitNormal = normalizeVec3(normal);
1075
+ return subVec3(vector, scaleVec3(unitNormal, 2 * dotVec3(vector, unitNormal)));
1076
+ }
1077
+ function directionFromYaw(yaw) {
1078
+ return normalizeVec3(vec3(Math.sin(yaw), 0, Math.cos(yaw)));
1079
+ }
1080
+ function perpendicularOnWater(direction) {
1081
+ return vec3(-direction.z, 0, direction.x);
1082
+ }
1083
+ function rotateY(point, angle) {
1084
+ const cosine = Math.cos(angle);
1085
+ const sine = Math.sin(angle);
1086
+ return vec3(
1087
+ point.x * cosine - point.z * sine,
1088
+ point.y,
1089
+ point.x * sine + point.z * cosine
1090
+ );
1091
+ }
1092
+ function transformPoint(point, transform) {
1093
+ const scale = typeof transform.scale === "number" ? { x: transform.scale, y: transform.scale, z: transform.scale } : transform.scale;
1094
+ const scaled = vec3(point.x * scale.x, point.y * scale.y, point.z * scale.z);
1095
+ const rotated = rotateY(scaled, transform.rotationY);
1096
+ return addVec3(rotated, transform.position);
1097
+ }
1098
+ function transformDirection(direction, transform) {
1099
+ const scale = typeof transform.scale === "number" ? { x: transform.scale, y: transform.scale, z: transform.scale } : transform.scale;
1100
+ const scaled = vec3(direction.x * scale.x, direction.y * scale.y, direction.z * scale.z);
1101
+ return normalizeVec3(rotateY(scaled, transform.rotationY));
1102
+ }
1103
+ function projectPoint(point, camera, viewport) {
1104
+ const relative = subVec3(point, camera.eye);
1105
+ const viewX = dotVec3(relative, camera.right);
1106
+ const viewY = dotVec3(relative, camera.up);
1107
+ const viewZ = dotVec3(relative, camera.forward);
1108
+ if (viewZ <= 0.1) {
1109
+ return null;
1110
+ }
1111
+ const focal = 1 / Math.tan(camera.fov * Math.PI / 360);
1112
+ const ndcX = viewX * focal / (viewZ * camera.aspect);
1113
+ const ndcY = viewY * focal / viewZ;
1114
+ return {
1115
+ x: (ndcX * 0.5 + 0.5) * viewport.width,
1116
+ y: (-ndcY * 0.5 + 0.5) * viewport.height,
1117
+ depth: viewZ
1118
+ };
1119
+ }
1120
+ function colorToRgba(color, alpha = 1) {
1121
+ const r = Math.round(clamp(color.r, 0, 1) * 255);
1122
+ const g = Math.round(clamp(color.g, 0, 1) * 255);
1123
+ const b = Math.round(clamp(color.b, 0, 1) * 255);
1124
+ return `rgba(${r}, ${g}, ${b}, ${clamp(alpha, 0, 1)})`;
1125
+ }
1126
+ function mixColor(a, b, t) {
1127
+ return {
1128
+ r: mix(a.r, b.r, t),
1129
+ g: mix(a.g, b.g, t),
1130
+ b: mix(a.b, b.b, t),
1131
+ a: mix(a.a ?? 1, b.a ?? 1, t)
1132
+ };
1133
+ }
1134
+ function multiplyColor(a, b) {
1135
+ return {
1136
+ r: a.r * b.r,
1137
+ g: a.g * b.g,
1138
+ b: a.b * b.b,
1139
+ a: (a.a ?? 1) * (b.a ?? 1)
1140
+ };
1141
+ }
1142
+ function createLegacyMeshPrimitive(mesh) {
1143
+ return Object.freeze({
1144
+ name: mesh.name ?? "legacy-mesh",
1145
+ positions: mesh.positions,
1146
+ indices: mesh.indices,
1147
+ normals: null,
1148
+ colors: null,
1149
+ material: Object.freeze({
1150
+ name: "legacy-material",
1151
+ color: mesh.color ?? { r: 0.56, g: 0.33, b: 0.22, a: 1 },
1152
+ roughness: 0.88,
1153
+ metallic: 0.08,
1154
+ emissive: Object.freeze({ r: 0, g: 0, b: 0 })
1155
+ })
1156
+ });
1157
+ }
1158
+ function isFeatureEnabled(featureFlags, featureName, fallback = true) {
1159
+ const directValue = typeof featureFlags?.[featureName] === "boolean" ? featureFlags[featureName] : featureFlags?.flags?.[featureName];
1160
+ if (typeof directValue === "boolean") {
1161
+ return directValue;
1162
+ }
1163
+ const enabledValue = typeof featureFlags?.enabled?.[featureName] === "boolean" ? featureFlags.enabled[featureName] : void 0;
1164
+ if (typeof enabledValue === "boolean") {
1165
+ return enabledValue;
1166
+ }
1167
+ return fallback;
1168
+ }
1169
+ function getMeshPrimitives(mesh) {
1170
+ return Array.isArray(mesh?.primitives) && mesh.primitives.length > 0 ? mesh.primitives : [createLegacyMeshPrimitive(mesh)];
1171
+ }
1172
+ function tintPrimitiveColor(material, colorOverride) {
1173
+ if (!colorOverride) {
1174
+ return material.color;
1175
+ }
1176
+ const name = String(material.name ?? "").toLowerCase();
1177
+ if (name.includes("sail") || name.includes("glass") || name.includes("roof")) {
1178
+ return material.color;
1179
+ }
1180
+ const tintAmount = name.includes("hull") ? 0.54 : name.includes("trim") ? 0.22 : name.includes("deck") ? 0.12 : 0;
1181
+ if (tintAmount <= 0) {
1182
+ return material.color;
1183
+ }
1184
+ return mixColor(material.color, multiplyColor(material.color, colorOverride), tintAmount);
1185
+ }
1186
+ function projectShadowPoint(point, lightDir, planeY) {
1187
+ const shadowDir = scaleVec3(lightDir, -1);
1188
+ if (Math.abs(shadowDir.y) < 1e-4) {
1189
+ return null;
1190
+ }
1191
+ const distance = (planeY - point.y) / shadowDir.y;
1192
+ if (!Number.isFinite(distance) || distance < 0) {
1193
+ return null;
1194
+ }
1195
+ return addVec3(point, scaleVec3(shadowDir, distance));
1196
+ }
1197
+ function shadeColor(base, normal, lightDir, heightBias = 0, accent = 0) {
1198
+ const diffuse = clamp(dotVec3(normalizeVec3(normal), lightDir), 0, 1);
1199
+ const brightness = 0.24 + diffuse * 0.72 + heightBias * 0.08 + accent;
1200
+ return {
1201
+ r: clamp(base.r * brightness, 0, 1),
1202
+ g: clamp(base.g * brightness, 0, 1),
1203
+ b: clamp(base.b * (brightness + 0.03), 0, 1)
1204
+ };
1205
+ }
1206
+ function getMaterialSeed(materialName) {
1207
+ let seed = 0;
1208
+ for (let index = 0; index < materialName.length; index += 1) {
1209
+ seed += materialName.charCodeAt(index) * (index + 1);
1210
+ }
1211
+ return seed;
1212
+ }
1213
+ function getMaterialDetailStrength(material, surfaceType) {
1214
+ const name = String(material?.name ?? "").toLowerCase();
1215
+ if (surfaceType === "water" || name.includes("glass")) {
1216
+ return 0.018;
1217
+ }
1218
+ if (name.includes("wood") || name.includes("timber") || name.includes("plank")) {
1219
+ return 0.13;
1220
+ }
1221
+ if (name.includes("stone") || name.includes("concrete") || name.includes("plaster")) {
1222
+ return 0.1;
1223
+ }
1224
+ if (name.includes("roof") || name.includes("crate")) {
1225
+ return 0.09;
1226
+ }
1227
+ if (name.includes("paint")) {
1228
+ return 0.045;
1229
+ }
1230
+ if (name.includes("metal")) {
1231
+ return 0.035;
1232
+ }
1233
+ return 0.04;
1234
+ }
1235
+ function applyMaterialDetail(color, material, worldCenter, normal, surfaceType) {
1236
+ const materialName = String(material?.name ?? surfaceType ?? "material");
1237
+ const detailStrength = getMaterialDetailStrength(material, surfaceType);
1238
+ const sample = worldCenter.x * 3.17 + worldCenter.y * 5.29 + worldCenter.z * 7.83 + getMaterialSeed(materialName) * 0.013;
1239
+ const grain = (pseudoRandom(sample) - 0.5) * detailStrength;
1240
+ const lowerSurface = smoothstep(7.5, -0.8, worldCenter.y);
1241
+ const verticalSurface = 1 - clamp(Math.abs(normal.y), 0, 1);
1242
+ const materialLowerWear = /stone|concrete|plaster|paint|wood|timber|plank|crate/.test(materialName.toLowerCase()) ? lowerSurface * verticalSurface * 0.055 : 0;
1243
+ const wetlineWear = surfaceType === "ship" && worldCenter.y < 0.72 ? smoothstep(0.72, -0.1, worldCenter.y) * 0.05 : 0;
1244
+ return {
1245
+ r: clamp(color.r * (1 + grain) - materialLowerWear - wetlineWear, 0, 1),
1246
+ g: clamp(color.g * (1 + grain * 0.82) - materialLowerWear * 0.9 - wetlineWear, 0, 1),
1247
+ b: clamp(color.b * (1 + grain * 0.62) - materialLowerWear * 0.68 - wetlineWear * 0.75, 0, 1)
1248
+ };
1249
+ }
1250
+ function buildCamera(state, canvas) {
1251
+ const preset = CAMERA_PRESETS[state.focus] ?? CAMERA_PRESETS.integrated;
1252
+ const yaw = state.camera.yaw ?? preset.yaw;
1253
+ const pitch = state.camera.pitch ?? preset.pitch;
1254
+ const distance = state.camera.distance ?? preset.distance;
1255
+ const target = state.camera.target ?? vec3(...preset.target);
1256
+ const eye = vec3(
1257
+ target.x + Math.sin(yaw) * Math.cos(pitch) * distance,
1258
+ target.y + Math.sin(pitch) * distance,
1259
+ target.z + Math.cos(yaw) * Math.cos(pitch) * distance
1260
+ );
1261
+ const forward = normalizeVec3(subVec3(target, eye));
1262
+ const right = normalizeVec3(crossVec3(forward, vec3(0, 1, 0)));
1263
+ const up = normalizeVec3(crossVec3(right, forward));
1264
+ return {
1265
+ eye,
1266
+ target,
1267
+ forward,
1268
+ right,
1269
+ up,
1270
+ fov: 54,
1271
+ aspect: canvas.width / canvas.height
1272
+ };
1273
+ }
1274
+ function buildTrianglesFromMesh(mesh, transform, colorOverride, camera, viewport, triangles, options = {}) {
1275
+ const primitives = getMeshPrimitives(mesh);
1276
+ for (const primitive of primitives) {
1277
+ const resolvedColor = tintPrimitiveColor(primitive.material, colorOverride);
1278
+ for (let index = 0; index < primitive.indices.length; index += 3) {
1279
+ const aIndex = primitive.indices[index] * 3;
1280
+ const bIndex = primitive.indices[index + 1] * 3;
1281
+ const cIndex = primitive.indices[index + 2] * 3;
1282
+ const a = transformPoint(
1283
+ vec3(
1284
+ primitive.positions[aIndex],
1285
+ primitive.positions[aIndex + 1],
1286
+ primitive.positions[aIndex + 2]
1287
+ ),
1288
+ transform
1289
+ );
1290
+ const b = transformPoint(
1291
+ vec3(
1292
+ primitive.positions[bIndex],
1293
+ primitive.positions[bIndex + 1],
1294
+ primitive.positions[bIndex + 2]
1295
+ ),
1296
+ transform
1297
+ );
1298
+ const c = transformPoint(
1299
+ vec3(
1300
+ primitive.positions[cIndex],
1301
+ primitive.positions[cIndex + 1],
1302
+ primitive.positions[cIndex + 2]
1303
+ ),
1304
+ transform
1305
+ );
1306
+ const ab = subVec3(b, a);
1307
+ const ac = subVec3(c, a);
1308
+ const faceNormal = normalizeVec3(crossVec3(ab, ac));
1309
+ let normal = faceNormal;
1310
+ if (Array.isArray(primitive.normals)) {
1311
+ const aNormal = transformDirection(
1312
+ vec3(
1313
+ primitive.normals[aIndex],
1314
+ primitive.normals[aIndex + 1],
1315
+ primitive.normals[aIndex + 2]
1316
+ ),
1317
+ transform
1318
+ );
1319
+ const bNormal = transformDirection(
1320
+ vec3(
1321
+ primitive.normals[bIndex],
1322
+ primitive.normals[bIndex + 1],
1323
+ primitive.normals[bIndex + 2]
1324
+ ),
1325
+ transform
1326
+ );
1327
+ const cNormal = transformDirection(
1328
+ vec3(
1329
+ primitive.normals[cIndex],
1330
+ primitive.normals[cIndex + 1],
1331
+ primitive.normals[cIndex + 2]
1332
+ ),
1333
+ transform
1334
+ );
1335
+ normal = normalizeVec3(
1336
+ scaleVec3(addVec3(addVec3(aNormal, bNormal), cNormal), 1 / 3)
1337
+ );
1338
+ }
1339
+ const viewDir = normalizeVec3(subVec3(camera.eye, a));
1340
+ if (dotVec3(faceNormal, viewDir) <= 0) {
1341
+ continue;
1342
+ }
1343
+ const projected = [
1344
+ projectPoint(a, camera, viewport),
1345
+ projectPoint(b, camera, viewport),
1346
+ projectPoint(c, camera, viewport)
1347
+ ];
1348
+ if (projected.some((value) => value === null)) {
1349
+ continue;
1350
+ }
1351
+ triangles.push({
1352
+ points: projected,
1353
+ depth: (projected[0].depth + projected[1].depth + projected[2].depth) / 3,
1354
+ worldCenter: scaleVec3(addVec3(addVec3(a, b), c), 1 / 3),
1355
+ normal,
1356
+ baseColor: resolvedColor,
1357
+ accent: options.accent ?? 0,
1358
+ material: primitive.material,
1359
+ reflection: options.reflection ?? 0,
1360
+ surfaceType: options.surfaceType ?? "solid"
1361
+ });
1362
+ }
1363
+ }
1364
+ }
1365
+ async function loadShowcaseAssetCatalog() {
1366
+ const [brigantine, cutter, lighthouse, harborDock] = await Promise.all([
1367
+ loadGltfModel(resolveShowcaseAssetUrl("brigantine")),
1368
+ loadGltfModel(resolveShowcaseAssetUrl("cutter")),
1369
+ loadGltfModel(resolveShowcaseAssetUrl("lighthouse")),
1370
+ loadGltfModel(resolveShowcaseAssetUrl("harbor-dock"))
1371
+ ]);
1372
+ return Object.freeze({
1373
+ primaryShipKey: "brigantine",
1374
+ ships: Object.freeze({
1375
+ brigantine,
1376
+ cutter
1377
+ }),
1378
+ environment: Object.freeze({
1379
+ lighthouse,
1380
+ "harbor-dock": harborDock
1381
+ })
1382
+ });
1383
+ }
1384
+ function createLegacyShowcaseAssetCatalog() {
1385
+ const brigantine = loadGltfModel(resolveShowcaseAssetUrl("brigantine"));
1386
+ return Promise.resolve(brigantine).then(
1387
+ (primary) => Object.freeze({
1388
+ primaryShipKey: "brigantine",
1389
+ ships: Object.freeze({
1390
+ brigantine: primary
1391
+ }),
1392
+ environment: Object.freeze({})
1393
+ })
1394
+ );
1395
+ }
1396
+ function resolveShipModel(state, ship, fallbackModel = null) {
1397
+ return state.assetCatalog?.ships?.[ship.modelKey ?? state.assetCatalog?.primaryShipKey ?? "brigantine"] ?? fallbackModel ?? state.shipModel;
1398
+ }
1399
+ function createPerformanceGovernor(performanceFeatures) {
1400
+ const createQualityLadderAdapter = assertRequiredFunction(
1401
+ performanceFeatures,
1402
+ "performance",
1403
+ "createQualityAdapter"
1404
+ );
1405
+ const createDeviceProfile = assertRequiredFunction(
1406
+ performanceFeatures,
1407
+ "performance",
1408
+ "createDeviceProfile"
1409
+ );
1410
+ const createGpuPerformanceGovernor = assertRequiredFunction(
1411
+ performanceFeatures,
1412
+ "performance",
1413
+ "createGovernor"
1414
+ );
1415
+ const fluidDetail = createQualityLadderAdapter({
1416
+ id: "fluid-detail",
1417
+ domain: "geometry",
1418
+ levels: [
1419
+ { id: "low", config: { nearResolution: 10, midResolution: 6, splashCount: 10 }, estimatedCostMs: 0.8 },
1420
+ { id: "medium", config: { nearResolution: 16, midResolution: 8, splashCount: 18 }, estimatedCostMs: 1.4 },
1421
+ { id: "high", config: { nearResolution: 24, midResolution: 12, splashCount: 28 }, estimatedCostMs: 2.4 }
1422
+ ],
1423
+ initialLevel: "high"
1424
+ });
1425
+ const clothDetail = createQualityLadderAdapter({
1426
+ id: "cloth-detail",
1427
+ domain: "cloth",
1428
+ levels: [
1429
+ { id: "low", config: { cols: 10, rows: 7 }, estimatedCostMs: 0.7 },
1430
+ { id: "medium", config: { cols: 16, rows: 11 }, estimatedCostMs: 1.3 },
1431
+ { id: "high", config: { cols: 24, rows: 16 }, estimatedCostMs: 2.1 }
1432
+ ],
1433
+ initialLevel: "high"
1434
+ });
1435
+ const lightingDetail = createQualityLadderAdapter({
1436
+ id: "lighting-detail",
1437
+ domain: "lighting",
1438
+ levels: [
1439
+ { id: "low", config: { shadowStrength: 0.18, reflectionStrength: 0.08 }, estimatedCostMs: 0.5 },
1440
+ { id: "medium", config: { shadowStrength: 0.34, reflectionStrength: 0.16 }, estimatedCostMs: 1 },
1441
+ { id: "high", config: { shadowStrength: 0.5, reflectionStrength: 0.24 }, estimatedCostMs: 1.8 }
1442
+ ],
1443
+ initialLevel: "high"
1444
+ });
1445
+ const governor = createGpuPerformanceGovernor({
1446
+ device: createDeviceProfile({
1447
+ deviceClass: "desktop",
1448
+ mode: "flat",
1449
+ refreshRateHz: 60,
1450
+ supportedFrameRates: [60, 90],
1451
+ supportsWebGpu: true
1452
+ }),
1453
+ modules: [fluidDetail, clothDetail, lightingDetail],
1454
+ adaptation: {
1455
+ sampleWindowSize: 10,
1456
+ minimumSamplesBeforeAdjustment: 4,
1457
+ degradeCooldownFrames: 1,
1458
+ upgradeCooldownFrames: 4,
1459
+ minStableFramesForRecovery: 3
1460
+ }
1461
+ });
1462
+ return { governor, fluidDetail, clothDetail, lightingDetail };
1463
+ }
1464
+ function buildDemoDom(root, options) {
1465
+ const t = options.translate;
1466
+ root.innerHTML = `
1467
+ <main class="plasius-demo">
1468
+ <section class="plasius-demo__hero">
1469
+ <section class="plasius-panel plasius-demo__hero-card">
1470
+ <p class="plasius-demo__eyebrow">${options.packageName}</p>
1471
+ <h1>${options.title}</h1>
1472
+ <p class="plasius-demo__lead">${options.subtitle}</p>
1473
+ </section>
1474
+ <section class="plasius-panel plasius-demo__status">
1475
+ <p id="demoStatus" class="plasius-demo__status-badge">${t(gpuSharedTranslationKeys.statusBooting)}</p>
1476
+ <p id="demoDetails" class="plasius-demo__status-text">
1477
+ ${t(gpuSharedTranslationKeys.detailsBooting)}
1478
+ </p>
1479
+ </section>
1480
+ </section>
1481
+ <section class="plasius-demo__layout">
1482
+ <section class="plasius-panel plasius-demo__canvas-panel">
1483
+ <canvas id="demoCanvas" class="plasius-demo__canvas" width="${DEFAULT_CANVAS_WIDTH}" height="${DEFAULT_CANVAS_HEIGHT}"></canvas>
1484
+ <div class="plasius-demo__toolbar">
1485
+ <button id="pauseButton" type="button">${t(gpuSharedTranslationKeys.pause)}</button>
1486
+ <label class="plasius-toggle">
1487
+ <input id="stressToggle" type="checkbox" />
1488
+ ${t(gpuSharedTranslationKeys.stressMode)}
1489
+ </label>
1490
+ <label class="plasius-toggle">
1491
+ ${t(gpuSharedTranslationKeys.focus)}
1492
+ <select id="focusMode">
1493
+ ${showcaseFocusModes.map(
1494
+ (mode) => `<option value="${mode}">${t(FOCUS_MODE_TRANSLATION_KEYS[mode])}</option>`
1495
+ ).join("")}
1496
+ </select>
1497
+ </label>
1498
+ </div>
1499
+ <div class="plasius-demo__legend">
1500
+ <strong>${t(gpuSharedTranslationKeys.legendTitle)}</strong>
1501
+ ${t(gpuSharedTranslationKeys.legendShipMetadata)}<br />
1502
+ ${t(gpuSharedTranslationKeys.legendLighting)}<br />
1503
+ ${t(gpuSharedTranslationKeys.legendCollisions)}
1504
+ </div>
1505
+ </section>
1506
+ <aside class="plasius-demo__sidebar">
1507
+ <section class="plasius-panel plasius-demo__card">
1508
+ <h2>${t(gpuSharedTranslationKeys.sceneState)}</h2>
1509
+ <ul id="sceneMetrics" class="plasius-demo__metrics"></ul>
1510
+ </section>
1511
+ <section class="plasius-panel plasius-demo__card">
1512
+ <h2>${t(gpuSharedTranslationKeys.qualityBudgets)}</h2>
1513
+ <ul id="qualityMetrics" class="plasius-demo__metrics"></ul>
1514
+ </section>
1515
+ <section class="plasius-panel plasius-demo__card">
1516
+ <h2>${t(gpuSharedTranslationKeys.debugTelemetry)}</h2>
1517
+ <ul id="debugMetrics" class="plasius-demo__metrics"></ul>
1518
+ </section>
1519
+ <section class="plasius-panel plasius-demo__card">
1520
+ <h2>${t(gpuSharedTranslationKeys.notes)}</h2>
1521
+ <ul id="sceneNotes" class="plasius-demo__metrics"></ul>
1522
+ </section>
1523
+ </aside>
1524
+ </section>
1525
+ <p class="plasius-demo__footer">
1526
+ This visual example is shared across the GPU packages to keep manual validation fast and consistent.
1527
+ </p>
1528
+ </main>
1529
+ `;
1530
+ return {
1531
+ status: root.querySelector("#demoStatus"),
1532
+ details: root.querySelector("#demoDetails"),
1533
+ canvas: root.querySelector("#demoCanvas"),
1534
+ pauseButton: root.querySelector("#pauseButton"),
1535
+ stressToggle: root.querySelector("#stressToggle"),
1536
+ focusMode: root.querySelector("#focusMode"),
1537
+ sceneMetrics: root.querySelector("#sceneMetrics"),
1538
+ qualityMetrics: root.querySelector("#qualityMetrics"),
1539
+ debugMetrics: root.querySelector("#debugMetrics"),
1540
+ sceneNotes: root.querySelector("#sceneNotes")
1541
+ };
1542
+ }
1543
+ function buildSceneSnapshot(state, shipModel) {
1544
+ const shipPhysics = Object.freeze(
1545
+ Object.fromEntries(
1546
+ state.ships.map((ship) => [ship.id, resolveShipModel(state, ship, shipModel)?.physics ?? null])
1547
+ )
1548
+ );
1549
+ return Object.freeze({
1550
+ focus: state.focus,
1551
+ frame: state.frame,
1552
+ time: state.time,
1553
+ stress: state.stress,
1554
+ collisions: state.contactCount,
1555
+ collisionCount: state.collisionCount,
1556
+ collisionFlash: state.collisionFlash,
1557
+ sprays: Object.freeze(
1558
+ state.sprays.map(
1559
+ (spray) => Object.freeze({
1560
+ life: spray.life,
1561
+ position: Object.freeze({ ...spray.position }),
1562
+ velocity: Object.freeze({ ...spray.velocity })
1563
+ })
1564
+ )
1565
+ ),
1566
+ ships: Object.freeze(
1567
+ state.ships.map(
1568
+ (ship) => Object.freeze({
1569
+ id: ship.id,
1570
+ modelKey: ship.modelKey ?? "brigantine",
1571
+ position: Object.freeze({ ...ship.position }),
1572
+ velocity: Object.freeze({ ...ship.velocity }),
1573
+ rotationY: ship.rotationY,
1574
+ angularVelocity: ship.angularVelocity,
1575
+ tint: Object.freeze({ ...ship.tint })
1576
+ })
1577
+ )
1578
+ ),
1579
+ waveImpulses: Object.freeze(
1580
+ state.waveImpulses.map(
1581
+ (impulse) => Object.freeze({
1582
+ x: impulse.x,
1583
+ z: impulse.z,
1584
+ strength: impulse.strength,
1585
+ radius: impulse.radius,
1586
+ life: impulse.life
1587
+ })
1588
+ )
1589
+ ),
1590
+ shipPhysics: shipModel?.physics ?? null,
1591
+ shipModels: shipPhysics,
1592
+ physics: Object.freeze({
1593
+ profile: state.physics.profile,
1594
+ plan: state.physics.plan,
1595
+ manifest: state.physics.manifest,
1596
+ snapshot: state.physics.snapshot,
1597
+ shipPhysics
1598
+ })
1599
+ });
1600
+ }
1601
+ function resolveSceneDescription(state, options, shipModel) {
1602
+ const scene = buildSceneSnapshot(state, shipModel);
1603
+ if (typeof options.describeState !== "function") {
1604
+ return { scene, description: null };
1605
+ }
1606
+ const description = options.describeState(state.packageState, scene) ?? null;
1607
+ return { scene, description };
1608
+ }
1609
+ function updatePackageState(state, options, shipModel, dt) {
1610
+ if (typeof options.updateState !== "function") {
1611
+ return;
1612
+ }
1613
+ const scene = buildSceneSnapshot(state, shipModel);
1614
+ const nextState = options.updateState(state.packageState, scene, dt);
1615
+ if (typeof nextState !== "undefined") {
1616
+ state.packageState = nextState;
1617
+ }
1618
+ }
1619
+ function normalizeColorOverride(color, fallback) {
1620
+ if (!color || typeof color !== "object") {
1621
+ return fallback;
1622
+ }
1623
+ return {
1624
+ r: typeof color.r === "number" ? color.r : fallback.r,
1625
+ g: typeof color.g === "number" ? color.g : fallback.g,
1626
+ b: typeof color.b === "number" ? color.b : fallback.b
1627
+ };
1628
+ }
1629
+ function readVisualNumber(value, fallback) {
1630
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
1631
+ }
1632
+ function readPositiveNumber(value, fallback) {
1633
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
1634
+ }
1635
+ function isTruthyCaptureValue(value) {
1636
+ return value === "1" || value === "true" || value === "scene" || value === "video";
1637
+ }
1638
+ function resolveCaptureSettings(options) {
1639
+ const explicitCaptureMode = typeof options.captureMode === "boolean" ? options.captureMode : void 0;
1640
+ let captureMode = explicitCaptureMode ?? false;
1641
+ let renderScale = readPositiveNumber(options.renderScale, void 0);
1642
+ try {
1643
+ const params = new URLSearchParams(window.location.search);
1644
+ if (explicitCaptureMode === void 0) {
1645
+ captureMode = isTruthyCaptureValue(params.get("capture")) || params.get("presentation") === "capture";
1646
+ }
1647
+ renderScale = readPositiveNumber(Number(params.get("renderScale")), renderScale);
1648
+ } catch {
1649
+ }
1650
+ return {
1651
+ captureMode,
1652
+ renderScale
1653
+ };
1654
+ }
1655
+ function getCanvasDisplaySize(canvas) {
1656
+ const rect = typeof canvas.getBoundingClientRect === "function" ? canvas.getBoundingClientRect() : null;
1657
+ const width = Math.round(
1658
+ readPositiveNumber(rect?.width, readPositiveNumber(canvas.clientWidth, canvas.width))
1659
+ );
1660
+ const height = Math.round(
1661
+ readPositiveNumber(rect?.height, readPositiveNumber(canvas.clientHeight, canvas.height))
1662
+ );
1663
+ return {
1664
+ width: Math.max(1, width || DEFAULT_CANVAS_WIDTH),
1665
+ height: Math.max(1, height || DEFAULT_CANVAS_HEIGHT)
1666
+ };
1667
+ }
1668
+ function resizeCanvasToDisplaySize(canvas, state) {
1669
+ const { width, height } = getCanvasDisplaySize(canvas);
1670
+ const deviceScale = readPositiveNumber(globalThis.devicePixelRatio, 1);
1671
+ const requestedScale = readPositiveNumber(state.renderScale, deviceScale);
1672
+ const maxScale = state.captureMode ? 2 : 1.5;
1673
+ let scale = clamp(requestedScale, 1, maxScale);
1674
+ const pixelBudget = state.captureMode ? CAPTURE_CANVAS_PIXEL_BUDGET : DEFAULT_CANVAS_WIDTH * DEFAULT_CANVAS_HEIGHT * 1.5;
1675
+ const projectedPixels = width * height * scale * scale;
1676
+ if (projectedPixels > pixelBudget) {
1677
+ scale = Math.sqrt(pixelBudget / Math.max(1, width * height));
1678
+ }
1679
+ const targetWidth = Math.max(1, Math.round(width * scale));
1680
+ const targetHeight = Math.max(1, Math.round(height * scale));
1681
+ if (canvas.width !== targetWidth || canvas.height !== targetHeight) {
1682
+ canvas.width = targetWidth;
1683
+ canvas.height = targetHeight;
1684
+ }
1685
+ state.renderScale = scale;
1686
+ }
1687
+ function resolveClothPresentation(state, meshDetail, clothFeatures) {
1688
+ const clothPlan = clothFeatures.createPlan({
1689
+ garmentId: "shore-flag",
1690
+ kind: state.focus === "cloth" ? "flag" : clothFeatures.garmentKinds[0],
1691
+ profile: state.focus === "cloth" ? "cinematic" : clothFeatures.profileNames[0],
1692
+ supportsRayTracing: true,
1693
+ nearFieldMaxMeters: 18,
1694
+ midFieldMaxMeters: 55,
1695
+ farFieldMaxMeters: 180
1696
+ });
1697
+ const preset = CAMERA_PRESETS[state.focus] ?? CAMERA_PRESETS.integrated;
1698
+ const fallbackEye = state.camera.eye ? state.camera.eye : addVec3(
1699
+ state.camera.target,
1700
+ vec3(
1701
+ Math.sin(state.camera.yaw ?? preset.yaw) * Math.cos(state.camera.pitch ?? preset.pitch) * (state.camera.distance ?? preset.distance),
1702
+ Math.sin(state.camera.pitch ?? preset.pitch) * (state.camera.distance ?? preset.distance),
1703
+ Math.cos(state.camera.yaw ?? preset.yaw) * Math.cos(state.camera.pitch ?? preset.pitch) * (state.camera.distance ?? preset.distance)
1704
+ )
1705
+ );
1706
+ const cameraDistance = lengthVec3(subVec3(state.camera.target, fallbackEye));
1707
+ const band = clothFeatures.selectBand(cameraDistance, clothPlan.thresholds);
1708
+ const representation = clothPlan.representations.find((entry) => entry.band === band) ?? clothPlan.representations[0];
1709
+ return {
1710
+ clothPlan,
1711
+ band,
1712
+ continuity: representation.continuity,
1713
+ representation
1714
+ };
1715
+ }
1716
+ function getFlagRestPosition(rows, cols, row, column) {
1717
+ const u = cols <= 1 ? 0 : column / (cols - 1);
1718
+ const v = rows <= 1 ? 0 : row / (rows - 1);
1719
+ return vec3(
1720
+ FLAG_LAYOUT.origin.x + u * FLAG_LAYOUT.mastOffsetX,
1721
+ FLAG_LAYOUT.origin.y - FLAG_LAYOUT.height * v - u * u * 0.08,
1722
+ FLAG_LAYOUT.origin.z + FLAG_LAYOUT.width * u
1723
+ );
1724
+ }
1725
+ function buildClothConstraints(rows, cols, restPositions) {
1726
+ const constraints = [];
1727
+ const indexFor = (row, column) => row * cols + column;
1728
+ const pushConstraint = (a, b, stiffness) => {
1729
+ constraints.push(
1730
+ Object.freeze({
1731
+ a,
1732
+ b,
1733
+ restLength: lengthVec3(subVec3(restPositions[a], restPositions[b])),
1734
+ stiffness
1735
+ })
1736
+ );
1737
+ };
1738
+ for (let row = 0; row < rows; row += 1) {
1739
+ for (let column = 0; column < cols; column += 1) {
1740
+ const index = indexFor(row, column);
1741
+ if (column + 1 < cols) {
1742
+ pushConstraint(index, indexFor(row, column + 1), 0.92);
1743
+ }
1744
+ if (row + 1 < rows) {
1745
+ pushConstraint(index, indexFor(row + 1, column), 0.9);
1746
+ }
1747
+ if (column + 1 < cols && row + 1 < rows) {
1748
+ pushConstraint(index, indexFor(row + 1, column + 1), 0.66);
1749
+ }
1750
+ if (column - 1 >= 0 && row + 1 < rows) {
1751
+ pushConstraint(index, indexFor(row + 1, column - 1), 0.66);
1752
+ }
1753
+ if (column + 2 < cols) {
1754
+ pushConstraint(index, indexFor(row, column + 2), 0.22);
1755
+ }
1756
+ if (row + 2 < rows) {
1757
+ pushConstraint(index, indexFor(row + 2, column), 0.18);
1758
+ }
1759
+ }
1760
+ }
1761
+ return Object.freeze(constraints);
1762
+ }
1763
+ function createShowcaseClothSimulationState(options = {}) {
1764
+ const rows = Math.max(4, options.rows ?? 11);
1765
+ const cols = Math.max(4, options.cols ?? 16);
1766
+ const continuity = options.continuity ?? {
1767
+ broadMotionFloor: 0.72,
1768
+ wrinkleFloor: 0.56
1769
+ };
1770
+ const representation = options.representation ?? {
1771
+ mesh: {
1772
+ solverIterations: 6,
1773
+ wrinkleLayers: 2
1774
+ }
1775
+ };
1776
+ const restPositions = [];
1777
+ const positions = [];
1778
+ const previousPositions = [];
1779
+ const uvs = [];
1780
+ const phaseOffsets = [];
1781
+ const pinned = [];
1782
+ for (let row = 0; row < rows; row += 1) {
1783
+ for (let column = 0; column < cols; column += 1) {
1784
+ const index = row * cols + column;
1785
+ const u = cols <= 1 ? 0 : column / (cols - 1);
1786
+ const v = rows <= 1 ? 0 : row / (rows - 1);
1787
+ const rest = getFlagRestPosition(rows, cols, row, column);
1788
+ const preload = vec3(
1789
+ u * 0.04,
1790
+ Math.sin(v * Math.PI) * 0.02 * continuity.wrinkleFloor,
1791
+ -u * 0.12
1792
+ );
1793
+ const pinnedPoint = column === 0;
1794
+ restPositions.push(rest);
1795
+ positions.push(pinnedPoint ? vec3(rest.x, rest.y, rest.z) : addVec3(rest, preload));
1796
+ previousPositions.push(
1797
+ pinnedPoint ? vec3(rest.x, rest.y, rest.z) : addVec3(rest, scaleVec3(preload, 0.35))
1798
+ );
1799
+ uvs.push(Object.freeze({ u, v }));
1800
+ phaseOffsets.push(pseudoRandom(index + 17) * Math.PI * 2);
1801
+ pinned.push(pinnedPoint);
1802
+ }
1803
+ }
1804
+ return {
1805
+ rows,
1806
+ cols,
1807
+ continuity,
1808
+ representation,
1809
+ restPositions,
1810
+ positions,
1811
+ previousPositions,
1812
+ constraints: buildClothConstraints(rows, cols, restPositions),
1813
+ indices: Object.freeze(
1814
+ Array.from({ length: (rows - 1) * (cols - 1) * 6 }, (_, listIndex) => listIndex).map((_, listIndex, source) => {
1815
+ if (listIndex >= source.length) {
1816
+ return 0;
1817
+ }
1818
+ const quadIndex = Math.floor(listIndex / 6);
1819
+ const quadColumn = quadIndex % (cols - 1);
1820
+ const quadRow = Math.floor(quadIndex / (cols - 1));
1821
+ const base = quadRow * cols + quadColumn;
1822
+ return [base, base + 1, base + cols + 1, base, base + cols + 1, base + cols][listIndex % 6];
1823
+ })
1824
+ ),
1825
+ uvs,
1826
+ phaseOffsets,
1827
+ pinned
1828
+ };
1829
+ }
1830
+ function resetPinnedClothPoints(clothState) {
1831
+ for (let index = 0; index < clothState.positions.length; index += 1) {
1832
+ if (!clothState.pinned[index]) {
1833
+ continue;
1834
+ }
1835
+ const anchor = clothState.restPositions[index];
1836
+ clothState.positions[index] = vec3(anchor.x, anchor.y, anchor.z);
1837
+ clothState.previousPositions[index] = vec3(anchor.x, anchor.y, anchor.z);
1838
+ }
1839
+ }
1840
+ function satisfyClothConstraint(clothState, constraint) {
1841
+ const a = clothState.positions[constraint.a];
1842
+ const b = clothState.positions[constraint.b];
1843
+ const delta = subVec3(b, a);
1844
+ const distance = lengthVec3(delta);
1845
+ if (distance <= 1e-4) {
1846
+ return;
1847
+ }
1848
+ const correctionScale = (distance - constraint.restLength) / distance * 0.5 * constraint.stiffness;
1849
+ const correction = scaleVec3(delta, correctionScale);
1850
+ if (!clothState.pinned[constraint.a]) {
1851
+ clothState.positions[constraint.a] = addVec3(a, correction);
1852
+ }
1853
+ if (!clothState.pinned[constraint.b]) {
1854
+ clothState.positions[constraint.b] = subVec3(b, correction);
1855
+ }
1856
+ }
1857
+ function advanceShowcaseClothSimulationState(clothState, options = {}) {
1858
+ const dt = clamp(options.dt ?? 1 / 60, 1 / 240, 1 / 18);
1859
+ const time = readVisualNumber(options.time, 0);
1860
+ const flagMotion = readVisualNumber(options.flagMotion, 0.92);
1861
+ const waveInfluence = readVisualNumber(options.waveInfluence, 0);
1862
+ const wrinkleLayers = Math.max(1, clothState.representation.mesh?.wrinkleLayers ?? 2);
1863
+ const solverIterations = clamp(
1864
+ Math.round(clothState.representation.mesh?.solverIterations ?? 6),
1865
+ 2,
1866
+ 10
1867
+ );
1868
+ for (let index = 0; index < clothState.positions.length; index += 1) {
1869
+ if (clothState.pinned[index]) {
1870
+ continue;
1871
+ }
1872
+ const current = clothState.positions[index];
1873
+ const previous = clothState.previousPositions[index];
1874
+ const { u, v } = clothState.uvs[index];
1875
+ const phase = clothState.phaseOffsets[index];
1876
+ const broadMotion = clothState.continuity.broadMotionFloor;
1877
+ const wrinkleMotion = clothState.continuity.wrinkleFloor;
1878
+ const gustPhase = time * 2.1 + phase + u * 4.4 + v * 2.3;
1879
+ const wrinklePhase = time * 5.3 + phase * 0.72 + u * 9.6 + v * 7.1;
1880
+ const windDirection = normalizeVec3(
1881
+ vec3(
1882
+ 0.18 + Math.sin(gustPhase) * (0.12 + broadMotion * 0.09),
1883
+ Math.cos(time * 1.4 + phase + v * 4.8) * 0.06 * wrinkleMotion,
1884
+ 1 + Math.sin(gustPhase * 0.74) * 0.18
1885
+ )
1886
+ );
1887
+ const windStrength = (1.6 + broadMotion * 1.25 + wrinkleLayers * 0.12) * flagMotion * (0.44 + u * 1.14);
1888
+ const wrinkleForce = vec3(
1889
+ Math.sin(wrinklePhase) * 0.22 * wrinkleMotion * flagMotion,
1890
+ Math.cos(wrinklePhase * 0.7) * 0.08 * wrinkleMotion,
1891
+ Math.cos(wrinklePhase) * 0.14 * broadMotion * flagMotion
1892
+ );
1893
+ const acceleration = addVec3(
1894
+ vec3(0, -0.48 - u * 0.08, 0),
1895
+ addVec3(
1896
+ scaleVec3(windDirection, windStrength),
1897
+ addVec3(
1898
+ wrinkleForce,
1899
+ vec3(waveInfluence * (0.04 + u * 0.08), 0, waveInfluence * 0.16)
1900
+ )
1901
+ )
1902
+ );
1903
+ const inertia = scaleVec3(subVec3(current, previous), 0.987);
1904
+ const next = addVec3(addVec3(current, inertia), scaleVec3(acceleration, dt * dt));
1905
+ clothState.previousPositions[index] = vec3(current.x, current.y, current.z);
1906
+ clothState.positions[index] = next;
1907
+ }
1908
+ resetPinnedClothPoints(clothState);
1909
+ for (let iteration = 0; iteration < solverIterations; iteration += 1) {
1910
+ for (const constraint of clothState.constraints) {
1911
+ satisfyClothConstraint(clothState, constraint);
1912
+ }
1913
+ resetPinnedClothPoints(clothState);
1914
+ }
1915
+ return clothState;
1916
+ }
1917
+ function ensureShowcaseClothState(state, meshDetail, clothPresentation) {
1918
+ if (!state.clothState || state.clothState.rows !== meshDetail.rows || state.clothState.cols !== meshDetail.cols) {
1919
+ state.clothState = createShowcaseClothSimulationState({
1920
+ rows: meshDetail.rows,
1921
+ cols: meshDetail.cols,
1922
+ continuity: clothPresentation.continuity,
1923
+ representation: clothPresentation.representation
1924
+ });
1925
+ } else {
1926
+ state.clothState.continuity = clothPresentation.continuity;
1927
+ state.clothState.representation = clothPresentation.representation;
1928
+ }
1929
+ return state.clothState;
1930
+ }
1931
+ function resolveVisualConfig(nearLighting, lightingSnapshot, customVisuals = {}) {
1932
+ const premiumShadows = nearLighting.primaryShadowSource === "ray-traced-primary";
1933
+ const defaults = {
1934
+ skyTop: premiumShadows ? "#040c1a" : "#06101f",
1935
+ skyMid: premiumShadows ? "#11203b" : "#152643",
1936
+ skyBottom: premiumShadows ? "#2f4468" : "#364d73",
1937
+ duskGlow: premiumShadows ? "rgba(116, 142, 201, 0.26)" : "rgba(104, 128, 188, 0.22)",
1938
+ seaTop: premiumShadows ? "#102946" : "#153050",
1939
+ seaMid: premiumShadows ? "#0a1d33" : "#0d2138",
1940
+ seaBottom: "#04101d",
1941
+ moonCore: "rgba(241, 246, 255, 0.98)",
1942
+ moonHalo: "rgba(167, 191, 255, 0.24)",
1943
+ moonReflection: "rgba(192, 214, 255, 0.22)",
1944
+ starColor: "rgba(232, 239, 255, 0.82)",
1945
+ ambientMist: "rgba(41, 63, 97, 0.16)",
1946
+ reflectionStrength: lightingSnapshot.currentLevel.config.reflectionStrength,
1947
+ shadowAccent: lightingSnapshot.currentLevel.config.shadowStrength,
1948
+ waveAmplitude: 0.94,
1949
+ waveDirection: { x: 0.88, z: 0.28 },
1950
+ wavePhaseSpeed: 0.88,
1951
+ wakeStrength: 0.31,
1952
+ wakeLength: 18,
1953
+ collisionRippleStrength: 0.42,
1954
+ waterNear: { r: 0.08, g: 0.23, b: 0.33 },
1955
+ waterFar: { r: 0.18, g: 0.35, b: 0.49 },
1956
+ harborWall: { r: 0.26, g: 0.24, b: 0.28 },
1957
+ harborDeck: { r: 0.33, g: 0.22, b: 0.16 },
1958
+ harborTower: { r: 0.23, g: 0.24, b: 0.29 },
1959
+ flagColor: { r: 0.66, g: 0.16, b: 0.13 },
1960
+ flagMotion: 0.92,
1961
+ lanternCore: { r: 0.98, g: 0.8, b: 0.48 },
1962
+ lanternGlow: { r: 1, g: 0.56, b: 0.2 },
1963
+ lanternReflectionStrength: 0.42,
1964
+ torchCore: { r: 0.99, g: 0.72, b: 0.36 },
1965
+ torchGlow: { r: 0.98, g: 0.38, b: 0.15 },
1966
+ collisionFlash: "rgba(255, 212, 168, 0.16)"
1967
+ };
1968
+ return {
1969
+ skyTop: typeof customVisuals.skyTop === "string" ? customVisuals.skyTop : defaults.skyTop,
1970
+ skyMid: typeof customVisuals.skyMid === "string" ? customVisuals.skyMid : defaults.skyMid,
1971
+ skyBottom: typeof customVisuals.skyBottom === "string" ? customVisuals.skyBottom : defaults.skyBottom,
1972
+ seaTop: typeof customVisuals.seaTop === "string" ? customVisuals.seaTop : defaults.seaTop,
1973
+ seaMid: typeof customVisuals.seaMid === "string" ? customVisuals.seaMid : defaults.seaMid,
1974
+ seaBottom: typeof customVisuals.seaBottom === "string" ? customVisuals.seaBottom : defaults.seaBottom,
1975
+ duskGlow: typeof customVisuals.duskGlow === "string" ? customVisuals.duskGlow : defaults.duskGlow,
1976
+ moonCore: typeof customVisuals.moonCore === "string" ? customVisuals.moonCore : typeof customVisuals.sunCore === "string" ? customVisuals.sunCore : defaults.moonCore,
1977
+ moonHalo: typeof customVisuals.moonHalo === "string" ? customVisuals.moonHalo : defaults.moonHalo,
1978
+ moonReflection: typeof customVisuals.moonReflection === "string" ? customVisuals.moonReflection : defaults.moonReflection,
1979
+ starColor: typeof customVisuals.starColor === "string" ? customVisuals.starColor : defaults.starColor,
1980
+ ambientMist: typeof customVisuals.ambientMist === "string" ? customVisuals.ambientMist : defaults.ambientMist,
1981
+ reflectionStrength: readVisualNumber(
1982
+ customVisuals.reflectionStrength,
1983
+ defaults.reflectionStrength
1984
+ ),
1985
+ shadowAccent: readVisualNumber(customVisuals.shadowAccent, defaults.shadowAccent),
1986
+ waveAmplitude: readVisualNumber(customVisuals.waveAmplitude, defaults.waveAmplitude),
1987
+ waveDirection: customVisuals.waveDirection && typeof customVisuals.waveDirection.x === "number" && typeof customVisuals.waveDirection.z === "number" ? { x: customVisuals.waveDirection.x, z: customVisuals.waveDirection.z } : defaults.waveDirection,
1988
+ wavePhaseSpeed: readVisualNumber(customVisuals.wavePhaseSpeed, defaults.wavePhaseSpeed),
1989
+ wakeStrength: readVisualNumber(customVisuals.wakeStrength, defaults.wakeStrength),
1990
+ wakeLength: readVisualNumber(customVisuals.wakeLength, defaults.wakeLength),
1991
+ collisionRippleStrength: readVisualNumber(
1992
+ customVisuals.collisionRippleStrength,
1993
+ defaults.collisionRippleStrength
1994
+ ),
1995
+ waterNear: normalizeColorOverride(customVisuals.waterNear, defaults.waterNear),
1996
+ waterFar: normalizeColorOverride(customVisuals.waterFar, defaults.waterFar),
1997
+ harborWall: normalizeColorOverride(customVisuals.harborWall, defaults.harborWall),
1998
+ harborDeck: normalizeColorOverride(customVisuals.harborDeck, defaults.harborDeck),
1999
+ harborTower: normalizeColorOverride(customVisuals.harborTower, defaults.harborTower),
2000
+ flagColor: normalizeColorOverride(customVisuals.flagColor, defaults.flagColor),
2001
+ flagMotion: readVisualNumber(customVisuals.flagMotion, defaults.flagMotion),
2002
+ lanternCore: normalizeColorOverride(customVisuals.lanternCore, defaults.lanternCore),
2003
+ lanternGlow: normalizeColorOverride(customVisuals.lanternGlow, defaults.lanternGlow),
2004
+ lanternReflectionStrength: readVisualNumber(
2005
+ customVisuals.lanternReflectionStrength,
2006
+ defaults.lanternReflectionStrength
2007
+ ),
2008
+ torchCore: normalizeColorOverride(customVisuals.torchCore, defaults.torchCore),
2009
+ torchGlow: normalizeColorOverride(customVisuals.torchGlow, defaults.torchGlow),
2010
+ collisionFlash: typeof customVisuals.collisionFlash === "string" ? customVisuals.collisionFlash : defaults.collisionFlash
2011
+ };
2012
+ }
2013
+ function buildClothSurface(model, state, meshDetail, visuals, clothFeatures) {
2014
+ const resolvedClothFeatures = normalizeClothFeatureAdapters(clothFeatures);
2015
+ const clothPresentation = resolveClothPresentation(state, meshDetail, resolvedClothFeatures);
2016
+ const clothState = ensureShowcaseClothState(state, meshDetail, clothPresentation);
2017
+ return {
2018
+ clothPlan: clothPresentation.clothPlan,
2019
+ band: clothPresentation.band,
2020
+ representation: clothPresentation.representation,
2021
+ continuity: clothPresentation.continuity,
2022
+ color: visuals.flagColor,
2023
+ positions: clothState.positions.map((point) => vec3(point.x, point.y, point.z)),
2024
+ indices: clothState.indices,
2025
+ grid: { rows: clothState.rows, cols: clothState.cols }
2026
+ };
2027
+ }
2028
+ function resolveWaveDirection(state) {
2029
+ const direction = state.demoVisuals?.waveDirection;
2030
+ if (direction && typeof direction === "object" && typeof direction.x === "number" && typeof direction.z === "number") {
2031
+ return normalizeVec3(vec3(direction.x, 0, direction.z));
2032
+ }
2033
+ return normalizeVec3(vec3(0.86, 0, 0.34));
2034
+ }
2035
+ function sampleShipWake(state, x, z, time) {
2036
+ const wakeStrength = readVisualNumber(state.demoVisuals?.wakeStrength, 0.24);
2037
+ const wakeLength = readVisualNumber(state.demoVisuals?.wakeLength, 15);
2038
+ let total = 0;
2039
+ for (const ship of state.ships) {
2040
+ const speed = Math.hypot(ship.velocity.x, ship.velocity.z);
2041
+ if (speed <= 0.05) {
2042
+ continue;
2043
+ }
2044
+ const direction = normalizeVec3(vec3(ship.velocity.x, 0, ship.velocity.z));
2045
+ const behind = scaleVec3(direction, -1);
2046
+ const lateral = vec3(-direction.z, 0, direction.x);
2047
+ const delta = vec3(x - ship.position.x, 0, z - ship.position.z);
2048
+ const along = dotVec3(delta, behind);
2049
+ if (along < 0 || along > wakeLength) {
2050
+ continue;
2051
+ }
2052
+ const cross = Math.abs(dotVec3(delta, lateral));
2053
+ const width = 0.9 + along * 0.2;
2054
+ if (cross > width * 3.2) {
2055
+ continue;
2056
+ }
2057
+ const envelope = Math.exp(-along * 0.14) * Math.exp(-(cross * cross / Math.max(0.4, width * width * 2.4)));
2058
+ total += Math.sin(along * 1.6 - time * 4.2) * speed * wakeStrength * envelope;
2059
+ }
2060
+ return total;
2061
+ }
2062
+ function sampleWaveImpulses(state, x, z, time) {
2063
+ const rippleStrength = readVisualNumber(state.demoVisuals?.collisionRippleStrength, 0.34);
2064
+ let total = 0;
2065
+ for (const impulse of state.waveImpulses) {
2066
+ const dx = x - impulse.x;
2067
+ const dz = z - impulse.z;
2068
+ const distance = Math.hypot(dx, dz);
2069
+ const radius = impulse.radius + (1 - impulse.life) * 4.8;
2070
+ if (distance > radius * 2.8) {
2071
+ continue;
2072
+ }
2073
+ const phase = distance * 1.8 - (1 - impulse.life) * 10 - time * 0.4;
2074
+ const envelope = Math.exp(-distance / Math.max(0.1, radius)) * impulse.life;
2075
+ total += Math.sin(phase) * impulse.strength * rippleStrength * envelope * 0.18;
2076
+ }
2077
+ return total;
2078
+ }
2079
+ function sampleWave(state, x, z, time) {
2080
+ const direction = resolveWaveDirection(state);
2081
+ const lateral = vec3(-direction.z, 0, direction.x);
2082
+ const along = x * direction.x + z * direction.z;
2083
+ const cross = x * lateral.x + z * lateral.z;
2084
+ const phaseSpeed = readVisualNumber(state.demoVisuals?.wavePhaseSpeed, 1);
2085
+ const amplitude = readVisualNumber(state.demoVisuals?.waveAmplitude, 1);
2086
+ const base = Math.sin(along * 0.22 - time * 1.12 * phaseSpeed) * 0.42 + Math.cos(along * 0.11 + cross * 0.07 - time * 0.78 * phaseSpeed) * 0.26 + Math.sin(cross * 0.19 - time * 1.34 * phaseSpeed) * 0.16;
2087
+ return base * amplitude + sampleShipWake(state, x, z, time) + sampleWaveImpulses(state, x, z, time);
2088
+ }
2089
+ function resolveFluidBandContinuity(continuity, band) {
2090
+ if (continuity?.bands && continuity.bands[band]) {
2091
+ return continuity.bands[band];
2092
+ }
2093
+ return continuity ?? { amplitudeFloor: 1, frequencyFloor: 1 };
2094
+ }
2095
+ function buildWaterMotionEffects(state) {
2096
+ const wakeTrails = [];
2097
+ const rippleRings = state.waveImpulses.map((impulse) => {
2098
+ const radius = impulse.radius + (1 - impulse.life) * 4.8;
2099
+ return Object.freeze({
2100
+ center: vec3(
2101
+ impulse.x,
2102
+ sampleWave(state, impulse.x, impulse.z, state.time) * 0.24 + 0.06,
2103
+ impulse.z
2104
+ ),
2105
+ radius,
2106
+ opacity: clamp(impulse.life * 0.28, 0.08, 0.3)
2107
+ });
2108
+ });
2109
+ for (const ship of state.ships) {
2110
+ const speed = Math.hypot(ship.velocity.x, ship.velocity.z);
2111
+ if (speed <= 0.18) {
2112
+ continue;
2113
+ }
2114
+ const direction = normalizeVec3(vec3(ship.velocity.x, 0, ship.velocity.z));
2115
+ const behind = scaleVec3(direction, -1);
2116
+ const lateral = vec3(-direction.z, 0, direction.x);
2117
+ const points = [];
2118
+ for (let sampleIndex = 0; sampleIndex < 6; sampleIndex += 1) {
2119
+ const along = 1 + sampleIndex * 1.45;
2120
+ const lateralOffset = Math.sin(state.time * 1.2 + sampleIndex * 0.8 + readVisualNumber(ship.wanderPhase, 0)) * 0.12;
2121
+ const worldPoint = addVec3(
2122
+ ship.position,
2123
+ addVec3(scaleVec3(behind, along), scaleVec3(lateral, lateralOffset))
2124
+ );
2125
+ points.push(
2126
+ Object.freeze({
2127
+ center: vec3(
2128
+ worldPoint.x,
2129
+ sampleWave(state, worldPoint.x, worldPoint.z, state.time) * 0.24 + 0.04,
2130
+ worldPoint.z
2131
+ ),
2132
+ width: 0.34 + sampleIndex * 0.13
2133
+ })
2134
+ );
2135
+ }
2136
+ wakeTrails.push(
2137
+ Object.freeze({
2138
+ opacity: clamp(0.18 + speed * 0.09, 0.22, 0.46),
2139
+ points: Object.freeze(points)
2140
+ })
2141
+ );
2142
+ }
2143
+ return Object.freeze({
2144
+ wakeTrails: Object.freeze(wakeTrails),
2145
+ rippleRings: Object.freeze(rippleRings)
2146
+ });
2147
+ }
2148
+ function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
2149
+ const resolvedFluidFeatures = normalizeFluidFeatureAdapters(fluidFeatures);
2150
+ const fluidPlan = resolvedFluidFeatures.createPlan({
2151
+ fluidBodyId: "harbor",
2152
+ kind: state.focus === "fluid" ? "ocean" : resolvedFluidFeatures.bodyKinds[0],
2153
+ profile: state.focus === "fluid" ? "cinematic" : resolvedFluidFeatures.profileNames[0],
2154
+ supportsRayTracing: true,
2155
+ nearFieldMaxMeters: 40,
2156
+ midFieldMaxMeters: 150,
2157
+ farFieldMaxMeters: 600
2158
+ });
2159
+ const bandMeshes = [];
2160
+ const bandExtents = Object.freeze([
2161
+ { band: "near", width: 20, depth: 18, step: 1, y: 0.2 },
2162
+ { band: "mid", width: 34, depth: 28, step: 2, y: 0.05 },
2163
+ { band: "far", width: 54, depth: 42, step: 3.5, y: -0.05 },
2164
+ { band: "horizon", width: 80, depth: 76, step: 7, y: -0.14 }
2165
+ ]);
2166
+ for (const bandSpec of bandExtents) {
2167
+ const representation = fluidPlan.representations.find((entry) => entry.band === bandSpec.band) ?? fluidPlan.representations[0];
2168
+ const continuity = resolvedFluidFeatures.createContinuityEnvelope({ fluidBodyId: "harbor" });
2169
+ const bandContinuity = resolveFluidBandContinuity(continuity, bandSpec.band);
2170
+ const bandResolution = bandSpec.band === "near" ? fluidDetail.nearResolution : bandSpec.band === "mid" ? fluidDetail.midResolution : bandSpec.band === "far" ? 5 : 3;
2171
+ const cols = Math.max(4, bandResolution * 2);
2172
+ const rows = Math.max(4, bandResolution + 2);
2173
+ const positions = [];
2174
+ const indices = [];
2175
+ const originX = -bandSpec.width * 0.5;
2176
+ const originZ = -6;
2177
+ for (let row = 0; row < rows; row += 1) {
2178
+ for (let column = 0; column < cols; column += 1) {
2179
+ const u = column / (cols - 1);
2180
+ const v = row / (rows - 1);
2181
+ const x = originX + bandSpec.width * u;
2182
+ const z = originZ + bandSpec.depth * v;
2183
+ const y = bandSpec.y + sampleWave(state, x, z, state.time) * bandContinuity.amplitudeFloor * (bandSpec.band === "near" ? 0.9 : bandSpec.band === "mid" ? 0.55 : 0.3);
2184
+ positions.push(vec3(x, y, z));
2185
+ }
2186
+ }
2187
+ for (let row = 0; row < rows - 1; row += 1) {
2188
+ for (let column = 0; column < cols - 1; column += 1) {
2189
+ const a = row * cols + column;
2190
+ const b = a + 1;
2191
+ const c = a + cols + 1;
2192
+ const d = a + cols;
2193
+ indices.push(a, b, c, a, c, d);
2194
+ }
2195
+ }
2196
+ bandMeshes.push({
2197
+ band: bandSpec.band,
2198
+ representation,
2199
+ continuity: bandContinuity,
2200
+ rows,
2201
+ cols,
2202
+ positions,
2203
+ indices,
2204
+ color: bandSpec.band === "near" ? visuals.waterNear : bandSpec.band === "mid" ? {
2205
+ r: mix(visuals.waterNear.r, visuals.waterFar.r, 0.4),
2206
+ g: mix(visuals.waterNear.g, visuals.waterFar.g, 0.4),
2207
+ b: mix(visuals.waterNear.b, visuals.waterFar.b, 0.4)
2208
+ } : bandSpec.band === "far" ? visuals.waterFar : {
2209
+ r: mix(visuals.waterFar.r, 0.76, 0.2),
2210
+ g: mix(visuals.waterFar.g, 0.78, 0.2),
2211
+ b: mix(visuals.waterFar.b, 0.82, 0.2)
2212
+ }
2213
+ });
2214
+ }
2215
+ return { fluidPlan, bandMeshes };
2216
+ }
2217
+ function createSceneState(options, featureAdapters) {
2218
+ const translate = options.translate;
2219
+ const { governor, fluidDetail, clothDetail, lightingDetail } = createPerformanceGovernor(
2220
+ featureAdapters.performance
2221
+ );
2222
+ const physicsProfile = featureAdapters.physics.defaultProfile;
2223
+ const createPhysicsSimulationPlan = assertRequiredFunction(
2224
+ featureAdapters.physics,
2225
+ "physics",
2226
+ "createSimulationPlan"
2227
+ );
2228
+ const getPhysicsWorkerManifest = assertRequiredFunction(
2229
+ featureAdapters.physics,
2230
+ "physics",
2231
+ "getManifest"
2232
+ );
2233
+ const createGpuDebugSession = assertRequiredFunction(
2234
+ featureAdapters.debug,
2235
+ "debug",
2236
+ "createSession"
2237
+ );
2238
+ const physicsPlan = createPhysicsSimulationPlan(physicsProfile);
2239
+ const physicsManifest = getPhysicsWorkerManifest(physicsProfile);
2240
+ const debugSession = createGpuDebugSession({
2241
+ enabled: true,
2242
+ adapter: {
2243
+ label: translate(gpuSharedTranslationKeys.debugAdapterShowcase),
2244
+ memoryCapacityHintBytes: 6 * 1024 * 1024 * 1024,
2245
+ coreCountHint: 12
2246
+ }
2247
+ });
2248
+ debugSession.trackAllocation({
2249
+ id: "showcase.color",
2250
+ owner: "renderer",
2251
+ category: "texture",
2252
+ sizeBytes: 1280 * 720 * 4,
2253
+ label: translate(gpuSharedTranslationKeys.debugMainColorBuffer)
2254
+ });
2255
+ debugSession.trackAllocation({
2256
+ id: "showcase.shadow-impression",
2257
+ owner: "lighting",
2258
+ category: "texture",
2259
+ sizeBytes: 12 * 1024 * 1024,
2260
+ label: translate(gpuSharedTranslationKeys.debugShadowImpressionAtlas)
2261
+ });
2262
+ return {
2263
+ translate,
2264
+ focus: options.focus,
2265
+ governor,
2266
+ fluidDetail,
2267
+ clothDetail,
2268
+ lightingDetail,
2269
+ debugSession,
2270
+ showcaseRealisticModelsEnabled: options.realisticModelsEnabled !== false,
2271
+ captureMode: options.captureMode === true,
2272
+ renderScale: readPositiveNumber(options.renderScale, void 0),
2273
+ packageState: void 0,
2274
+ demoDescription: null,
2275
+ demoVisuals: null,
2276
+ time: 0,
2277
+ lastTimeMs: null,
2278
+ paused: false,
2279
+ stress: false,
2280
+ camera: {
2281
+ ...CAMERA_PRESETS[options.focus],
2282
+ target: vec3(...CAMERA_PRESETS[options.focus].target)
2283
+ },
2284
+ ships: [
2285
+ {
2286
+ id: "northwind",
2287
+ modelKey: "brigantine",
2288
+ position: vec3(-5.2, 0, 7.2),
2289
+ velocity: vec3(2.35, 0, -1.08),
2290
+ rotationY: 0.58,
2291
+ angularVelocity: 0.09,
2292
+ tint: { r: 0.62, g: 0.39, b: 0.23 },
2293
+ massScale: 1.42,
2294
+ cruiseSpeed: 2.25,
2295
+ throttleResponse: 0.46,
2296
+ rudderResponse: 0.54,
2297
+ wanderPhase: 0.35,
2298
+ lanterns: CUTTER_LANTERNS,
2299
+ lanternStrength: 1.06,
2300
+ collisionRadiusScale: 1.04
2301
+ },
2302
+ {
2303
+ id: "tidecaller",
2304
+ modelKey: "cutter",
2305
+ position: vec3(4.8, 0, 4.4),
2306
+ velocity: vec3(-2.15, 0, 1.74),
2307
+ rotationY: -2.48,
2308
+ angularVelocity: -0.2,
2309
+ tint: { r: 0.58, g: 0.24, b: 0.16 },
2310
+ massScale: 0.84,
2311
+ cruiseSpeed: 2.68,
2312
+ throttleResponse: 0.7,
2313
+ rudderResponse: 0.78,
2314
+ wanderPhase: 1.6,
2315
+ lanterns: SHIP_LANTERNS,
2316
+ lanternStrength: 1.18,
2317
+ collisionRadiusScale: 0.94
2318
+ }
2319
+ ],
2320
+ sprays: [],
2321
+ waveImpulses: [],
2322
+ frame: 0,
2323
+ contactCount: 0,
2324
+ collisionCount: 0,
2325
+ collisionFlash: 0,
2326
+ clothState: null,
2327
+ physics: {
2328
+ profile: physicsProfile,
2329
+ plan: physicsPlan,
2330
+ manifest: physicsManifest,
2331
+ snapshot: null
2332
+ },
2333
+ assetCatalog: null,
2334
+ shipModel: null
2335
+ };
2336
+ }
2337
+ function setListContent(element, values) {
2338
+ element.innerHTML = values.map((value) => `<li>${value}</li>`).join("");
2339
+ }
2340
+ function drawSkyAndShore(ctx, canvas, state, nearLighting, reflectionStrength, shadowStrength, visuals) {
2341
+ const sky = ctx.createLinearGradient(0, 0, 0, canvas.height * 0.5);
2342
+ sky.addColorStop(0, visuals.skyTop);
2343
+ sky.addColorStop(0.54, visuals.skyMid);
2344
+ sky.addColorStop(1, visuals.skyBottom);
2345
+ ctx.fillStyle = sky;
2346
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
2347
+ for (let index = 0; index < 70; index += 1) {
2348
+ const x = pseudoRandom(index + 13) * canvas.width;
2349
+ const y = pseudoRandom(index * 7 + 5) * canvas.height * 0.42;
2350
+ const twinkle = 0.45 + Math.sin(state.time * 1.4 + index * 0.73) * 0.25;
2351
+ const radius = 0.6 + pseudoRandom(index * 11 + 2) * 1.9;
2352
+ ctx.fillStyle = visuals.starColor.replace(/[\d.]+\)$/u, `${clamp(twinkle, 0.16, 0.92)})`);
2353
+ ctx.beginPath();
2354
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
2355
+ ctx.fill();
2356
+ }
2357
+ const horizonGlow = ctx.createLinearGradient(0, canvas.height * 0.22, 0, canvas.height * 0.62);
2358
+ horizonGlow.addColorStop(0, "rgba(0, 0, 0, 0)");
2359
+ horizonGlow.addColorStop(1, visuals.duskGlow);
2360
+ ctx.fillStyle = horizonGlow;
2361
+ ctx.fillRect(0, canvas.height * 0.2, canvas.width, canvas.height * 0.45);
2362
+ const shoreline = ctx.createLinearGradient(0, canvas.height * 0.45, 0, canvas.height);
2363
+ shoreline.addColorStop(0, visuals.seaTop);
2364
+ shoreline.addColorStop(0.52, visuals.seaMid);
2365
+ shoreline.addColorStop(1, visuals.seaBottom);
2366
+ ctx.fillStyle = shoreline;
2367
+ ctx.fillRect(0, canvas.height * 0.45, canvas.width, canvas.height * 0.55);
2368
+ const moonX = canvas.width * 0.76 + Math.sin(state.time * 0.045) * 18;
2369
+ const moonY = canvas.height * 0.17 + Math.cos(state.time * 0.05) * 10;
2370
+ const moon = ctx.createRadialGradient(moonX, moonY, 14, moonX, moonY, 126);
2371
+ moon.addColorStop(0, visuals.moonCore);
2372
+ moon.addColorStop(0.46, visuals.moonHalo);
2373
+ moon.addColorStop(1, "rgba(167, 191, 255, 0)");
2374
+ ctx.fillStyle = moon;
2375
+ ctx.beginPath();
2376
+ ctx.arc(moonX, moonY, 94, 0, Math.PI * 2);
2377
+ ctx.fill();
2378
+ const moonCore = ctx.createRadialGradient(moonX, moonY, 4, moonX, moonY, 28);
2379
+ moonCore.addColorStop(0, "rgba(255, 255, 255, 0.98)");
2380
+ moonCore.addColorStop(1, visuals.moonCore);
2381
+ ctx.fillStyle = moonCore;
2382
+ ctx.beginPath();
2383
+ ctx.arc(moonX, moonY, 24, 0, Math.PI * 2);
2384
+ ctx.fill();
2385
+ const track = ctx.createLinearGradient(moonX, canvas.height * 0.44, moonX, canvas.height * 0.98);
2386
+ track.addColorStop(0, visuals.moonReflection.replace(/[\d.]+\)$/u, `${0.08 + reflectionStrength * 0.12})`));
2387
+ track.addColorStop(0.42, visuals.moonReflection.replace(/[\d.]+\)$/u, `${0.04 + reflectionStrength * 0.18})`));
2388
+ track.addColorStop(1, "rgba(192, 214, 255, 0)");
2389
+ ctx.save();
2390
+ ctx.globalCompositeOperation = "screen";
2391
+ ctx.fillStyle = track;
2392
+ ctx.beginPath();
2393
+ ctx.ellipse(moonX, canvas.height * 0.75, 38 + shadowStrength * 42, canvas.height * 0.24, 0, 0, Math.PI * 2);
2394
+ ctx.fill();
2395
+ ctx.restore();
2396
+ const mist = ctx.createLinearGradient(0, canvas.height * 0.5, 0, canvas.height);
2397
+ mist.addColorStop(0, "rgba(0, 0, 0, 0)");
2398
+ mist.addColorStop(1, visuals.ambientMist);
2399
+ ctx.fillStyle = mist;
2400
+ ctx.fillRect(0, canvas.height * 0.45, canvas.width, canvas.height * 0.55);
2401
+ if (state.collisionFlash > 0.01) {
2402
+ ctx.fillStyle = visuals.collisionFlash.replace(
2403
+ /[\d.]+\)$/u,
2404
+ `${clamp(state.collisionFlash * 0.22, 0, 0.26)})`
2405
+ );
2406
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
2407
+ }
2408
+ }
2409
+ function resolveLocalLightContribution(triangle, lightSources) {
2410
+ const contribution = { r: 0, g: 0, b: 0 };
2411
+ if (!Array.isArray(lightSources) || triangle.surfaceType === "water") {
2412
+ return contribution;
2413
+ }
2414
+ const normal = normalizeVec3(triangle.normal);
2415
+ for (const source of lightSources.slice(0, 8)) {
2416
+ const delta = subVec3(source.point, triangle.worldCenter);
2417
+ const distance = lengthVec3(delta);
2418
+ const attenuation = (source.glowScale ?? 1) / Math.max(1, 0.68 + distance * distance * 0.2);
2419
+ if (attenuation < 0.012) {
2420
+ continue;
2421
+ }
2422
+ const lightDir = normalizeVec3(delta);
2423
+ const facing = clamp(dotVec3(normal, lightDir), 0, 1);
2424
+ const response = attenuation * (0.18 + facing * 0.82);
2425
+ const glowColor = source.glowColor ?? source.coreColor ?? { r: 1, g: 0.72, b: 0.4 };
2426
+ contribution.r += glowColor.r * response * 0.32;
2427
+ contribution.g += glowColor.g * response * 0.26;
2428
+ contribution.b += glowColor.b * response * 0.18;
2429
+ }
2430
+ return contribution;
2431
+ }
2432
+ function drawTriangles(ctx, triangles, lightDir, reflectionStrength, camera, shadowStrength, localLights = []) {
2433
+ triangles.sort((left, right) => right.depth - left.depth);
2434
+ for (const triangle of triangles) {
2435
+ const surfaceNormal = normalizeVec3(triangle.normal);
2436
+ const material = triangle.material ?? {
2437
+ roughness: 0.88,
2438
+ metallic: 0.08,
2439
+ emissive: { r: 0, g: 0, b: 0 }
2440
+ };
2441
+ const shaded = shadeColor(
2442
+ triangle.baseColor,
2443
+ surfaceNormal,
2444
+ lightDir,
2445
+ clamp((triangle.worldCenter.y + 3) / 10, 0, 1),
2446
+ triangle.accent
2447
+ );
2448
+ const reflection = reflectionStrength * (triangle.reflection ?? 0);
2449
+ const viewDir = normalizeVec3(subVec3(camera.eye, triangle.worldCenter));
2450
+ const reflectedLight = reflectVec3(scaleVec3(lightDir, -1), surfaceNormal);
2451
+ const gloss = mix(0.78, 0.14, clamp(material.roughness ?? 0.88, 0, 1)) + (material.metallic ?? 0) * 0.18;
2452
+ const specularPower = mix(26, 7, clamp(material.roughness ?? 0.88, 0, 1));
2453
+ const specular = Math.pow(clamp(dotVec3(reflectedLight, viewDir), 0, 1), specularPower) * gloss;
2454
+ const emissive = material.emissive ?? { r: 0, g: 0, b: 0 };
2455
+ const localLight = resolveLocalLightContribution(triangle, localLights);
2456
+ const occlusion = triangle.surfaceType === "water" ? shadowStrength * 0.018 : shadowStrength * 0.04;
2457
+ const detailed = applyMaterialDetail(
2458
+ {
2459
+ r: clamp(
2460
+ shaded.r + reflection * 0.08 + specular * 0.16 + emissive.r * 0.42 + localLight.r - occlusion,
2461
+ 0,
2462
+ 1
2463
+ ),
2464
+ g: clamp(
2465
+ shaded.g + reflection * 0.08 + specular * 0.16 + emissive.g * 0.42 + localLight.g - occlusion,
2466
+ 0,
2467
+ 1
2468
+ ),
2469
+ b: clamp(
2470
+ shaded.b + reflection * 0.16 + specular * 0.22 + emissive.b * 0.46 + localLight.b - occlusion * 0.5,
2471
+ 0,
2472
+ 1
2473
+ )
2474
+ },
2475
+ material,
2476
+ triangle.worldCenter,
2477
+ surfaceNormal,
2478
+ triangle.surfaceType
2479
+ );
2480
+ const fill = colorToRgba(
2481
+ detailed,
2482
+ triangle.baseColor.a ?? 0.98
2483
+ );
2484
+ ctx.fillStyle = fill;
2485
+ ctx.beginPath();
2486
+ ctx.moveTo(triangle.points[0].x, triangle.points[0].y);
2487
+ ctx.lineTo(triangle.points[1].x, triangle.points[1].y);
2488
+ ctx.lineTo(triangle.points[2].x, triangle.points[2].y);
2489
+ ctx.closePath();
2490
+ ctx.fill();
2491
+ }
2492
+ }
2493
+ function renderProjectedShadow(ctx, worldPoints, camera, viewport, lightDir, options = {}) {
2494
+ const planeY = options.planeY ?? 0;
2495
+ const projected = worldPoints.map((point) => projectShadowPoint(point, lightDir, planeY)).filter(Boolean).map((point) => projectPoint(point, camera, viewport)).filter(Boolean);
2496
+ if (projected.length < 3) {
2497
+ return;
2498
+ }
2499
+ ctx.save();
2500
+ ctx.globalCompositeOperation = "multiply";
2501
+ ctx.fillStyle = options.color ?? `rgba(12, 24, 36, ${clamp(options.alpha ?? 0.16, 0, 0.5)})`;
2502
+ ctx.shadowColor = options.color ?? "rgba(12, 24, 36, 0.22)";
2503
+ ctx.shadowBlur = options.blur ?? 18;
2504
+ ctx.beginPath();
2505
+ ctx.moveTo(projected[0].x, projected[0].y);
2506
+ for (let index = 1; index < projected.length; index += 1) {
2507
+ ctx.lineTo(projected[index].x, projected[index].y);
2508
+ }
2509
+ ctx.closePath();
2510
+ ctx.fill();
2511
+ ctx.restore();
2512
+ }
2513
+ function pushHarborGeometry(camera, viewport, triangles, state) {
2514
+ if (!state.showcaseRealisticModelsEnabled) {
2515
+ for (const object of LEGACY_HARBOR_LAYOUT) {
2516
+ buildTrianglesFromMesh(
2517
+ { positions: [object], indices: [0], normals: null, colors: null, material: createLegacyMeshPrimitive({})?.material, bounds: null, name: "legacy-structure" },
2518
+ {
2519
+ position: object.position,
2520
+ rotationY: object.rotationY,
2521
+ scale: object.scale
2522
+ },
2523
+ object.color,
2524
+ camera,
2525
+ viewport,
2526
+ triangles,
2527
+ {
2528
+ accent: object.accent,
2529
+ reflection: 0,
2530
+ surfaceType: "structure"
2531
+ }
2532
+ );
2533
+ }
2534
+ return;
2535
+ }
2536
+ for (const placement of SHOWCASE_ENVIRONMENT_LAYOUT) {
2537
+ const mesh = state.assetCatalog?.environment?.[placement.assetKey] ?? null;
2538
+ if (!mesh) {
2539
+ continue;
2540
+ }
2541
+ buildTrianglesFromMesh(
2542
+ mesh,
2543
+ {
2544
+ position: vec3(placement.position.x, placement.position.y, placement.position.z),
2545
+ rotationY: placement.rotationY,
2546
+ scale: placement.scale
2547
+ },
2548
+ null,
2549
+ camera,
2550
+ viewport,
2551
+ triangles,
2552
+ {
2553
+ accent: placement.accent,
2554
+ reflection: 0,
2555
+ surfaceType: "structure"
2556
+ }
2557
+ );
2558
+ }
2559
+ }
2560
+ function renderShipRigging(ctx, ship, camera, viewport) {
2561
+ const transform = { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE };
2562
+ const layout = ship.modelKey === "cutter" ? {
2563
+ lineColor: "rgba(85, 89, 97, 0.92)",
2564
+ sailColor: "rgba(218, 232, 244, 0.28)",
2565
+ points: [
2566
+ vec3(0, 0.88, -0.32),
2567
+ vec3(0, 2.4, -0.28),
2568
+ vec3(0.1, 1.92, -0.3),
2569
+ vec3(1.18, 1.72, -0.18),
2570
+ vec3(1.04, 1.08, -0.12)
2571
+ ],
2572
+ mastPairs: [[0, 1], [2, 3]],
2573
+ sailTriangle: [2, 3, 4]
2574
+ } : {
2575
+ lineColor: "rgba(73, 54, 45, 0.94)",
2576
+ sailColor: "rgba(238, 232, 214, 0.88)",
2577
+ points: [
2578
+ vec3(0, 0.38, -0.4),
2579
+ vec3(0, 3.8, -0.2),
2580
+ vec3(-0.25, 0.32, -1.9),
2581
+ vec3(-0.15, 2.7, -1.75),
2582
+ vec3(0.08, 3.2, -0.2),
2583
+ vec3(0.12, 1.2, -0.5),
2584
+ vec3(2.25, 2.25, 0.15)
2585
+ ],
2586
+ mastPairs: [[0, 1], [2, 3]],
2587
+ sailTriangle: [4, 5, 6]
2588
+ };
2589
+ const projected = layout.points.map((point) => transformPoint(point, transform)).map((point) => projectPoint(point, camera, viewport));
2590
+ if (projected.some((value) => value === null)) {
2591
+ return;
2592
+ }
2593
+ ctx.strokeStyle = layout.lineColor;
2594
+ ctx.lineWidth = ship.modelKey === "cutter" ? 2.2 : 3.5;
2595
+ ctx.beginPath();
2596
+ for (const [from, to] of layout.mastPairs) {
2597
+ ctx.moveTo(projected[from].x, projected[from].y);
2598
+ ctx.lineTo(projected[to].x, projected[to].y);
2599
+ }
2600
+ ctx.stroke();
2601
+ const [a, b, c] = layout.sailTriangle;
2602
+ ctx.fillStyle = layout.sailColor;
2603
+ ctx.beginPath();
2604
+ ctx.moveTo(projected[a].x, projected[a].y);
2605
+ ctx.lineTo(projected[b].x, projected[b].y);
2606
+ ctx.lineTo(projected[c].x, projected[c].y);
2607
+ ctx.closePath();
2608
+ ctx.fill();
2609
+ }
2610
+ function renderClothAccent(ctx, cloth, camera, viewport) {
2611
+ const projected = cloth.positions.map((point) => projectPoint(point, camera, viewport));
2612
+ ctx.strokeStyle = "rgba(255, 241, 226, 0.92)";
2613
+ ctx.lineWidth = 1.7;
2614
+ for (let row = 0; row < cloth.grid.rows; row += Math.max(1, Math.floor(cloth.grid.rows / 5))) {
2615
+ ctx.beginPath();
2616
+ let started = false;
2617
+ for (let column = 0; column < cloth.grid.cols; column += 1) {
2618
+ const point = projected[row * cloth.grid.cols + column];
2619
+ if (!point) {
2620
+ continue;
2621
+ }
2622
+ if (!started) {
2623
+ ctx.moveTo(point.x, point.y);
2624
+ started = true;
2625
+ } else {
2626
+ ctx.lineTo(point.x, point.y);
2627
+ }
2628
+ }
2629
+ if (started) {
2630
+ ctx.stroke();
2631
+ }
2632
+ }
2633
+ const borderIndices = [
2634
+ 0,
2635
+ cloth.grid.cols - 1,
2636
+ cloth.grid.rows * cloth.grid.cols - 1,
2637
+ (cloth.grid.rows - 1) * cloth.grid.cols
2638
+ ];
2639
+ ctx.fillStyle = colorToRgba(cloth.color, 0.95);
2640
+ for (const index of borderIndices) {
2641
+ const point = projected[index];
2642
+ if (!point) {
2643
+ continue;
2644
+ }
2645
+ ctx.beginPath();
2646
+ ctx.arc(point.x, point.y, 2.8, 0, Math.PI * 2);
2647
+ ctx.fill();
2648
+ }
2649
+ }
2650
+ function renderWaterHighlights(ctx, waterBands, camera, viewport) {
2651
+ for (const band of waterBands) {
2652
+ if (band.band === "horizon") {
2653
+ continue;
2654
+ }
2655
+ const interval = band.band === "near" ? 2 : 3;
2656
+ const alpha = band.band === "near" ? 0.22 : 0.14;
2657
+ ctx.strokeStyle = `rgba(232, 247, 255, ${alpha})`;
2658
+ ctx.lineWidth = band.band === "near" ? 1.3 : 0.9;
2659
+ for (let row = interval; row < band.rows - 1; row += interval) {
2660
+ ctx.beginPath();
2661
+ let started = false;
2662
+ for (let column = 0; column < band.cols; column += 1) {
2663
+ const point = projectPoint(
2664
+ band.positions[row * band.cols + column],
2665
+ camera,
2666
+ viewport
2667
+ );
2668
+ if (!point) {
2669
+ continue;
2670
+ }
2671
+ if (!started) {
2672
+ ctx.moveTo(point.x, point.y);
2673
+ started = true;
2674
+ } else {
2675
+ ctx.lineTo(point.x, point.y);
2676
+ }
2677
+ }
2678
+ if (started) {
2679
+ ctx.stroke();
2680
+ }
2681
+ }
2682
+ }
2683
+ }
2684
+ function readPhysicsNumber(physics, key, fallback) {
2685
+ const value = physics?.[key];
2686
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
2687
+ }
2688
+ function getShipMass(ship, shipModel) {
2689
+ const baseMass = readPhysicsNumber(shipModel.physics, "mass", 3200);
2690
+ return baseMass * readVisualNumber(ship.massScale, 1);
2691
+ }
2692
+ function getShipHalfExtents(ship, shipModel) {
2693
+ const physicsHalfExtents = Array.isArray(shipModel.physics.halfExtents) ? shipModel.physics.halfExtents : [1.35, 0.95, 3.9];
2694
+ const scale = SHIP_SCALE * readVisualNumber(ship.collisionRadiusScale, 1);
2695
+ return {
2696
+ x: physicsHalfExtents[0] * scale,
2697
+ y: physicsHalfExtents[1] * scale,
2698
+ z: physicsHalfExtents[2] * scale
2699
+ };
2700
+ }
2701
+ function getShipCollisionRadius(ship, shipModel) {
2702
+ const halfExtents = getShipHalfExtents(ship, shipModel);
2703
+ return Math.max(halfExtents.x * 1.08, halfExtents.z * 0.62);
2704
+ }
2705
+ function getShipInverseMass(ship, shipModel) {
2706
+ return 1 / Math.max(1, getShipMass(ship, shipModel));
2707
+ }
2708
+ function getShipInverseInertia(ship, shipModel) {
2709
+ const radius = getShipCollisionRadius(ship, shipModel);
2710
+ const inertia = getShipMass(ship, shipModel) * radius * radius * 0.72;
2711
+ return 1 / Math.max(1, inertia);
2712
+ }
2713
+ function spawnSpray(state, point, intensity) {
2714
+ const count = state.fluidDetail.getSnapshot().currentLevel.config.splashCount;
2715
+ for (let index = 0; index < count; index += 1) {
2716
+ const angle = index / count * Math.PI * 2;
2717
+ const speed = 0.9 + Math.random() * intensity * 0.45;
2718
+ state.sprays.push({
2719
+ position: vec3(point.x, point.y, point.z),
2720
+ velocity: vec3(Math.cos(angle) * speed * 0.35, 1.1 + Math.random() * 0.8, Math.sin(angle) * speed * 0.25),
2721
+ life: 1.2 + Math.random() * 0.4
2722
+ });
2723
+ }
2724
+ }
2725
+ function resolveShipRoute(ship, state, radius) {
2726
+ if (typeof ship.routeDirection !== "number") {
2727
+ ship.routeDirection = ship.velocity.x >= 0 ? 1 : -1;
2728
+ }
2729
+ if (ship.position.x > HARBOR_BOUNDS.maxX - radius * 1.1) {
2730
+ ship.routeDirection = -1;
2731
+ } else if (ship.position.x < HARBOR_BOUNDS.minX + radius * 1.1) {
2732
+ ship.routeDirection = 1;
2733
+ }
2734
+ const wander = Math.sin(state.time * 0.22 + readVisualNumber(ship.wanderPhase, 0));
2735
+ const crossCurrent = Math.cos(state.time * 0.31 + readVisualNumber(ship.wanderPhase, 0));
2736
+ const laneCenter = ship.id === "northwind" ? 10.2 + wander * 2.1 + crossCurrent * 0.6 : 7 + wander * 3.3 - crossCurrent * 1.1;
2737
+ const targetX = ship.routeDirection > 0 ? HARBOR_BOUNDS.maxX - radius * 1.7 : HARBOR_BOUNDS.minX + radius * 1.7;
2738
+ return vec3(targetX, 0, clamp(laneCenter, HARBOR_BOUNDS.minZ + 1.8, HARBOR_BOUNDS.maxZ - 1.8));
2739
+ }
2740
+ function updateShipMotion(state, ship, dt, shipModel) {
2741
+ const physics = shipModel.physics;
2742
+ const massScale = Math.max(0.55, readVisualNumber(ship.massScale, 1));
2743
+ const radius = getShipCollisionRadius(ship, shipModel);
2744
+ const waterline = readPhysicsNumber(physics, "waterline", 0.42);
2745
+ const linearDamping = readPhysicsNumber(physics, "linearDamping", 0.04);
2746
+ const angularDamping = readPhysicsNumber(physics, "angularDamping", 0.08);
2747
+ const throttleResponse = readVisualNumber(ship.throttleResponse, 0.58);
2748
+ const rudderResponse = readVisualNumber(ship.rudderResponse, 0.62);
2749
+ const cruiseSpeed = readVisualNumber(ship.cruiseSpeed, 2.4);
2750
+ ship.collisionCooldown = Math.max(0, readVisualNumber(ship.collisionCooldown, 0) - dt);
2751
+ const forward = directionFromYaw(ship.rotationY);
2752
+ const lateral = perpendicularOnWater(forward);
2753
+ const routeTarget = resolveShipRoute(ship, state, radius);
2754
+ const desiredHeading = Math.atan2(routeTarget.x - ship.position.x, routeTarget.z - ship.position.z);
2755
+ const headingError = Math.atan2(
2756
+ Math.sin(desiredHeading - ship.rotationY),
2757
+ Math.cos(desiredHeading - ship.rotationY)
2758
+ );
2759
+ ship.angularVelocity += headingError * rudderResponse * dt * (1.18 / Math.sqrt(massScale)) + Math.sin(state.time * 0.9 + readVisualNumber(ship.wanderPhase, 0)) * dt * 0.04;
2760
+ const waveDirection = resolveWaveDirection(state);
2761
+ const forwardSpeed = dotVec3(ship.velocity, forward);
2762
+ const lateralSpeed = dotVec3(ship.velocity, lateral);
2763
+ const thrust = (cruiseSpeed - forwardSpeed) * throttleResponse;
2764
+ const currentDrift = sampleWave(state, ship.position.x, ship.position.z, state.time) * 0.016;
2765
+ const acceleration = addVec3(
2766
+ scaleVec3(forward, thrust),
2767
+ addVec3(
2768
+ scaleVec3(lateral, -lateralSpeed * (1.28 + rudderResponse * 0.4)),
2769
+ scaleVec3(waveDirection, currentDrift / Math.sqrt(massScale))
2770
+ )
2771
+ );
2772
+ ship.velocity = addVec3(ship.velocity, scaleVec3(acceleration, dt));
2773
+ ship.velocity = scaleVec3(
2774
+ ship.velocity,
2775
+ Math.max(0, 1 - linearDamping / Math.pow(massScale, 0.22) * dt)
2776
+ );
2777
+ ship.angularVelocity *= Math.max(
2778
+ 0,
2779
+ 1 - angularDamping / Math.pow(massScale, 0.15) * dt
2780
+ );
2781
+ ship.rotationY += ship.angularVelocity * dt;
2782
+ ship.position = addVec3(ship.position, scaleVec3(ship.velocity, dt));
2783
+ ship.position.y = sampleWave(state, ship.position.x, ship.position.z, state.time) * 0.24 + waterline;
2784
+ }
2785
+ function resolveBoundaryCollision(ship, state, shipModel) {
2786
+ const restitution = readPhysicsNumber(shipModel.physics, "restitution", 0.22) * 0.56;
2787
+ const radius = getShipCollisionRadius(ship, shipModel);
2788
+ const boundaries = [
2789
+ { axis: "x", min: HARBOR_BOUNDS.minX + radius, max: HARBOR_BOUNDS.maxX - radius, normalMin: vec3(1, 0, 0), normalMax: vec3(-1, 0, 0) },
2790
+ { axis: "z", min: HARBOR_BOUNDS.minZ + radius, max: HARBOR_BOUNDS.maxZ - radius, normalMin: vec3(0, 0, 1), normalMax: vec3(0, 0, -1) }
2791
+ ];
2792
+ for (const boundary of boundaries) {
2793
+ if (ship.position[boundary.axis] < boundary.min) {
2794
+ ship.position[boundary.axis] = boundary.min;
2795
+ const normal = boundary.normalMin;
2796
+ const speedIntoWall = dotVec3(ship.velocity, normal);
2797
+ if (speedIntoWall < 0) {
2798
+ ship.velocity = subVec3(
2799
+ ship.velocity,
2800
+ scaleVec3(normal, (1 + restitution) * speedIntoWall)
2801
+ );
2802
+ const tangent = vec3(-normal.z, 0, normal.x);
2803
+ const tangentSpeed = dotVec3(ship.velocity, tangent);
2804
+ ship.velocity = subVec3(ship.velocity, scaleVec3(tangent, tangentSpeed * 0.12));
2805
+ ship.angularVelocity += tangentSpeed * 4e-3;
2806
+ }
2807
+ } else if (ship.position[boundary.axis] > boundary.max) {
2808
+ ship.position[boundary.axis] = boundary.max;
2809
+ const normal = boundary.normalMax;
2810
+ const speedIntoWall = dotVec3(ship.velocity, normal);
2811
+ if (speedIntoWall < 0) {
2812
+ ship.velocity = subVec3(
2813
+ ship.velocity,
2814
+ scaleVec3(normal, (1 + restitution) * speedIntoWall)
2815
+ );
2816
+ const tangent = vec3(-normal.z, 0, normal.x);
2817
+ const tangentSpeed = dotVec3(ship.velocity, tangent);
2818
+ ship.velocity = subVec3(ship.velocity, scaleVec3(tangent, tangentSpeed * 0.12));
2819
+ ship.angularVelocity += tangentSpeed * 4e-3;
2820
+ }
2821
+ }
2822
+ }
2823
+ }
2824
+ function resolveShipCollision(state, a, b, shipModelA, shipModelB) {
2825
+ const delta = subVec3(b.position, a.position);
2826
+ const radiusA = getShipCollisionRadius(a, shipModelA);
2827
+ const radiusB = getShipCollisionRadius(b, shipModelB);
2828
+ const distance = Math.hypot(delta.x, delta.z);
2829
+ const minDistance = radiusA + radiusB;
2830
+ if (distance >= minDistance) {
2831
+ return false;
2832
+ }
2833
+ const normal = distance > 1e-4 ? normalizeVec3(vec3(delta.x / distance, 0, delta.z / distance)) : normalizeVec3(vec3(Math.cos(state.time * 5.2), 0, Math.sin(state.time * 4.8)));
2834
+ const tangent = vec3(-normal.z, 0, normal.x);
2835
+ const penetration = minDistance - distance;
2836
+ const invMassA = getShipInverseMass(a, shipModelA);
2837
+ const invMassB = getShipInverseMass(b, shipModelB);
2838
+ const invMassSum = invMassA + invMassB;
2839
+ const correction = scaleVec3(normal, penetration / Math.max(1e-4, invMassSum) * 0.72);
2840
+ a.position = subVec3(a.position, scaleVec3(correction, invMassA));
2841
+ b.position = addVec3(b.position, scaleVec3(correction, invMassB));
2842
+ const relativeVelocity = subVec3(b.velocity, a.velocity);
2843
+ const velocityAlongNormal = dotVec3(relativeVelocity, normal);
2844
+ const restitution = (readPhysicsNumber(shipModelA.physics, "restitution", 0.22) + readPhysicsNumber(shipModelB.physics, "restitution", 0.22)) / 2 * 0.88;
2845
+ if (velocityAlongNormal < 0) {
2846
+ const impulseMagnitude = -(1 + restitution) * velocityAlongNormal / Math.max(1e-4, invMassSum);
2847
+ const impulse = scaleVec3(normal, impulseMagnitude);
2848
+ a.velocity = subVec3(a.velocity, scaleVec3(impulse, invMassA));
2849
+ b.velocity = addVec3(b.velocity, scaleVec3(impulse, invMassB));
2850
+ const tangentSpeed = dotVec3(relativeVelocity, tangent);
2851
+ const frictionMagnitude = clamp(
2852
+ -tangentSpeed / Math.max(1e-4, invMassSum),
2853
+ -impulseMagnitude * 0.16,
2854
+ impulseMagnitude * 0.16
2855
+ );
2856
+ const frictionImpulse = scaleVec3(tangent, frictionMagnitude);
2857
+ a.velocity = subVec3(a.velocity, scaleVec3(frictionImpulse, invMassA));
2858
+ b.velocity = addVec3(b.velocity, scaleVec3(frictionImpulse, invMassB));
2859
+ a.angularVelocity -= tangentSpeed * radiusA * getShipInverseInertia(a, shipModelA) * 0.2 + impulseMagnitude * 24e-5;
2860
+ b.angularVelocity += tangentSpeed * radiusB * getShipInverseInertia(b, shipModelB) * 0.2 + impulseMagnitude * 24e-5;
2861
+ const impactSpeed = Math.abs(velocityAlongNormal);
2862
+ if (impactSpeed > 0.18 && Math.max(readVisualNumber(a.collisionCooldown, 0), readVisualNumber(b.collisionCooldown, 0)) <= 0) {
2863
+ const contactPoint = vec3(
2864
+ (a.position.x + b.position.x) * 0.5,
2865
+ (a.position.y + b.position.y) * 0.5 + 0.14,
2866
+ (a.position.z + b.position.z) * 0.5
2867
+ );
2868
+ spawnSpray(state, contactPoint, impactSpeed * 2.4 + penetration * 8);
2869
+ state.waveImpulses.push({
2870
+ x: contactPoint.x,
2871
+ z: contactPoint.z,
2872
+ strength: clamp(0.24 + impactSpeed * 0.46 + penetration * 0.9, 0.2, 1.7),
2873
+ radius: 0.9 + penetration * 1.4,
2874
+ life: 1
2875
+ });
2876
+ state.collisionCount += 1;
2877
+ state.collisionFlash = Math.max(
2878
+ state.collisionFlash,
2879
+ clamp(impactSpeed * 0.55 + penetration * 1.8, 0.16, 1)
2880
+ );
2881
+ a.collisionCooldown = 0.2;
2882
+ b.collisionCooldown = 0.2;
2883
+ }
2884
+ }
2885
+ state.contactCount += 1;
2886
+ return true;
2887
+ }
2888
+ function updateShips(state, dt, shipModel) {
2889
+ let collided = false;
2890
+ state.contactCount = 0;
2891
+ for (const ship of state.ships) {
2892
+ const activeShipModel = resolveShipModel(state, ship, shipModel);
2893
+ updateShipMotion(state, ship, dt, activeShipModel);
2894
+ resolveBoundaryCollision(ship, state, activeShipModel);
2895
+ }
2896
+ for (let index = 0; index < state.ships.length; index += 1) {
2897
+ for (let otherIndex = index + 1; otherIndex < state.ships.length; otherIndex += 1) {
2898
+ const shipA = state.ships[index];
2899
+ const shipB = state.ships[otherIndex];
2900
+ const shipModelA = resolveShipModel(state, shipA, shipModel);
2901
+ const shipModelB = resolveShipModel(state, shipB, shipModel);
2902
+ collided = resolveShipCollision(state, shipA, shipB, shipModelA, shipModelB) || collided;
2903
+ }
2904
+ }
2905
+ state.collisionFlash = collided ? Math.max(0.12, state.collisionFlash) : Math.max(0, state.collisionFlash - dt * 1.3);
2906
+ }
2907
+ function updateWaveImpulses(state, dt) {
2908
+ state.waveImpulses = state.waveImpulses.map((impulse) => ({
2909
+ ...impulse,
2910
+ life: impulse.life - dt * 0.55
2911
+ })).filter((impulse) => impulse.life > 0);
2912
+ }
2913
+ function updateSpray(state, dt) {
2914
+ state.sprays = state.sprays.map((particle) => {
2915
+ const nextVelocity = vec3(particle.velocity.x, particle.velocity.y - 4.2 * dt, particle.velocity.z);
2916
+ const nextPosition = addVec3(particle.position, scaleVec3(nextVelocity, dt));
2917
+ return {
2918
+ position: nextPosition,
2919
+ velocity: nextVelocity,
2920
+ life: particle.life - dt
2921
+ };
2922
+ }).filter((particle) => particle.life > 0 && particle.position.y > -0.2);
2923
+ }
2924
+ function recordTelemetry(state, frameTimeMs) {
2925
+ const frameId = `showcase-${state.frame}`;
2926
+ const quality = {
2927
+ fluid: state.fluidDetail.getSnapshot(),
2928
+ cloth: state.clothDetail.getSnapshot(),
2929
+ lighting: state.lightingDetail.getSnapshot()
2930
+ };
2931
+ const synthetic = frameTimeMs + state.sprays.length * 0.1 + (state.stress ? 6.5 : 0);
2932
+ const decision = state.governor.recordFrame({ frameTimeMs: synthetic });
2933
+ const queueDepth = Math.min(32, Math.round(6 + state.sprays.length / 2 + (state.stress ? 10 : 0)));
2934
+ const readyLaneDepth = Math.min(
2935
+ 16,
2936
+ 4 + Math.round(Math.max(0, Math.sin(state.time * 1.7 + 0.8)) * 9)
2937
+ );
2938
+ state.debugSession.recordQueue({
2939
+ owner: "renderer",
2940
+ queueClass: "render",
2941
+ depth: queueDepth,
2942
+ capacity: 32,
2943
+ frameId
2944
+ });
2945
+ state.debugSession.recordReadyLane({
2946
+ owner: "lighting",
2947
+ queueClass: "lighting",
2948
+ laneId: "critical",
2949
+ priority: 920,
2950
+ depth: readyLaneDepth,
2951
+ capacity: 16,
2952
+ frameId
2953
+ });
2954
+ state.debugSession.recordDispatch({
2955
+ owner: "lighting",
2956
+ queueClass: "lighting",
2957
+ jobType: "lighting.integration",
2958
+ frameId,
2959
+ durationMs: quality.lighting.currentLevel.estimatedCostMs ?? 1.2,
2960
+ workgroups: { x: quality.fluid.currentLevel.config.nearResolution, y: 1, z: 1 },
2961
+ workgroupSize: { x: 8, y: 8, z: 1 }
2962
+ });
2963
+ state.debugSession.recordDependencyUnlock({
2964
+ owner: "scene",
2965
+ queueClass: "render",
2966
+ sourceJobType: "physics.resolve",
2967
+ unlockedJobType: "lighting.integration",
2968
+ priority: 920,
2969
+ unlockCount: 2 + Math.round(Math.max(0, Math.sin(state.time * 1.1)) * 4),
2970
+ frameId
2971
+ });
2972
+ state.debugSession.recordPipelinePhase({
2973
+ owner: "scene",
2974
+ pipeline: "scene-preparation",
2975
+ stage: "stable-visual-snapshot",
2976
+ frameId,
2977
+ durationMs: synthetic * 0.38,
2978
+ snapshotAgeMs: Math.max(0, synthetic - 8)
2979
+ });
2980
+ state.debugSession.recordFrame({
2981
+ frameId,
2982
+ frameTimeMs: synthetic,
2983
+ targetFrameTimeMs: 16.67,
2984
+ gpuBusyMs: synthetic * 0.56,
2985
+ dropped: synthetic > 18
2986
+ });
2987
+ return decision;
2988
+ }
2989
+ function renderSprays(ctx, sprays, camera, viewport) {
2990
+ for (const spray of sprays) {
2991
+ const projected = projectPoint(spray.position, camera, viewport);
2992
+ if (!projected) {
2993
+ continue;
2994
+ }
2995
+ const radius = clamp(1 / projected.depth * 260, 1.5, 7.5);
2996
+ ctx.fillStyle = `rgba(225, 243, 250, ${clamp(spray.life / 1.6, 0, 0.9)})`;
2997
+ ctx.beginPath();
2998
+ ctx.arc(projected.x, projected.y, radius, 0, Math.PI * 2);
2999
+ ctx.fill();
3000
+ }
3001
+ }
3002
+ function renderFlagPole(ctx, camera, viewport) {
3003
+ const base = projectPoint(vec3(-3.5, 0.7, 2.4), camera, viewport);
3004
+ const top = projectPoint(vec3(-3.5, 6.3, 2.4), camera, viewport);
3005
+ if (!base || !top) {
3006
+ return;
3007
+ }
3008
+ ctx.strokeStyle = "rgba(77, 52, 41, 0.95)";
3009
+ ctx.lineWidth = 6;
3010
+ ctx.beginPath();
3011
+ ctx.moveTo(base.x, base.y);
3012
+ ctx.lineTo(top.x, top.y);
3013
+ ctx.stroke();
3014
+ }
3015
+ function renderShipShadow(ctx, shipModel, ship, state, camera, viewport, lightDir, shadowStrength) {
3016
+ const bounds = shipModel.bounds;
3017
+ const keelY = (shipModel.physics.waterline ?? 0.42) - 0.28;
3018
+ const transform = { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE };
3019
+ const hullCorners = [
3020
+ vec3(bounds.min[0], keelY, bounds.min[2]),
3021
+ vec3(bounds.max[0], keelY, bounds.min[2]),
3022
+ vec3(bounds.max[0], keelY, bounds.max[2]),
3023
+ vec3(bounds.min[0], keelY, bounds.max[2])
3024
+ ].map((point) => transformPoint(point, transform));
3025
+ renderProjectedShadow(ctx, hullCorners, camera, viewport, lightDir, {
3026
+ planeY: sampleWave(state, ship.position.x, ship.position.z, state.time) * 0.24 - 0.03,
3027
+ alpha: 0.08 + shadowStrength * 0.2,
3028
+ blur: 14 + shadowStrength * 24
3029
+ });
3030
+ }
3031
+ function renderFlagShadow(ctx, cloth, camera, viewport, lightDir, shadowStrength) {
3032
+ const clothPoints = [
3033
+ cloth.positions[0],
3034
+ cloth.positions[cloth.grid.cols - 1],
3035
+ cloth.positions[cloth.positions.length - 1],
3036
+ cloth.positions[cloth.positions.length - cloth.grid.cols]
3037
+ ];
3038
+ renderProjectedShadow(ctx, clothPoints, camera, viewport, lightDir, {
3039
+ planeY: 0.56,
3040
+ alpha: 0.05 + shadowStrength * 0.16,
3041
+ blur: 12 + shadowStrength * 20
3042
+ });
3043
+ }
3044
+ function collectSceneLightSources(state, visuals) {
3045
+ const directLights = [];
3046
+ const reflectionLights = [];
3047
+ const pushLight = (point, glowScale, reflectionStrength, coreColor, glowColor) => {
3048
+ directLights.push(
3049
+ Object.freeze({
3050
+ pass: "direct-glow",
3051
+ point,
3052
+ coreColor,
3053
+ glowColor,
3054
+ glowScale
3055
+ })
3056
+ );
3057
+ if (reflectionStrength > 0) {
3058
+ reflectionLights.push(
3059
+ Object.freeze({
3060
+ pass: "water-reflection",
3061
+ point,
3062
+ coreColor,
3063
+ glowColor,
3064
+ glowScale,
3065
+ reflectionStrength
3066
+ })
3067
+ );
3068
+ }
3069
+ };
3070
+ for (const torch of HARBOR_TORCHES) {
3071
+ pushLight(
3072
+ vec3(torch.x, torch.y, torch.z),
3073
+ torch.glow,
3074
+ visuals.lanternReflectionStrength * 0.55,
3075
+ visuals.torchCore,
3076
+ visuals.torchGlow
3077
+ );
3078
+ }
3079
+ for (const ship of state.ships) {
3080
+ const lanterns = Array.isArray(ship.lanterns) ? ship.lanterns : SHIP_LANTERNS;
3081
+ const strength = readVisualNumber(ship.lanternStrength, 1);
3082
+ for (const lantern of lanterns) {
3083
+ const point = transformPoint(
3084
+ vec3(lantern.x, lantern.y, lantern.z),
3085
+ { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE }
3086
+ );
3087
+ pushLight(
3088
+ point,
3089
+ lantern.glow * strength,
3090
+ visuals.lanternReflectionStrength,
3091
+ visuals.lanternCore,
3092
+ visuals.lanternGlow
3093
+ );
3094
+ }
3095
+ }
3096
+ return Object.freeze({
3097
+ directLights: Object.freeze(directLights),
3098
+ reflectionLights: Object.freeze(reflectionLights)
3099
+ });
3100
+ }
3101
+ function renderDirectLightGlow(ctx, source, camera, viewport) {
3102
+ const projected = projectPoint(source.point, camera, viewport);
3103
+ if (!projected) {
3104
+ return;
3105
+ }
3106
+ const radius = clamp(1 / projected.depth * 420 * source.glowScale, 4, 34);
3107
+ const halo = ctx.createRadialGradient(projected.x, projected.y, radius * 0.12, projected.x, projected.y, radius);
3108
+ halo.addColorStop(0, colorToRgba(source.coreColor, 0.98));
3109
+ halo.addColorStop(0.5, colorToRgba(source.glowColor, 0.42));
3110
+ halo.addColorStop(1, colorToRgba(source.glowColor, 0));
3111
+ ctx.save();
3112
+ ctx.globalCompositeOperation = "screen";
3113
+ ctx.fillStyle = halo;
3114
+ ctx.beginPath();
3115
+ ctx.arc(projected.x, projected.y, radius, 0, Math.PI * 2);
3116
+ ctx.fill();
3117
+ ctx.restore();
3118
+ ctx.fillStyle = colorToRgba(source.coreColor, 0.98);
3119
+ ctx.beginPath();
3120
+ ctx.arc(projected.x, projected.y, Math.max(1.2, radius * 0.16), 0, Math.PI * 2);
3121
+ ctx.fill();
3122
+ }
3123
+ function renderWaterLightReflection(ctx, source, state, camera, viewport) {
3124
+ const projected = projectPoint(source.point, camera, viewport);
3125
+ if (!projected) {
3126
+ return;
3127
+ }
3128
+ const radius = clamp(1 / projected.depth * 420 * source.glowScale, 4, 34);
3129
+ const waterline = sampleWave(state, source.point.x, source.point.z, state.time) * 0.22;
3130
+ const reflectedPoint = vec3(
3131
+ source.point.x,
3132
+ waterline - (source.point.y - waterline) * 0.58,
3133
+ source.point.z + 0.08
3134
+ );
3135
+ const reflected = projectPoint(reflectedPoint, camera, viewport);
3136
+ if (!reflected) {
3137
+ return;
3138
+ }
3139
+ const reflectionRadius = radius * 0.72;
3140
+ const glow = ctx.createRadialGradient(
3141
+ reflected.x,
3142
+ reflected.y,
3143
+ reflectionRadius * 0.1,
3144
+ reflected.x,
3145
+ reflected.y,
3146
+ reflectionRadius
3147
+ );
3148
+ glow.addColorStop(0, colorToRgba(source.coreColor, source.reflectionStrength * 0.34));
3149
+ glow.addColorStop(1, colorToRgba(source.glowColor, 0));
3150
+ ctx.save();
3151
+ ctx.globalCompositeOperation = "screen";
3152
+ ctx.fillStyle = glow;
3153
+ ctx.beginPath();
3154
+ ctx.ellipse(
3155
+ reflected.x,
3156
+ reflected.y,
3157
+ reflectionRadius * 0.34,
3158
+ reflectionRadius,
3159
+ 0,
3160
+ 0,
3161
+ Math.PI * 2
3162
+ );
3163
+ ctx.fill();
3164
+ ctx.restore();
3165
+ }
3166
+ function renderLighthouseBeam(ctx, state, camera, viewport, visuals) {
3167
+ const lighthousePlacement = SHOWCASE_ENVIRONMENT_LAYOUT.find(
3168
+ (placement) => placement.assetKey === "lighthouse"
3169
+ );
3170
+ if (!lighthousePlacement || !state.showcaseRealisticModelsEnabled) {
3171
+ return;
3172
+ }
3173
+ const source = transformPoint(
3174
+ vec3(0, 11.34, 0),
3175
+ {
3176
+ position: vec3(
3177
+ lighthousePlacement.position.x,
3178
+ lighthousePlacement.position.y,
3179
+ lighthousePlacement.position.z
3180
+ ),
3181
+ rotationY: lighthousePlacement.rotationY,
3182
+ scale: lighthousePlacement.scale
3183
+ }
3184
+ );
3185
+ const sweep = state.time * 0.22 + 0.8;
3186
+ const direction = normalizeVec3(vec3(Math.sin(sweep), -0.07, Math.cos(sweep)));
3187
+ const spread = perpendicularOnWater(direction);
3188
+ const farCenter = addVec3(source, scaleVec3(direction, 34));
3189
+ const left = addVec3(farCenter, scaleVec3(spread, 7.4));
3190
+ const right = addVec3(farCenter, scaleVec3(spread, -7.4));
3191
+ const projectedSource = projectPoint(source, camera, viewport);
3192
+ const projectedLeft = projectPoint(left, camera, viewport);
3193
+ const projectedRight = projectPoint(right, camera, viewport);
3194
+ if (!projectedSource || !projectedLeft || !projectedRight) {
3195
+ return;
3196
+ }
3197
+ const pulse = 0.72 + Math.sin(state.time * 1.7) * 0.08;
3198
+ ctx.save();
3199
+ ctx.globalCompositeOperation = "screen";
3200
+ ctx.fillStyle = colorToRgba(visuals.torchCore, 0.055 * pulse);
3201
+ ctx.beginPath();
3202
+ ctx.moveTo(projectedSource.x, projectedSource.y);
3203
+ ctx.lineTo(projectedLeft.x, projectedLeft.y);
3204
+ ctx.lineTo(projectedRight.x, projectedRight.y);
3205
+ ctx.closePath();
3206
+ ctx.fill();
3207
+ const beamLength = Math.hypot(
3208
+ projectedLeft.x - projectedSource.x,
3209
+ projectedLeft.y - projectedSource.y
3210
+ );
3211
+ const core = ctx.createRadialGradient(
3212
+ projectedSource.x,
3213
+ projectedSource.y,
3214
+ 2,
3215
+ projectedSource.x,
3216
+ projectedSource.y,
3217
+ clamp(beamLength * 0.22, 18, 80)
3218
+ );
3219
+ core.addColorStop(0, colorToRgba(visuals.torchCore, 0.58));
3220
+ core.addColorStop(0.5, colorToRgba(visuals.torchGlow, 0.18));
3221
+ core.addColorStop(1, colorToRgba(visuals.torchGlow, 0));
3222
+ ctx.fillStyle = core;
3223
+ ctx.beginPath();
3224
+ ctx.arc(projectedSource.x, projectedSource.y, clamp(beamLength * 0.18, 14, 64), 0, Math.PI * 2);
3225
+ ctx.fill();
3226
+ ctx.restore();
3227
+ }
3228
+ function renderAtmosphericGrade(ctx, canvas, state, visuals) {
3229
+ const vignette = ctx.createRadialGradient(
3230
+ canvas.width * 0.5,
3231
+ canvas.height * 0.48,
3232
+ canvas.width * 0.2,
3233
+ canvas.width * 0.5,
3234
+ canvas.height * 0.5,
3235
+ canvas.width * 0.72
3236
+ );
3237
+ vignette.addColorStop(0, "rgba(0, 0, 0, 0)");
3238
+ vignette.addColorStop(0.68, "rgba(0, 0, 0, 0.08)");
3239
+ vignette.addColorStop(1, "rgba(0, 0, 0, 0.32)");
3240
+ ctx.fillStyle = vignette;
3241
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
3242
+ const seaHaze = ctx.createLinearGradient(0, canvas.height * 0.34, 0, canvas.height);
3243
+ seaHaze.addColorStop(0, "rgba(0, 0, 0, 0)");
3244
+ seaHaze.addColorStop(0.5, visuals.ambientMist);
3245
+ seaHaze.addColorStop(1, "rgba(3, 8, 16, 0.18)");
3246
+ ctx.fillStyle = seaHaze;
3247
+ ctx.fillRect(0, canvas.height * 0.34, canvas.width, canvas.height * 0.66);
3248
+ if (state.captureMode) {
3249
+ ctx.save();
3250
+ ctx.globalCompositeOperation = "screen";
3251
+ for (let index = 0; index < 70; index += 1) {
3252
+ const x = pseudoRandom(index * 19 + 3) * canvas.width;
3253
+ const y = pseudoRandom(index * 23 + 7) * canvas.height;
3254
+ const alpha = 8e-3 + pseudoRandom(index * 31 + 11) * 0.012;
3255
+ ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
3256
+ ctx.fillRect(x, y, 1.1, 1.1);
3257
+ }
3258
+ ctx.restore();
3259
+ }
3260
+ }
3261
+ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
3262
+ ctx.save();
3263
+ ctx.globalCompositeOperation = "screen";
3264
+ for (const wake of effects.wakeTrails) {
3265
+ const projected = wake.points.map((point) => ({
3266
+ projected: projectPoint(point.center, camera, viewport),
3267
+ width: point.width
3268
+ })).filter((entry) => entry.projected);
3269
+ if (projected.length < 2) {
3270
+ continue;
3271
+ }
3272
+ const averageDepth = projected.reduce((total, entry) => total + entry.projected.depth, 0) / projected.length;
3273
+ const averageWidth = projected.reduce((total, entry) => total + entry.width, 0) / projected.length;
3274
+ const baseWidth = clamp(averageWidth / Math.max(0.25, averageDepth) * 180, 1.6, 5.4);
3275
+ ctx.strokeStyle = `rgba(146, 194, 236, ${wake.opacity * 0.52})`;
3276
+ ctx.lineWidth = baseWidth * 1.9;
3277
+ ctx.lineCap = "round";
3278
+ ctx.lineJoin = "round";
3279
+ ctx.beginPath();
3280
+ ctx.moveTo(projected[0].projected.x, projected[0].projected.y);
3281
+ for (let index = 1; index < projected.length; index += 1) {
3282
+ ctx.lineTo(projected[index].projected.x, projected[index].projected.y);
3283
+ }
3284
+ ctx.stroke();
3285
+ ctx.strokeStyle = `rgba(234, 247, 255, ${wake.opacity})`;
3286
+ ctx.lineWidth = baseWidth;
3287
+ ctx.lineCap = "round";
3288
+ ctx.lineJoin = "round";
3289
+ ctx.beginPath();
3290
+ ctx.moveTo(projected[0].projected.x, projected[0].projected.y);
3291
+ for (let index = 1; index < projected.length; index += 1) {
3292
+ ctx.lineTo(projected[index].projected.x, projected[index].projected.y);
3293
+ }
3294
+ ctx.stroke();
3295
+ for (const entry of projected.slice(1, 5)) {
3296
+ ctx.fillStyle = `rgba(239, 248, 255, ${wake.opacity * 0.76})`;
3297
+ ctx.beginPath();
3298
+ ctx.ellipse(
3299
+ entry.projected.x,
3300
+ entry.projected.y,
3301
+ baseWidth * 0.72,
3302
+ baseWidth * 0.44,
3303
+ 0,
3304
+ 0,
3305
+ Math.PI * 2
3306
+ );
3307
+ ctx.fill();
3308
+ }
3309
+ }
3310
+ for (const ring of effects.rippleRings) {
3311
+ const center = projectPoint(ring.center, camera, viewport);
3312
+ const xAxis = projectPoint(addVec3(ring.center, vec3(ring.radius, 0, 0)), camera, viewport);
3313
+ const zAxis = projectPoint(addVec3(ring.center, vec3(0, 0, ring.radius)), camera, viewport);
3314
+ if (!center || !xAxis || !zAxis) {
3315
+ continue;
3316
+ }
3317
+ const radiusX = Math.hypot(xAxis.x - center.x, xAxis.y - center.y);
3318
+ const radiusY = Math.hypot(zAxis.x - center.x, zAxis.y - center.y);
3319
+ ctx.strokeStyle = `rgba(216, 235, 255, ${ring.opacity})`;
3320
+ ctx.lineWidth = clamp((radiusX + radiusY) * 0.02, 1, 3.1);
3321
+ ctx.beginPath();
3322
+ ctx.ellipse(center.x, center.y, radiusX, radiusY, 0, 0, Math.PI * 2);
3323
+ ctx.stroke();
3324
+ }
3325
+ ctx.restore();
3326
+ }
3327
+ function renderScene(ctx, canvas, state, shipModel, dom, lightingFeatures, fluidFeatures, clothFeatures) {
3328
+ const viewport = { width: canvas.width, height: canvas.height };
3329
+ const camera = buildCamera(state, canvas);
3330
+ state.camera.eye = camera.eye;
3331
+ const lightingPlan = lightingFeatures.createBandPlan({
3332
+ profile: state.focus === "lighting" ? lightingFeatures.defaultProfile : lightingFeatures.getProfile(lightingFeatures.defaultProfile).name,
3333
+ importance: state.focus === "lighting" ? "critical" : "high"
3334
+ });
3335
+ const nearLighting = lightingPlan.bands.find((entry) => entry.band === "near") ?? lightingPlan.bands[0];
3336
+ const lightDir = normalizeVec3(vec3(-0.22, 0.94, -0.31));
3337
+ const lightingSnapshot = state.lightingDetail.getSnapshot();
3338
+ const visuals = resolveVisualConfig(
3339
+ nearLighting,
3340
+ lightingSnapshot,
3341
+ state.demoDescription?.visuals
3342
+ );
3343
+ state.demoVisuals = visuals;
3344
+ const reflectionStrength = visuals.reflectionStrength;
3345
+ const shadowStrength = visuals.shadowAccent;
3346
+ drawSkyAndShore(
3347
+ ctx,
3348
+ canvas,
3349
+ state,
3350
+ nearLighting,
3351
+ reflectionStrength,
3352
+ shadowStrength,
3353
+ visuals
3354
+ );
3355
+ const waterTriangles = [];
3356
+ const sceneTriangles = [];
3357
+ const water = buildWaterBands(
3358
+ state,
3359
+ state.fluidDetail.getSnapshot().currentLevel.config,
3360
+ visuals,
3361
+ fluidFeatures
3362
+ );
3363
+ for (const bandMesh of water.bandMeshes) {
3364
+ const bandAccent = bandMesh.band === "near" ? 0.06 : bandMesh.band === "mid" ? 0.04 : 0;
3365
+ for (let index = 0; index < bandMesh.indices.length; index += 3) {
3366
+ const a = bandMesh.positions[bandMesh.indices[index]];
3367
+ const b = bandMesh.positions[bandMesh.indices[index + 1]];
3368
+ const c = bandMesh.positions[bandMesh.indices[index + 2]];
3369
+ const normal = normalizeVec3(crossVec3(subVec3(b, a), subVec3(c, a)));
3370
+ const projected = [projectPoint(a, camera, viewport), projectPoint(b, camera, viewport), projectPoint(c, camera, viewport)];
3371
+ if (projected.some((value) => value === null)) {
3372
+ continue;
3373
+ }
3374
+ waterTriangles.push({
3375
+ points: projected,
3376
+ depth: (projected[0].depth + projected[1].depth + projected[2].depth) / 3,
3377
+ worldCenter: scaleVec3(addVec3(addVec3(a, b), c), 1 / 3),
3378
+ normal,
3379
+ baseColor: bandMesh.color,
3380
+ accent: bandAccent,
3381
+ material: {
3382
+ name: "water-surface",
3383
+ color: bandMesh.color,
3384
+ roughness: 0.2,
3385
+ metallic: 0,
3386
+ emissive: { r: 0, g: 0, b: 0 }
3387
+ },
3388
+ reflection: 1,
3389
+ surfaceType: "water"
3390
+ });
3391
+ }
3392
+ }
3393
+ const waterMotionEffects = buildWaterMotionEffects(state);
3394
+ const lightSources = collectSceneLightSources(state, visuals);
3395
+ pushHarborGeometry(camera, viewport, sceneTriangles, state);
3396
+ const cloth = buildClothSurface(
3397
+ state,
3398
+ state,
3399
+ state.clothDetail.getSnapshot().currentLevel.config,
3400
+ visuals,
3401
+ clothFeatures
3402
+ );
3403
+ for (let index = 0; index < cloth.indices.length; index += 3) {
3404
+ const a = cloth.positions[cloth.indices[index]];
3405
+ const b = cloth.positions[cloth.indices[index + 1]];
3406
+ const c = cloth.positions[cloth.indices[index + 2]];
3407
+ const normal = normalizeVec3(crossVec3(subVec3(b, a), subVec3(c, a)));
3408
+ const projected = [projectPoint(a, camera, viewport), projectPoint(b, camera, viewport), projectPoint(c, camera, viewport)];
3409
+ if (projected.some((value) => value === null)) {
3410
+ continue;
3411
+ }
3412
+ sceneTriangles.push({
3413
+ points: projected,
3414
+ depth: (projected[0].depth + projected[1].depth + projected[2].depth) / 3,
3415
+ worldCenter: scaleVec3(addVec3(addVec3(a, b), c), 1 / 3),
3416
+ normal,
3417
+ baseColor: cloth.color,
3418
+ accent: cloth.band === "near" ? 0.1 : 0.04,
3419
+ material: {
3420
+ name: "flag-cloth",
3421
+ color: cloth.color,
3422
+ roughness: 0.94,
3423
+ metallic: 0,
3424
+ emissive: { r: 0, g: 0, b: 0 }
3425
+ },
3426
+ reflection: 0,
3427
+ surfaceType: "cloth"
3428
+ });
3429
+ }
3430
+ for (const ship of state.ships) {
3431
+ const activeShipModel = resolveShipModel(state, ship, shipModel);
3432
+ buildTrianglesFromMesh(
3433
+ activeShipModel,
3434
+ { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE },
3435
+ ship.tint,
3436
+ camera,
3437
+ viewport,
3438
+ sceneTriangles,
3439
+ {
3440
+ accent: nearLighting.rtParticipation.directShadows === "premium" ? 0.08 : 0.02,
3441
+ reflection: 0,
3442
+ surfaceType: "ship"
3443
+ }
3444
+ );
3445
+ }
3446
+ drawTriangles(ctx, waterTriangles, lightDir, reflectionStrength, camera, shadowStrength);
3447
+ for (const ship of state.ships) {
3448
+ renderShipShadow(
3449
+ ctx,
3450
+ resolveShipModel(state, ship, shipModel),
3451
+ ship,
3452
+ state,
3453
+ camera,
3454
+ viewport,
3455
+ lightDir,
3456
+ shadowStrength
3457
+ );
3458
+ }
3459
+ renderFlagShadow(ctx, cloth, camera, viewport, lightDir, shadowStrength);
3460
+ for (const source of lightSources.reflectionLights) {
3461
+ renderWaterLightReflection(ctx, source, state, camera, viewport);
3462
+ }
3463
+ renderWaterMotionEffects(ctx, waterMotionEffects, camera, viewport);
3464
+ renderWaterHighlights(ctx, water.bandMeshes, camera, viewport);
3465
+ drawTriangles(
3466
+ ctx,
3467
+ sceneTriangles,
3468
+ lightDir,
3469
+ reflectionStrength,
3470
+ camera,
3471
+ shadowStrength,
3472
+ lightSources.directLights
3473
+ );
3474
+ renderFlagPole(ctx, camera, viewport);
3475
+ renderClothAccent(ctx, cloth, camera, viewport);
3476
+ renderLighthouseBeam(ctx, state, camera, viewport, visuals);
3477
+ for (const source of lightSources.directLights) {
3478
+ renderDirectLightGlow(ctx, source, camera, viewport);
3479
+ }
3480
+ for (const ship of state.ships) {
3481
+ renderShipRigging(ctx, ship, camera, viewport);
3482
+ }
3483
+ renderSprays(ctx, state.sprays, camera, viewport);
3484
+ renderAtmosphericGrade(ctx, canvas, state, visuals);
3485
+ const debugSnapshot = state.debugSession.getSnapshot();
3486
+ const quality = {
3487
+ fluid: state.fluidDetail.getSnapshot(),
3488
+ cloth: state.clothDetail.getSnapshot(),
3489
+ lighting: lightingSnapshot
3490
+ };
3491
+ const sceneMetrics = [
3492
+ `focus: ${state.focus}`,
3493
+ `ships: ${state.ships.length} active GLTF hulls across ${new Set(state.ships.map((ship) => ship.modelKey)).size} model families`,
3494
+ `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`,
3495
+ `physics snapshot: ${state.physics.snapshot.stage} (${state.physics.snapshot.stability})`,
3496
+ `physics contacts: ${state.contactCount}`,
3497
+ `mass split: ${state.ships.map((ship) => `${ship.id} ${(getShipMass(ship, resolveShipModel(state, ship, shipModel)) / 1e3).toFixed(1)}t`).join(" \xB7 ")}`,
3498
+ `cloth band: ${cloth.band} -> ${cloth.representation.output}`,
3499
+ `fluid near band: ${water.bandMeshes[0].representation.output}`,
3500
+ `lighting profile: ${lightingPlan.profile} (${lightingFeatures.distanceBands.length} bands)`
3501
+ ];
3502
+ const qualityMetrics = [
3503
+ `fluid detail: ${quality.fluid.currentLevel.id} (${quality.fluid.currentLevel.config.nearResolution} near cells)`,
3504
+ `cloth detail: ${quality.cloth.currentLevel.id} (${quality.cloth.currentLevel.config.cols}x${quality.cloth.currentLevel.config.rows})`,
3505
+ `lighting detail: ${quality.lighting.currentLevel.id}`,
3506
+ `near shadows: ${nearLighting.primaryShadowSource}`,
3507
+ `near reflections: ${nearLighting.rtParticipation.reflections}`,
3508
+ `governor pressure: ${state.lastDecision.pressureLevel}`,
3509
+ `frame avg: ${state.lastDecision.metrics.averageFrameTimeMs.toFixed(2)} ms`
3510
+ ];
3511
+ const debugMetrics = [
3512
+ `queue samples: ${debugSnapshot.queues.sampleCount}`,
3513
+ `dispatch avg: ${(debugSnapshot.dispatch.averageDurationMs ?? 0).toFixed(2)} ms`,
3514
+ `ready lane peak: ${debugSnapshot.dag.peakReadyLaneDepth.toFixed(1)}`,
3515
+ `pipeline samples: ${debugSnapshot.pipeline.sampleCount}`,
3516
+ `tracked memory: ${(debugSnapshot.memory.totalTrackedBytes / (1024 * 1024)).toFixed(1)} MB`
3517
+ ];
3518
+ const sceneNotes = state.focus === "physics" ? PHYSICS_SCENE_NOTE_KEYS.map((key) => state.translate(key)) : SCENE_NOTE_KEYS.map((key) => state.translate(key));
3519
+ const custom = state.demoDescription ?? null;
3520
+ setListContent(
3521
+ dom.sceneMetrics,
3522
+ Array.isArray(custom?.sceneMetrics) ? custom.sceneMetrics : sceneMetrics
3523
+ );
3524
+ setListContent(
3525
+ dom.qualityMetrics,
3526
+ Array.isArray(custom?.qualityMetrics) ? custom.qualityMetrics : qualityMetrics
3527
+ );
3528
+ setListContent(
3529
+ dom.debugMetrics,
3530
+ Array.isArray(custom?.debugMetrics) ? custom.debugMetrics : debugMetrics
3531
+ );
3532
+ setListContent(dom.sceneNotes, Array.isArray(custom?.notes) ? custom.notes : sceneNotes);
3533
+ dom.status.textContent = typeof custom?.status === "string" ? custom.status : state.translate(gpuSharedTranslationKeys.statusLive, {
3534
+ fps: state.lastDecision.metrics.fps.toFixed(1)
3535
+ });
3536
+ dom.details.textContent = typeof custom?.details === "string" ? custom.details : state.focus === "physics" ? state.translate(gpuSharedTranslationKeys.detailsPhysics, {
3537
+ snapshotStageId: state.physics.plan.snapshotStageId
3538
+ }) : state.showcaseRealisticModelsEnabled ? state.translate(gpuSharedTranslationKeys.detailsRealistic, {
3539
+ pressureLevel: state.lastDecision.pressureLevel
3540
+ }) : state.translate(gpuSharedTranslationKeys.detailsLegacy, {
3541
+ pressureLevel: state.lastDecision.pressureLevel
3542
+ });
3543
+ }
3544
+ function updateSceneState(state, dt, shipModel, featureAdapters) {
3545
+ updateShips(state, dt, shipModel);
3546
+ updateWaveImpulses(state, dt);
3547
+ updateSpray(state, dt);
3548
+ const clothPresentation = resolveClothPresentation(
3549
+ state,
3550
+ state.clothDetail.getSnapshot().currentLevel.config,
3551
+ featureAdapters.cloth
3552
+ );
3553
+ const clothState = ensureShowcaseClothState(
3554
+ state,
3555
+ state.clothDetail.getSnapshot().currentLevel.config,
3556
+ clothPresentation
3557
+ );
3558
+ advanceShowcaseClothSimulationState(clothState, {
3559
+ dt,
3560
+ time: state.time,
3561
+ flagMotion: readVisualNumber(state.demoVisuals?.flagMotion, 0.92),
3562
+ waveInfluence: sampleWave(state, FLAG_LAYOUT.origin.x + FLAG_LAYOUT.width * 0.55, FLAG_LAYOUT.origin.z + FLAG_LAYOUT.width * 0.48, state.time)
3563
+ });
3564
+ updatePhysicsSnapshot(state, shipModel, featureAdapters.physics);
3565
+ }
3566
+ function syncTextState(state, shipModel, featureAdapters) {
3567
+ const snapshot = {
3568
+ coordinateSystem: "right-handed world; +x right, +y up, +z forward from the shore",
3569
+ focus: state.focus,
3570
+ stress: state.stress,
3571
+ ships: state.ships.map((ship) => ({
3572
+ id: ship.id,
3573
+ modelKey: ship.modelKey ?? "brigantine",
3574
+ x: Number(ship.position.x.toFixed(2)),
3575
+ y: Number(ship.position.y.toFixed(2)),
3576
+ z: Number(ship.position.z.toFixed(2)),
3577
+ vx: Number(ship.velocity.x.toFixed(2)),
3578
+ vz: Number(ship.velocity.z.toFixed(2)),
3579
+ massKg: Math.round(getShipMass(ship, resolveShipModel(state, ship, shipModel))),
3580
+ lanterns: Array.isArray(ship.lanterns) ? ship.lanterns.length : 0
3581
+ })),
3582
+ shipPhysics: Object.fromEntries(
3583
+ state.ships.map((ship) => [ship.id, resolveShipModel(state, ship, shipModel)?.physics ?? null])
3584
+ ),
3585
+ sprays: state.sprays.length,
3586
+ waveImpulses: state.waveImpulses.length,
3587
+ pressure: state.lastDecision?.pressureLevel ?? "stable",
3588
+ physics: {
3589
+ profile: state.physics.profile,
3590
+ snapshotStageId: state.physics.plan.snapshotStageId,
3591
+ workerJobCount: state.physics.manifest.jobs.length,
3592
+ snapshot: state.physics.snapshot
3593
+ },
3594
+ package: state.demoDescription?.textState ?? null
3595
+ };
3596
+ window.render_game_to_text = () => JSON.stringify(snapshot);
3597
+ window.advanceTime = (ms) => {
3598
+ const step = Math.max(1, Math.round(ms / (1e3 / 60)));
3599
+ for (let index = 0; index < step; index += 1) {
3600
+ state.frame += 1;
3601
+ state.time += 1 / 60;
3602
+ updateSceneState(state, 1 / 60, shipModel, featureAdapters);
3603
+ state.lastDecision = recordTelemetry(state, 16.67 + (state.stress ? 6.5 : 0));
3604
+ }
3605
+ };
3606
+ }
3607
+ async function mountGpuShowcase(options = {}, featureFlags = null) {
3608
+ const featureAdapters = await resolveShowcaseFeatureAdapters(options);
3609
+ injectStyles();
3610
+ const root = options.root ?? document.body;
3611
+ root.classList?.add?.(ROOT_CLASS);
3612
+ const captureSettings = resolveCaptureSettings(options);
3613
+ if (captureSettings.captureMode) {
3614
+ root.classList?.add?.(CAPTURE_CLASS);
3615
+ }
3616
+ const previousMarkup = root.innerHTML;
3617
+ const previousRenderGameToText = window.render_game_to_text;
3618
+ const previousAdvanceTime = window.advanceTime;
3619
+ const focus = options.focus ?? new URLSearchParams(window.location.search).get("focus") ?? "integrated";
3620
+ const translate = createGpuSharedTranslator(options.translate);
3621
+ const dom = buildDemoDom(root, {
3622
+ packageName: options.packageName ?? "@plasius/gpu-demo-viewer",
3623
+ title: options.title ?? translate(gpuSharedTranslationKeys.showcaseTitle),
3624
+ subtitle: options.subtitle ?? translate(gpuSharedTranslationKeys.showcaseSubtitle),
3625
+ translate
3626
+ });
3627
+ dom.focusMode.value = focus;
3628
+ const state = createSceneState(
3629
+ {
3630
+ focus,
3631
+ translate,
3632
+ realisticModelsEnabled: isFeatureEnabled(featureFlags, GPU_SHOWCASE_REALISTIC_MODELS_FEATURE, true),
3633
+ captureMode: captureSettings.captureMode,
3634
+ renderScale: captureSettings.renderScale
3635
+ },
3636
+ featureAdapters
3637
+ );
3638
+ const assetCatalog = await (state.showcaseRealisticModelsEnabled ? loadShowcaseAssetCatalog() : createLegacyShowcaseAssetCatalog());
3639
+ const shipModel = assetCatalog.ships[assetCatalog.primaryShipKey];
3640
+ state.assetCatalog = assetCatalog;
3641
+ state.shipModel = shipModel;
3642
+ state.packageState = typeof options.createState === "function" ? options.createState() : void 0;
3643
+ updatePhysicsSnapshot(state, shipModel, featureAdapters.physics);
3644
+ state.lastDecision = recordTelemetry(state, 16.4);
3645
+ state.demoDescription = resolveSceneDescription(state, options, shipModel).description;
3646
+ syncTextState(state, shipModel, featureAdapters);
3647
+ const ctx = dom.canvas.getContext("2d");
3648
+ if (!ctx) {
3649
+ throw new Error("2D canvas context is required for the shared showcase.");
3650
+ }
3651
+ ctx.imageSmoothingEnabled = true;
3652
+ ctx.imageSmoothingQuality = "high";
3653
+ let animationFrameId = 0;
3654
+ let destroyed = false;
3655
+ const renderFrame = (nowMs) => {
3656
+ if (destroyed) {
3657
+ return;
3658
+ }
3659
+ if (!state.paused) {
3660
+ if (state.lastTimeMs == null) {
3661
+ state.lastTimeMs = nowMs;
3662
+ }
3663
+ const dt = Math.min(0.033, (nowMs - state.lastTimeMs) / 1e3);
3664
+ state.lastTimeMs = nowMs;
3665
+ state.time += dt;
3666
+ state.frame += 1;
3667
+ updateSceneState(state, dt, shipModel, featureAdapters);
3668
+ updatePackageState(state, options, shipModel, dt);
3669
+ const syntheticFrame = 14.2 + state.sprays.length * 0.1 + (state.stress ? 6.4 : 0);
3670
+ state.lastDecision = recordTelemetry(state, syntheticFrame);
3671
+ }
3672
+ state.demoDescription = resolveSceneDescription(state, options, shipModel).description;
3673
+ resizeCanvasToDisplaySize(dom.canvas, state);
3674
+ renderScene(
3675
+ ctx,
3676
+ dom.canvas,
3677
+ state,
3678
+ shipModel,
3679
+ dom,
3680
+ featureAdapters.lighting,
3681
+ featureAdapters.fluid,
3682
+ featureAdapters.cloth
3683
+ );
3684
+ syncTextState(state, shipModel, featureAdapters);
3685
+ animationFrameId = requestAnimationFrame(renderFrame);
3686
+ };
3687
+ const handlePauseClick = () => {
3688
+ state.paused = !state.paused;
3689
+ dom.pauseButton.textContent = state.paused ? state.translate(gpuSharedTranslationKeys.resume) : state.translate(gpuSharedTranslationKeys.pause);
3690
+ };
3691
+ const handleStressChange = () => {
3692
+ state.stress = dom.stressToggle.checked;
3693
+ };
3694
+ const handleFocusChange = () => {
3695
+ state.focus = dom.focusMode.value;
3696
+ Object.assign(state.camera, {
3697
+ ...CAMERA_PRESETS[state.focus],
3698
+ target: vec3(...CAMERA_PRESETS[state.focus].target)
3699
+ });
3700
+ };
3701
+ dom.pauseButton.addEventListener("click", handlePauseClick);
3702
+ dom.stressToggle.addEventListener("change", handleStressChange);
3703
+ dom.focusMode.addEventListener("change", handleFocusChange);
3704
+ animationFrameId = requestAnimationFrame(renderFrame);
3705
+ const destroy = () => {
3706
+ if (destroyed) {
3707
+ return;
3708
+ }
3709
+ destroyed = true;
3710
+ if (animationFrameId) {
3711
+ cancelAnimationFrame(animationFrameId);
3712
+ }
3713
+ dom.pauseButton.removeEventListener("click", handlePauseClick);
3714
+ dom.stressToggle.removeEventListener("change", handleStressChange);
3715
+ dom.focusMode.removeEventListener("change", handleFocusChange);
3716
+ try {
3717
+ if (typeof options.destroyState === "function") {
3718
+ options.destroyState(state.packageState);
3719
+ }
3720
+ } finally {
3721
+ state.packageState = void 0;
3722
+ }
3723
+ root.classList?.remove?.(ROOT_CLASS);
3724
+ root.classList?.remove?.(CAPTURE_CLASS);
3725
+ root.innerHTML = previousMarkup;
3726
+ if (typeof previousRenderGameToText === "function") {
3727
+ window.render_game_to_text = previousRenderGameToText;
3728
+ } else {
3729
+ delete window.render_game_to_text;
3730
+ }
3731
+ if (typeof previousAdvanceTime === "function") {
3732
+ window.advanceTime = previousAdvanceTime;
3733
+ } else {
3734
+ delete window.advanceTime;
3735
+ }
3736
+ };
3737
+ return {
3738
+ state,
3739
+ shipModel,
3740
+ canvas: dom.canvas,
3741
+ destroy
3742
+ };
3743
+ }
3744
+ function updatePhysicsSnapshot(state, shipModel, physicsFeatures) {
3745
+ const createPhysicsWorldSnapshot = assertRequiredFunction(
3746
+ physicsFeatures,
3747
+ "physics",
3748
+ "createWorldSnapshot"
3749
+ );
3750
+ const rigidBodyShapes = Object.fromEntries(
3751
+ state.ships.map((ship) => [
3752
+ ship.id,
3753
+ resolveShipModel(state, ship, shipModel)?.physics?.shape ?? "box"
3754
+ ])
3755
+ );
3756
+ state.physics.snapshot = createPhysicsWorldSnapshot({
3757
+ frameId: `showcase-${state.frame}`,
3758
+ tick: state.frame,
3759
+ simulationTimeMs: Number((state.time * 1e3).toFixed(2)),
3760
+ profile: state.physics.profile,
3761
+ authoritativeTransformRevision: state.frame,
3762
+ secondarySimulationRevision: state.frame,
3763
+ animationInputRevision: state.frame,
3764
+ bodyCount: state.ships.length + 2,
3765
+ dynamicBodyCount: state.ships.length,
3766
+ contactCount: state.contactCount,
3767
+ metadata: {
3768
+ collisionCount: state.collisionCount,
3769
+ contactCount: state.contactCount,
3770
+ snapshotStageId: state.physics.plan.snapshotStageId,
3771
+ rigidBodyShape: shipModel.physics.shape ?? "box",
3772
+ rigidBodyShapes
3773
+ }
3774
+ });
3775
+ }
3776
+ export {
3777
+ advanceShowcaseClothSimulationState as __testOnlyAdvanceShowcaseClothSimulationState,
3778
+ buildWaterBands as __testOnlyBuildWaterBands,
3779
+ buildWaterMotionEffects as __testOnlyBuildWaterMotionEffects,
3780
+ collectSceneLightSources as __testOnlyCollectSceneLightSources,
3781
+ createShowcaseClothSimulationState as __testOnlyCreateShowcaseClothSimulationState,
3782
+ mountGpuShowcase,
3783
+ showcaseFocusModes
3784
+ };
3785
+ //# sourceMappingURL=showcase-runtime-SNCUFSSC.js.map