@plasius/gpu-shared 0.1.13 → 0.1.15

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