@plasius/gpu-shared 0.1.11 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +58 -3
  2. package/README.md +110 -4
  3. package/assets/brigantine.gltf +549 -24
  4. package/assets/cutter.gltf +538 -0
  5. package/assets/harbor-dock.gltf +680 -0
  6. package/assets/lighthouse.gltf +604 -0
  7. package/dist/chunk-2GM64LB6.js +9 -0
  8. package/dist/chunk-2GM64LB6.js.map +1 -0
  9. package/dist/chunk-3ARPGHCQ.js +119 -0
  10. package/dist/chunk-3ARPGHCQ.js.map +1 -0
  11. package/dist/chunk-4ZJ24VRS.js +402 -0
  12. package/dist/chunk-4ZJ24VRS.js.map +1 -0
  13. package/dist/chunk-W5GA3VA6.js +442 -0
  14. package/dist/chunk-W5GA3VA6.js.map +1 -0
  15. package/dist/gltf-loader-YDPLZS5Q.js +8 -0
  16. package/dist/index.cjs +2432 -6424
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.js +45 -6
  19. package/dist/index.js.map +1 -1
  20. package/dist/product-studio-runtime-HDAUDWYO.js +11 -0
  21. package/dist/showcase-inline-assets-WT4PSNKI.js +7 -0
  22. package/dist/showcase-inline-assets-WT4PSNKI.js.map +1 -0
  23. package/dist/showcase-runtime-SNCUFSSC.js +3785 -0
  24. package/dist/showcase-runtime-SNCUFSSC.js.map +1 -0
  25. package/package.json +20 -8
  26. package/src/asset-url.js +62 -11
  27. package/src/feature-flags.js +2 -0
  28. package/src/gltf-loader.js +330 -32
  29. package/src/i18n.js +71 -0
  30. package/src/index.d.ts +187 -2
  31. package/src/index.js +42 -1
  32. package/src/product-studio-runtime.js +465 -0
  33. package/src/showcase-inline-assets.js +3 -0
  34. package/src/showcase-runtime.js +1779 -252
  35. package/src/translations/en-GB.js +55 -0
  36. package/dist/chunk-DGUM43GV.js +0 -11
  37. package/dist/chunk-OTCJ3VOK.js +0 -35
  38. package/dist/chunk-OTCJ3VOK.js.map +0 -1
  39. package/dist/chunk-QBMXJ3V2.js +0 -142
  40. package/dist/chunk-QBMXJ3V2.js.map +0 -1
  41. package/dist/gltf-loader-LKALCZAV.js +0 -8
  42. package/dist/showcase-runtime-2ZNPKD7D.js +0 -8593
  43. package/dist/showcase-runtime-2ZNPKD7D.js.map +0 -1
  44. /package/dist/{chunk-DGUM43GV.js.map → gltf-loader-YDPLZS5Q.js.map} +0 -0
  45. /package/dist/{gltf-loader-LKALCZAV.js.map → product-studio-runtime-HDAUDWYO.js.map} +0 -0
@@ -1,43 +1,17 @@
1
- import {
2
- clothGarmentKinds,
3
- clothProfileNames,
4
- createClothRepresentationPlan,
5
- selectClothRepresentationBand,
6
- } from "@plasius/gpu-cloth";
7
- import {
8
- fluidBodyKinds,
9
- fluidProfileNames,
10
- createFluidContinuityEnvelope,
11
- createFluidRepresentationPlan,
12
- selectFluidRepresentationBand,
13
- } from "@plasius/gpu-fluid";
14
- import {
15
- createLightingBandPlan,
16
- defaultLightingProfile,
17
- getLightingProfile,
18
- lightingDistanceBands,
19
- } from "@plasius/gpu-lighting";
20
- import {
21
- createDeviceProfile,
22
- createGpuPerformanceGovernor,
23
- createQualityLadderAdapter,
24
- } from "@plasius/gpu-performance";
25
- import { createGpuDebugSession } from "@plasius/gpu-debug";
26
- import {
27
- createPhysicsSimulationPlan,
28
- createPhysicsWorldSnapshot,
29
- defaultPhysicsWorkerProfile,
30
- getPhysicsWorkerManifest,
31
- } from "@plasius/gpu-physics/browser";
32
-
33
1
  import { resolveShowcaseAssetUrl } from "./asset-url.js";
34
2
  import { loadGltfModel } from "./gltf-loader.js";
3
+ import { GPU_SHOWCASE_REALISTIC_MODELS_FEATURE } from "./feature-flags.js";
4
+ import {
5
+ createGpuSharedTranslator,
6
+ gpuSharedTranslationKeys,
7
+ } from "./i18n.js";
35
8
 
36
9
  const STYLE_ID = "plasius-shared-3d-showcase-style";
37
10
  const ROOT_CLASS = "plasius-showcase-root";
38
- const DEFAULT_TITLE = "Flag by the Sea";
39
- const DEFAULT_SUBTITLE =
40
- "Shared 3D validation scene using GLTF ships, cloth, fluid continuity, adaptive performance, and telemetry.";
11
+ const CAPTURE_CLASS = "plasius-showcase-root--capture";
12
+ const DEFAULT_CANVAS_WIDTH = 1280;
13
+ const DEFAULT_CANVAS_HEIGHT = 720;
14
+ const CAPTURE_CANVAS_PIXEL_BUDGET = 1920 * 1080;
41
15
  const SHIP_SCALE = 1.1;
42
16
  const HARBOR_BOUNDS = Object.freeze({
43
17
  minX: -11.2,
@@ -54,42 +28,848 @@ const CAMERA_PRESETS = Object.freeze({
54
28
  performance: Object.freeze({ yaw: -0.65, pitch: 0.36, distance: 24, target: [0, 2.2, 0] }),
55
29
  debug: Object.freeze({ yaw: -0.7, pitch: 0.32, distance: 24, target: [0, 2.2, 0] }),
56
30
  });
57
- export const showcaseFocusModes = Object.freeze(Object.keys(CAMERA_PRESETS));
58
31
 
59
- const SCENE_NOTES = Object.freeze([
60
- "Ships are loaded from a GLTF asset and carry mass, damping, restitution, and hull extents from node extras.",
61
- "Moonlight sets the cold ambient read while deck lanterns and harbor torches provide warm local contrast.",
62
- "Cloth and fluid continuity stay coherent across near, mid, far, and horizon bands even in the darker night palette.",
63
- "Performance pressure reduces visual detail before mass-weighted authoritative collision motion is touched.",
32
+ const FALLBACK_LIGHTING_DISTANCE_BANDS = Object.freeze([
33
+ Object.freeze({
34
+ band: "near",
35
+ primaryShadowSource: "ray-traced-primary",
36
+ rtParticipation: Object.freeze({
37
+ reflections: "full",
38
+ globalIllumination: "high",
39
+ directShadows: "premium",
40
+ }),
41
+ updateCadenceDivisor: 1,
42
+ }),
43
+ Object.freeze({
44
+ band: "mid",
45
+ primaryShadowSource: "ray-traced-secondary",
46
+ rtParticipation: Object.freeze({
47
+ reflections: "medium",
48
+ globalIllumination: "medium",
49
+ directShadows: "selective",
50
+ }),
51
+ updateCadenceDivisor: 2,
52
+ }),
53
+ Object.freeze({
54
+ band: "far",
55
+ primaryShadowSource: "baked",
56
+ rtParticipation: Object.freeze({
57
+ reflections: "low",
58
+ globalIllumination: "low",
59
+ directShadows: "none",
60
+ }),
61
+ updateCadenceDivisor: 4,
62
+ }),
63
+ Object.freeze({
64
+ band: "horizon",
65
+ primaryShadowSource: "impression",
66
+ rtParticipation: Object.freeze({
67
+ reflections: "off",
68
+ globalIllumination: "off",
69
+ directShadows: "none",
70
+ }),
71
+ updateCadenceDivisor: 8,
72
+ }),
64
73
  ]);
65
-
66
- const UNIT_BOX_MESH = Object.freeze({
67
- positions: Object.freeze([
68
- -0.5, -0.5, -0.5,
69
- 0.5, -0.5, -0.5,
70
- 0.5, 0.5, -0.5,
71
- -0.5, 0.5, -0.5,
72
- -0.5, -0.5, 0.5,
73
- 0.5, -0.5, 0.5,
74
- 0.5, 0.5, 0.5,
75
- -0.5, 0.5, 0.5,
74
+ const FALLBACK_LIGHTING_PROFILE = "cinematic";
75
+ const FALLBACK_PHYSICS_PROFILE = "cinematic";
76
+ const FALLBACK_PERFORMANCE_LEVELS = Object.freeze({
77
+ fluid: Object.freeze([
78
+ Object.freeze({
79
+ id: "low",
80
+ config: Object.freeze({ nearResolution: 10, midResolution: 6, splashCount: 10 }),
81
+ estimatedCostMs: 0.8,
82
+ }),
83
+ Object.freeze({
84
+ id: "medium",
85
+ config: Object.freeze({ nearResolution: 16, midResolution: 8, splashCount: 18 }),
86
+ estimatedCostMs: 1.4,
87
+ }),
88
+ Object.freeze({
89
+ id: "high",
90
+ config: Object.freeze({ nearResolution: 24, midResolution: 12, splashCount: 28 }),
91
+ estimatedCostMs: 2.4,
92
+ }),
76
93
  ]),
77
- indices: Object.freeze([
78
- 0, 1, 2, 0, 2, 3,
79
- 5, 4, 7, 5, 7, 6,
80
- 4, 0, 3, 4, 3, 7,
81
- 1, 5, 6, 1, 6, 2,
82
- 3, 2, 6, 3, 6, 7,
83
- 4, 5, 1, 4, 1, 0,
94
+ cloth: Object.freeze([
95
+ Object.freeze({
96
+ id: "low",
97
+ config: Object.freeze({ cols: 10, rows: 7 }),
98
+ estimatedCostMs: 0.7,
99
+ }),
100
+ Object.freeze({
101
+ id: "medium",
102
+ config: Object.freeze({ cols: 16, rows: 11 }),
103
+ estimatedCostMs: 1.3,
104
+ }),
105
+ Object.freeze({
106
+ id: "high",
107
+ config: Object.freeze({ cols: 24, rows: 16 }),
108
+ estimatedCostMs: 2.1,
109
+ }),
110
+ ]),
111
+ lighting: Object.freeze([
112
+ Object.freeze({
113
+ id: "low",
114
+ config: Object.freeze({ shadowStrength: 0.18, reflectionStrength: 0.08 }),
115
+ estimatedCostMs: 0.5,
116
+ }),
117
+ Object.freeze({
118
+ id: "medium",
119
+ config: Object.freeze({ shadowStrength: 0.34, reflectionStrength: 0.16 }),
120
+ estimatedCostMs: 1.0,
121
+ }),
122
+ Object.freeze({
123
+ id: "high",
124
+ config: Object.freeze({ shadowStrength: 0.5, reflectionStrength: 0.24 }),
125
+ estimatedCostMs: 1.8,
126
+ }),
84
127
  ]),
85
128
  });
86
129
 
130
+ function createFallbackClothFeatureModule() {
131
+ const fallback = createFallbackClothFeatureAdapters();
132
+ return {
133
+ clothGarmentKinds: fallback.garmentKinds,
134
+ clothProfileNames: fallback.profileNames,
135
+ createClothRepresentationPlan: fallback.createPlan,
136
+ selectClothRepresentationBand: fallback.selectBand,
137
+ };
138
+ }
139
+
140
+ function createFallbackFluidFeatureModule() {
141
+ const fallback = createFallbackFluidFeatureAdapters();
142
+ return {
143
+ fluidBodyKinds: fallback.bodyKinds,
144
+ fluidProfileNames: fallback.profileNames,
145
+ createFluidContinuityEnvelope: fallback.createContinuityEnvelope,
146
+ createFluidRepresentationPlan: fallback.createPlan,
147
+ selectFluidRepresentationBand: fallback.selectBand,
148
+ };
149
+ }
150
+
151
+ function createFallbackLightingFeatureModule() {
152
+ return {
153
+ createLightingBandPlan({ profile = FALLBACK_LIGHTING_PROFILE, importance = "high" } = {}) {
154
+ const defaultPlan = {
155
+ profile,
156
+ importance,
157
+ bands: FALLBACK_LIGHTING_DISTANCE_BANDS,
158
+ };
159
+ return defaultPlan;
160
+ },
161
+ defaultLightingProfile: FALLBACK_LIGHTING_PROFILE,
162
+ getLightingProfile(profile = FALLBACK_LIGHTING_PROFILE) {
163
+ return {
164
+ name: profile,
165
+ bands: FALLBACK_LIGHTING_DISTANCE_BANDS,
166
+ };
167
+ },
168
+ lightingDistanceBands: FALLBACK_LIGHTING_DISTANCE_BANDS,
169
+ };
170
+ }
171
+
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))
178
+ ? Number(entry.estimatedCostMs)
179
+ : 1.0,
180
+ }));
181
+ if (resolvedLevels.length === 0) {
182
+ return {
183
+ module: {
184
+ id: "high",
185
+ config: Object.freeze({}),
186
+ estimatedCostMs: 1.0,
187
+ },
188
+ getSnapshot() {
189
+ return {
190
+ currentLevel: {
191
+ id: "high",
192
+ config: Object.freeze({}),
193
+ estimatedCostMs: 1.0,
194
+ },
195
+ };
196
+ },
197
+ id: "high",
198
+ };
199
+ }
200
+ const initial = resolvedLevels.find((entry) => entry.id === initialLevel) ?? resolvedLevels[0];
201
+ return {
202
+ id: initial.id,
203
+ getSnapshot() {
204
+ return {
205
+ currentLevel: {
206
+ id: initial.id,
207
+ config: initial.config,
208
+ estimatedCostMs: initial.estimatedCostMs,
209
+ },
210
+ };
211
+ },
212
+ };
213
+ }
214
+
215
+ function createFallbackPerformanceFeatureModule() {
216
+ const moduleIds = new Set(["fluid-detail", "cloth-detail", "lighting-detail"]);
217
+
218
+ return {
219
+ createDeviceProfile(profile = {}) {
220
+ return {
221
+ deviceClass: "desktop",
222
+ mode: "flat",
223
+ refreshRateHz: Number.isFinite(profile?.refreshRateHz)
224
+ ? Number(profile.refreshRateHz)
225
+ : 60,
226
+ supportedFrameRates: Array.isArray(profile?.supportedFrameRates)
227
+ ? profile.supportedFrameRates
228
+ : [60, 90],
229
+ supportsWebGpu: true,
230
+ };
231
+ },
232
+ createQualityLadderAdapter({ id, domain, levels, initialLevel }) {
233
+ return {
234
+ id: String(id ?? ""),
235
+ domain,
236
+ ...createFallbackPerformanceQualityState(
237
+ domain === "fluid" ? FALLBACK_PERFORMANCE_LEVELS.fluid : domain === "cloth" ? FALLBACK_PERFORMANCE_LEVELS.cloth : FALLBACK_PERFORMANCE_LEVELS.lighting,
238
+ initialLevel,
239
+ domain
240
+ ),
241
+ };
242
+ },
243
+ createGpuPerformanceGovernor({ device, modules = [], adaptation = {} } = {}) {
244
+ let pressureLevel = "stable";
245
+ let frameSamples = 0;
246
+ let averageMs = 16.67;
247
+
248
+ const clamp = (next = 16.67) => (Number.isFinite(next) ? Math.max(1, next) : 16.67);
249
+ const target = Object.freeze({
250
+ targetFrameTimeMs: 16.67,
251
+ downgradeFrameTimeMs: clamp(adaptation?.degradeThresholdMs ?? 20),
252
+ upgradeFrameTimeMs: clamp(adaptation?.upgradeThresholdMs ?? 14),
253
+ });
254
+
255
+ return {
256
+ recordFrame({ frameTimeMs = averageMs } = {}) {
257
+ const sample = Number.isFinite(Number(frameTimeMs)) ? Number(frameTimeMs) : averageMs;
258
+ frameSamples += 1;
259
+ averageMs = clamp((averageMs * (frameSamples - 1) + sample) / frameSamples);
260
+ const fps = 1000 / averageMs;
261
+
262
+ pressureLevel =
263
+ sample > target.downgradeFrameTimeMs
264
+ ? "degrade"
265
+ : pressureLevel === "degrade" && sample <= target.upgradeFrameTimeMs
266
+ ? "stable"
267
+ : pressureLevel;
268
+
269
+ return {
270
+ pressureLevel,
271
+ metrics: {
272
+ fps,
273
+ averageFrameTimeMs: averageMs,
274
+ },
275
+ adjustments: pressureLevel === "degrade" ? [{ type: "capability-step-down" }] : [],
276
+ };
277
+ },
278
+ getTarget() {
279
+ return target;
280
+ },
281
+ getState() {
282
+ return {
283
+ modules: modules
284
+ .filter((entry) => entry != null && typeof entry.id === "string" && moduleIds.has(entry.id))
285
+ .map((entry) => ({
286
+ isAtMaximum: pressureLevel === "stable",
287
+ })),
288
+ };
289
+ },
290
+ };
291
+ },
292
+ };
293
+ }
294
+
295
+ function createFallbackDebugFeatureModule() {
296
+ let queueSamples = 0;
297
+ let queuePeakDepth = 0;
298
+ let readyLaneSamples = 0;
299
+ let readyLanePeakDepth = 0;
300
+ let dispatchSamples = 0;
301
+ let dispatchDurationTotal = 0;
302
+ let dependencyUnlockSamples = 0;
303
+ let dependencyUnlockCount = 0;
304
+ let pipelineSamples = 0;
305
+ let pipelineAgeTotal = 0;
306
+ let frameSamples = 0;
307
+ let frameTimeTotal = 0;
308
+ let gpuBusyTotal = 0;
309
+ let frameDroppedSamples = 0;
310
+ let memoryTotalBytes = 0;
311
+
312
+ function ensureNumber(value, fallback = 0) {
313
+ const asNumber = Number(value);
314
+ return Number.isFinite(asNumber) ? asNumber : fallback;
315
+ }
316
+
317
+ function createDebugSession({ adapter } = {}) {
318
+ const memoryHint = Number.isFinite(Number(adapter?.memoryCapacityHintBytes))
319
+ ? Number(adapter.memoryCapacityHintBytes)
320
+ : 0;
321
+
322
+ memoryTotalBytes = Math.max(0, memoryHint);
323
+
324
+ return {
325
+ trackAllocation({ sizeBytes = 0 } = {}) {
326
+ memoryTotalBytes += ensureNumber(sizeBytes);
327
+ },
328
+ recordQueue({ depth = 0 } = {}) {
329
+ queueSamples += 1;
330
+ queuePeakDepth = Math.max(queuePeakDepth, ensureNumber(depth, 0));
331
+ },
332
+ recordReadyLane({ depth = 0 } = {}) {
333
+ readyLaneSamples += 1;
334
+ readyLanePeakDepth = Math.max(readyLanePeakDepth, ensureNumber(depth, 0));
335
+ },
336
+ recordDispatch({ durationMs = 0 } = {}) {
337
+ dispatchSamples += 1;
338
+ dispatchDurationTotal += ensureNumber(durationMs);
339
+ },
340
+ recordDependencyUnlock({ unlockCount = 0 } = {}) {
341
+ dependencyUnlockSamples += 1;
342
+ dependencyUnlockCount += ensureNumber(unlockCount);
343
+ },
344
+ recordPipelinePhase({ snapshotAgeMs = 0 } = {}) {
345
+ pipelineSamples += 1;
346
+ pipelineAgeTotal += ensureNumber(snapshotAgeMs);
347
+ },
348
+ recordFrame({
349
+ frameTimeMs = 16.67,
350
+ targetFrameTimeMs = 16.67,
351
+ gpuBusyMs = 0,
352
+ dropped = false,
353
+ } = {}) {
354
+ frameSamples += 1;
355
+ frameTimeTotal += ensureNumber(frameTimeMs);
356
+ gpuBusyTotal += ensureNumber(gpuBusyMs);
357
+ frameDroppedSamples += dropped === true ? 1 : 0;
358
+ targetFrameTimeMs;
359
+ },
360
+ getSnapshot() {
361
+ const queueAverageDepth = queueSamples > 0 ? queuePeakDepth / queueSamples : 0;
362
+ return {
363
+ frames: {
364
+ sampleCount: frameSamples,
365
+ averageFrameTimeMs: frameSamples > 0 ? frameTimeTotal / frameSamples : 0,
366
+ averageGpuBusyMs: frameSamples > 0 ? gpuBusyTotal / frameSamples : 0,
367
+ droppedCount: frameDroppedSamples,
368
+ },
369
+ queues: {
370
+ sampleCount: queueSamples,
371
+ peakDepth: queuePeakDepth,
372
+ averageDepth: queueAverageDepth,
373
+ },
374
+ dispatch: {
375
+ sampleCount: dispatchSamples,
376
+ averageDurationMs: dispatchSamples > 0 ? dispatchDurationTotal / dispatchSamples : 0,
377
+ },
378
+ dag: {
379
+ peakReadyLaneDepth: readyLanePeakDepth,
380
+ totalUnlockCount: dependencyUnlockCount,
381
+ unlockSamples: dependencyUnlockSamples,
382
+ },
383
+ pipeline: {
384
+ sampleCount: pipelineSamples,
385
+ averageSnapshotAgeMs: pipelineSamples > 0 ? pipelineAgeTotal / pipelineSamples : 0,
386
+ },
387
+ memory: {
388
+ totalTrackedBytes: memoryTotalBytes,
389
+ },
390
+ };
391
+ },
392
+ };
393
+ }
394
+
395
+ return {
396
+ createGpuDebugSession({ adapter } = {}) {
397
+ return createDebugSession({ adapter });
398
+ },
399
+ createSession({ adapter } = {}) {
400
+ return createDebugSession({ adapter });
401
+ },
402
+ };
403
+ }
404
+
405
+ function createFallbackPhysicsFeatureModule() {
406
+ const fallbackPlan = Object.freeze({
407
+ snapshotStageId: "baseline",
408
+ stageOrder: Object.freeze(["authoritative"]),
409
+ secondarySimulationStageIds: Object.freeze(["visual"]),
410
+ });
411
+ return {
412
+ createPhysicsSimulationPlan() {
413
+ return {
414
+ snapshotStageId: "baseline",
415
+ stageOrder: fallbackPlan.stageOrder,
416
+ secondarySimulationStageIds: fallbackPlan.secondarySimulationStageIds,
417
+ };
418
+ },
419
+ createPhysicsWorldSnapshot(input = {}) {
420
+ return {
421
+ stage: "baseline",
422
+ stability: "stable",
423
+ stageId: fallbackPlan.snapshotStageId,
424
+ metadata: input.metadata ?? {},
425
+ bodyCount: input.bodyCount ?? 0,
426
+ dynamicBodyCount: input.dynamicBodyCount ?? 0,
427
+ contactCount: input.contactCount ?? 0,
428
+ snapshotStageId: fallbackPlan.snapshotStageId,
429
+ };
430
+ },
431
+ defaultPhysicsWorkerProfile: FALLBACK_PHYSICS_PROFILE,
432
+ getPhysicsWorkerManifest() {
433
+ return {
434
+ jobs: [
435
+ Object.freeze({
436
+ worker: Object.freeze({ authority: "authoritative", jobType: "simulate" }),
437
+ }),
438
+ ],
439
+ suggestedAllocationIds: Object.freeze(["physics-workspace"]),
440
+ };
441
+ },
442
+ };
443
+ }
444
+
445
+ const SHOWCASE_FEATURE_LOADERS = Object.freeze({
446
+ cloth: () => Promise.resolve(createFallbackClothFeatureModule()),
447
+ fluid: () => Promise.resolve(createFallbackFluidFeatureModule()),
448
+ lighting: () => Promise.resolve(createFallbackLightingFeatureModule()),
449
+ performance: () => Promise.resolve(createFallbackPerformanceFeatureModule()),
450
+ debug: () => Promise.resolve(createFallbackDebugFeatureModule()),
451
+ physics: () => Promise.resolve(createFallbackPhysicsFeatureModule()),
452
+ });
453
+
454
+ const DEFAULT_FLUID_BAND_THRESHOLDS = Object.freeze({
455
+ near: Object.freeze({ minDistance: 0, maxDistance: 22 }),
456
+ mid: Object.freeze({ minDistance: 22, maxDistance: 90 }),
457
+ far: Object.freeze({ minDistance: 90, maxDistance: 260 }),
458
+ horizon: Object.freeze({ minDistance: 260, maxDistance: Number.POSITIVE_INFINITY }),
459
+ });
460
+
461
+ const DEFAULT_CLOTH_BAND_THRESHOLDS = Object.freeze({
462
+ near: Object.freeze({ minDistance: 0, maxDistance: 24 }),
463
+ mid: Object.freeze({ minDistance: 24, maxDistance: 86 }),
464
+ far: Object.freeze({ minDistance: 86, maxDistance: 190 }),
465
+ horizon: Object.freeze({ minDistance: 190, maxDistance: Number.POSITIVE_INFINITY }),
466
+ });
467
+
468
+ function resolveFluidBandSelection(distance, thresholds = DEFAULT_FLUID_BAND_THRESHOLDS) {
469
+ if (!Number.isFinite(distance)) {
470
+ return "horizon";
471
+ }
472
+ if (distance <= (thresholds.near?.maxDistance ?? DEFAULT_FLUID_BAND_THRESHOLDS.near.maxDistance)) {
473
+ return "near";
474
+ }
475
+ if (distance <= (thresholds.mid?.maxDistance ?? DEFAULT_FLUID_BAND_THRESHOLDS.mid.maxDistance)) {
476
+ return "mid";
477
+ }
478
+ if (distance <= (thresholds.far?.maxDistance ?? DEFAULT_FLUID_BAND_THRESHOLDS.far.maxDistance)) {
479
+ return "far";
480
+ }
481
+ return "horizon";
482
+ }
483
+
484
+ function createFallbackFluidFeatureAdapters() {
485
+ const defaultContinuityBand = Object.freeze({
486
+ amplitudeFloor: 0.22,
487
+ frequencyFloor: 0.05,
488
+ blendWindowMeters: 14,
489
+ retainFoamHistory: true,
490
+ retainDirectionality: true,
491
+ });
492
+ return {
493
+ bodyKinds: Object.freeze(["ocean"]),
494
+ profileNames: Object.freeze(["cinematic"]),
495
+ createContinuityEnvelope() {
496
+ return Object.freeze({
497
+ bands: Object.freeze({
498
+ near: defaultContinuityBand,
499
+ mid: Object.freeze({
500
+ ...defaultContinuityBand,
501
+ blendWindowMeters: 22,
502
+ amplitudeFloor: 0.17,
503
+ }),
504
+ far: Object.freeze({
505
+ ...defaultContinuityBand,
506
+ blendWindowMeters: 34,
507
+ amplitudeFloor: 0.12,
508
+ }),
509
+ horizon: Object.freeze({
510
+ ...defaultContinuityBand,
511
+ blendWindowMeters: 42,
512
+ amplitudeFloor: 0.09,
513
+ }),
514
+ }),
515
+ });
516
+ },
517
+ createPlan({ fluidBodyId = "harbor", kind = "ocean", profile = "cinematic" }) {
518
+ return Object.freeze({
519
+ fluidBodyId,
520
+ kind,
521
+ profile,
522
+ thresholds: DEFAULT_FLUID_BAND_THRESHOLDS,
523
+ representations: Object.freeze([
524
+ Object.freeze({
525
+ band: "near",
526
+ output: "raster",
527
+ rtParticipation: "full",
528
+ shading: Object.freeze({ refraction: 0.14, reflectionMode: "full", caustics: true }),
529
+ }),
530
+ Object.freeze({
531
+ band: "mid",
532
+ output: "raster",
533
+ rtParticipation: "reduced",
534
+ shading: Object.freeze({ reflectionMode: "partial" }),
535
+ }),
536
+ Object.freeze({
537
+ band: "far",
538
+ output: "raster",
539
+ rtParticipation: "low",
540
+ shading: Object.freeze({ reflectionMode: "partial" }),
541
+ }),
542
+ Object.freeze({
543
+ band: "horizon",
544
+ output: "coarse",
545
+ rtParticipation: "off",
546
+ shading: Object.freeze({ reflectionMode: "reduced" }),
547
+ }),
548
+ ]),
549
+ });
550
+ },
551
+ selectBand(distance, thresholds = DEFAULT_FLUID_BAND_THRESHOLDS) {
552
+ return resolveFluidBandSelection(distance, thresholds);
553
+ },
554
+ };
555
+ }
556
+
557
+ function createFallbackClothFeatureAdapters() {
558
+ const defaultContinuity = Object.freeze({
559
+ amplitudeFloor: 0.22,
560
+ wrinkleFloor: 0.32,
561
+ damping: 0.58,
562
+ creaseBias: 0.14,
563
+ });
564
+ return {
565
+ garmentKinds: Object.freeze(["flag"]),
566
+ profileNames: Object.freeze(["cinematic"]),
567
+ createPlan() {
568
+ return Object.freeze({
569
+ thresholds: DEFAULT_CLOTH_BAND_THRESHOLDS,
570
+ representations: Object.freeze([
571
+ Object.freeze({
572
+ band: "near",
573
+ continuity: defaultContinuity,
574
+ }),
575
+ Object.freeze({
576
+ band: "mid",
577
+ continuity: defaultContinuity,
578
+ }),
579
+ Object.freeze({
580
+ band: "far",
581
+ continuity: defaultContinuity,
582
+ }),
583
+ Object.freeze({
584
+ band: "horizon",
585
+ continuity: defaultContinuity,
586
+ }),
587
+ ]),
588
+ });
589
+ },
590
+ selectBand(distance, thresholds = DEFAULT_CLOTH_BAND_THRESHOLDS) {
591
+ if (!Number.isFinite(distance)) {
592
+ return "horizon";
593
+ }
594
+ if (distance <= (thresholds.near?.maxDistance ?? DEFAULT_CLOTH_BAND_THRESHOLDS.near.maxDistance)) {
595
+ return "near";
596
+ }
597
+ if (distance <= (thresholds.mid?.maxDistance ?? DEFAULT_CLOTH_BAND_THRESHOLDS.mid.maxDistance)) {
598
+ return "mid";
599
+ }
600
+ if (distance <= (thresholds.far?.maxDistance ?? DEFAULT_CLOTH_BAND_THRESHOLDS.far.maxDistance)) {
601
+ return "far";
602
+ }
603
+ return "horizon";
604
+ },
605
+ };
606
+ }
607
+
608
+ function normalizeClothFeatureAdapters(clothFeatures) {
609
+ const fallback = createFallbackClothFeatureAdapters();
610
+ if (clothFeatures == null || typeof clothFeatures !== "object") {
611
+ return fallback;
612
+ }
613
+
614
+ return {
615
+ garmentKinds:
616
+ Array.isArray(clothFeatures.garmentKinds) && clothFeatures.garmentKinds.length > 0
617
+ ? clothFeatures.garmentKinds
618
+ : fallback.garmentKinds,
619
+ profileNames:
620
+ Array.isArray(clothFeatures.profileNames) && clothFeatures.profileNames.length > 0
621
+ ? clothFeatures.profileNames
622
+ : fallback.profileNames,
623
+ createPlan: assertRequiredFunction(clothFeatures, "cloth", "createPlan"),
624
+ selectBand: assertRequiredFunction(clothFeatures, "cloth", "selectBand"),
625
+ };
626
+ }
627
+
628
+ function normalizeFluidFeatureAdapters(fluidFeatures) {
629
+ const fallback = createFallbackFluidFeatureAdapters();
630
+ if (fluidFeatures == null || typeof fluidFeatures !== "object") {
631
+ return fallback;
632
+ }
633
+
634
+ return {
635
+ bodyKinds:
636
+ Array.isArray(fluidFeatures.bodyKinds) && fluidFeatures.bodyKinds.length > 0
637
+ ? fluidFeatures.bodyKinds
638
+ : fallback.bodyKinds,
639
+ profileNames:
640
+ Array.isArray(fluidFeatures.profileNames) && fluidFeatures.profileNames.length > 0
641
+ ? fluidFeatures.profileNames
642
+ : fallback.profileNames,
643
+ createContinuityEnvelope: assertRequiredFunction(
644
+ fluidFeatures,
645
+ "fluid",
646
+ "createContinuityEnvelope"
647
+ ),
648
+ createPlan: assertRequiredFunction(fluidFeatures, "fluid", "createPlan"),
649
+ selectBand: assertRequiredFunction(fluidFeatures, "fluid", "selectBand"),
650
+ };
651
+ }
652
+
653
+ function assertRequiredFunction(module, featureLabel, exportName) {
654
+ const value = module?.[exportName];
655
+ if (typeof value !== "function") {
656
+ throw new Error(
657
+ `Showcase ${featureLabel} feature package must export "${exportName}" as a function.`
658
+ );
659
+ }
660
+ return value;
661
+ }
662
+
663
+ function assertRequiredArray(module, featureLabel, exportName) {
664
+ const value = module?.[exportName];
665
+ if (!Array.isArray(value)) {
666
+ throw new Error(
667
+ `Showcase ${featureLabel} feature package must export "${exportName}" as an array.`
668
+ );
669
+ }
670
+ return value;
671
+ }
672
+
673
+ async function loadShowcaseFeatureModule(featureLabel, loader) {
674
+ try {
675
+ const module = await loader();
676
+ if (module == null || typeof module !== "object") {
677
+ throw new Error("module is missing or not an object.");
678
+ }
679
+ return module;
680
+ } catch (error) {
681
+ const message = error?.message ?? String(error);
682
+ throw new Error(`Unable to load showcase ${featureLabel} feature package: ${message}`, {
683
+ cause: error,
684
+ });
685
+ }
686
+ }
687
+
688
+ function resolveShowcaseFeatureLoaders(options = {}) {
689
+ const overrides = options.__showcaseFeatureLoaders;
690
+ return {
691
+ cloth:
692
+ typeof overrides?.cloth === "function"
693
+ ? overrides.cloth
694
+ : SHOWCASE_FEATURE_LOADERS.cloth,
695
+ fluid:
696
+ typeof overrides?.fluid === "function"
697
+ ? overrides.fluid
698
+ : SHOWCASE_FEATURE_LOADERS.fluid,
699
+ lighting:
700
+ typeof overrides?.lighting === "function"
701
+ ? overrides.lighting
702
+ : SHOWCASE_FEATURE_LOADERS.lighting,
703
+ performance:
704
+ typeof overrides?.performance === "function"
705
+ ? overrides.performance
706
+ : SHOWCASE_FEATURE_LOADERS.performance,
707
+ debug:
708
+ typeof overrides?.debug === "function"
709
+ ? overrides.debug
710
+ : SHOWCASE_FEATURE_LOADERS.debug,
711
+ physics:
712
+ typeof overrides?.physics === "function"
713
+ ? overrides.physics
714
+ : SHOWCASE_FEATURE_LOADERS.physics,
715
+ };
716
+ }
717
+
718
+ async function resolveShowcaseFeatureAdapters(options = {}) {
719
+ const loaders = resolveShowcaseFeatureLoaders(options);
720
+ const [
721
+ clothModule,
722
+ fluidModule,
723
+ lightingModule,
724
+ performanceModule,
725
+ debugModule,
726
+ physicsModule,
727
+ ] = await Promise.all([
728
+ loadShowcaseFeatureModule("cloth", loaders.cloth),
729
+ loadShowcaseFeatureModule("fluid", loaders.fluid),
730
+ loadShowcaseFeatureModule("lighting", loaders.lighting),
731
+ loadShowcaseFeatureModule("performance", loaders.performance),
732
+ loadShowcaseFeatureModule("debug", loaders.debug),
733
+ loadShowcaseFeatureModule("physics", loaders.physics),
734
+ ]);
735
+
736
+ return {
737
+ cloth: {
738
+ garmentKinds: assertRequiredArray(clothModule, "cloth", "clothGarmentKinds"),
739
+ profileNames: assertRequiredArray(clothModule, "cloth", "clothProfileNames"),
740
+ createPlan: assertRequiredFunction(clothModule, "cloth", "createClothRepresentationPlan"),
741
+ selectBand: assertRequiredFunction(clothModule, "cloth", "selectClothRepresentationBand"),
742
+ },
743
+ fluid: {
744
+ bodyKinds: assertRequiredArray(fluidModule, "fluid", "fluidBodyKinds"),
745
+ profileNames: assertRequiredArray(fluidModule, "fluid", "fluidProfileNames"),
746
+ createContinuityEnvelope: assertRequiredFunction(
747
+ fluidModule,
748
+ "fluid",
749
+ "createFluidContinuityEnvelope"
750
+ ),
751
+ createPlan: assertRequiredFunction(fluidModule, "fluid", "createFluidRepresentationPlan"),
752
+ selectBand: assertRequiredFunction(fluidModule, "fluid", "selectFluidRepresentationBand"),
753
+ },
754
+ lighting: {
755
+ createBandPlan: assertRequiredFunction(lightingModule, "lighting", "createLightingBandPlan"),
756
+ defaultProfile: lightingModule.defaultLightingProfile,
757
+ getProfile: assertRequiredFunction(lightingModule, "lighting", "getLightingProfile"),
758
+ distanceBands: assertRequiredArray(lightingModule, "lighting", "lightingDistanceBands"),
759
+ },
760
+ performance: {
761
+ createDeviceProfile: assertRequiredFunction(
762
+ performanceModule,
763
+ "performance",
764
+ "createDeviceProfile"
765
+ ),
766
+ createGovernor: assertRequiredFunction(
767
+ performanceModule,
768
+ "performance",
769
+ "createGpuPerformanceGovernor"
770
+ ),
771
+ createQualityAdapter: assertRequiredFunction(
772
+ performanceModule,
773
+ "performance",
774
+ "createQualityLadderAdapter"
775
+ ),
776
+ },
777
+ debug: {
778
+ createSession: assertRequiredFunction(debugModule, "debug", "createGpuDebugSession"),
779
+ },
780
+ physics: {
781
+ createSimulationPlan: assertRequiredFunction(
782
+ physicsModule,
783
+ "physics",
784
+ "createPhysicsSimulationPlan"
785
+ ),
786
+ createWorldSnapshot: assertRequiredFunction(
787
+ physicsModule,
788
+ "physics",
789
+ "createPhysicsWorldSnapshot"
790
+ ),
791
+ defaultProfile: physicsModule.defaultPhysicsWorkerProfile,
792
+ getManifest: assertRequiredFunction(physicsModule, "physics", "getPhysicsWorkerManifest"),
793
+ },
794
+ };
795
+ }
796
+
797
+ export const showcaseFocusModes = Object.freeze(Object.keys(CAMERA_PRESETS));
798
+
799
+ const FOCUS_MODE_TRANSLATION_KEYS = Object.freeze({
800
+ integrated: gpuSharedTranslationKeys.focusIntegrated,
801
+ lighting: gpuSharedTranslationKeys.focusLighting,
802
+ cloth: gpuSharedTranslationKeys.focusCloth,
803
+ fluid: gpuSharedTranslationKeys.focusFluid,
804
+ physics: gpuSharedTranslationKeys.focusPhysics,
805
+ performance: gpuSharedTranslationKeys.focusPerformance,
806
+ debug: gpuSharedTranslationKeys.focusDebug,
807
+ });
808
+
809
+ const SCENE_NOTE_KEYS = Object.freeze([
810
+ gpuSharedTranslationKeys.noteAssetLoading,
811
+ gpuSharedTranslationKeys.noteMoonlight,
812
+ gpuSharedTranslationKeys.noteContinuity,
813
+ gpuSharedTranslationKeys.notePerformance,
814
+ ]);
815
+
816
+ const PHYSICS_SCENE_NOTE_KEYS = Object.freeze([
817
+ gpuSharedTranslationKeys.notePhysicsSnapshots,
818
+ gpuSharedTranslationKeys.notePhysicsCollisions,
819
+ gpuSharedTranslationKeys.notePhysicsLighting,
820
+ ]);
821
+
822
+ const LEGACY_HARBOR_LAYOUT = Object.freeze([
823
+ Object.freeze({
824
+ position: Object.freeze({ x: -8.2, y: 1.1, z: -0.9 }),
825
+ rotationY: -0.16,
826
+ scale: 5.4,
827
+ color: { r: 0.32, g: 0.27, b: 0.23, a: 1 },
828
+ accent: 0.06,
829
+ }),
830
+ Object.freeze({
831
+ position: Object.freeze({ x: -5.7, y: 0.45, z: 1.4 }),
832
+ rotationY: -0.08,
833
+ scale: { x: 6.8, y: 0.3, z: 2.1 },
834
+ color: { r: 0.31, g: 0.31, b: 0.34, a: 1 },
835
+ accent: 0.04,
836
+ }),
837
+ Object.freeze({
838
+ position: Object.freeze({ x: -10.4, y: 0.28, z: 0.8 }),
839
+ rotationY: 0.22,
840
+ scale: { x: 1.2, y: 0.9, z: 1.2 },
841
+ color: { r: 0.31, g: 0.35, b: 0.39, a: 1 },
842
+ accent: 0.02,
843
+ }),
844
+ ]);
845
+
846
+ const SHOWCASE_ENVIRONMENT_LAYOUT = Object.freeze([
847
+ Object.freeze({
848
+ assetKey: "harbor-dock",
849
+ position: Object.freeze({ x: -4.6, y: 0.16, z: 0.7 }),
850
+ rotationY: -0.08,
851
+ scale: 0.84,
852
+ accent: 0.04,
853
+ }),
854
+ Object.freeze({
855
+ assetKey: "lighthouse",
856
+ position: Object.freeze({ x: -9.8, y: 0, z: -0.58 }),
857
+ rotationY: 0.12,
858
+ scale: 0.56,
859
+ accent: 0.08,
860
+ }),
861
+ ]);
862
+
87
863
  const SHIP_LANTERNS = Object.freeze([
88
864
  Object.freeze({ x: 0.94, y: 1.54, z: 2.52, glow: 1 }),
89
865
  Object.freeze({ x: -0.9, y: 1.58, z: 2.44, glow: 0.92 }),
90
866
  Object.freeze({ x: 0.62, y: 1.42, z: -2.18, glow: 0.88 }),
91
867
  Object.freeze({ x: -0.58, y: 1.46, z: -2.04, glow: 0.84 }),
92
868
  ]);
869
+ const CUTTER_LANTERNS = Object.freeze([
870
+ Object.freeze({ x: 0.42, y: 1.04, z: 1.18, glow: 0.94 }),
871
+ Object.freeze({ x: -0.42, y: 1.04, z: 1.12, glow: 0.88 }),
872
+ ]);
93
873
 
94
874
  const HARBOR_TORCHES = Object.freeze([
95
875
  Object.freeze({ x: -5.2, y: 1.25, z: 1.36, glow: 1.1 }),
@@ -128,6 +908,11 @@ function injectStyles() {
128
908
  radial-gradient(circle at 82% 18%, rgba(240, 188, 103, 0.08), transparent 18%),
129
909
  linear-gradient(180deg, #04101d 0%, #0b1930 42%, #081321 100%);
130
910
  }
911
+ .${ROOT_CLASS}.${CAPTURE_CLASS} {
912
+ min-height: 100vh;
913
+ overflow: hidden;
914
+ background: #030710;
915
+ }
131
916
  .${ROOT_CLASS},
132
917
  .${ROOT_CLASS} * {
133
918
  box-sizing: border-box;
@@ -207,6 +992,40 @@ function injectStyles() {
207
992
  border: 1px solid rgba(159, 185, 223, 0.12);
208
993
  background: linear-gradient(180deg, #071220 0%, #132440 42%, #10344b 42%, #05111d 100%);
209
994
  }
995
+ .${CAPTURE_CLASS} .plasius-demo {
996
+ width: 100vw;
997
+ height: 100vh;
998
+ padding: 0;
999
+ display: block;
1000
+ }
1001
+ .${CAPTURE_CLASS} .plasius-demo__hero,
1002
+ .${CAPTURE_CLASS} .plasius-demo__toolbar,
1003
+ .${CAPTURE_CLASS} .plasius-demo__legend,
1004
+ .${CAPTURE_CLASS} .plasius-demo__sidebar,
1005
+ .${CAPTURE_CLASS} .plasius-demo__footer {
1006
+ display: none;
1007
+ }
1008
+ .${CAPTURE_CLASS} .plasius-demo__layout {
1009
+ display: block;
1010
+ height: 100%;
1011
+ }
1012
+ .${CAPTURE_CLASS} .plasius-demo__canvas-panel {
1013
+ height: 100%;
1014
+ padding: 0;
1015
+ border: 0;
1016
+ border-radius: 0;
1017
+ background: transparent;
1018
+ box-shadow: none;
1019
+ backdrop-filter: none;
1020
+ }
1021
+ .${CAPTURE_CLASS} .plasius-demo__canvas {
1022
+ width: 100%;
1023
+ height: 100%;
1024
+ aspect-ratio: auto;
1025
+ border: 0;
1026
+ border-radius: 0;
1027
+ background: #030710;
1028
+ }
210
1029
  .plasius-demo__toolbar {
211
1030
  position: absolute;
212
1031
  top: 26px;
@@ -381,6 +1200,15 @@ function transformPoint(point, transform) {
381
1200
  return addVec3(rotated, transform.position);
382
1201
  }
383
1202
 
1203
+ function transformDirection(direction, transform) {
1204
+ const scale =
1205
+ typeof transform.scale === "number"
1206
+ ? { x: transform.scale, y: transform.scale, z: transform.scale }
1207
+ : transform.scale;
1208
+ const scaled = vec3(direction.x * scale.x, direction.y * scale.y, direction.z * scale.z);
1209
+ return normalizeVec3(rotateY(scaled, transform.rotationY));
1210
+ }
1211
+
384
1212
  function projectPoint(point, camera, viewport) {
385
1213
  const relative = subVec3(point, camera.eye);
386
1214
  const viewX = dotVec3(relative, camera.right);
@@ -406,6 +1234,92 @@ function colorToRgba(color, alpha = 1) {
406
1234
  return `rgba(${r}, ${g}, ${b}, ${clamp(alpha, 0, 1)})`;
407
1235
  }
408
1236
 
1237
+ function mixColor(a, b, t) {
1238
+ return {
1239
+ r: mix(a.r, b.r, t),
1240
+ g: mix(a.g, b.g, t),
1241
+ b: mix(a.b, b.b, t),
1242
+ a: mix(a.a ?? 1, b.a ?? 1, t),
1243
+ };
1244
+ }
1245
+
1246
+ function multiplyColor(a, b) {
1247
+ return {
1248
+ r: a.r * b.r,
1249
+ g: a.g * b.g,
1250
+ b: a.b * b.b,
1251
+ a: (a.a ?? 1) * (b.a ?? 1),
1252
+ };
1253
+ }
1254
+
1255
+ function createLegacyMeshPrimitive(mesh) {
1256
+ return Object.freeze({
1257
+ name: mesh.name ?? "legacy-mesh",
1258
+ positions: mesh.positions,
1259
+ indices: mesh.indices,
1260
+ normals: null,
1261
+ colors: null,
1262
+ material: Object.freeze({
1263
+ name: "legacy-material",
1264
+ color: mesh.color ?? { r: 0.56, g: 0.33, b: 0.22, a: 1 },
1265
+ roughness: 0.88,
1266
+ metallic: 0.08,
1267
+ emissive: Object.freeze({ r: 0, g: 0, b: 0 }),
1268
+ }),
1269
+ });
1270
+ }
1271
+
1272
+ function isFeatureEnabled(featureFlags, featureName, fallback = true) {
1273
+ const directValue =
1274
+ typeof featureFlags?.[featureName] === "boolean"
1275
+ ? featureFlags[featureName]
1276
+ : featureFlags?.flags?.[featureName];
1277
+ if (typeof directValue === "boolean") {
1278
+ return directValue;
1279
+ }
1280
+
1281
+ const enabledValue =
1282
+ typeof featureFlags?.enabled?.[featureName] === "boolean"
1283
+ ? featureFlags.enabled[featureName]
1284
+ : undefined;
1285
+ if (typeof enabledValue === "boolean") {
1286
+ return enabledValue;
1287
+ }
1288
+
1289
+ return fallback;
1290
+ }
1291
+
1292
+ function getMeshPrimitives(mesh) {
1293
+ return Array.isArray(mesh?.primitives) && mesh.primitives.length > 0
1294
+ ? mesh.primitives
1295
+ : [createLegacyMeshPrimitive(mesh)];
1296
+ }
1297
+
1298
+ function tintPrimitiveColor(material, colorOverride) {
1299
+ if (!colorOverride) {
1300
+ return material.color;
1301
+ }
1302
+
1303
+ const name = String(material.name ?? "").toLowerCase();
1304
+ if (name.includes("sail") || name.includes("glass") || name.includes("roof")) {
1305
+ return material.color;
1306
+ }
1307
+
1308
+ const tintAmount = name.includes("hull")
1309
+ ? 0.54
1310
+ : name.includes("trim")
1311
+ ? 0.22
1312
+ : name.includes("deck")
1313
+ ? 0.12
1314
+ : 0;
1315
+
1316
+ if (tintAmount <= 0) {
1317
+ return material.color;
1318
+ }
1319
+
1320
+ return mixColor(material.color, multiplyColor(material.color, colorOverride), tintAmount);
1321
+ }
1322
+
409
1323
  function projectShadowPoint(point, lightDir, planeY) {
410
1324
  const shadowDir = scaleVec3(lightDir, -1);
411
1325
  if (Math.abs(shadowDir.y) < 0.0001) {
@@ -430,6 +1344,64 @@ function shadeColor(base, normal, lightDir, heightBias = 0, accent = 0) {
430
1344
  };
431
1345
  }
432
1346
 
1347
+ function getMaterialSeed(materialName) {
1348
+ let seed = 0;
1349
+ for (let index = 0; index < materialName.length; index += 1) {
1350
+ seed += materialName.charCodeAt(index) * (index + 1);
1351
+ }
1352
+ return seed;
1353
+ }
1354
+
1355
+ function getMaterialDetailStrength(material, surfaceType) {
1356
+ const name = String(material?.name ?? "").toLowerCase();
1357
+ if (surfaceType === "water" || name.includes("glass")) {
1358
+ return 0.018;
1359
+ }
1360
+ if (name.includes("wood") || name.includes("timber") || name.includes("plank")) {
1361
+ return 0.13;
1362
+ }
1363
+ if (name.includes("stone") || name.includes("concrete") || name.includes("plaster")) {
1364
+ return 0.1;
1365
+ }
1366
+ if (name.includes("roof") || name.includes("crate")) {
1367
+ return 0.09;
1368
+ }
1369
+ if (name.includes("paint")) {
1370
+ return 0.045;
1371
+ }
1372
+ if (name.includes("metal")) {
1373
+ return 0.035;
1374
+ }
1375
+ return 0.04;
1376
+ }
1377
+
1378
+ function applyMaterialDetail(color, material, worldCenter, normal, surfaceType) {
1379
+ const materialName = String(material?.name ?? surfaceType ?? "material");
1380
+ const detailStrength = getMaterialDetailStrength(material, surfaceType);
1381
+ const sample =
1382
+ worldCenter.x * 3.17 +
1383
+ worldCenter.y * 5.29 +
1384
+ worldCenter.z * 7.83 +
1385
+ getMaterialSeed(materialName) * 0.013;
1386
+ const grain = (pseudoRandom(sample) - 0.5) * detailStrength;
1387
+ const lowerSurface = smoothstep(7.5, -0.8, worldCenter.y);
1388
+ const verticalSurface = 1 - clamp(Math.abs(normal.y), 0, 1);
1389
+ const materialLowerWear =
1390
+ /stone|concrete|plaster|paint|wood|timber|plank|crate/.test(materialName.toLowerCase())
1391
+ ? lowerSurface * verticalSurface * 0.055
1392
+ : 0;
1393
+ const wetlineWear =
1394
+ surfaceType === "ship" && worldCenter.y < 0.72
1395
+ ? smoothstep(0.72, -0.1, worldCenter.y) * 0.05
1396
+ : 0;
1397
+
1398
+ return {
1399
+ r: clamp(color.r * (1 + grain) - materialLowerWear - wetlineWear, 0, 1),
1400
+ g: clamp(color.g * (1 + grain * 0.82) - materialLowerWear * 0.9 - wetlineWear, 0, 1),
1401
+ b: clamp(color.b * (1 + grain * 0.62) - materialLowerWear * 0.68 - wetlineWear * 0.75, 0, 1),
1402
+ };
1403
+ }
1404
+
433
1405
  function buildCamera(state, canvas) {
434
1406
  const preset = CAMERA_PRESETS[state.focus] ?? CAMERA_PRESETS.integrated;
435
1407
  const yaw = state.camera.yaw ?? preset.yaw;
@@ -455,50 +1427,170 @@ function buildCamera(state, canvas) {
455
1427
  };
456
1428
  }
457
1429
 
458
- function buildTrianglesFromMesh(mesh, transform, baseColor, camera, viewport, triangles, accent = 0) {
459
- for (let index = 0; index < mesh.indices.length; index += 3) {
460
- const aIndex = mesh.indices[index] * 3;
461
- const bIndex = mesh.indices[index + 1] * 3;
462
- const cIndex = mesh.indices[index + 2] * 3;
1430
+ function buildTrianglesFromMesh(
1431
+ mesh,
1432
+ transform,
1433
+ colorOverride,
1434
+ camera,
1435
+ viewport,
1436
+ triangles,
1437
+ options = {}
1438
+ ) {
1439
+ const primitives = getMeshPrimitives(mesh);
1440
+ for (const primitive of primitives) {
1441
+ const resolvedColor = tintPrimitiveColor(primitive.material, colorOverride);
1442
+ for (let index = 0; index < primitive.indices.length; index += 3) {
1443
+ const aIndex = primitive.indices[index] * 3;
1444
+ const bIndex = primitive.indices[index + 1] * 3;
1445
+ const cIndex = primitive.indices[index + 2] * 3;
1446
+
1447
+ const a = transformPoint(
1448
+ vec3(
1449
+ primitive.positions[aIndex],
1450
+ primitive.positions[aIndex + 1],
1451
+ primitive.positions[aIndex + 2]
1452
+ ),
1453
+ transform
1454
+ );
1455
+ const b = transformPoint(
1456
+ vec3(
1457
+ primitive.positions[bIndex],
1458
+ primitive.positions[bIndex + 1],
1459
+ primitive.positions[bIndex + 2]
1460
+ ),
1461
+ transform
1462
+ );
1463
+ const c = transformPoint(
1464
+ vec3(
1465
+ primitive.positions[cIndex],
1466
+ primitive.positions[cIndex + 1],
1467
+ primitive.positions[cIndex + 2]
1468
+ ),
1469
+ transform
1470
+ );
463
1471
 
464
- const a = transformPoint(
465
- vec3(mesh.positions[aIndex], mesh.positions[aIndex + 1], mesh.positions[aIndex + 2]),
466
- transform
467
- );
468
- const b = transformPoint(
469
- vec3(mesh.positions[bIndex], mesh.positions[bIndex + 1], mesh.positions[bIndex + 2]),
470
- transform
471
- );
472
- const c = transformPoint(
473
- vec3(mesh.positions[cIndex], mesh.positions[cIndex + 1], mesh.positions[cIndex + 2]),
474
- transform
475
- );
1472
+ const ab = subVec3(b, a);
1473
+ const ac = subVec3(c, a);
1474
+ const faceNormal = normalizeVec3(crossVec3(ab, ac));
1475
+ let normal = faceNormal;
1476
+ if (Array.isArray(primitive.normals)) {
1477
+ const aNormal = transformDirection(
1478
+ vec3(
1479
+ primitive.normals[aIndex],
1480
+ primitive.normals[aIndex + 1],
1481
+ primitive.normals[aIndex + 2]
1482
+ ),
1483
+ transform
1484
+ );
1485
+ const bNormal = transformDirection(
1486
+ vec3(
1487
+ primitive.normals[bIndex],
1488
+ primitive.normals[bIndex + 1],
1489
+ primitive.normals[bIndex + 2]
1490
+ ),
1491
+ transform
1492
+ );
1493
+ const cNormal = transformDirection(
1494
+ vec3(
1495
+ primitive.normals[cIndex],
1496
+ primitive.normals[cIndex + 1],
1497
+ primitive.normals[cIndex + 2]
1498
+ ),
1499
+ transform
1500
+ );
1501
+ normal = normalizeVec3(
1502
+ scaleVec3(addVec3(addVec3(aNormal, bNormal), cNormal), 1 / 3)
1503
+ );
1504
+ }
476
1505
 
477
- const ab = subVec3(b, a);
478
- const ac = subVec3(c, a);
479
- const normal = normalizeVec3(crossVec3(ab, ac));
480
- const viewDir = normalizeVec3(subVec3(camera.eye, a));
481
- if (dotVec3(normal, viewDir) <= 0) {
482
- continue;
483
- }
1506
+ const viewDir = normalizeVec3(subVec3(camera.eye, a));
1507
+ if (dotVec3(faceNormal, viewDir) <= 0) {
1508
+ continue;
1509
+ }
484
1510
 
485
- const projected = [projectPoint(a, camera, viewport), projectPoint(b, camera, viewport), projectPoint(c, camera, viewport)];
486
- if (projected.some((value) => value === null)) {
487
- continue;
488
- }
1511
+ const projected = [
1512
+ projectPoint(a, camera, viewport),
1513
+ projectPoint(b, camera, viewport),
1514
+ projectPoint(c, camera, viewport),
1515
+ ];
1516
+ if (projected.some((value) => value === null)) {
1517
+ continue;
1518
+ }
489
1519
 
490
- triangles.push({
491
- points: projected,
492
- depth: (projected[0].depth + projected[1].depth + projected[2].depth) / 3,
493
- worldCenter: scaleVec3(addVec3(addVec3(a, b), c), 1 / 3),
494
- normal,
495
- baseColor,
496
- accent,
497
- });
1520
+ triangles.push({
1521
+ points: projected,
1522
+ depth: (projected[0].depth + projected[1].depth + projected[2].depth) / 3,
1523
+ worldCenter: scaleVec3(addVec3(addVec3(a, b), c), 1 / 3),
1524
+ normal,
1525
+ baseColor: resolvedColor,
1526
+ accent: options.accent ?? 0,
1527
+ material: primitive.material,
1528
+ reflection: options.reflection ?? 0,
1529
+ surfaceType: options.surfaceType ?? "solid",
1530
+ });
1531
+ }
498
1532
  }
499
1533
  }
500
1534
 
501
- function createPerformanceGovernor() {
1535
+ async function loadShowcaseAssetCatalog() {
1536
+ const [brigantine, cutter, lighthouse, harborDock] = await Promise.all([
1537
+ loadGltfModel(resolveShowcaseAssetUrl("brigantine")),
1538
+ loadGltfModel(resolveShowcaseAssetUrl("cutter")),
1539
+ loadGltfModel(resolveShowcaseAssetUrl("lighthouse")),
1540
+ loadGltfModel(resolveShowcaseAssetUrl("harbor-dock")),
1541
+ ]);
1542
+
1543
+ return Object.freeze({
1544
+ primaryShipKey: "brigantine",
1545
+ ships: Object.freeze({
1546
+ brigantine,
1547
+ cutter,
1548
+ }),
1549
+ environment: Object.freeze({
1550
+ lighthouse,
1551
+ "harbor-dock": harborDock,
1552
+ }),
1553
+ });
1554
+ }
1555
+
1556
+ function createLegacyShowcaseAssetCatalog() {
1557
+ const brigantine = loadGltfModel(resolveShowcaseAssetUrl("brigantine"));
1558
+ return Promise.resolve(brigantine).then((primary) =>
1559
+ Object.freeze({
1560
+ primaryShipKey: "brigantine",
1561
+ ships: Object.freeze({
1562
+ brigantine: primary,
1563
+ }),
1564
+ environment: Object.freeze({}),
1565
+ })
1566
+ );
1567
+ }
1568
+
1569
+ function resolveShipModel(state, ship, fallbackModel = null) {
1570
+ return (
1571
+ state.assetCatalog?.ships?.[ship.modelKey ?? state.assetCatalog?.primaryShipKey ?? "brigantine"] ??
1572
+ fallbackModel ??
1573
+ state.shipModel
1574
+ );
1575
+ }
1576
+
1577
+ function createPerformanceGovernor(performanceFeatures) {
1578
+ const createQualityLadderAdapter = assertRequiredFunction(
1579
+ performanceFeatures,
1580
+ "performance",
1581
+ "createQualityAdapter"
1582
+ );
1583
+ const createDeviceProfile = assertRequiredFunction(
1584
+ performanceFeatures,
1585
+ "performance",
1586
+ "createDeviceProfile"
1587
+ );
1588
+ const createGpuPerformanceGovernor = assertRequiredFunction(
1589
+ performanceFeatures,
1590
+ "performance",
1591
+ "createGovernor"
1592
+ );
1593
+
502
1594
  const fluidDetail = createQualityLadderAdapter({
503
1595
  id: "fluid-detail",
504
1596
  domain: "geometry",
@@ -554,6 +1646,7 @@ function createPerformanceGovernor() {
554
1646
  }
555
1647
 
556
1648
  function buildDemoDom(root, options) {
1649
+ const t = options.translate;
557
1650
  root.innerHTML = `
558
1651
  <main class="plasius-demo">
559
1652
  <section class="plasius-demo__hero">
@@ -563,56 +1656,55 @@ function buildDemoDom(root, options) {
563
1656
  <p class="plasius-demo__lead">${options.subtitle}</p>
564
1657
  </section>
565
1658
  <section class="plasius-panel plasius-demo__status">
566
- <p id="demoStatus" class="plasius-demo__status-badge">Booting 3D scene…</p>
1659
+ <p id="demoStatus" class="plasius-demo__status-badge">${t(gpuSharedTranslationKeys.statusBooting)}</p>
567
1660
  <p id="demoDetails" class="plasius-demo__status-text">
568
- Preparing a moonlit harbor scene, GLTF hull data, cloth and fluid continuity plans, and adaptive quality metadata.
1661
+ ${t(gpuSharedTranslationKeys.detailsBooting)}
569
1662
  </p>
570
1663
  </section>
571
1664
  </section>
572
1665
  <section class="plasius-demo__layout">
573
1666
  <section class="plasius-panel plasius-demo__canvas-panel">
574
- <canvas id="demoCanvas" class="plasius-demo__canvas" width="1280" height="720"></canvas>
1667
+ <canvas id="demoCanvas" class="plasius-demo__canvas" width="${DEFAULT_CANVAS_WIDTH}" height="${DEFAULT_CANVAS_HEIGHT}"></canvas>
575
1668
  <div class="plasius-demo__toolbar">
576
- <button id="pauseButton" type="button">Pause</button>
1669
+ <button id="pauseButton" type="button">${t(gpuSharedTranslationKeys.pause)}</button>
577
1670
  <label class="plasius-toggle">
578
1671
  <input id="stressToggle" type="checkbox" />
579
- Stress mode
1672
+ ${t(gpuSharedTranslationKeys.stressMode)}
580
1673
  </label>
581
1674
  <label class="plasius-toggle">
582
- Focus
1675
+ ${t(gpuSharedTranslationKeys.focus)}
583
1676
  <select id="focusMode">
584
- <option value="integrated">integrated</option>
585
- <option value="lighting">lighting</option>
586
- <option value="cloth">cloth</option>
587
- <option value="fluid">fluid</option>
588
- <option value="physics">physics</option>
589
- <option value="performance">performance</option>
590
- <option value="debug">debug</option>
1677
+ ${showcaseFocusModes
1678
+ .map(
1679
+ (mode) =>
1680
+ `<option value="${mode}">${t(FOCUS_MODE_TRANSLATION_KEYS[mode])}</option>`
1681
+ )
1682
+ .join("")}
591
1683
  </select>
592
1684
  </label>
593
1685
  </div>
594
1686
  <div class="plasius-demo__legend">
595
- <strong>Scene</strong>
596
- GLTF ships carry hull mass and damping metadata.<br />
597
- Lanterns and torches warm the moonlit harbor.<br />
598
- Mass-aware collisions stay authoritative near the camera.
1687
+ <strong>${t(gpuSharedTranslationKeys.legendTitle)}</strong>
1688
+ ${t(gpuSharedTranslationKeys.legendShipMetadata)}<br />
1689
+ ${t(gpuSharedTranslationKeys.legendLighting)}<br />
1690
+ ${t(gpuSharedTranslationKeys.legendCollisions)}
599
1691
  </div>
600
1692
  </section>
601
1693
  <aside class="plasius-demo__sidebar">
602
1694
  <section class="plasius-panel plasius-demo__card">
603
- <h2>Scene State</h2>
1695
+ <h2>${t(gpuSharedTranslationKeys.sceneState)}</h2>
604
1696
  <ul id="sceneMetrics" class="plasius-demo__metrics"></ul>
605
1697
  </section>
606
1698
  <section class="plasius-panel plasius-demo__card">
607
- <h2>Quality + Budgets</h2>
1699
+ <h2>${t(gpuSharedTranslationKeys.qualityBudgets)}</h2>
608
1700
  <ul id="qualityMetrics" class="plasius-demo__metrics"></ul>
609
1701
  </section>
610
1702
  <section class="plasius-panel plasius-demo__card">
611
- <h2>Debug Telemetry</h2>
1703
+ <h2>${t(gpuSharedTranslationKeys.debugTelemetry)}</h2>
612
1704
  <ul id="debugMetrics" class="plasius-demo__metrics"></ul>
613
1705
  </section>
614
1706
  <section class="plasius-panel plasius-demo__card">
615
- <h2>Notes</h2>
1707
+ <h2>${t(gpuSharedTranslationKeys.notes)}</h2>
616
1708
  <ul id="sceneNotes" class="plasius-demo__metrics"></ul>
617
1709
  </section>
618
1710
  </aside>
@@ -638,6 +1730,12 @@ function buildDemoDom(root, options) {
638
1730
  }
639
1731
 
640
1732
  function buildSceneSnapshot(state, shipModel) {
1733
+ const shipPhysics = Object.freeze(
1734
+ Object.fromEntries(
1735
+ state.ships.map((ship) => [ship.id, resolveShipModel(state, ship, shipModel)?.physics ?? null])
1736
+ )
1737
+ );
1738
+
641
1739
  return Object.freeze({
642
1740
  focus: state.focus,
643
1741
  frame: state.frame,
@@ -659,6 +1757,7 @@ function buildSceneSnapshot(state, shipModel) {
659
1757
  state.ships.map((ship) =>
660
1758
  Object.freeze({
661
1759
  id: ship.id,
1760
+ modelKey: ship.modelKey ?? "brigantine",
662
1761
  position: Object.freeze({ ...ship.position }),
663
1762
  velocity: Object.freeze({ ...ship.velocity }),
664
1763
  rotationY: ship.rotationY,
@@ -679,12 +1778,13 @@ function buildSceneSnapshot(state, shipModel) {
679
1778
  )
680
1779
  ),
681
1780
  shipPhysics: shipModel?.physics ?? null,
1781
+ shipModels: shipPhysics,
682
1782
  physics: Object.freeze({
683
1783
  profile: state.physics.profile,
684
1784
  plan: state.physics.plan,
685
1785
  manifest: state.physics.manifest,
686
1786
  snapshot: state.physics.snapshot,
687
- shipPhysics: shipModel?.physics ?? null,
1787
+ shipPhysics,
688
1788
  }),
689
1789
  });
690
1790
  }
@@ -727,11 +1827,88 @@ function readVisualNumber(value, fallback) {
727
1827
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
728
1828
  }
729
1829
 
730
- function resolveClothPresentation(state, meshDetail) {
731
- const clothPlan = createClothRepresentationPlan({
1830
+ function readPositiveNumber(value, fallback) {
1831
+ return typeof value === "number" && Number.isFinite(value) && value > 0
1832
+ ? value
1833
+ : fallback;
1834
+ }
1835
+
1836
+ function isTruthyCaptureValue(value) {
1837
+ return value === "1" || value === "true" || value === "scene" || value === "video";
1838
+ }
1839
+
1840
+ function resolveCaptureSettings(options) {
1841
+ const explicitCaptureMode =
1842
+ typeof options.captureMode === "boolean" ? options.captureMode : undefined;
1843
+ let captureMode = explicitCaptureMode ?? false;
1844
+ let renderScale = readPositiveNumber(options.renderScale, undefined);
1845
+
1846
+ try {
1847
+ const params = new URLSearchParams(window.location.search);
1848
+ if (explicitCaptureMode === undefined) {
1849
+ captureMode =
1850
+ isTruthyCaptureValue(params.get("capture")) ||
1851
+ params.get("presentation") === "capture";
1852
+ }
1853
+ renderScale = readPositiveNumber(Number(params.get("renderScale")), renderScale);
1854
+ } catch {
1855
+ // Query-string capture controls are optional and only available in browsers.
1856
+ }
1857
+
1858
+ return {
1859
+ captureMode,
1860
+ renderScale,
1861
+ };
1862
+ }
1863
+
1864
+ function getCanvasDisplaySize(canvas) {
1865
+ const rect =
1866
+ typeof canvas.getBoundingClientRect === "function"
1867
+ ? canvas.getBoundingClientRect()
1868
+ : null;
1869
+ const width = Math.round(
1870
+ readPositiveNumber(rect?.width, readPositiveNumber(canvas.clientWidth, canvas.width))
1871
+ );
1872
+ const height = Math.round(
1873
+ readPositiveNumber(rect?.height, readPositiveNumber(canvas.clientHeight, canvas.height))
1874
+ );
1875
+
1876
+ return {
1877
+ width: Math.max(1, width || DEFAULT_CANVAS_WIDTH),
1878
+ height: Math.max(1, height || DEFAULT_CANVAS_HEIGHT),
1879
+ };
1880
+ }
1881
+
1882
+ function resizeCanvasToDisplaySize(canvas, state) {
1883
+ const { width, height } = getCanvasDisplaySize(canvas);
1884
+ const deviceScale = readPositiveNumber(globalThis.devicePixelRatio, 1);
1885
+ const requestedScale = readPositiveNumber(state.renderScale, deviceScale);
1886
+ const maxScale = state.captureMode ? 2 : 1.5;
1887
+ let scale = clamp(requestedScale, 1, maxScale);
1888
+ const pixelBudget = state.captureMode
1889
+ ? CAPTURE_CANVAS_PIXEL_BUDGET
1890
+ : DEFAULT_CANVAS_WIDTH * DEFAULT_CANVAS_HEIGHT * 1.5;
1891
+ const projectedPixels = width * height * scale * scale;
1892
+
1893
+ if (projectedPixels > pixelBudget) {
1894
+ scale = Math.sqrt(pixelBudget / Math.max(1, width * height));
1895
+ }
1896
+
1897
+ const targetWidth = Math.max(1, Math.round(width * scale));
1898
+ const targetHeight = Math.max(1, Math.round(height * scale));
1899
+ if (canvas.width !== targetWidth || canvas.height !== targetHeight) {
1900
+ canvas.width = targetWidth;
1901
+ canvas.height = targetHeight;
1902
+ }
1903
+
1904
+ state.renderScale = scale;
1905
+ }
1906
+
1907
+ function resolveClothPresentation(state, meshDetail, clothFeatures) {
1908
+ const clothPlan = clothFeatures.createPlan({
732
1909
  garmentId: "shore-flag",
733
- kind: state.focus === "cloth" ? "flag" : clothGarmentKinds[0],
734
- profile: state.focus === "cloth" ? "cinematic" : clothProfileNames[0],
1910
+ kind: state.focus === "cloth" ? "flag" : clothFeatures.garmentKinds[0],
1911
+ profile: state.focus === "cloth" ? "cinematic" : clothFeatures.profileNames[0],
735
1912
  supportsRayTracing: true,
736
1913
  nearFieldMaxMeters: 18,
737
1914
  midFieldMaxMeters: 55,
@@ -749,7 +1926,7 @@ function resolveClothPresentation(state, meshDetail) {
749
1926
  )
750
1927
  );
751
1928
  const cameraDistance = lengthVec3(subVec3(state.camera.target, fallbackEye));
752
- const band = selectClothRepresentationBand(cameraDistance, clothPlan.thresholds);
1929
+ const band = clothFeatures.selectBand(cameraDistance, clothPlan.thresholds);
753
1930
  const representation =
754
1931
  clothPlan.representations.find((entry) => entry.band === band) ?? clothPlan.representations[0];
755
1932
  return {
@@ -1111,8 +2288,9 @@ function resolveVisualConfig(nearLighting, lightingSnapshot, customVisuals = {})
1111
2288
  };
1112
2289
  }
1113
2290
 
1114
- function buildClothSurface(model, state, meshDetail, visuals) {
1115
- const clothPresentation = resolveClothPresentation(state, meshDetail);
2291
+ function buildClothSurface(model, state, meshDetail, visuals, clothFeatures) {
2292
+ const resolvedClothFeatures = normalizeClothFeatureAdapters(clothFeatures);
2293
+ const clothPresentation = resolveClothPresentation(state, meshDetail, resolvedClothFeatures);
1116
2294
  const clothState = ensureShowcaseClothState(state, meshDetail, clothPresentation);
1117
2295
 
1118
2296
  return {
@@ -1281,11 +2459,12 @@ function buildWaterMotionEffects(state) {
1281
2459
  });
1282
2460
  }
1283
2461
 
1284
- function buildWaterBands(state, fluidDetail, visuals) {
1285
- const fluidPlan = createFluidRepresentationPlan({
2462
+ function buildWaterBands(state, fluidDetail, visuals, fluidFeatures) {
2463
+ const resolvedFluidFeatures = normalizeFluidFeatureAdapters(fluidFeatures);
2464
+ const fluidPlan = resolvedFluidFeatures.createPlan({
1286
2465
  fluidBodyId: "harbor",
1287
- kind: state.focus === "fluid" ? "ocean" : fluidBodyKinds[0],
1288
- profile: state.focus === "fluid" ? "cinematic" : fluidProfileNames[0],
2466
+ kind: state.focus === "fluid" ? "ocean" : resolvedFluidFeatures.bodyKinds[0],
2467
+ profile: state.focus === "fluid" ? "cinematic" : resolvedFluidFeatures.profileNames[0],
1289
2468
  supportsRayTracing: true,
1290
2469
  nearFieldMaxMeters: 40,
1291
2470
  midFieldMaxMeters: 150,
@@ -1304,7 +2483,7 @@ function buildWaterBands(state, fluidDetail, visuals) {
1304
2483
  const representation =
1305
2484
  fluidPlan.representations.find((entry) => entry.band === bandSpec.band) ??
1306
2485
  fluidPlan.representations[0];
1307
- const continuity = createFluidContinuityEnvelope({ fluidBodyId: "harbor" });
2486
+ const continuity = resolvedFluidFeatures.createContinuityEnvelope({ fluidBodyId: "harbor" });
1308
2487
  const bandContinuity = resolveFluidBandContinuity(continuity, bandSpec.band);
1309
2488
  const bandResolution =
1310
2489
  bandSpec.band === "near"
@@ -1374,15 +2553,34 @@ function buildWaterBands(state, fluidDetail, visuals) {
1374
2553
  return { fluidPlan, bandMeshes };
1375
2554
  }
1376
2555
 
1377
- function createSceneState(options) {
1378
- const { governor, fluidDetail, clothDetail, lightingDetail } = createPerformanceGovernor();
1379
- const physicsProfile = defaultPhysicsWorkerProfile;
2556
+ function createSceneState(options, featureAdapters) {
2557
+ const translate = options.translate;
2558
+ const { governor, fluidDetail, clothDetail, lightingDetail } = createPerformanceGovernor(
2559
+ featureAdapters.performance
2560
+ );
2561
+ const physicsProfile = featureAdapters.physics.defaultProfile;
2562
+ const createPhysicsSimulationPlan = assertRequiredFunction(
2563
+ featureAdapters.physics,
2564
+ "physics",
2565
+ "createSimulationPlan"
2566
+ );
2567
+ const getPhysicsWorkerManifest = assertRequiredFunction(
2568
+ featureAdapters.physics,
2569
+ "physics",
2570
+ "getManifest"
2571
+ );
2572
+ const createGpuDebugSession = assertRequiredFunction(
2573
+ featureAdapters.debug,
2574
+ "debug",
2575
+ "createSession"
2576
+ );
2577
+
1380
2578
  const physicsPlan = createPhysicsSimulationPlan(physicsProfile);
1381
2579
  const physicsManifest = getPhysicsWorkerManifest(physicsProfile);
1382
2580
  const debugSession = createGpuDebugSession({
1383
2581
  enabled: true,
1384
2582
  adapter: {
1385
- label: "3D showcase",
2583
+ label: translate(gpuSharedTranslationKeys.debugAdapterShowcase),
1386
2584
  memoryCapacityHintBytes: 6 * 1024 * 1024 * 1024,
1387
2585
  coreCountHint: 12,
1388
2586
  },
@@ -1392,23 +2590,27 @@ function createSceneState(options) {
1392
2590
  owner: "renderer",
1393
2591
  category: "texture",
1394
2592
  sizeBytes: 1280 * 720 * 4,
1395
- label: "Main color buffer",
2593
+ label: translate(gpuSharedTranslationKeys.debugMainColorBuffer),
1396
2594
  });
1397
2595
  debugSession.trackAllocation({
1398
2596
  id: "showcase.shadow-impression",
1399
2597
  owner: "lighting",
1400
2598
  category: "texture",
1401
2599
  sizeBytes: 12 * 1024 * 1024,
1402
- label: "Shadow impression atlas",
2600
+ label: translate(gpuSharedTranslationKeys.debugShadowImpressionAtlas),
1403
2601
  });
1404
2602
 
1405
2603
  return {
2604
+ translate,
1406
2605
  focus: options.focus,
1407
2606
  governor,
1408
2607
  fluidDetail,
1409
2608
  clothDetail,
1410
2609
  lightingDetail,
1411
2610
  debugSession,
2611
+ showcaseRealisticModelsEnabled: options.realisticModelsEnabled !== false,
2612
+ captureMode: options.captureMode === true,
2613
+ renderScale: readPositiveNumber(options.renderScale, undefined),
1412
2614
  packageState: undefined,
1413
2615
  demoDescription: null,
1414
2616
  demoVisuals: null,
@@ -1423,6 +2625,7 @@ function createSceneState(options) {
1423
2625
  ships: [
1424
2626
  {
1425
2627
  id: "northwind",
2628
+ modelKey: "brigantine",
1426
2629
  position: vec3(-5.2, 0, 7.2),
1427
2630
  velocity: vec3(2.35, 0, -1.08),
1428
2631
  rotationY: 0.58,
@@ -1433,17 +2636,18 @@ function createSceneState(options) {
1433
2636
  throttleResponse: 0.46,
1434
2637
  rudderResponse: 0.54,
1435
2638
  wanderPhase: 0.35,
1436
- lanterns: SHIP_LANTERNS,
2639
+ lanterns: CUTTER_LANTERNS,
1437
2640
  lanternStrength: 1.06,
1438
2641
  collisionRadiusScale: 1.04,
1439
2642
  },
1440
2643
  {
1441
2644
  id: "tidecaller",
2645
+ modelKey: "cutter",
1442
2646
  position: vec3(4.8, 0, 4.4),
1443
2647
  velocity: vec3(-2.15, 0, 1.74),
1444
2648
  rotationY: -2.48,
1445
2649
  angularVelocity: -0.2,
1446
- tint: { r: 0.48, g: 0.28, b: 0.19 },
2650
+ tint: { r: 0.58, g: 0.24, b: 0.16 },
1447
2651
  massScale: 0.84,
1448
2652
  cruiseSpeed: 2.68,
1449
2653
  throttleResponse: 0.7,
@@ -1467,6 +2671,7 @@ function createSceneState(options) {
1467
2671
  manifest: physicsManifest,
1468
2672
  snapshot: null,
1469
2673
  },
2674
+ assetCatalog: null,
1470
2675
  shipModel: null,
1471
2676
  };
1472
2677
  }
@@ -1553,10 +2758,51 @@ function drawSkyAndShore(ctx, canvas, state, nearLighting, reflectionStrength, s
1553
2758
  }
1554
2759
  }
1555
2760
 
1556
- function drawTriangles(ctx, triangles, lightDir, reflectionStrength, camera, shadowStrength) {
2761
+ function resolveLocalLightContribution(triangle, lightSources) {
2762
+ const contribution = { r: 0, g: 0, b: 0 };
2763
+ if (!Array.isArray(lightSources) || triangle.surfaceType === "water") {
2764
+ return contribution;
2765
+ }
2766
+
2767
+ const normal = normalizeVec3(triangle.normal);
2768
+ for (const source of lightSources.slice(0, 8)) {
2769
+ const delta = subVec3(source.point, triangle.worldCenter);
2770
+ const distance = lengthVec3(delta);
2771
+ const attenuation =
2772
+ (source.glowScale ?? 1) / Math.max(1, 0.68 + distance * distance * 0.2);
2773
+ if (attenuation < 0.012) {
2774
+ continue;
2775
+ }
2776
+
2777
+ const lightDir = normalizeVec3(delta);
2778
+ const facing = clamp(dotVec3(normal, lightDir), 0, 1);
2779
+ const response = attenuation * (0.18 + facing * 0.82);
2780
+ const glowColor = source.glowColor ?? source.coreColor ?? { r: 1, g: 0.72, b: 0.4 };
2781
+ contribution.r += glowColor.r * response * 0.32;
2782
+ contribution.g += glowColor.g * response * 0.26;
2783
+ contribution.b += glowColor.b * response * 0.18;
2784
+ }
2785
+
2786
+ return contribution;
2787
+ }
2788
+
2789
+ function drawTriangles(
2790
+ ctx,
2791
+ triangles,
2792
+ lightDir,
2793
+ reflectionStrength,
2794
+ camera,
2795
+ shadowStrength,
2796
+ localLights = []
2797
+ ) {
1557
2798
  triangles.sort((left, right) => right.depth - left.depth);
1558
2799
  for (const triangle of triangles) {
1559
2800
  const surfaceNormal = normalizeVec3(triangle.normal);
2801
+ const material = triangle.material ?? {
2802
+ roughness: 0.88,
2803
+ metallic: 0.08,
2804
+ emissive: { r: 0, g: 0, b: 0 },
2805
+ };
1560
2806
  const shaded = shadeColor(
1561
2807
  triangle.baseColor,
1562
2808
  surfaceNormal,
@@ -1564,19 +2810,42 @@ function drawTriangles(ctx, triangles, lightDir, reflectionStrength, camera, sha
1564
2810
  clamp((triangle.worldCenter.y + 3) / 10, 0, 1),
1565
2811
  triangle.accent
1566
2812
  );
1567
- const reflection = triangle.worldCenter.y < 0.8 ? reflectionStrength : 0;
2813
+ const reflection = reflectionStrength * (triangle.reflection ?? 0);
1568
2814
  const viewDir = normalizeVec3(subVec3(camera.eye, triangle.worldCenter));
1569
2815
  const reflectedLight = reflectVec3(scaleVec3(lightDir, -1), surfaceNormal);
1570
- const gloss = triangle.worldCenter.y < 0.9 ? 1 : triangle.accent > 0.05 ? 0.55 : 0.3;
1571
- const specular = Math.pow(clamp(dotVec3(reflectedLight, viewDir), 0, 1), triangle.worldCenter.y < 0.9 ? 18 : 12) * gloss;
1572
- const occlusion = triangle.worldCenter.y < 0.9 ? shadowStrength * 0.035 : 0;
1573
- const fill = colorToRgba(
2816
+ const gloss = mix(0.78, 0.14, clamp(material.roughness ?? 0.88, 0, 1)) + (material.metallic ?? 0) * 0.18;
2817
+ const specularPower = mix(26, 7, clamp(material.roughness ?? 0.88, 0, 1));
2818
+ const specular =
2819
+ Math.pow(clamp(dotVec3(reflectedLight, viewDir), 0, 1), specularPower) * gloss;
2820
+ const emissive = material.emissive ?? { r: 0, g: 0, b: 0 };
2821
+ const localLight = resolveLocalLightContribution(triangle, localLights);
2822
+ const occlusion = triangle.surfaceType === "water" ? shadowStrength * 0.018 : shadowStrength * 0.04;
2823
+ const detailed = applyMaterialDetail(
1574
2824
  {
1575
- r: clamp(shaded.r + reflection * 0.08 + specular * 0.14 - occlusion, 0, 1),
1576
- g: clamp(shaded.g + reflection * 0.08 + specular * 0.15 - occlusion, 0, 1),
1577
- b: clamp(shaded.b + reflection * 0.16 + specular * 0.2 - occlusion * 0.5, 0, 1),
2825
+ r: clamp(
2826
+ shaded.r + reflection * 0.08 + specular * 0.16 + emissive.r * 0.42 + localLight.r - occlusion,
2827
+ 0,
2828
+ 1
2829
+ ),
2830
+ g: clamp(
2831
+ shaded.g + reflection * 0.08 + specular * 0.16 + emissive.g * 0.42 + localLight.g - occlusion,
2832
+ 0,
2833
+ 1
2834
+ ),
2835
+ b: clamp(
2836
+ shaded.b + reflection * 0.16 + specular * 0.22 + emissive.b * 0.46 + localLight.b - occlusion * 0.5,
2837
+ 0,
2838
+ 1
2839
+ ),
1578
2840
  },
1579
- 0.98
2841
+ material,
2842
+ triangle.worldCenter,
2843
+ surfaceNormal,
2844
+ triangle.surfaceType
2845
+ );
2846
+ const fill = colorToRgba(
2847
+ detailed,
2848
+ triangle.baseColor.a ?? 0.98
1580
2849
  );
1581
2850
  ctx.fillStyle = fill;
1582
2851
  ctx.beginPath();
@@ -1615,78 +2884,111 @@ function renderProjectedShadow(ctx, worldPoints, camera, viewport, lightDir, opt
1615
2884
  ctx.restore();
1616
2885
  }
1617
2886
 
1618
- function pushHarborGeometry(camera, viewport, triangles, visuals) {
1619
- const harborObjects = [
1620
- {
1621
- position: vec3(-8.2, 1.1, -0.9),
1622
- rotationY: -0.16,
1623
- scale: { x: 5.4, y: 2.4, z: 4.2 },
1624
- color: visuals.harborWall,
1625
- accent: 0.06,
1626
- },
1627
- {
1628
- position: vec3(-5.7, 0.45, 1.4),
1629
- rotationY: -0.08,
1630
- scale: { x: 6.8, y: 0.3, z: 2.1 },
1631
- color: visuals.harborDeck,
1632
- accent: 0.04,
1633
- },
1634
- {
1635
- position: vec3(-10.4, 0.28, 0.8),
1636
- rotationY: 0.22,
1637
- scale: { x: 1.2, y: 0.9, z: 1.2 },
1638
- color: visuals.harborTower,
1639
- accent: 0.02,
1640
- },
1641
- ];
2887
+ function pushHarborGeometry(camera, viewport, triangles, state) {
2888
+ if (!state.showcaseRealisticModelsEnabled) {
2889
+ for (const object of LEGACY_HARBOR_LAYOUT) {
2890
+ buildTrianglesFromMesh(
2891
+ { positions: [object], indices: [0], normals: null, colors: null, material: createLegacyMeshPrimitive({})?.material, bounds: null, name: "legacy-structure" },
2892
+ {
2893
+ position: object.position,
2894
+ rotationY: object.rotationY,
2895
+ scale: object.scale,
2896
+ },
2897
+ object.color,
2898
+ camera,
2899
+ viewport,
2900
+ triangles,
2901
+ {
2902
+ accent: object.accent,
2903
+ reflection: 0,
2904
+ surfaceType: "structure",
2905
+ }
2906
+ );
2907
+ }
2908
+
2909
+ return;
2910
+ }
2911
+
2912
+ for (const placement of SHOWCASE_ENVIRONMENT_LAYOUT) {
2913
+ const mesh = state.assetCatalog?.environment?.[placement.assetKey] ?? null;
2914
+ if (!mesh) {
2915
+ continue;
2916
+ }
1642
2917
 
1643
- for (const object of harborObjects) {
1644
2918
  buildTrianglesFromMesh(
1645
- UNIT_BOX_MESH,
2919
+ mesh,
1646
2920
  {
1647
- position: object.position,
1648
- rotationY: object.rotationY,
1649
- scale: object.scale,
2921
+ position: vec3(placement.position.x, placement.position.y, placement.position.z),
2922
+ rotationY: placement.rotationY,
2923
+ scale: placement.scale,
1650
2924
  },
1651
- object.color,
2925
+ null,
1652
2926
  camera,
1653
2927
  viewport,
1654
2928
  triangles,
1655
- object.accent
2929
+ {
2930
+ accent: placement.accent,
2931
+ reflection: 0,
2932
+ surfaceType: "structure",
2933
+ }
1656
2934
  );
1657
2935
  }
1658
2936
  }
1659
2937
 
1660
2938
  function renderShipRigging(ctx, ship, camera, viewport) {
1661
2939
  const transform = { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE };
1662
- const mastBase = transformPoint(vec3(0, 0.38, -0.4), transform);
1663
- const mastTop = transformPoint(vec3(0, 3.8, -0.2), transform);
1664
- const aftBase = transformPoint(vec3(-0.25, 0.32, -1.9), transform);
1665
- const aftTop = transformPoint(vec3(-0.15, 2.7, -1.75), transform);
1666
- const sailA = transformPoint(vec3(0.08, 3.2, -0.2), transform);
1667
- const sailB = transformPoint(vec3(0.12, 1.2, -0.5), transform);
1668
- const sailC = transformPoint(vec3(2.25, 2.25, 0.15), transform);
1669
- const projected = [mastBase, mastTop, aftBase, aftTop, sailA, sailB, sailC].map((point) =>
1670
- projectPoint(point, camera, viewport)
1671
- );
2940
+ const layout =
2941
+ ship.modelKey === "cutter"
2942
+ ? {
2943
+ lineColor: "rgba(85, 89, 97, 0.92)",
2944
+ sailColor: "rgba(218, 232, 244, 0.28)",
2945
+ points: [
2946
+ vec3(0, 0.88, -0.32),
2947
+ vec3(0, 2.4, -0.28),
2948
+ vec3(0.1, 1.92, -0.3),
2949
+ vec3(1.18, 1.72, -0.18),
2950
+ vec3(1.04, 1.08, -0.12),
2951
+ ],
2952
+ mastPairs: [[0, 1], [2, 3]],
2953
+ sailTriangle: [2, 3, 4],
2954
+ }
2955
+ : {
2956
+ lineColor: "rgba(73, 54, 45, 0.94)",
2957
+ sailColor: "rgba(238, 232, 214, 0.88)",
2958
+ points: [
2959
+ vec3(0, 0.38, -0.4),
2960
+ vec3(0, 3.8, -0.2),
2961
+ vec3(-0.25, 0.32, -1.9),
2962
+ vec3(-0.15, 2.7, -1.75),
2963
+ vec3(0.08, 3.2, -0.2),
2964
+ vec3(0.12, 1.2, -0.5),
2965
+ vec3(2.25, 2.25, 0.15),
2966
+ ],
2967
+ mastPairs: [[0, 1], [2, 3]],
2968
+ sailTriangle: [4, 5, 6],
2969
+ };
2970
+ const projected = layout.points
2971
+ .map((point) => transformPoint(point, transform))
2972
+ .map((point) => projectPoint(point, camera, viewport));
1672
2973
  if (projected.some((value) => value === null)) {
1673
2974
  return;
1674
2975
  }
1675
2976
 
1676
- ctx.strokeStyle = "rgba(73, 54, 45, 0.94)";
1677
- ctx.lineWidth = 3.5;
2977
+ ctx.strokeStyle = layout.lineColor;
2978
+ ctx.lineWidth = ship.modelKey === "cutter" ? 2.2 : 3.5;
1678
2979
  ctx.beginPath();
1679
- ctx.moveTo(projected[0].x, projected[0].y);
1680
- ctx.lineTo(projected[1].x, projected[1].y);
1681
- ctx.moveTo(projected[2].x, projected[2].y);
1682
- ctx.lineTo(projected[3].x, projected[3].y);
2980
+ for (const [from, to] of layout.mastPairs) {
2981
+ ctx.moveTo(projected[from].x, projected[from].y);
2982
+ ctx.lineTo(projected[to].x, projected[to].y);
2983
+ }
1683
2984
  ctx.stroke();
1684
2985
 
1685
- ctx.fillStyle = "rgba(238, 232, 214, 0.88)";
2986
+ const [a, b, c] = layout.sailTriangle;
2987
+ ctx.fillStyle = layout.sailColor;
1686
2988
  ctx.beginPath();
1687
- ctx.moveTo(projected[4].x, projected[4].y);
1688
- ctx.lineTo(projected[5].x, projected[5].y);
1689
- ctx.lineTo(projected[6].x, projected[6].y);
2989
+ ctx.moveTo(projected[a].x, projected[a].y);
2990
+ ctx.lineTo(projected[b].x, projected[b].y);
2991
+ ctx.lineTo(projected[c].x, projected[c].y);
1690
2992
  ctx.closePath();
1691
2993
  ctx.fill();
1692
2994
  }
@@ -1941,10 +3243,10 @@ function resolveBoundaryCollision(ship, state, shipModel) {
1941
3243
  }
1942
3244
  }
1943
3245
 
1944
- function resolveShipCollision(state, a, b, shipModel) {
3246
+ function resolveShipCollision(state, a, b, shipModelA, shipModelB) {
1945
3247
  const delta = subVec3(b.position, a.position);
1946
- const radiusA = getShipCollisionRadius(a, shipModel);
1947
- const radiusB = getShipCollisionRadius(b, shipModel);
3248
+ const radiusA = getShipCollisionRadius(a, shipModelA);
3249
+ const radiusB = getShipCollisionRadius(b, shipModelB);
1948
3250
  const distance = Math.hypot(delta.x, delta.z);
1949
3251
  const minDistance = radiusA + radiusB;
1950
3252
  if (distance >= minDistance) {
@@ -1957,8 +3259,8 @@ function resolveShipCollision(state, a, b, shipModel) {
1957
3259
  : normalizeVec3(vec3(Math.cos(state.time * 5.2), 0, Math.sin(state.time * 4.8)));
1958
3260
  const tangent = vec3(-normal.z, 0, normal.x);
1959
3261
  const penetration = minDistance - distance;
1960
- const invMassA = getShipInverseMass(a, shipModel);
1961
- const invMassB = getShipInverseMass(b, shipModel);
3262
+ const invMassA = getShipInverseMass(a, shipModelA);
3263
+ const invMassB = getShipInverseMass(b, shipModelB);
1962
3264
  const invMassSum = invMassA + invMassB;
1963
3265
  const correction = scaleVec3(normal, (penetration / Math.max(0.0001, invMassSum)) * 0.72);
1964
3266
  a.position = subVec3(a.position, scaleVec3(correction, invMassA));
@@ -1966,7 +3268,11 @@ function resolveShipCollision(state, a, b, shipModel) {
1966
3268
 
1967
3269
  const relativeVelocity = subVec3(b.velocity, a.velocity);
1968
3270
  const velocityAlongNormal = dotVec3(relativeVelocity, normal);
1969
- const restitution = readPhysicsNumber(shipModel.physics, "restitution", 0.22) * 0.88;
3271
+ const restitution =
3272
+ ((readPhysicsNumber(shipModelA.physics, "restitution", 0.22) +
3273
+ readPhysicsNumber(shipModelB.physics, "restitution", 0.22)) /
3274
+ 2) *
3275
+ 0.88;
1970
3276
  if (velocityAlongNormal < 0) {
1971
3277
  const impulseMagnitude =
1972
3278
  (-(1 + restitution) * velocityAlongNormal) / Math.max(0.0001, invMassSum);
@@ -1985,10 +3291,10 @@ function resolveShipCollision(state, a, b, shipModel) {
1985
3291
  b.velocity = addVec3(b.velocity, scaleVec3(frictionImpulse, invMassB));
1986
3292
 
1987
3293
  a.angularVelocity -=
1988
- tangentSpeed * radiusA * getShipInverseInertia(a, shipModel) * 0.2 +
3294
+ tangentSpeed * radiusA * getShipInverseInertia(a, shipModelA) * 0.2 +
1989
3295
  impulseMagnitude * 0.00024;
1990
3296
  b.angularVelocity +=
1991
- tangentSpeed * radiusB * getShipInverseInertia(b, shipModel) * 0.2 +
3297
+ tangentSpeed * radiusB * getShipInverseInertia(b, shipModelB) * 0.2 +
1992
3298
  impulseMagnitude * 0.00024;
1993
3299
 
1994
3300
  const impactSpeed = Math.abs(velocityAlongNormal);
@@ -2028,14 +3334,19 @@ function updateShips(state, dt, shipModel) {
2028
3334
  state.contactCount = 0;
2029
3335
 
2030
3336
  for (const ship of state.ships) {
2031
- updateShipMotion(state, ship, dt, shipModel);
2032
- resolveBoundaryCollision(ship, state, shipModel);
3337
+ const activeShipModel = resolveShipModel(state, ship, shipModel);
3338
+ updateShipMotion(state, ship, dt, activeShipModel);
3339
+ resolveBoundaryCollision(ship, state, activeShipModel);
2033
3340
  }
2034
3341
 
2035
3342
  for (let index = 0; index < state.ships.length; index += 1) {
2036
3343
  for (let otherIndex = index + 1; otherIndex < state.ships.length; otherIndex += 1) {
3344
+ const shipA = state.ships[index];
3345
+ const shipB = state.ships[otherIndex];
3346
+ const shipModelA = resolveShipModel(state, shipA, shipModel);
3347
+ const shipModelB = resolveShipModel(state, shipB, shipModel);
2037
3348
  collided =
2038
- resolveShipCollision(state, state.ships[index], state.ships[otherIndex], shipModel) ||
3349
+ resolveShipCollision(state, shipA, shipB, shipModelA, shipModelB) ||
2039
3350
  collided;
2040
3351
  }
2041
3352
  }
@@ -2327,6 +3638,108 @@ function renderWaterLightReflection(ctx, source, state, camera, viewport) {
2327
3638
  ctx.restore();
2328
3639
  }
2329
3640
 
3641
+ function renderLighthouseBeam(ctx, state, camera, viewport, visuals) {
3642
+ const lighthousePlacement = SHOWCASE_ENVIRONMENT_LAYOUT.find(
3643
+ (placement) => placement.assetKey === "lighthouse"
3644
+ );
3645
+ if (!lighthousePlacement || !state.showcaseRealisticModelsEnabled) {
3646
+ return;
3647
+ }
3648
+
3649
+ const source = transformPoint(
3650
+ vec3(0, 11.34, 0),
3651
+ {
3652
+ position: vec3(
3653
+ lighthousePlacement.position.x,
3654
+ lighthousePlacement.position.y,
3655
+ lighthousePlacement.position.z
3656
+ ),
3657
+ rotationY: lighthousePlacement.rotationY,
3658
+ scale: lighthousePlacement.scale,
3659
+ }
3660
+ );
3661
+ const sweep = state.time * 0.22 + 0.8;
3662
+ const direction = normalizeVec3(vec3(Math.sin(sweep), -0.07, Math.cos(sweep)));
3663
+ const spread = perpendicularOnWater(direction);
3664
+ const farCenter = addVec3(source, scaleVec3(direction, 34));
3665
+ const left = addVec3(farCenter, scaleVec3(spread, 7.4));
3666
+ const right = addVec3(farCenter, scaleVec3(spread, -7.4));
3667
+ const projectedSource = projectPoint(source, camera, viewport);
3668
+ const projectedLeft = projectPoint(left, camera, viewport);
3669
+ const projectedRight = projectPoint(right, camera, viewport);
3670
+ if (!projectedSource || !projectedLeft || !projectedRight) {
3671
+ return;
3672
+ }
3673
+
3674
+ const pulse = 0.72 + Math.sin(state.time * 1.7) * 0.08;
3675
+ ctx.save();
3676
+ ctx.globalCompositeOperation = "screen";
3677
+ ctx.fillStyle = colorToRgba(visuals.torchCore, 0.055 * pulse);
3678
+ ctx.beginPath();
3679
+ ctx.moveTo(projectedSource.x, projectedSource.y);
3680
+ ctx.lineTo(projectedLeft.x, projectedLeft.y);
3681
+ ctx.lineTo(projectedRight.x, projectedRight.y);
3682
+ ctx.closePath();
3683
+ ctx.fill();
3684
+
3685
+ const beamLength = Math.hypot(
3686
+ projectedLeft.x - projectedSource.x,
3687
+ projectedLeft.y - projectedSource.y
3688
+ );
3689
+ const core = ctx.createRadialGradient(
3690
+ projectedSource.x,
3691
+ projectedSource.y,
3692
+ 2,
3693
+ projectedSource.x,
3694
+ projectedSource.y,
3695
+ clamp(beamLength * 0.22, 18, 80)
3696
+ );
3697
+ core.addColorStop(0, colorToRgba(visuals.torchCore, 0.58));
3698
+ core.addColorStop(0.5, colorToRgba(visuals.torchGlow, 0.18));
3699
+ core.addColorStop(1, colorToRgba(visuals.torchGlow, 0));
3700
+ ctx.fillStyle = core;
3701
+ ctx.beginPath();
3702
+ ctx.arc(projectedSource.x, projectedSource.y, clamp(beamLength * 0.18, 14, 64), 0, Math.PI * 2);
3703
+ ctx.fill();
3704
+ ctx.restore();
3705
+ }
3706
+
3707
+ function renderAtmosphericGrade(ctx, canvas, state, visuals) {
3708
+ const vignette = ctx.createRadialGradient(
3709
+ canvas.width * 0.5,
3710
+ canvas.height * 0.48,
3711
+ canvas.width * 0.2,
3712
+ canvas.width * 0.5,
3713
+ canvas.height * 0.5,
3714
+ canvas.width * 0.72
3715
+ );
3716
+ vignette.addColorStop(0, "rgba(0, 0, 0, 0)");
3717
+ vignette.addColorStop(0.68, "rgba(0, 0, 0, 0.08)");
3718
+ vignette.addColorStop(1, "rgba(0, 0, 0, 0.32)");
3719
+ ctx.fillStyle = vignette;
3720
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
3721
+
3722
+ const seaHaze = ctx.createLinearGradient(0, canvas.height * 0.34, 0, canvas.height);
3723
+ seaHaze.addColorStop(0, "rgba(0, 0, 0, 0)");
3724
+ seaHaze.addColorStop(0.5, visuals.ambientMist);
3725
+ seaHaze.addColorStop(1, "rgba(3, 8, 16, 0.18)");
3726
+ ctx.fillStyle = seaHaze;
3727
+ ctx.fillRect(0, canvas.height * 0.34, canvas.width, canvas.height * 0.66);
3728
+
3729
+ if (state.captureMode) {
3730
+ ctx.save();
3731
+ ctx.globalCompositeOperation = "screen";
3732
+ for (let index = 0; index < 70; index += 1) {
3733
+ const x = pseudoRandom(index * 19 + 3) * canvas.width;
3734
+ const y = pseudoRandom(index * 23 + 7) * canvas.height;
3735
+ const alpha = 0.008 + pseudoRandom(index * 31 + 11) * 0.012;
3736
+ ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
3737
+ ctx.fillRect(x, y, 1.1, 1.1);
3738
+ }
3739
+ ctx.restore();
3740
+ }
3741
+ }
3742
+
2330
3743
  function renderWaterMotionEffects(ctx, effects, camera, viewport) {
2331
3744
  ctx.save();
2332
3745
  ctx.globalCompositeOperation = "screen";
@@ -2405,12 +3818,24 @@ function renderWaterMotionEffects(ctx, effects, camera, viewport) {
2405
3818
  ctx.restore();
2406
3819
  }
2407
3820
 
2408
- function renderScene(ctx, canvas, state, shipModel, dom) {
3821
+ function renderScene(
3822
+ ctx,
3823
+ canvas,
3824
+ state,
3825
+ shipModel,
3826
+ dom,
3827
+ lightingFeatures,
3828
+ fluidFeatures,
3829
+ clothFeatures
3830
+ ) {
2409
3831
  const viewport = { width: canvas.width, height: canvas.height };
2410
3832
  const camera = buildCamera(state, canvas);
2411
3833
  state.camera.eye = camera.eye;
2412
- const lightingPlan = createLightingBandPlan({
2413
- profile: state.focus === "lighting" ? defaultLightingProfile : getLightingProfile(defaultLightingProfile).name,
3834
+ const lightingPlan = lightingFeatures.createBandPlan({
3835
+ profile:
3836
+ state.focus === "lighting"
3837
+ ? lightingFeatures.defaultProfile
3838
+ : lightingFeatures.getProfile(lightingFeatures.defaultProfile).name,
2414
3839
  importance: state.focus === "lighting" ? "critical" : "high",
2415
3840
  });
2416
3841
  const nearLighting = lightingPlan.bands.find((entry) => entry.band === "near") ?? lightingPlan.bands[0];
@@ -2439,7 +3864,8 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2439
3864
  const water = buildWaterBands(
2440
3865
  state,
2441
3866
  state.fluidDetail.getSnapshot().currentLevel.config,
2442
- visuals
3867
+ visuals,
3868
+ fluidFeatures
2443
3869
  );
2444
3870
  for (const bandMesh of water.bandMeshes) {
2445
3871
  const bandAccent = bandMesh.band === "near" ? 0.06 : bandMesh.band === "mid" ? 0.04 : 0;
@@ -2459,6 +3885,15 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2459
3885
  normal,
2460
3886
  baseColor: bandMesh.color,
2461
3887
  accent: bandAccent,
3888
+ material: {
3889
+ name: "water-surface",
3890
+ color: bandMesh.color,
3891
+ roughness: 0.2,
3892
+ metallic: 0,
3893
+ emissive: { r: 0, g: 0, b: 0 },
3894
+ },
3895
+ reflection: 1,
3896
+ surfaceType: "water",
2462
3897
  });
2463
3898
  }
2464
3899
  }
@@ -2466,12 +3901,13 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2466
3901
  const waterMotionEffects = buildWaterMotionEffects(state);
2467
3902
  const lightSources = collectSceneLightSources(state, visuals);
2468
3903
 
2469
- pushHarborGeometry(camera, viewport, sceneTriangles, visuals);
3904
+ pushHarborGeometry(camera, viewport, sceneTriangles, state);
2470
3905
  const cloth = buildClothSurface(
2471
3906
  state,
2472
3907
  state,
2473
3908
  state.clothDetail.getSnapshot().currentLevel.config,
2474
- visuals
3909
+ visuals,
3910
+ clothFeatures
2475
3911
  );
2476
3912
  for (let index = 0; index < cloth.indices.length; index += 3) {
2477
3913
  const a = cloth.positions[cloth.indices[index]];
@@ -2489,24 +3925,47 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2489
3925
  normal,
2490
3926
  baseColor: cloth.color,
2491
3927
  accent: cloth.band === "near" ? 0.1 : 0.04,
3928
+ material: {
3929
+ name: "flag-cloth",
3930
+ color: cloth.color,
3931
+ roughness: 0.94,
3932
+ metallic: 0,
3933
+ emissive: { r: 0, g: 0, b: 0 },
3934
+ },
3935
+ reflection: 0,
3936
+ surfaceType: "cloth",
2492
3937
  });
2493
3938
  }
2494
3939
 
2495
3940
  for (const ship of state.ships) {
3941
+ const activeShipModel = resolveShipModel(state, ship, shipModel);
2496
3942
  buildTrianglesFromMesh(
2497
- shipModel,
3943
+ activeShipModel,
2498
3944
  { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE },
2499
3945
  ship.tint,
2500
3946
  camera,
2501
3947
  viewport,
2502
3948
  sceneTriangles,
2503
- nearLighting.rtParticipation.directShadows === "premium" ? 0.08 : 0.02
3949
+ {
3950
+ accent: nearLighting.rtParticipation.directShadows === "premium" ? 0.08 : 0.02,
3951
+ reflection: 0,
3952
+ surfaceType: "ship",
3953
+ }
2504
3954
  );
2505
3955
  }
2506
3956
 
2507
3957
  drawTriangles(ctx, waterTriangles, lightDir, reflectionStrength, camera, shadowStrength);
2508
3958
  for (const ship of state.ships) {
2509
- renderShipShadow(ctx, shipModel, ship, state, camera, viewport, lightDir, shadowStrength);
3959
+ renderShipShadow(
3960
+ ctx,
3961
+ resolveShipModel(state, ship, shipModel),
3962
+ ship,
3963
+ state,
3964
+ camera,
3965
+ viewport,
3966
+ lightDir,
3967
+ shadowStrength
3968
+ );
2510
3969
  }
2511
3970
  renderFlagShadow(ctx, cloth, camera, viewport, lightDir, shadowStrength);
2512
3971
  for (const source of lightSources.reflectionLights) {
@@ -2514,9 +3973,18 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2514
3973
  }
2515
3974
  renderWaterMotionEffects(ctx, waterMotionEffects, camera, viewport);
2516
3975
  renderWaterHighlights(ctx, water.bandMeshes, camera, viewport);
2517
- drawTriangles(ctx, sceneTriangles, lightDir, reflectionStrength, camera, shadowStrength);
3976
+ drawTriangles(
3977
+ ctx,
3978
+ sceneTriangles,
3979
+ lightDir,
3980
+ reflectionStrength,
3981
+ camera,
3982
+ shadowStrength,
3983
+ lightSources.directLights
3984
+ );
2518
3985
  renderFlagPole(ctx, camera, viewport);
2519
3986
  renderClothAccent(ctx, cloth, camera, viewport);
3987
+ renderLighthouseBeam(ctx, state, camera, viewport, visuals);
2520
3988
  for (const source of lightSources.directLights) {
2521
3989
  renderDirectLightGlow(ctx, source, camera, viewport);
2522
3990
  }
@@ -2524,6 +3992,7 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2524
3992
  renderShipRigging(ctx, ship, camera, viewport);
2525
3993
  }
2526
3994
  renderSprays(ctx, state.sprays, camera, viewport);
3995
+ renderAtmosphericGrade(ctx, canvas, state, visuals);
2527
3996
 
2528
3997
  const debugSnapshot = state.debugSession.getSnapshot();
2529
3998
  const quality = {
@@ -2534,14 +4003,14 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2534
4003
 
2535
4004
  const sceneMetrics = [
2536
4005
  `focus: ${state.focus}`,
2537
- `ships: ${state.ships.length} active GLTF hulls`,
2538
- `moonlight: cold overhead key + ${HARBOR_TORCHES.length + state.ships.length * SHIP_LANTERNS.length} warm deck and harbor lights`,
4006
+ `ships: ${state.ships.length} active GLTF hulls across ${new Set(state.ships.map((ship) => ship.modelKey)).size} model families`,
4007
+ `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`,
2539
4008
  `physics snapshot: ${state.physics.snapshot.stage} (${state.physics.snapshot.stability})`,
2540
4009
  `physics contacts: ${state.contactCount}`,
2541
- `mass split: ${state.ships.map((ship) => `${ship.id} ${(getShipMass(ship, shipModel) / 1000).toFixed(1)}t`).join(" · ")}`,
4010
+ `mass split: ${state.ships.map((ship) => `${ship.id} ${(getShipMass(ship, resolveShipModel(state, ship, shipModel)) / 1000).toFixed(1)}t`).join(" · ")}`,
2542
4011
  `cloth band: ${cloth.band} -> ${cloth.representation.output}`,
2543
4012
  `fluid near band: ${water.bandMeshes[0].representation.output}`,
2544
- `lighting profile: ${lightingPlan.profile} (${lightingDistanceBands.length} bands)`,
4013
+ `lighting profile: ${lightingPlan.profile} (${lightingFeatures.distanceBands.length} bands)`,
2545
4014
  ];
2546
4015
  const qualityMetrics = [
2547
4016
  `fluid detail: ${quality.fluid.currentLevel.id} (${quality.fluid.currentLevel.config.nearResolution} near cells)`,
@@ -2561,12 +4030,8 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2561
4030
  ];
2562
4031
  const sceneNotes =
2563
4032
  state.focus === "physics"
2564
- ? [
2565
- "Stable world snapshots are taken after the authoritative rigid-body commit and before visual follow-up work.",
2566
- "The ships collide with mass-weighted impulses and positional correction, so the heavier hull keeps more of its line.",
2567
- "Moonlight keeps the overall read legible while lanterns and torches make collision moments easy to track against the water.",
2568
- ]
2569
- : SCENE_NOTES;
4033
+ ? PHYSICS_SCENE_NOTE_KEYS.map((key) => state.translate(key))
4034
+ : SCENE_NOTE_KEYS.map((key) => state.translate(key));
2570
4035
  const custom = state.demoDescription ?? null;
2571
4036
 
2572
4037
  setListContent(
@@ -2586,22 +4051,33 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
2586
4051
  dom.status.textContent =
2587
4052
  typeof custom?.status === "string"
2588
4053
  ? custom.status
2589
- : `3D scene live · ${state.lastDecision.metrics.fps.toFixed(1)} FPS`;
4054
+ : state.translate(gpuSharedTranslationKeys.statusLive, {
4055
+ fps: state.lastDecision.metrics.fps.toFixed(1),
4056
+ });
2590
4057
  dom.details.textContent =
2591
4058
  typeof custom?.details === "string"
2592
4059
  ? custom.details
2593
4060
  : state.focus === "physics"
2594
- ? `Stable world snapshots are emitted from ${state.physics.plan.snapshotStageId} after the authoritative solver; the heavier hull now carries momentum through mass-aware collision impulses while cloth and fluid remain downstream.`
2595
- : `Moonlit GLTF ships collide on ${shipModel.physics.shape ?? "box"} physics volumes; lantern reflections, cloth, and fluid remain continuous while the governor pressure is ${state.lastDecision.pressureLevel}.`;
4061
+ ? state.translate(gpuSharedTranslationKeys.detailsPhysics, {
4062
+ snapshotStageId: state.physics.plan.snapshotStageId,
4063
+ })
4064
+ : state.showcaseRealisticModelsEnabled
4065
+ ? state.translate(gpuSharedTranslationKeys.detailsRealistic, {
4066
+ pressureLevel: state.lastDecision.pressureLevel,
4067
+ })
4068
+ : state.translate(gpuSharedTranslationKeys.detailsLegacy, {
4069
+ pressureLevel: state.lastDecision.pressureLevel,
4070
+ });
2596
4071
  }
2597
4072
 
2598
- function updateSceneState(state, dt, shipModel) {
4073
+ function updateSceneState(state, dt, shipModel, featureAdapters) {
2599
4074
  updateShips(state, dt, shipModel);
2600
4075
  updateWaveImpulses(state, dt);
2601
4076
  updateSpray(state, dt);
2602
4077
  const clothPresentation = resolveClothPresentation(
2603
4078
  state,
2604
- state.clothDetail.getSnapshot().currentLevel.config
4079
+ state.clothDetail.getSnapshot().currentLevel.config,
4080
+ featureAdapters.cloth
2605
4081
  );
2606
4082
  const clothState = ensureShowcaseClothState(
2607
4083
  state,
@@ -2614,25 +4090,28 @@ function updateSceneState(state, dt, shipModel) {
2614
4090
  flagMotion: readVisualNumber(state.demoVisuals?.flagMotion, 0.92),
2615
4091
  waveInfluence: sampleWave(state, FLAG_LAYOUT.origin.x + FLAG_LAYOUT.width * 0.55, FLAG_LAYOUT.origin.z + FLAG_LAYOUT.width * 0.48, state.time),
2616
4092
  });
2617
- updatePhysicsSnapshot(state, shipModel);
4093
+ updatePhysicsSnapshot(state, shipModel, featureAdapters.physics);
2618
4094
  }
2619
4095
 
2620
- function syncTextState(state, shipModel) {
4096
+ function syncTextState(state, shipModel, featureAdapters) {
2621
4097
  const snapshot = {
2622
4098
  coordinateSystem: "right-handed world; +x right, +y up, +z forward from the shore",
2623
4099
  focus: state.focus,
2624
4100
  stress: state.stress,
2625
4101
  ships: state.ships.map((ship) => ({
2626
4102
  id: ship.id,
4103
+ modelKey: ship.modelKey ?? "brigantine",
2627
4104
  x: Number(ship.position.x.toFixed(2)),
2628
4105
  y: Number(ship.position.y.toFixed(2)),
2629
4106
  z: Number(ship.position.z.toFixed(2)),
2630
4107
  vx: Number(ship.velocity.x.toFixed(2)),
2631
4108
  vz: Number(ship.velocity.z.toFixed(2)),
2632
- massKg: Math.round(getShipMass(ship, shipModel)),
4109
+ massKg: Math.round(getShipMass(ship, resolveShipModel(state, ship, shipModel))),
2633
4110
  lanterns: Array.isArray(ship.lanterns) ? ship.lanterns.length : 0,
2634
4111
  })),
2635
- shipPhysics: shipModel.physics,
4112
+ shipPhysics: Object.fromEntries(
4113
+ state.ships.map((ship) => [ship.id, resolveShipModel(state, ship, shipModel)?.physics ?? null])
4114
+ ),
2636
4115
  sprays: state.sprays.length,
2637
4116
  waveImpulses: state.waveImpulses.length,
2638
4117
  pressure: state.lastDecision?.pressureLevel ?? "stable",
@@ -2650,41 +4129,63 @@ function syncTextState(state, shipModel) {
2650
4129
  for (let index = 0; index < step; index += 1) {
2651
4130
  state.frame += 1;
2652
4131
  state.time += 1 / 60;
2653
- updateSceneState(state, 1 / 60, shipModel);
4132
+ updateSceneState(state, 1 / 60, shipModel, featureAdapters);
2654
4133
  state.lastDecision = recordTelemetry(state, 16.67 + (state.stress ? 6.5 : 0));
2655
4134
  }
2656
4135
  };
2657
4136
  }
2658
4137
 
2659
- export async function mountGpuShowcase(options = {}) {
4138
+ export async function mountGpuShowcase(options = {}, featureFlags = null) {
4139
+ const featureAdapters = await resolveShowcaseFeatureAdapters(options);
2660
4140
  injectStyles();
2661
4141
  const root = options.root ?? document.body;
2662
4142
  root.classList?.add?.(ROOT_CLASS);
4143
+ const captureSettings = resolveCaptureSettings(options);
4144
+ if (captureSettings.captureMode) {
4145
+ root.classList?.add?.(CAPTURE_CLASS);
4146
+ }
2663
4147
  const previousMarkup = root.innerHTML;
2664
4148
  const previousRenderGameToText = window.render_game_to_text;
2665
4149
  const previousAdvanceTime = window.advanceTime;
2666
4150
  const focus = options.focus ?? new URLSearchParams(window.location.search).get("focus") ?? "integrated";
4151
+ const translate = createGpuSharedTranslator(options.translate);
2667
4152
  const dom = buildDemoDom(root, {
2668
4153
  packageName: options.packageName ?? "@plasius/gpu-demo-viewer",
2669
- title: options.title ?? DEFAULT_TITLE,
2670
- subtitle: options.subtitle ?? DEFAULT_SUBTITLE,
4154
+ title: options.title ?? translate(gpuSharedTranslationKeys.showcaseTitle),
4155
+ subtitle: options.subtitle ?? translate(gpuSharedTranslationKeys.showcaseSubtitle),
4156
+ translate,
2671
4157
  });
2672
4158
  dom.focusMode.value = focus;
4159
+ const state = createSceneState(
4160
+ {
4161
+ focus,
4162
+ translate,
4163
+ realisticModelsEnabled: isFeatureEnabled(featureFlags, GPU_SHOWCASE_REALISTIC_MODELS_FEATURE, true),
4164
+ captureMode: captureSettings.captureMode,
4165
+ renderScale: captureSettings.renderScale,
4166
+ },
4167
+ featureAdapters
4168
+ );
4169
+ const assetCatalog = await (state.showcaseRealisticModelsEnabled
4170
+ ? loadShowcaseAssetCatalog()
4171
+ : createLegacyShowcaseAssetCatalog());
4172
+ const shipModel = assetCatalog.ships[assetCatalog.primaryShipKey];
2673
4173
 
2674
- const state = createSceneState({ focus });
2675
- const shipModel = await loadGltfModel(resolveShowcaseAssetUrl());
4174
+ state.assetCatalog = assetCatalog;
2676
4175
  state.shipModel = shipModel;
2677
4176
  state.packageState =
2678
4177
  typeof options.createState === "function" ? options.createState() : undefined;
2679
- updatePhysicsSnapshot(state, shipModel);
4178
+ updatePhysicsSnapshot(state, shipModel, featureAdapters.physics);
2680
4179
  state.lastDecision = recordTelemetry(state, 16.4);
2681
4180
  state.demoDescription = resolveSceneDescription(state, options, shipModel).description;
2682
- syncTextState(state, shipModel);
4181
+ syncTextState(state, shipModel, featureAdapters);
2683
4182
 
2684
4183
  const ctx = dom.canvas.getContext("2d");
2685
4184
  if (!ctx) {
2686
4185
  throw new Error("2D canvas context is required for the shared showcase.");
2687
4186
  }
4187
+ ctx.imageSmoothingEnabled = true;
4188
+ ctx.imageSmoothingQuality = "high";
2688
4189
  let animationFrameId = 0;
2689
4190
  let destroyed = false;
2690
4191
  const renderFrame = (nowMs) => {
@@ -2699,21 +4200,33 @@ export async function mountGpuShowcase(options = {}) {
2699
4200
  state.lastTimeMs = nowMs;
2700
4201
  state.time += dt;
2701
4202
  state.frame += 1;
2702
- updateSceneState(state, dt, shipModel);
4203
+ updateSceneState(state, dt, shipModel, featureAdapters);
2703
4204
  updatePackageState(state, options, shipModel, dt);
2704
4205
  const syntheticFrame = 14.2 + state.sprays.length * 0.1 + (state.stress ? 6.4 : 0);
2705
4206
  state.lastDecision = recordTelemetry(state, syntheticFrame);
2706
4207
  }
2707
4208
 
2708
4209
  state.demoDescription = resolveSceneDescription(state, options, shipModel).description;
2709
- renderScene(ctx, dom.canvas, state, shipModel, dom);
2710
- syncTextState(state, shipModel);
4210
+ resizeCanvasToDisplaySize(dom.canvas, state);
4211
+ renderScene(
4212
+ ctx,
4213
+ dom.canvas,
4214
+ state,
4215
+ shipModel,
4216
+ dom,
4217
+ featureAdapters.lighting,
4218
+ featureAdapters.fluid,
4219
+ featureAdapters.cloth
4220
+ );
4221
+ syncTextState(state, shipModel, featureAdapters);
2711
4222
  animationFrameId = requestAnimationFrame(renderFrame);
2712
4223
  };
2713
4224
 
2714
4225
  const handlePauseClick = () => {
2715
4226
  state.paused = !state.paused;
2716
- dom.pauseButton.textContent = state.paused ? "Resume" : "Pause";
4227
+ dom.pauseButton.textContent = state.paused
4228
+ ? state.translate(gpuSharedTranslationKeys.resume)
4229
+ : state.translate(gpuSharedTranslationKeys.pause);
2717
4230
  };
2718
4231
  const handleStressChange = () => {
2719
4232
  state.stress = dom.stressToggle.checked;
@@ -2750,6 +4263,7 @@ export async function mountGpuShowcase(options = {}) {
2750
4263
  state.packageState = undefined;
2751
4264
  }
2752
4265
  root.classList?.remove?.(ROOT_CLASS);
4266
+ root.classList?.remove?.(CAPTURE_CLASS);
2753
4267
  root.innerHTML = previousMarkup;
2754
4268
  if (typeof previousRenderGameToText === "function") {
2755
4269
  window.render_game_to_text = previousRenderGameToText;
@@ -2770,7 +4284,19 @@ export async function mountGpuShowcase(options = {}) {
2770
4284
  };
2771
4285
  }
2772
4286
 
2773
- function updatePhysicsSnapshot(state, shipModel) {
4287
+ function updatePhysicsSnapshot(state, shipModel, physicsFeatures) {
4288
+ const createPhysicsWorldSnapshot = assertRequiredFunction(
4289
+ physicsFeatures,
4290
+ "physics",
4291
+ "createWorldSnapshot"
4292
+ );
4293
+ const rigidBodyShapes = Object.fromEntries(
4294
+ state.ships.map((ship) => [
4295
+ ship.id,
4296
+ resolveShipModel(state, ship, shipModel)?.physics?.shape ?? "box",
4297
+ ])
4298
+ );
4299
+
2774
4300
  state.physics.snapshot = createPhysicsWorldSnapshot({
2775
4301
  frameId: `showcase-${state.frame}`,
2776
4302
  tick: state.frame,
@@ -2787,6 +4313,7 @@ function updatePhysicsSnapshot(state, shipModel) {
2787
4313
  contactCount: state.contactCount,
2788
4314
  snapshotStageId: state.physics.plan.snapshotStageId,
2789
4315
  rigidBodyShape: shipModel.physics.shape ?? "box",
4316
+ rigidBodyShapes,
2790
4317
  },
2791
4318
  });
2792
4319
  }