@plasius/gpu-renderer 0.2.45 → 0.2.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -28,10 +28,12 @@ __export(index_exports, {
28
28
  bucketFeedbackGameFrameTime: () => bucketFeedbackGameFrameTime,
29
29
  bucketFeedbackGameViewport: () => bucketFeedbackGameViewport,
30
30
  createAnimatedSceneRenderer: () => createAnimatedSceneRenderer,
31
+ createCanonicalWavefrontMeshInputs: () => createCanonicalWavefrontMeshInputs,
31
32
  createDefaultWavefrontSceneObjects: () => createDefaultWavefrontSceneObjects,
32
33
  createFeedbackGameDiagnosticSnapshot: () => createFeedbackGameDiagnosticSnapshot,
33
34
  createGpuRenderer: () => createGpuRenderer,
34
35
  createMediumStack: () => createMediumStack,
36
+ createNativeSceneRenderer: () => createNativeSceneRenderer,
35
37
  createProfessionalAnimatedSceneRenderer: () => createProfessionalAnimatedSceneRenderer,
36
38
  createRayTracingRenderPlan: () => createRayTracingRenderPlan,
37
39
  createRendererDebugHooks: () => createRendererDebugHooks,
@@ -91,1864 +93,2735 @@ __export(index_exports, {
91
93
  });
92
94
  module.exports = __toCommonJS(index_exports);
93
95
 
94
- // src/animated-scene-renderer.js
95
- var import_gpu_camera = require("@plasius/gpu-camera");
96
+ // src/renderer-constants.js
97
+ var DEFAULT_CLEAR_COLOR = Object.freeze([0.07, 0.11, 0.18, 1]);
98
+ var DEFAULT_CANVAS_SELECTOR = "canvas[data-plasius-gpu-renderer]";
99
+ var rendererDebugOwner = "renderer";
100
+ var rendererWorkerQueueClass = "render";
101
+ var defaultRendererWorkerProfile = "realtime";
102
+ var rendererRepresentationBands = Object.freeze([
103
+ "near",
104
+ "mid",
105
+ "far",
106
+ "horizon"
107
+ ]);
108
+ var rendererAccelerationStructureUpdateClasses = Object.freeze([
109
+ "static",
110
+ "rigid-dynamic",
111
+ "deforming",
112
+ "proxy"
113
+ ]);
114
+ var rendererRayTracingStageOrder = Object.freeze([
115
+ "primaryVisibility",
116
+ "shadowAssist",
117
+ "opaqueFoundation",
118
+ "rtDirectLighting",
119
+ "rtReflections",
120
+ "rtGlobalIllumination",
121
+ "denoiseTemporal",
122
+ "transparents",
123
+ "composition",
124
+ "present"
125
+ ]);
126
+ var rendererWavefrontBufferSchemaVersion = 1;
127
+ var rendererWavefrontQueuePairStrategy = "ping-pong-active-next";
128
+ var rendererWavefrontHitTypes = Object.freeze([
129
+ "surface",
130
+ "emissive",
131
+ "environment",
132
+ "transparent",
133
+ "miss"
134
+ ]);
135
+ var rendererWavefrontPassOrder = Object.freeze([
136
+ "generatePrimaryRays",
137
+ "intersectActiveQueue",
138
+ "resolveSurfaceRecords",
139
+ "accumulateTerminalRadiance",
140
+ "scatterContinuations",
141
+ "compactAndSwapQueues"
142
+ ]);
96
143
 
97
- // src/animated-gltf-model.js
98
- var GLB_MAGIC = 1179937895;
99
- var GLB_VERSION = 2;
100
- var GLB_JSON_CHUNK = 1313821514;
101
- var GLB_BIN_CHUNK = 5130562;
102
- var COMPONENT_READERS = Object.freeze({
103
- 5120: { size: 1, read: (view, offset) => view.getInt8(offset), normalizedDivisor: 127 },
104
- 5121: { size: 1, read: (view, offset) => view.getUint8(offset), normalizedDivisor: 255 },
105
- 5122: { size: 2, read: (view, offset) => view.getInt16(offset, true), normalizedDivisor: 32767 },
106
- 5123: { size: 2, read: (view, offset) => view.getUint16(offset, true), normalizedDivisor: 65535 },
107
- 5125: { size: 4, read: (view, offset) => view.getUint32(offset, true), normalizedDivisor: 4294967295 },
108
- 5126: { size: 4, read: (view, offset) => view.getFloat32(offset, true), normalizedDivisor: 1 }
109
- });
110
- var ACCESSOR_COMPONENTS = Object.freeze({
111
- SCALAR: 1,
112
- VEC2: 2,
113
- VEC3: 3,
114
- VEC4: 4,
115
- MAT4: 16
116
- });
117
- function identityMatrix() {
118
- return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
119
- }
120
- function multiplyMatrix(a, b) {
121
- const out = new Array(16).fill(0);
122
- for (let column = 0; column < 4; column += 1) {
123
- for (let row = 0; row < 4; row += 1) {
124
- out[column * 4 + row] = a[0 * 4 + row] * b[column * 4 + 0] + a[1 * 4 + row] * b[column * 4 + 1] + a[2 * 4 + row] * b[column * 4 + 2] + a[3 * 4 + row] * b[column * 4 + 3];
125
- }
144
+ // src/renderer-validation.js
145
+ function readPositiveNumber(name, value) {
146
+ if (value === void 0) {
147
+ return void 0;
126
148
  }
127
- return out;
128
- }
129
- function transformPoint(matrix, point) {
130
- const [x, y, z] = point;
131
- return [
132
- matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12],
133
- matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13],
134
- matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14]
135
- ];
149
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
150
+ throw new Error(`${name} must be a finite number greater than zero.`);
151
+ }
152
+ return value;
136
153
  }
137
- function composeMatrix(translation = [0, 0, 0], rotation = [0, 0, 0, 1], scale2 = [1, 1, 1]) {
138
- const [x, y, z, w] = normalizeQuaternion(rotation);
139
- const [sx, sy, sz] = scale2;
140
- const x2 = x + x;
141
- const y2 = y + y;
142
- const z2 = z + z;
143
- const xx = x * x2;
144
- const xy = x * y2;
145
- const xz = x * z2;
146
- const yy = y * y2;
147
- const yz = y * z2;
148
- const zz = z * z2;
149
- const wx = w * x2;
150
- const wy = w * y2;
151
- const wz = w * z2;
152
- return [
153
- (1 - (yy + zz)) * sx,
154
- (xy + wz) * sx,
155
- (xz - wy) * sx,
156
- 0,
157
- (xy - wz) * sy,
158
- (1 - (xx + zz)) * sy,
159
- (yz + wx) * sy,
160
- 0,
161
- (xz + wy) * sz,
162
- (yz - wx) * sz,
163
- (1 - (xx + yy)) * sz,
164
- 0,
165
- translation[0] ?? 0,
166
- translation[1] ?? 0,
167
- translation[2] ?? 0,
168
- 1
169
- ];
154
+ function readPositiveInteger(name, value) {
155
+ if (!Number.isInteger(value) || value <= 0) {
156
+ throw new Error(`${name} must be a positive integer.`);
157
+ }
158
+ return value;
170
159
  }
171
- function normalizeQuaternion(quaternion = [0, 0, 0, 1]) {
172
- const length = Math.hypot(quaternion[0] ?? 0, quaternion[1] ?? 0, quaternion[2] ?? 0, quaternion[3] ?? 1) || 1;
173
- return [
174
- (quaternion[0] ?? 0) / length,
175
- (quaternion[1] ?? 0) / length,
176
- (quaternion[2] ?? 0) / length,
177
- (quaternion[3] ?? 1) / length
178
- ];
160
+ function readNonNegativeInteger(name, value) {
161
+ if (!Number.isInteger(value) || value < 0) {
162
+ throw new Error(`${name} must be a non-negative integer.`);
163
+ }
164
+ return value;
179
165
  }
180
- function lerp(a, b, t) {
181
- return a + (b - a) * t;
166
+ function assertRendererIdentifier(name, value) {
167
+ if (typeof value !== "string" || value.trim().length === 0) {
168
+ throw new Error(`${name} must be a non-empty string.`);
169
+ }
170
+ return value.trim();
182
171
  }
183
- function lerpArray(a, b, t) {
184
- return a.map((value, index) => lerp(value, b[index] ?? value, t));
172
+
173
+ // src/renderer-webgpu-runtime.js
174
+ function clamp01(value) {
175
+ return Math.min(1, Math.max(0, value));
185
176
  }
186
- function nlerpQuaternion(a, b, t) {
187
- let end = b;
188
- const dot2 = a.reduce((total, value, index) => total + value * (b[index] ?? 0), 0);
189
- if (dot2 < 0) {
190
- end = b.map((value) => -value);
191
- }
192
- return normalizeQuaternion(lerpArray(a, end, t));
177
+ function parseHexChannel(channel) {
178
+ return parseInt(channel, 16) / 255;
193
179
  }
194
- function parseGlb(buffer) {
195
- if (!(buffer instanceof ArrayBuffer)) {
196
- return null;
197
- }
198
- const view = new DataView(buffer);
199
- if (view.byteLength < 20 || view.getUint32(0, true) !== GLB_MAGIC || view.getUint32(4, true) !== GLB_VERSION) {
200
- return null;
180
+ function normalizeColor(value) {
181
+ if (Array.isArray(value)) {
182
+ const [r = 0, g = 0, b = 0, a = 1] = value;
183
+ return [clamp01(Number(r) || 0), clamp01(Number(g) || 0), clamp01(Number(b) || 0), clamp01(Number(a) || 0)];
201
184
  }
202
- let offset = 12;
203
- let json = null;
204
- let binary = null;
205
- while (offset + 8 <= view.byteLength) {
206
- const length = view.getUint32(offset, true);
207
- const type = view.getUint32(offset + 4, true);
208
- const start = offset + 8;
209
- const end = start + length;
210
- if (end > view.byteLength) {
211
- return null;
185
+ if (typeof value === "string") {
186
+ const trimmed = value.trim();
187
+ if (/^#[0-9a-f]{3}$/i.test(trimmed)) {
188
+ const r = trimmed[1];
189
+ const g = trimmed[2];
190
+ const b = trimmed[3];
191
+ return [
192
+ parseHexChannel(r + r),
193
+ parseHexChannel(g + g),
194
+ parseHexChannel(b + b),
195
+ 1
196
+ ];
212
197
  }
213
- if (type === GLB_JSON_CHUNK) {
214
- const text = new TextDecoder().decode(new Uint8Array(buffer, start, length)).trim();
215
- json = JSON.parse(text);
216
- } else if (type === GLB_BIN_CHUNK) {
217
- binary = new Uint8Array(buffer, start, length);
198
+ if (/^#[0-9a-f]{6}$/i.test(trimmed)) {
199
+ return [
200
+ parseHexChannel(trimmed.slice(1, 3)),
201
+ parseHexChannel(trimmed.slice(3, 5)),
202
+ parseHexChannel(trimmed.slice(5, 7)),
203
+ 1
204
+ ];
218
205
  }
219
- offset = end;
220
- }
221
- if (!json || !binary) {
222
- return null;
223
206
  }
224
- return { json, binary };
207
+ return [...DEFAULT_CLEAR_COLOR];
225
208
  }
226
- function readAccessor(document, accessorIndex) {
227
- const accessor = document.json.accessors?.[accessorIndex];
228
- if (!accessor) {
229
- return [];
230
- }
231
- const bufferView = document.json.bufferViews?.[accessor.bufferView];
232
- const component = COMPONENT_READERS[accessor.componentType];
233
- const componentCount = ACCESSOR_COMPONENTS[accessor.type] ?? 1;
234
- if (!bufferView || !component) {
235
- return [];
236
- }
237
- const view = new DataView(
238
- document.binary.buffer,
239
- document.binary.byteOffset + (bufferView.byteOffset ?? 0) + (accessor.byteOffset ?? 0),
240
- bufferView.byteLength ?? 0
241
- );
242
- const stride = bufferView.byteStride ?? component.size * componentCount;
243
- const values = [];
244
- for (let index = 0; index < accessor.count; index += 1) {
245
- const item = [];
246
- const baseOffset = index * stride;
247
- for (let componentIndex = 0; componentIndex < componentCount; componentIndex += 1) {
248
- const raw = component.read(view, baseOffset + componentIndex * component.size);
249
- item.push(accessor.normalized && accessor.componentType !== 5126 ? raw / component.normalizedDivisor : raw);
250
- }
251
- values.push(componentCount === 1 ? item[0] : item);
209
+ function now() {
210
+ if (typeof performance !== "undefined" && typeof performance.now === "function") {
211
+ return performance.now();
252
212
  }
253
- return values;
213
+ return Date.now();
254
214
  }
255
- function readMatrices(document, accessorIndex) {
256
- return readAccessor(document, accessorIndex).map((matrix) => Array.from(matrix));
215
+ function normalizeFrameId(value) {
216
+ if (typeof value !== "string" || value.trim().length === 0) {
217
+ throw new Error("frameIdFactory must return a non-empty string.");
218
+ }
219
+ return value.trim();
257
220
  }
258
- function textureImage(document, textureIndex) {
259
- if (typeof textureIndex !== "number") {
260
- return null;
221
+ function resolveTargetFrameTimeMs(options, event) {
222
+ const {
223
+ targetFrameTimeMs: fixedTargetFrameTimeMs,
224
+ targetFrameRate,
225
+ getTargetFrameTimeMs
226
+ } = options;
227
+ if (typeof getTargetFrameTimeMs === "function") {
228
+ const resolved = getTargetFrameTimeMs(event);
229
+ return readPositiveNumber("getTargetFrameTimeMs()", resolved);
261
230
  }
262
- const texture = document.json.textures?.[textureIndex];
263
- return typeof texture?.source === "number" ? document.json.images?.[texture.source] ?? null : null;
264
- }
265
- function imageHasBuffer(document, image) {
266
- if (!image) {
267
- return false;
231
+ if (fixedTargetFrameTimeMs !== void 0) {
232
+ return fixedTargetFrameTimeMs;
268
233
  }
269
- if (typeof image.uri === "string" && image.uri.trim().length > 0) {
270
- return true;
234
+ if (targetFrameRate !== void 0) {
235
+ return 1e3 / targetFrameRate;
271
236
  }
272
- return typeof image.bufferView === "number" && Boolean(document.json.bufferViews?.[image.bufferView]);
237
+ return void 0;
273
238
  }
274
- function extractMaterialTextureMetadata(document, primitive) {
275
- const material = document.json.materials?.[primitive?.material];
276
- const baseColorTextureIndex = material?.pbrMetallicRoughness?.baseColorTexture?.index;
277
- const normalTextureIndex = material?.normalTexture?.index;
278
- const missingTextureReferences = [];
279
- if (typeof baseColorTextureIndex === "number" && !imageHasBuffer(document, textureImage(document, baseColorTextureIndex))) {
280
- missingTextureReferences.push("baseColorTexture");
239
+ function createRendererDebugHooks(options = {}) {
240
+ const {
241
+ debugSession,
242
+ targetFrameTimeMs,
243
+ targetFrameRate,
244
+ getTargetFrameTimeMs,
245
+ onFrameStart,
246
+ onFrameComplete
247
+ } = options;
248
+ if (!debugSession || typeof debugSession.recordFrame !== "function") {
249
+ throw new Error(
250
+ "debugSession must expose recordFrame(sample). Use @plasius/gpu-debug createGpuDebugSession()."
251
+ );
281
252
  }
282
- if (typeof normalTextureIndex === "number" && !imageHasBuffer(document, textureImage(document, normalTextureIndex))) {
283
- missingTextureReferences.push("normalTexture");
253
+ const fixedTargetFrameTimeMs = readPositiveNumber(
254
+ "targetFrameTimeMs",
255
+ targetFrameTimeMs
256
+ );
257
+ const fixedTargetFrameRate = readPositiveNumber(
258
+ "targetFrameRate",
259
+ targetFrameRate
260
+ );
261
+ if (fixedTargetFrameTimeMs !== void 0 && fixedTargetFrameRate !== void 0) {
262
+ throw new Error(
263
+ "Provide either targetFrameTimeMs or targetFrameRate, not both."
264
+ );
284
265
  }
285
- return Object.freeze({
286
- materialCount: document.json.materials?.length ?? 0,
287
- textureCount: document.json.textures?.length ?? 0,
288
- imageCount: document.json.images?.length ?? 0,
289
- embeddedImageCount: (document.json.images ?? []).filter((image) => typeof image.bufferView === "number").length,
290
- baseColorTextureCount: typeof baseColorTextureIndex === "number" ? 1 : 0,
291
- normalTextureCount: typeof normalTextureIndex === "number" ? 1 : 0,
292
- hasBaseColorTexture: typeof baseColorTextureIndex === "number",
293
- hasNormalTexture: typeof normalTextureIndex === "number",
294
- missingTextureReferences: Object.freeze(missingTextureReferences),
295
- materialName: material?.name ?? ""
296
- });
266
+ if (getTargetFrameTimeMs !== void 0 && typeof getTargetFrameTimeMs !== "function") {
267
+ throw new Error("getTargetFrameTimeMs must be a function when provided.");
268
+ }
269
+ const resolvedOptions = {
270
+ targetFrameTimeMs: fixedTargetFrameTimeMs,
271
+ targetFrameRate: fixedTargetFrameRate,
272
+ getTargetFrameTimeMs
273
+ };
274
+ return {
275
+ onFrameStart(event) {
276
+ if (typeof onFrameStart === "function") {
277
+ onFrameStart({
278
+ ...event,
279
+ owner: rendererDebugOwner
280
+ });
281
+ }
282
+ },
283
+ onFrameComplete(event) {
284
+ const resolvedTargetFrameTimeMs = resolveTargetFrameTimeMs(
285
+ resolvedOptions,
286
+ event
287
+ );
288
+ if (typeof event.frameTimeMs === "number" && Number.isFinite(event.frameTimeMs) && event.frameTimeMs > 0) {
289
+ debugSession.recordFrame({
290
+ frameId: event.frameId,
291
+ frameTimeMs: event.frameTimeMs,
292
+ targetFrameTimeMs: resolvedTargetFrameTimeMs
293
+ });
294
+ }
295
+ if (typeof onFrameComplete === "function") {
296
+ onFrameComplete({
297
+ ...event,
298
+ owner: rendererDebugOwner,
299
+ targetFrameTimeMs: resolvedTargetFrameTimeMs
300
+ });
301
+ }
302
+ }
303
+ };
297
304
  }
298
- function nodeLocalMatrix(node, override = {}) {
299
- if (node.matrix && !override.translation && !override.rotation && !override.scale) {
300
- return [...node.matrix];
305
+ function readNavigator(navigatorOverride) {
306
+ const currentNavigator = navigatorOverride ?? globalThis.navigator;
307
+ if (!currentNavigator || typeof currentNavigator !== "object") {
308
+ throw new Error("Navigator unavailable. Provide a browser-like navigator object.");
301
309
  }
302
- return composeMatrix(
303
- override.translation ?? node.translation ?? [0, 0, 0],
304
- override.rotation ?? node.rotation ?? [0, 0, 0, 1],
305
- override.scale ?? node.scale ?? [1, 1, 1]
306
- );
310
+ return currentNavigator;
307
311
  }
308
- function computeWorldMatrices(nodes, overridesByName = /* @__PURE__ */ new Map()) {
309
- const parents = /* @__PURE__ */ new Set();
310
- for (const node of nodes) {
311
- for (const child of node.children ?? []) {
312
- parents.add(child);
313
- }
312
+ function readDocument(documentOverride) {
313
+ const doc = documentOverride ?? globalThis.document;
314
+ if (!doc || typeof doc !== "object") {
315
+ throw new Error("Document unavailable. Provide a browser-like document object.");
314
316
  }
315
- const roots = nodes.map((_, index) => index).filter((index) => !parents.has(index));
316
- const worldMatrices = nodes.map(() => identityMatrix());
317
- function visit(nodeIndex, parentMatrix) {
318
- const node = nodes[nodeIndex];
319
- if (!node) {
320
- return;
321
- }
322
- const worldMatrix = multiplyMatrix(parentMatrix, nodeLocalMatrix(node, overridesByName.get(node.name) ?? {}));
323
- worldMatrices[nodeIndex] = worldMatrix;
324
- for (const child of node.children ?? []) {
325
- visit(child, worldMatrix);
326
- }
317
+ return doc;
318
+ }
319
+ function resolveCanvas(canvasOrSelector, documentOverride) {
320
+ if (canvasOrSelector && typeof canvasOrSelector === "object") {
321
+ return canvasOrSelector;
327
322
  }
328
- for (const root of roots) {
329
- visit(root, identityMatrix());
323
+ const doc = readDocument(documentOverride);
324
+ const selector = typeof canvasOrSelector === "string" && canvasOrSelector.trim().length > 0 ? canvasOrSelector : DEFAULT_CANVAS_SELECTOR;
325
+ const resolved = doc.querySelector(selector);
326
+ if (!resolved) {
327
+ throw new Error(`Unable to find canvas for selector "${selector}".`);
330
328
  }
331
- return worldMatrices;
329
+ return resolved;
332
330
  }
333
- function normalizeWeights(weights) {
334
- const total = weights.reduce((sum, value) => sum + Math.max(0, value), 0);
335
- if (total <= 0) {
336
- return [1, 0, 0, 0];
331
+ function readGpu(navigatorOverride) {
332
+ const currentNavigator = readNavigator(navigatorOverride);
333
+ const gpu = currentNavigator.gpu;
334
+ if (!gpu || typeof gpu.requestAdapter !== "function") {
335
+ throw new Error("WebGPU runtime unavailable. navigator.gpu is missing.");
337
336
  }
338
- return weights.map((value) => Math.max(0, value) / total);
337
+ return gpu;
339
338
  }
340
- function deriveBounds(vertices) {
341
- const min = [Infinity, Infinity, Infinity];
342
- const max = [-Infinity, -Infinity, -Infinity];
343
- for (const vertex of vertices) {
344
- for (let axis = 0; axis < 3; axis += 1) {
345
- min[axis] = Math.min(min[axis], vertex[axis]);
346
- max[axis] = Math.max(max[axis], vertex[axis]);
347
- }
348
- }
349
- if (!Number.isFinite(min[0])) {
350
- return { min: [0, 0, 0], max: [0, 1, 0], size: [1, 1, 1] };
339
+ function configureContext(context, device, format, alphaMode) {
340
+ if (typeof context.configure !== "function") {
341
+ throw new Error("Canvas WebGPU context does not support configure().");
351
342
  }
343
+ context.configure({
344
+ device,
345
+ format,
346
+ alphaMode
347
+ });
348
+ }
349
+ function createRenderPassDescriptor(view, clearColor) {
352
350
  return {
353
- min,
354
- max,
355
- size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]]
351
+ colorAttachments: [
352
+ {
353
+ view,
354
+ loadOp: "clear",
355
+ clearValue: {
356
+ r: clearColor[0],
357
+ g: clearColor[1],
358
+ b: clearColor[2],
359
+ a: clearColor[3]
360
+ },
361
+ storeOp: "store"
362
+ }
363
+ ]
356
364
  };
357
365
  }
358
- function sampleChannel(channel, sampler, document, timeSeconds) {
359
- const input = readAccessor(document, sampler.input);
360
- const output = readAccessor(document, sampler.output);
361
- if (!input.length || !output.length) {
362
- return null;
363
- }
364
- const duration = input.at(-1) || 0;
365
- const sampleTime = duration > 0 ? timeSeconds % duration : 0;
366
- let frame = 0;
367
- while (frame < input.length - 2 && sampleTime > input[frame + 1]) {
368
- frame += 1;
366
+ function supportsWebGpu(options = {}) {
367
+ try {
368
+ const gpu = readGpu(options.navigator);
369
+ return Boolean(gpu);
370
+ } catch {
371
+ return false;
369
372
  }
370
- const start = input[frame] ?? 0;
371
- const end = input[frame + 1] ?? start;
372
- const t = end > start ? (sampleTime - start) / (end - start) : 0;
373
- const startValue = Array.isArray(output[frame]) ? output[frame] : [output[frame] ?? 0];
374
- const endValue = Array.isArray(output[frame + 1]) ? output[frame + 1] : startValue;
375
- return channel.target.path === "rotation" ? nlerpQuaternion(startValue, endValue, t) : lerpArray(startValue, endValue, t);
376
373
  }
377
- function rootTranslationDistance(clipPayload) {
378
- if (!clipPayload) {
379
- return 0;
374
+ async function createGpuRenderer(options = {}) {
375
+ const {
376
+ canvas,
377
+ navigator: navigatorOverride,
378
+ document: documentOverride,
379
+ powerPreference = "high-performance",
380
+ alpha = true,
381
+ format,
382
+ clearColor = DEFAULT_CLEAR_COLOR,
383
+ requestAnimationFrame = globalThis.requestAnimationFrame?.bind(globalThis),
384
+ cancelAnimationFrame = globalThis.cancelAnimationFrame?.bind(globalThis),
385
+ frameIdFactory,
386
+ onFrameStart,
387
+ encodeFrame,
388
+ onBeforeEncode,
389
+ onAfterSubmit,
390
+ onFrameComplete
391
+ } = options;
392
+ const gpu = readGpu(navigatorOverride);
393
+ const adapter = await gpu.requestAdapter({ powerPreference });
394
+ if (!adapter) {
395
+ throw new Error("Unable to obtain GPU adapter.");
380
396
  }
381
- let distance = 0;
382
- for (const channel of clipPayload.animation.channels ?? []) {
383
- if (channel.target?.path !== "translation") {
384
- continue;
397
+ const device = await adapter.requestDevice();
398
+ const targetCanvas = resolveCanvas(canvas, documentOverride);
399
+ const context = targetCanvas.getContext?.("webgpu");
400
+ if (!context) {
401
+ throw new Error("Unable to obtain WebGPU canvas context.");
402
+ }
403
+ const resolvedFormat = format || (typeof gpu.getPreferredCanvasFormat === "function" ? gpu.getPreferredCanvasFormat() : "bgra8unorm");
404
+ configureContext(context, device, resolvedFormat, alpha ? "premultiplied" : "opaque");
405
+ let running = false;
406
+ let destroyed = false;
407
+ let frame = 0;
408
+ let lastTimestamp = 0;
409
+ let rafId = null;
410
+ let clear = normalizeColor(clearColor);
411
+ let xrActive = false;
412
+ let detachXrBinding = null;
413
+ const renderOnce = (timestamp = now()) => {
414
+ if (destroyed) {
415
+ throw new Error("Renderer was destroyed.");
385
416
  }
386
- const nodeName = clipPayload.document.json.nodes?.[channel.target.node]?.name ?? "";
387
- if (!/(^|:)Hips$/u.test(nodeName) && nodeName !== "mixamorigHips") {
388
- continue;
417
+ const frameNumber = frame + 1;
418
+ const frameId = normalizeFrameId(
419
+ typeof frameIdFactory === "function" ? frameIdFactory({
420
+ frame: frameNumber,
421
+ timestamp,
422
+ canvas: targetCanvas,
423
+ xrActive
424
+ }) : `renderer.frame.${frameNumber}`
425
+ );
426
+ const frameTimeMs = lastTimestamp > 0 ? Math.max(0, timestamp - lastTimestamp) : void 0;
427
+ if (typeof onFrameStart === "function") {
428
+ onFrameStart({
429
+ frame: frameNumber,
430
+ frameId,
431
+ frameTimeMs,
432
+ timestamp,
433
+ device,
434
+ context,
435
+ canvas: targetCanvas,
436
+ xrActive
437
+ });
389
438
  }
390
- const sampler = clipPayload.animation.samplers?.[channel.sampler];
391
- const output = sampler ? readAccessor(clipPayload.document, sampler.output) : [];
392
- if (output.length < 2) {
393
- continue;
439
+ const texture = context.getCurrentTexture?.();
440
+ if (!texture || typeof texture.createView !== "function") {
441
+ throw new Error("WebGPU context returned an invalid current texture.");
394
442
  }
395
- const start = Array.isArray(output[0]) ? output[0] : [0, 0, 0];
396
- const end = Array.isArray(output.at(-1)) ? output.at(-1) : start;
397
- distance = Math.max(distance, Math.hypot((end[0] ?? 0) - (start[0] ?? 0), (end[2] ?? 0) - (start[2] ?? 0)));
398
- }
399
- return distance;
400
- }
401
- function createClipPayload(clipRef) {
402
- const document = parseGlb(clipRef?.asset);
403
- const animation = document?.json.animations?.[0];
404
- if (!document || !animation) {
405
- return null;
406
- }
407
- const nodeNames = /* @__PURE__ */ new Set();
408
- let durationSeconds = 0;
409
- for (const channel of animation.channels ?? []) {
410
- const nodeName = document.json.nodes?.[channel.target?.node]?.name;
411
- if (nodeName) {
412
- nodeNames.add(nodeName);
443
+ const encoder = device.createCommandEncoder({
444
+ label: `plasius.gpu-renderer.frame.${frame}`
445
+ });
446
+ const view = texture.createView();
447
+ if (typeof encodeFrame === "function") {
448
+ encodeFrame({ device, context, encoder, view, canvas: targetCanvas, timestamp });
449
+ } else {
450
+ const pass = encoder.beginRenderPass(createRenderPassDescriptor(view, clear));
451
+ if (typeof onBeforeEncode === "function") {
452
+ onBeforeEncode({
453
+ frame,
454
+ frameNumber,
455
+ frameId,
456
+ frameTimeMs,
457
+ timestamp,
458
+ device,
459
+ context,
460
+ encoder,
461
+ pass,
462
+ canvas: targetCanvas,
463
+ xrActive
464
+ });
465
+ }
466
+ if (typeof pass.end === "function") {
467
+ pass.end();
468
+ }
413
469
  }
414
- const sampler = animation.samplers?.[channel.sampler];
415
- const input = sampler ? readAccessor(document, sampler.input) : [];
416
- durationSeconds = Math.max(durationSeconds, input.at(-1) ?? 0);
417
- }
418
- return {
419
- id: clipRef.id,
420
- document,
421
- animation,
422
- durationSeconds,
423
- nodeNames
470
+ const commandBuffer = encoder.finish();
471
+ device.queue.submit([commandBuffer]);
472
+ frame = frameNumber;
473
+ lastTimestamp = timestamp;
474
+ if (typeof onAfterSubmit === "function") {
475
+ onAfterSubmit({
476
+ frame: frameNumber,
477
+ frameNumber,
478
+ frameId,
479
+ frameTimeMs,
480
+ timestamp,
481
+ device,
482
+ context,
483
+ canvas: targetCanvas,
484
+ xrActive
485
+ });
486
+ }
487
+ if (typeof onFrameComplete === "function") {
488
+ onFrameComplete({
489
+ frame: frameNumber,
490
+ frameId,
491
+ frameTimeMs,
492
+ timestamp,
493
+ device,
494
+ context,
495
+ canvas: targetCanvas,
496
+ xrActive
497
+ });
498
+ }
499
+ return {
500
+ frame: frameNumber,
501
+ frameId,
502
+ frameTimeMs,
503
+ timestamp
504
+ };
424
505
  };
425
- }
426
- function sampleClipOverrides(clipPayload, timeSeconds) {
427
- const overrides = /* @__PURE__ */ new Map();
428
- if (!clipPayload) {
429
- return overrides;
430
- }
431
- for (const channel of clipPayload.animation.channels ?? []) {
432
- const targetNode = clipPayload.document.json.nodes?.[channel.target?.node];
433
- const sampler = clipPayload.animation.samplers?.[channel.sampler];
434
- if (!targetNode?.name || !sampler) {
435
- continue;
506
+ const tick = (timestamp) => {
507
+ if (!running || destroyed) {
508
+ return;
436
509
  }
437
- const value = sampleChannel(channel, sampler, clipPayload.document, timeSeconds);
438
- if (!value) {
439
- continue;
510
+ renderOnce(timestamp);
511
+ if (typeof requestAnimationFrame === "function") {
512
+ rafId = requestAnimationFrame(tick);
440
513
  }
441
- const existing = overrides.get(targetNode.name) ?? {};
442
- overrides.set(targetNode.name, {
443
- ...existing,
444
- [channel.target.path]: value
445
- });
446
- }
447
- return overrides;
448
- }
449
- function createAnimatedGltfModel(modelAsset, clipRefs = []) {
450
- const document = parseGlb(modelAsset);
451
- if (!document) {
452
- return null;
453
- }
454
- const nodes = document.json.nodes ?? [];
455
- const meshNodeIndex = nodes.findIndex((node) => node.mesh !== void 0 && node.skin !== void 0);
456
- const meshNode = nodes[meshNodeIndex];
457
- const mesh = document.json.meshes?.[meshNode?.mesh];
458
- const primitive = mesh?.primitives?.find((candidate) => candidate.attributes?.POSITION !== void 0);
459
- const skin = document.json.skins?.[meshNode?.skin];
460
- if (!meshNode || !primitive || !skin) {
461
- return null;
462
- }
463
- const positions = readAccessor(document, primitive.attributes.POSITION);
464
- const normals = primitive.attributes.NORMAL === void 0 ? [] : readAccessor(document, primitive.attributes.NORMAL);
465
- const texcoords = primitive.attributes.TEXCOORD_0 === void 0 ? [] : readAccessor(document, primitive.attributes.TEXCOORD_0);
466
- const joints = primitive.attributes.JOINTS_0 === void 0 ? positions.map(() => [0, 0, 0, 0]) : readAccessor(document, primitive.attributes.JOINTS_0);
467
- const weights = primitive.attributes.WEIGHTS_0 === void 0 ? positions.map(() => [1, 0, 0, 0]) : readAccessor(document, primitive.attributes.WEIGHTS_0).map(normalizeWeights);
468
- const indices = primitive.indices === void 0 ? positions.map((_, index) => index) : readAccessor(document, primitive.indices);
469
- const inverseBindMatrices = skin.inverseBindMatrices === void 0 ? skin.joints.map(() => identityMatrix()) : readMatrices(document, skin.inverseBindMatrices);
470
- const clips = clipRefs.map(createClipPayload).filter(Boolean);
471
- const bindWorldMatrices = computeWorldMatrices(nodes);
472
- const bindVertices = skinVertices({
473
- positions,
474
- joints,
475
- weights,
476
- skin,
477
- inverseBindMatrices,
478
- worldMatrices: bindWorldMatrices
479
- });
480
- const bounds = deriveBounds(bindVertices);
481
- const materialTextureMetadata = extractMaterialTextureMetadata(document, primitive);
482
- const clipRootMotionDistances = new Map(clips.map((clip) => [clip.id, rootTranslationDistance(clip)]));
483
- return {
484
- name: mesh.name ?? meshNode.name ?? "skinned-gltf-model",
485
- vertexCount: positions.length,
486
- triangleCount: Math.floor(indices.length / 3),
487
- jointCount: skin.joints.length,
488
- animatedNodeCount: new Set(clips.flatMap((clip) => [...clip.nodeNames])).size,
489
- clipCount: clips.length,
490
- materialTextureMetadata,
491
- textureCount: materialTextureMetadata.textureCount,
492
- materialCount: materialTextureMetadata.materialCount,
493
- hasBaseColorTexture: materialTextureMetadata.hasBaseColorTexture,
494
- hasNormalTexture: materialTextureMetadata.hasNormalTexture,
495
- hasUv: texcoords.length === positions.length,
496
- hasNormals: normals.length === positions.length,
497
- professionalRenderable: materialTextureMetadata.hasBaseColorTexture && materialTextureMetadata.hasNormalTexture && materialTextureMetadata.missingTextureReferences.length === 0 && texcoords.length === positions.length && normals.length === positions.length && skin.joints.length > 0,
498
- clips,
499
- sample(activeClipId, clipTimeMs) {
500
- const clip = clips.find((candidate) => candidate.id === activeClipId) ?? clips[0] ?? null;
501
- const overrides = sampleClipOverrides(clip, Math.max(0, clipTimeMs) / 1e3);
502
- const worldMatrices = computeWorldMatrices(nodes, overrides);
503
- const vertices = skinVertices({
504
- positions,
505
- joints,
506
- weights,
507
- skin,
508
- inverseBindMatrices,
509
- worldMatrices
510
- });
511
- return {
512
- vertices,
513
- indices,
514
- bounds: deriveBounds(vertices),
515
- bindBounds: bounds,
516
- activeClipRenderable: Boolean(clip),
517
- activeClipDurationSeconds: clip?.durationSeconds ?? 0,
518
- activeClipRootTranslationDistance: clipRootMotionDistances.get(clip?.id) ?? 0
519
- };
514
+ };
515
+ const start = () => {
516
+ if (destroyed) {
517
+ throw new Error("Renderer was destroyed.");
518
+ }
519
+ if (running) {
520
+ return false;
521
+ }
522
+ running = true;
523
+ if (typeof requestAnimationFrame === "function") {
524
+ rafId = requestAnimationFrame(tick);
525
+ } else {
526
+ renderOnce();
520
527
  }
528
+ return true;
521
529
  };
522
- }
523
- function skinVertices({ positions, joints, weights, skin, inverseBindMatrices, worldMatrices }) {
524
- return positions.map((position, vertexIndex) => {
525
- const jointIndices = joints[vertexIndex] ?? [0, 0, 0, 0];
526
- const jointWeights = weights[vertexIndex] ?? [1, 0, 0, 0];
527
- const skinned = [0, 0, 0];
528
- for (let influence = 0; influence < 4; influence += 1) {
529
- const weight = jointWeights[influence] ?? 0;
530
- if (weight <= 0) {
531
- continue;
532
- }
533
- const jointNodeIndex = skin.joints[jointIndices[influence] ?? 0] ?? skin.joints[0];
534
- const jointMatrix = multiplyMatrix(worldMatrices[jointNodeIndex] ?? identityMatrix(), inverseBindMatrices[jointIndices[influence] ?? 0] ?? identityMatrix());
535
- const transformed = transformPoint(jointMatrix, position);
536
- skinned[0] += transformed[0] * weight;
537
- skinned[1] += transformed[1] * weight;
538
- skinned[2] += transformed[2] * weight;
530
+ const stop = () => {
531
+ if (!running) {
532
+ return false;
539
533
  }
540
- return skinned;
541
- });
542
- }
543
-
544
- // src/animated-scene-renderer.js
545
- var DEFAULT_CAMERA = Object.freeze({
546
- mode: "lagged-follow",
547
- viewMode: "spectator",
548
- cubicBezier: [0.22, 0.61, 0.36, 1],
549
- lagMs: 240,
550
- lookAheadMs: 320,
551
- offset: [0, 2.4, 5.5],
552
- constraints: Object.freeze({
553
- maxDistance: 10,
554
- firstPersonHeadOffset: 0.05
555
- }),
556
- headLook: Object.freeze({
557
- enabled: true,
558
- returnMs: 240
559
- })
560
- });
561
- function nowMs() {
562
- return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
563
- }
564
- function clamp01(value) {
565
- return Math.min(1, Math.max(0, value));
566
- }
567
- function lerp2(a, b, t) {
568
- return a + (b - a) * t;
569
- }
570
- function lerp3(a, b, t) {
571
- return [
572
- lerp2(a[0] ?? 0, b[0] ?? 0, t),
573
- lerp2(a[1] ?? 0, b[1] ?? 0, t),
574
- lerp2(a[2] ?? 0, b[2] ?? 0, t)
575
- ];
534
+ running = false;
535
+ if (rafId !== null && typeof cancelAnimationFrame === "function") {
536
+ cancelAnimationFrame(rafId);
537
+ }
538
+ rafId = null;
539
+ return true;
540
+ };
541
+ const resize = (cssWidth, cssHeight, devicePixelRatio = globalThis.devicePixelRatio ?? 1) => {
542
+ const width = Math.max(1, Math.floor(cssWidth * devicePixelRatio));
543
+ const height = Math.max(1, Math.floor(cssHeight * devicePixelRatio));
544
+ targetCanvas.width = width;
545
+ targetCanvas.height = height;
546
+ if (targetCanvas.style) {
547
+ targetCanvas.style.width = `${Math.max(1, Math.floor(cssWidth))}px`;
548
+ targetCanvas.style.height = `${Math.max(1, Math.floor(cssHeight))}px`;
549
+ }
550
+ return { width, height };
551
+ };
552
+ const setClearColor = (value) => {
553
+ clear = normalizeColor(value);
554
+ return [...clear];
555
+ };
556
+ const setXrActive = (active) => {
557
+ xrActive = Boolean(active);
558
+ };
559
+ const getSnapshot = () => {
560
+ const width = Number(targetCanvas.width) || 0;
561
+ const height = Number(targetCanvas.height) || 0;
562
+ return {
563
+ running,
564
+ frame,
565
+ lastTimestamp,
566
+ format: resolvedFormat,
567
+ width,
568
+ height,
569
+ xrActive
570
+ };
571
+ };
572
+ const renderer = {
573
+ canvas: targetCanvas,
574
+ context,
575
+ device,
576
+ format: resolvedFormat,
577
+ renderOnce,
578
+ start,
579
+ stop,
580
+ resize,
581
+ setClearColor,
582
+ setXrActive,
583
+ getSnapshot,
584
+ bindXrManager(xrManager, bindOptions = {}) {
585
+ if (detachXrBinding) {
586
+ detachXrBinding();
587
+ }
588
+ detachXrBinding = bindRendererToXrManager(renderer, xrManager, bindOptions);
589
+ return detachXrBinding;
590
+ },
591
+ destroy() {
592
+ stop();
593
+ destroyed = true;
594
+ if (detachXrBinding) {
595
+ detachXrBinding();
596
+ detachXrBinding = null;
597
+ }
598
+ if (typeof context.unconfigure === "function") {
599
+ context.unconfigure();
600
+ }
601
+ }
602
+ };
603
+ return renderer;
576
604
  }
577
- function normalize3(value, fallback = [0, 0, -1]) {
578
- if (!Array.isArray(value) || value.length < 3) {
579
- return [...fallback];
605
+ function snapshotFromXrManager(xrManager) {
606
+ if (xrManager && typeof xrManager.getState === "function") {
607
+ return xrManager.getState();
580
608
  }
581
- const vector = [
582
- Number.isFinite(Number(value[0])) ? Number(value[0]) : fallback[0],
583
- Number.isFinite(Number(value[1])) ? Number(value[1]) : fallback[1],
584
- Number.isFinite(Number(value[2])) ? Number(value[2]) : fallback[2]
585
- ];
586
- const length = Math.hypot(vector[0], vector[1], vector[2]);
587
- if (length <= 1e-6) {
588
- return [...fallback];
609
+ if (xrManager?.store && typeof xrManager.store.getSnapshot === "function") {
610
+ return xrManager.store.getSnapshot();
589
611
  }
590
- return [vector[0] / length, vector[1] / length, vector[2] / length];
591
- }
592
- function clamp(value, min, max) {
593
- return Math.min(max, Math.max(min, value));
594
- }
595
- function bezierY([, y1, , y2], t) {
596
- const u = 1 - t;
597
- return 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t;
598
- }
599
- function durationFromBeats(beats) {
600
- return beats.reduce((total, beat) => total + Math.max(0, beat.durationMs ?? 0), 0);
612
+ return null;
601
613
  }
602
- function definedCameraOverrides(camera) {
603
- if (!camera || typeof camera !== "object") {
604
- return {};
614
+ function bindRendererToXrManager(renderer, xrManager, options = {}) {
615
+ if (!xrManager || typeof xrManager.subscribe !== "function") {
616
+ throw new Error("XR manager must expose subscribe(listener). Use @plasius/gpu-xr createXrManager().");
605
617
  }
606
- return Object.fromEntries(Object.entries(camera).filter(([, value]) => value !== void 0));
607
- }
608
- function resolveBeat(beats, loopTimeMs) {
609
- let cursor = 0;
610
- for (const beat of beats) {
611
- const durationMs = Math.max(1, beat.durationMs ?? 1);
612
- if (loopTimeMs < cursor + durationMs) {
613
- return { beat, beatTimeMs: loopTimeMs - cursor, durationMs };
618
+ const { onSessionStart, onSessionEnd } = options;
619
+ let previousSession = null;
620
+ const applyState = (state) => {
621
+ const session = state?.activeSession ?? null;
622
+ if (session === previousSession) {
623
+ return;
614
624
  }
615
- cursor += durationMs;
616
- }
617
- const fallback = beats.at(-1);
618
- return { beat: fallback, beatTimeMs: fallback?.durationMs ?? 0, durationMs: fallback?.durationMs ?? 1 };
619
- }
620
- function resolveRoutePosition(route, timeMs) {
621
- if (!route.length) {
622
- return [0, 0, 0];
623
- }
624
- if (route.length === 1 || timeMs <= (route[0].arriveMs ?? 0)) {
625
- return [...route[0].position];
626
- }
627
- for (let index = 1; index < route.length; index += 1) {
628
- const previous = route[index - 1];
629
- const next = route[index];
630
- const start = previous.arriveMs ?? 0;
631
- const end = next.arriveMs ?? start + 1;
632
- if (timeMs <= end) {
633
- return lerp3(previous.position, next.position, clamp01((timeMs - start) / Math.max(1, end - start)));
625
+ previousSession = session;
626
+ if (typeof renderer.setXrActive === "function") {
627
+ renderer.setXrActive(Boolean(session));
634
628
  }
635
- }
636
- return [...route.at(-1).position];
637
- }
638
- function resolveRouteForward(route, timeMs) {
639
- if (route.length < 2) {
640
- return [0, 0, -1];
641
- }
642
- let previous = route[0];
643
- let next = route[1];
644
- for (let index = 1; index < route.length; index += 1) {
645
- next = route[index];
646
- if (timeMs <= (next.arriveMs ?? 0)) {
647
- previous = route[index - 1];
648
- break;
629
+ if (session && typeof onSessionStart === "function") {
630
+ onSessionStart(session, renderer);
649
631
  }
650
- }
651
- return normalize3([
652
- (next.position?.[0] ?? 0) - (previous.position?.[0] ?? 0),
653
- (next.position?.[1] ?? 0) - (previous.position?.[1] ?? 0),
654
- (next.position?.[2] ?? 0) - (previous.position?.[2] ?? 0)
655
- ]);
656
- }
657
- function distance3(a = [0, 0, 0], b = [0, 0, 0]) {
658
- return Math.hypot(
659
- (b[0] ?? 0) - (a[0] ?? 0),
660
- (b[1] ?? 0) - (a[1] ?? 0),
661
- (b[2] ?? 0) - (a[2] ?? 0)
662
- );
632
+ if (!session && typeof onSessionEnd === "function") {
633
+ onSessionEnd(renderer);
634
+ }
635
+ };
636
+ applyState(snapshotFromXrManager(xrManager));
637
+ return xrManager.subscribe(applyState);
663
638
  }
664
- function movementRequirementType(requirement, beat) {
665
- if (requirement?.type) {
666
- return requirement.type;
667
- }
668
- if (beat?.kind === "locomotion") {
669
- return beat?.rootMotion === "in-place" ? "stationary" : "travel";
670
- }
671
- if (beat?.kind === "action" || beat?.kind === "idle") {
672
- return "stationary";
673
- }
674
- const clipId = String(beat?.clipId ?? "").toLowerCase();
675
- if (/(idle|gesture|dig|plant|water|pick|serve|swing|fire|block|hoe)/iu.test(clipId)) {
676
- return "stationary";
677
- }
678
- if (/(walk|run|jump|climb|crawl|locomotion)/iu.test(clipId)) {
679
- return clipId.includes("jump") ? "jump" : "travel";
639
+ var defaultRendererClearColor = DEFAULT_CLEAR_COLOR;
640
+
641
+ // src/native-scene-shader.js
642
+ var nativeSceneShader = (
643
+ /* wgsl */
644
+ `
645
+ struct Frame {
646
+ eye: vec4f, right: vec4f, up: vec4f, forward: vec4f,
647
+ settings: vec4f, sun: vec4f,
648
+ lightRight: vec4f, lightUp: vec4f, lightForward: vec4f, lightOrigin: vec4f,
649
+ viewport: vec4f, wakes: array<vec4f, 4>, info: vec4f,
650
+ };
651
+ @group(0) @binding(0) var<uniform> frame: Frame;
652
+ @group(1) @binding(0) var shadowMap: texture_depth_2d;
653
+ @group(1) @binding(1) var shadowSampler: sampler_comparison;
654
+ @group(2) @binding(0) var reflectionMap: texture_2d<f32>;
655
+ @group(2) @binding(1) var reflectionSampler: sampler;
656
+ struct Surface {
657
+ @builtin(position) clip: vec4f,
658
+ @location(0) world: vec3f, @location(1) normal: vec3f,
659
+ @location(2) colour: vec3f, @location(3) material: vec3f,
660
+ };
661
+ fn project(p: vec3f) -> vec4f {
662
+ let d = p - frame.eye.xyz;
663
+ let z = dot(d, frame.forward.xyz);
664
+ return vec4f(dot(d, frame.right.xyz) / (frame.settings.x * frame.settings.y),
665
+ dot(d, frame.up.xyz) / frame.settings.x, (z - 0.08) * 500.0 / 499.92, z);
666
+ }
667
+ fn lightProject(p: vec3f) -> vec4f {
668
+ let d = p - frame.lightOrigin.xyz;
669
+ return vec4f(dot(d, frame.lightRight.xyz) / 34.0,
670
+ dot(d, frame.lightUp.xyz) / 34.0, dot(d, frame.lightForward.xyz) / 100.0, 1.0);
671
+ }
672
+ @vertex fn surfaceVertex(@location(0) p: vec3f, @location(1) n: vec3f,
673
+ @location(2) c: vec3f, @location(3) m: vec3f) -> Surface {
674
+ return Surface(project(p), p, n, c, m);
675
+ }
676
+ @vertex fn shadowVertex(@location(0) p: vec3f) -> @builtin(position) vec4f {
677
+ return lightProject(p);
678
+ }
679
+ @vertex fn fullscreenVertex(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
680
+ let positions = array<vec2f, 3>(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3));
681
+ return vec4f(positions[i], 1, 1);
682
+ }
683
+ fn cameraRay(pixel: vec2f) -> vec3f {
684
+ let uv = pixel / frame.viewport.xy * 2.0 - 1.0;
685
+ return normalize(frame.forward.xyz + frame.right.xyz * uv.x * frame.settings.x * frame.settings.y
686
+ - frame.up.xyz * uv.y * frame.settings.x);
687
+ }
688
+ fn display(c: vec3f) -> vec3f {
689
+ let x = max(c * frame.settings.w, vec3f(0));
690
+ let mapped = clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), vec3f(0), vec3f(1));
691
+ return pow(mapped, vec3f(1.0 / 2.2));
692
+ }
693
+ fn hash(p: vec3f) -> f32 { return fract(sin(dot(p, vec3f(127.1, 311.7, 74.7))) * 43758.5453); }
694
+ fn noise(p: vec3f) -> f32 {
695
+ let i = floor(p); let f = fract(p); let u = f * f * (3.0 - 2.0 * f);
696
+ return mix(mix(mix(hash(i), hash(i + vec3f(1,0,0)), u.x),
697
+ mix(hash(i + vec3f(0,1,0)), hash(i + vec3f(1,1,0)), u.x), u.y),
698
+ mix(mix(hash(i + vec3f(0,0,1)), hash(i + vec3f(1,0,1)), u.x),
699
+ mix(hash(i + vec3f(0,1,1)), hash(i + vec3f(1,1,1)), u.x), u.y), u.z);
700
+ }
701
+ fn sky(ray: vec3f) -> vec3f {
702
+ let elevation = pow(clamp(ray.y * 0.7 + 0.16, 0.0, 1.0), 0.6);
703
+ var c = mix(vec3f(0.42, 0.53, 0.61), vec3f(0.07, 0.19, 0.37), elevation);
704
+ let sunAngle = dot(ray, frame.sun.xyz);
705
+ c += vec3f(1.0, 0.73, 0.43) * pow(max(sunAngle, 0.0), 64.0) * 0.35;
706
+ c += vec3f(4.0, 3.2, 2.1) * smoothstep(0.99965, 0.99985, sunAngle);
707
+ let cloudPoint = ray / max(0.12, ray.y) * 2.1 + vec3f(0, 0, 8);
708
+ let cloud = smoothstep(0.49, 0.77, noise(cloudPoint) * 0.65 + noise(cloudPoint * 2.1) * 0.35);
709
+ c = mix(c, vec3f(0.85, 0.86, 0.82), cloud * smoothstep(0.03, 0.35, ray.y) * 0.65);
710
+ return c;
711
+ }
712
+ @fragment fn skyFragment(@builtin(position) pixel: vec4f) -> @location(0) vec4f {
713
+ return vec4f(display(sky(cameraRay(pixel.xy))), 1);
714
+ }
715
+ fn visibility(p: vec3f, n: vec3f) -> f32 {
716
+ let q = lightProject(p + n * 0.035).xyz;
717
+ let uv = vec2f(q.x * 0.5 + 0.5, 0.5 - q.y * 0.5);
718
+ if (any(uv < vec2f(0.005)) || any(uv > vec2f(0.995)) || q.z < 0.0 || q.z > 1.0) { return 1.0; }
719
+ var result = 0.0;
720
+ for (var y = -1; y <= 1; y++) {
721
+ for (var x = -1; x <= 1; x++) {
722
+ result += textureSampleCompareLevel(shadowMap, shadowSampler, uv + vec2f(f32(x), f32(y)) / 2048.0, q.z - 0.0006);
723
+ }
724
+ }
725
+ return result / 9.0;
726
+ }
727
+ fn brdf(c: vec3f, n: vec3f, v: vec3f, roughness: f32, metal: f32) -> vec3f {
728
+ let l = frame.sun.xyz; let h = normalize(l + v);
729
+ let nl = max(dot(n,l),0.0); let nv = max(dot(n,v),0.001);
730
+ let nh = max(dot(n,h),0.0); let vh = max(dot(v,h),0.0);
731
+ let a = max(0.035, roughness * roughness); let a2 = a * a;
732
+ let d = a2 / max(0.00001, 3.14159265 * pow(nh * nh * (a2 - 1.0) + 1.0, 2.0));
733
+ let k = pow(roughness + 1.0, 2.0) / 8.0;
734
+ let g = (nl / (nl * (1.0-k) + k)) * (nv / (nv * (1.0-k) + k));
735
+ let f0 = mix(vec3f(0.04), c, metal);
736
+ let f = f0 + (1.0-f0) * pow(1.0-vh, 5.0);
737
+ return ((1.0-f) * (1.0-metal) * c / 3.14159265 + d * g * f / max(0.001, 4.0*nl*nv)) * nl;
738
+ }
739
+ fn fog(c: vec3f, p: vec3f) -> vec3f {
740
+ let amount = 1.0 - exp(-distance(frame.eye.xyz, p) * 0.0018);
741
+ return mix(c, vec3f(0.32, 0.44, 0.55), amount);
742
+ }
743
+ fn surfaceRadiance(s: Surface, front: bool) -> vec3f {
744
+ let facing = select(select(-1.0,1.0,front),select(1.0,-1.0,front),frame.info.y>0.5);
745
+ var n = normalize(s.normal) * facing;
746
+ let v = normalize(frame.eye.xyz - s.world);
747
+ var c = s.colour;
748
+ if (s.material.z > 2.5) {
749
+ let rock = smoothstep(0.45,0.68,noise(s.world*0.27));
750
+ c = mix(c,vec3f(0.16,0.145,0.11),rock*0.7);
751
+ c *= 0.75+0.45*noise(s.world*2.4);
752
+ n = normalize(n+vec3f(noise(s.world*4.0)-0.5,0,noise(s.world*4.0+vec3f(13))-0.5)*0.28);
753
+ } else if (s.material.z > 0.5 && s.material.z < 1.5) {
754
+ let grain = noise(s.world * vec3f(18, 3, 1.6));
755
+ c *= 0.82 + 0.30 * grain;
756
+ } else if (s.material.z > 1.5) { c *= 0.88 + 0.18 * noise(s.world * 12.0); }
757
+ let hemi = mix(vec3f(0.06,0.05,0.035), vec3f(0.16,0.22,0.29), n.y * 0.5 + 0.5);
758
+ let direct = brdf(c, n, v, s.material.x, s.material.y) * vec3f(3.0, 2.65, 2.15) * visibility(s.world, n);
759
+ let ambient = c * hemi * (1.0 - s.material.y * 0.7);
760
+ let env = sky(reflect(-v,n)) * mix(vec3f(0.04), c, s.material.y) * (1.0-s.material.x) * 0.30;
761
+ return fog(direct + ambient + env, s.world);
762
+ }
763
+ @fragment fn surfaceFragment(s: Surface, @builtin(front_facing) front: bool) -> @location(0) vec4f {
764
+ return vec4f(display(surfaceRadiance(s,front)),1);
765
+ }
766
+ fn wave(p: vec2f) -> f32 {
767
+ let t = frame.settings.z;
768
+ return sin(dot(p,vec2f(0.35,0.23))-t*0.85)*0.13
769
+ + sin(dot(p,vec2f(-0.61,0.42))+t*1.12)*0.065
770
+ + sin(dot(p,vec2f(1.2,0.83))-t*1.4)*0.025
771
+ + sin(dot(p,vec2f(3.1,-2.1))+t*1.8)*0.009;
772
+ }
773
+ struct WaterResult { @location(0) colour: vec4f, @builtin(frag_depth) depth: f32 };
774
+ @fragment fn waterFragment(@builtin(position) pixel: vec4f) -> WaterResult {
775
+ let ray = cameraRay(pixel.xy);
776
+ if (ray.y > -0.001) { discard; }
777
+ var dist = (frame.viewport.z - frame.eye.y) / ray.y;
778
+ var p = frame.eye.xyz + ray * dist;
779
+ for (var i=0; i<3; i++) {
780
+ dist = (frame.viewport.z + wave(p.xz) - frame.eye.y) / ray.y;
781
+ p = frame.eye.xyz + ray * dist;
782
+ }
783
+ if (dist < 0.08 || dist > 499.0) { discard; }
784
+ let eps = 0.03;
785
+ let waveNormal = normalize(vec3f((wave(p.xz-vec2f(eps,0))-wave(p.xz+vec2f(eps,0)))/(2.0*eps),
786
+ 1.0, (wave(p.xz-vec2f(0,eps))-wave(p.xz+vec2f(0,eps)))/(2.0*eps)));
787
+ let n = normalize(mix(waveNormal,vec3f(0,1,0),smoothstep(35.0,150.0,dist)));
788
+ let v = -ray;
789
+ let fresnel = 0.02 + 0.98 * pow(1.0-max(dot(n,v),0.0),5.0);
790
+ let uv = clamp(pixel.xy / frame.viewport.xy + n.xz * 0.018, vec2f(0.002), vec2f(0.998));
791
+ // Reflection target is HDR linear; tone mapping happens once after mixing.
792
+ let reflected = textureSampleLevel(reflectionMap, reflectionSampler, uv, 0).rgb;
793
+ let shadow = visibility(p, n);
794
+ var c = mix(vec3f(0.008,0.05,0.065) * (0.5+0.5*shadow), reflected, fresnel);
795
+ c += brdf(vec3f(0.01),n,v,0.14,0.0) * vec3f(2.5,2.15,1.7) * shadow;
796
+ var foam = 0.0;
797
+ for (var i=0u; i<4u; i++) {
798
+ if (f32(i) >= frame.info.x) { break; }
799
+ let w = frame.wakes[i]; let delta = p.xz - w.xy;
800
+ let along = dot(delta, vec2f(sin(w.z), cos(w.z)));
801
+ let across = abs(dot(delta,vec2f(cos(w.z),-sin(w.z))));
802
+ let ribbon = exp(-pow((across - max(0.0,-along)*0.28 - 0.65)*4.0,2.0));
803
+ foam += ribbon * (1.0-smoothstep(-1.0,0.5,along)) * exp(min(0.0,along)*0.25) * (0.4+0.6*noise(vec3f(p.x*5.0,p.z*5.0,frame.settings.z*0.5)));
804
+ }
805
+ c = mix(c,vec3f(0.48,0.61,0.62),clamp(foam*0.42,0.0,0.55));
806
+ let clip = project(p);
807
+ return WaterResult(vec4f(display(fog(c,p)),1),clip.z/clip.w);
808
+ }
809
+ // Keep reflection radiance linear, including sky. Reusing the lighting calculation
810
+ // rather than sampling a screenshot avoids double tone mapping on the water.
811
+ @fragment fn reflectionSky(@builtin(position) pixel: vec4f) -> @location(0) vec4f {
812
+ return vec4f(sky(cameraRay(pixel.xy)),1);
813
+ }
814
+ @fragment fn reflectionSurface(s: Surface, @builtin(front_facing) front: bool) -> @location(0) vec4f {
815
+ if (s.world.y < frame.viewport.z) { discard; }
816
+ return vec4f(surfaceRadiance(s,front),1);
817
+ }
818
+ `
819
+ );
820
+
821
+ // src/native-scene-renderer.js
822
+ var STRIDE = 12;
823
+ var UNIFORM_BYTES = 256;
824
+ var SHADOW_SIZE = 2048;
825
+ var sub = (a, b) => a.map((v, i) => v - b[i]);
826
+ var cross = (a, b) => [
827
+ a[1] * b[2] - a[2] * b[1],
828
+ a[2] * b[0] - a[0] * b[2],
829
+ a[0] * b[1] - a[1] * b[0]
830
+ ];
831
+ var unit = (a) => {
832
+ const length = Math.hypot(...a);
833
+ return a.map((v) => v / length);
834
+ };
835
+ var finiteVector = (a) => Array.isArray(a) && a.length === 3 && a.every(Number.isFinite);
836
+ function validateFrame(frame, maxVertices) {
837
+ const { vertices, camera, time = 0 } = frame ?? {};
838
+ if (!(vertices instanceof Float32Array) || vertices.length % (STRIDE * 3) !== 0 || vertices.length > maxVertices * STRIDE || !vertices.every(Number.isFinite)) {
839
+ throw new Error(
840
+ `vertices must contain bounded, finite interleaved triangles (12 floats per vertex); received ${vertices?.length ?? 0} floats; first nonfinite component ${vertices instanceof Float32Array ? vertices.findIndex((v) => !Number.isFinite(v)) : "unknown"}.`
841
+ );
680
842
  }
681
- return beat?.pathPointId ? "travel" : "stationary";
682
- }
683
- function movementDistancePerLoop(profile = {}) {
684
- profile ??= {};
685
- const rootDistance = Number(profile.rootTranslationDistance ?? profile.rootTranslation?.distance ?? 0);
686
- if (Number.isFinite(rootDistance) && rootDistance > 0) {
687
- return rootDistance;
843
+ if (!finiteVector(camera?.eye) || !finiteVector(camera?.target) || Math.hypot(...sub(camera.eye, camera.target)) < 1e-3 || !Number.isFinite(camera.fov ?? 54) || (camera.fov ?? 54) < 10 || (camera.fov ?? 54) > 120) {
844
+ throw new Error(
845
+ "camera must provide distinct finite eye/target vectors and a 10\u2013120 degree fov."
846
+ );
688
847
  }
689
- const strideLength = Number(profile.strideLength ?? profile.strideLengthMeters ?? 0);
690
- if (Number.isFinite(strideLength) && strideLength > 0) {
691
- return strideLength;
848
+ if (!Number.isFinite(time)) throw new Error("time must be finite.");
849
+ if (!Number.isFinite(frame.waterLevel ?? 0))
850
+ throw new Error("waterLevel must be finite.");
851
+ if (frame.wakes !== void 0 && (!Array.isArray(frame.wakes) || frame.wakes.length > 4 || frame.wakes.some(
852
+ (w) => !Array.isArray(w) || w.length !== 3 || !w.every(Number.isFinite)
853
+ ))) {
854
+ throw new Error(
855
+ "wakes must contain at most four finite [x, z, heading] entries."
856
+ );
692
857
  }
693
- return 0;
694
- }
695
- function clipDurationMs(profile = {}, fallbackMs = 1) {
696
- profile ??= {};
697
- const duration = Number(profile.durationMs ?? 0);
698
- return Number.isFinite(duration) && duration > 0 ? duration : Math.max(1, fallbackMs);
699
- }
700
- function profileAllowsDisplacement(profile = {}, movementType) {
701
- profile ??= {};
702
- if (movementType === "stationary") {
703
- return profile.worldDisplacementAllowed !== true;
858
+ return { ...frame, time };
859
+ }
860
+ function frameUniforms(frame, width, height, reflected = false) {
861
+ const eye = [...frame.camera.eye];
862
+ let forward = unit(sub(frame.camera.target, eye));
863
+ const referenceUp = Math.abs(forward[1]) > 0.999 ? [0, 0, 1] : [0, 1, 0];
864
+ const right = unit(cross(forward, referenceUp));
865
+ const up = unit(cross(right, forward));
866
+ const waterLevel = frame.waterLevel ?? 0;
867
+ if (reflected) {
868
+ eye[1] = 2 * waterLevel - eye[1];
869
+ forward = [forward[0], -forward[1], forward[2]];
870
+ right[1] *= -1;
871
+ up[1] *= -1;
872
+ }
873
+ const sun = unit([-0.62, 0.48, 0.62]);
874
+ const lightForward = sun.map((x) => -x);
875
+ const lightRight = unit(cross(lightForward, [0, 1, 0]));
876
+ const lightUp = unit(cross(lightRight, lightForward));
877
+ const uniforms = new Float32Array(64);
878
+ uniforms.set(eye, 0);
879
+ uniforms.set(right, 4);
880
+ uniforms.set(up, 8);
881
+ uniforms.set(forward, 12);
882
+ uniforms.set(
883
+ [
884
+ Math.tan((frame.camera.fov ?? 54) * Math.PI / 360),
885
+ width / height,
886
+ frame.time,
887
+ 0.86
888
+ ],
889
+ 16
890
+ );
891
+ uniforms.set(sun, 20);
892
+ uniforms.set(lightRight, 24);
893
+ uniforms.set(lightUp, 28);
894
+ uniforms.set(lightForward, 32);
895
+ uniforms.set([sun[0] * 45, sun[1] * 45, sun[2] * 45 + 4], 36);
896
+ uniforms.set([width, height, waterLevel, 0], 40);
897
+ (frame.wakes ?? []).forEach(
898
+ (wake, index) => uniforms.set([...wake, 0], 44 + index * 4)
899
+ );
900
+ uniforms.set([(frame.wakes ?? []).length, reflected ? 1 : 0, 0, 0], 60);
901
+ return uniforms;
902
+ }
903
+ async function createNativeSceneRenderer(options = {}) {
904
+ const maxVertices = options.maxVertices ?? 3e5;
905
+ if (!Number.isInteger(maxVertices) || maxVertices < 3 || maxVertices > 6e5) {
906
+ throw new Error("maxVertices must be an integer between 3 and 600000.");
907
+ }
908
+ let base;
909
+ let destroyed = false;
910
+ let available = true;
911
+ let targets = null;
912
+ let vertexBuffer = null;
913
+ let vertexCapacity = 0;
914
+ let pendingFrame = null;
915
+ let submittedFrames = 0;
916
+ let vertexCount = 0;
917
+ const owned = /* @__PURE__ */ new Set();
918
+ const own = (resource) => {
919
+ owned.add(resource);
920
+ return resource;
921
+ };
922
+ const release = (resource) => {
923
+ resource.destroy();
924
+ owned.delete(resource);
925
+ };
926
+ const destroy = () => {
927
+ if (destroyed) return;
928
+ destroyed = true;
929
+ base?.destroy();
930
+ for (const resource of owned) resource.destroy();
931
+ owned.clear();
932
+ base?.device.destroy?.();
933
+ };
934
+ try {
935
+ let ensureTargets = function() {
936
+ const limit = Math.min(device.limits.maxTextureDimension2D, 3840);
937
+ const requestedWidth = Math.max(1, Number(base.canvas.width) || 1);
938
+ const requestedHeight = Math.max(1, Number(base.canvas.height) || 1);
939
+ const scale2 = Math.min(
940
+ 1,
941
+ limit / requestedWidth,
942
+ limit / requestedHeight,
943
+ Math.sqrt(1920 * 1080 / (requestedWidth * requestedHeight))
944
+ );
945
+ const width = Math.max(1, Math.floor(requestedWidth * scale2));
946
+ const height = Math.max(1, Math.floor(requestedHeight * scale2));
947
+ base.canvas.width = width;
948
+ base.canvas.height = height;
949
+ if (targets?.width === width && targets?.height === height) return;
950
+ if (targets) for (const texture2 of targets.textures) release(texture2);
951
+ const reflectionWidth = Math.min(1280, Math.max(1, Math.ceil(width / 2)));
952
+ const reflectionHeight = Math.max(
953
+ 1,
954
+ Math.round(height * reflectionWidth / width)
955
+ );
956
+ const texture = (label, w, h, pixelFormat, sampleCount = 1, usage = 16) => own(
957
+ device.createTexture({
958
+ label,
959
+ size: [w, h],
960
+ format: pixelFormat,
961
+ sampleCount,
962
+ usage
963
+ })
964
+ );
965
+ const colour = texture("native.msaa-colour", width, height, format, 4);
966
+ const depth = texture(
967
+ "native.msaa-depth",
968
+ width,
969
+ height,
970
+ "depth32float",
971
+ 4
972
+ );
973
+ const reflection = texture(
974
+ "native.reflection",
975
+ reflectionWidth,
976
+ reflectionHeight,
977
+ "rgba16float",
978
+ 1,
979
+ 20
980
+ );
981
+ const reflectionDepth = texture(
982
+ "native.reflection-depth",
983
+ reflectionWidth,
984
+ reflectionHeight,
985
+ "depth32float"
986
+ );
987
+ const reflectionView = reflection.createView();
988
+ targets = {
989
+ width,
990
+ height,
991
+ reflectionWidth,
992
+ reflectionHeight,
993
+ colour: colour.createView(),
994
+ depth: depth.createView(),
995
+ reflection: reflectionView,
996
+ reflectionDepth: reflectionDepth.createView(),
997
+ textures: [colour, depth, reflection, reflectionDepth],
998
+ reflectionGroup: device.createBindGroup({
999
+ layout: reflectionLayout,
1000
+ entries: [
1001
+ { binding: 0, resource: reflectionView },
1002
+ { binding: 1, resource: reflectionSampler }
1003
+ ]
1004
+ })
1005
+ };
1006
+ }, encode = function({ encoder, view }) {
1007
+ const depthAttachment = (depthView) => ({
1008
+ view: depthView,
1009
+ depthLoadOp: "clear",
1010
+ depthClearValue: 1,
1011
+ depthStoreOp: "store"
1012
+ });
1013
+ const drawGeometry = (pass2, p, uniforms) => {
1014
+ pass2.setPipeline(p);
1015
+ pass2.setBindGroup(0, uniforms);
1016
+ if (p !== shadowPipeline) pass2.setBindGroup(1, shadowGroup);
1017
+ if (vertexCount > 0) {
1018
+ pass2.setVertexBuffer(0, vertexBuffer);
1019
+ pass2.draw(vertexCount);
1020
+ }
1021
+ };
1022
+ const shadowPass = encoder.beginRenderPass({
1023
+ label: "native.shadow",
1024
+ colorAttachments: [],
1025
+ depthStencilAttachment: depthAttachment(shadowView)
1026
+ });
1027
+ drawGeometry(shadowPass, shadowPipeline, mainGroup);
1028
+ shadowPass.end();
1029
+ const reflectedPass = encoder.beginRenderPass({
1030
+ label: "native.reflection",
1031
+ colorAttachments: [
1032
+ {
1033
+ view: targets.reflection,
1034
+ loadOp: "clear",
1035
+ storeOp: "store",
1036
+ clearValue: [0, 0, 0, 1]
1037
+ }
1038
+ ],
1039
+ depthStencilAttachment: depthAttachment(targets.reflectionDepth)
1040
+ });
1041
+ reflectedPass.setPipeline(reflectedSkyPipeline);
1042
+ reflectedPass.setBindGroup(0, reflectedGroup);
1043
+ reflectedPass.draw(3);
1044
+ drawGeometry(reflectedPass, reflectedSurfacePipeline, reflectedGroup);
1045
+ reflectedPass.end();
1046
+ const pass = encoder.beginRenderPass({
1047
+ label: "native.scene",
1048
+ colorAttachments: [
1049
+ {
1050
+ view: targets.colour,
1051
+ resolveTarget: view,
1052
+ loadOp: "clear",
1053
+ storeOp: "discard",
1054
+ clearValue: [0, 0, 0, 1]
1055
+ }
1056
+ ],
1057
+ depthStencilAttachment: depthAttachment(targets.depth)
1058
+ });
1059
+ pass.setPipeline(skyPipeline);
1060
+ pass.setBindGroup(0, mainGroup);
1061
+ pass.draw(3);
1062
+ if (pendingFrame.water !== false) {
1063
+ pass.setPipeline(waterPipeline);
1064
+ pass.setBindGroup(1, shadowGroup);
1065
+ pass.setBindGroup(2, targets.reflectionGroup);
1066
+ pass.draw(3);
1067
+ }
1068
+ drawGeometry(pass, surfacePipeline, mainGroup);
1069
+ pass.end();
1070
+ };
1071
+ base = await createGpuRenderer({
1072
+ ...options,
1073
+ alpha: false,
1074
+ encodeFrame: encode
1075
+ });
1076
+ const { device, format } = base;
1077
+ device.lost?.then(() => {
1078
+ available = false;
1079
+ if (!destroyed)
1080
+ options.onUnavailable?.(
1081
+ "WebGPU device lost. Reload the demo to reconnect."
1082
+ );
1083
+ });
1084
+ const module2 = device.createShaderModule({
1085
+ label: "plasius.native-scene",
1086
+ code: nativeSceneShader
1087
+ });
1088
+ const compilation = await module2.getCompilationInfo?.();
1089
+ const shaderErrors = compilation?.messages.filter((m) => m.type === "error") ?? [];
1090
+ if (shaderErrors.length)
1091
+ throw new Error(
1092
+ `Native scene shader compilation failed: ${shaderErrors.map((m) => `${m.lineNum}: ${m.message}`).join("; ")}`
1093
+ );
1094
+ const uniformLayout = device.createBindGroupLayout({
1095
+ entries: [
1096
+ {
1097
+ binding: 0,
1098
+ visibility: 3,
1099
+ buffer: { type: "uniform", minBindingSize: UNIFORM_BYTES }
1100
+ }
1101
+ ]
1102
+ });
1103
+ const shadowLayout = device.createBindGroupLayout({
1104
+ entries: [
1105
+ { binding: 0, visibility: 2, texture: { sampleType: "depth" } },
1106
+ { binding: 1, visibility: 2, sampler: { type: "comparison" } }
1107
+ ]
1108
+ });
1109
+ const reflectionLayout = device.createBindGroupLayout({
1110
+ entries: [
1111
+ { binding: 0, visibility: 2, texture: { sampleType: "float" } },
1112
+ { binding: 1, visibility: 2, sampler: { type: "filtering" } }
1113
+ ]
1114
+ });
1115
+ const uniformBuffer = own(
1116
+ device.createBuffer({
1117
+ label: "native.frame",
1118
+ size: UNIFORM_BYTES,
1119
+ usage: 72
1120
+ })
1121
+ );
1122
+ const reflectionBuffer = own(
1123
+ device.createBuffer({
1124
+ label: "native.reflected-frame",
1125
+ size: UNIFORM_BYTES,
1126
+ usage: 72
1127
+ })
1128
+ );
1129
+ const bindUniform = (buffer) => device.createBindGroup({
1130
+ layout: uniformLayout,
1131
+ entries: [{ binding: 0, resource: { buffer } }]
1132
+ });
1133
+ const mainGroup = bindUniform(uniformBuffer);
1134
+ const reflectedGroup = bindUniform(reflectionBuffer);
1135
+ const shadowTexture = own(
1136
+ device.createTexture({
1137
+ label: "native.shadow",
1138
+ size: [SHADOW_SIZE, SHADOW_SIZE],
1139
+ format: "depth32float",
1140
+ usage: 20
1141
+ })
1142
+ );
1143
+ const shadowView = shadowTexture.createView();
1144
+ const shadowGroup = device.createBindGroup({
1145
+ layout: shadowLayout,
1146
+ entries: [
1147
+ { binding: 0, resource: shadowView },
1148
+ {
1149
+ binding: 1,
1150
+ resource: device.createSampler({
1151
+ compare: "less-equal",
1152
+ minFilter: "linear",
1153
+ magFilter: "linear"
1154
+ })
1155
+ }
1156
+ ]
1157
+ });
1158
+ const reflectionSampler = device.createSampler({
1159
+ minFilter: "linear",
1160
+ magFilter: "linear"
1161
+ });
1162
+ const attributes = [0, 1, 2, 3].map((i) => ({
1163
+ shaderLocation: i,
1164
+ offset: i * 12,
1165
+ format: "float32x3"
1166
+ }));
1167
+ const createPipeline = device.createRenderPipelineAsync?.bind(device) ?? device.createRenderPipeline.bind(device);
1168
+ const pipeline = (label, vertex, fragment, colourFormat, samples, layouts, geometry = false) => createPipeline({
1169
+ label: `native.${label}`,
1170
+ layout: device.createPipelineLayout({ bindGroupLayouts: layouts }),
1171
+ vertex: {
1172
+ module: module2,
1173
+ entryPoint: vertex,
1174
+ ...geometry ? {
1175
+ buffers: [
1176
+ {
1177
+ arrayStride: STRIDE * 4,
1178
+ attributes: vertex === "shadowVertex" ? attributes.slice(0, 1) : attributes
1179
+ }
1180
+ ]
1181
+ } : {}
1182
+ },
1183
+ ...fragment ? {
1184
+ fragment: {
1185
+ module: module2,
1186
+ entryPoint: fragment,
1187
+ targets: [{ format: colourFormat }]
1188
+ }
1189
+ } : {},
1190
+ primitive: { topology: "triangle-list", cullMode: "none" },
1191
+ depthStencil: {
1192
+ format: "depth32float",
1193
+ depthWriteEnabled: label !== "sky" && label !== "reflection-sky",
1194
+ depthCompare: "less-equal"
1195
+ },
1196
+ multisample: { count: samples }
1197
+ });
1198
+ const shadowPipeline = await pipeline(
1199
+ "shadow",
1200
+ "shadowVertex",
1201
+ null,
1202
+ null,
1203
+ 1,
1204
+ [uniformLayout],
1205
+ true
1206
+ );
1207
+ const surfacePipeline = await pipeline(
1208
+ "surface",
1209
+ "surfaceVertex",
1210
+ "surfaceFragment",
1211
+ format,
1212
+ 4,
1213
+ [uniformLayout, shadowLayout],
1214
+ true
1215
+ );
1216
+ const skyPipeline = await pipeline(
1217
+ "sky",
1218
+ "fullscreenVertex",
1219
+ "skyFragment",
1220
+ format,
1221
+ 4,
1222
+ [uniformLayout]
1223
+ );
1224
+ const waterPipeline = await pipeline(
1225
+ "water",
1226
+ "fullscreenVertex",
1227
+ "waterFragment",
1228
+ format,
1229
+ 4,
1230
+ [uniformLayout, shadowLayout, reflectionLayout]
1231
+ );
1232
+ const reflectedSurfacePipeline = await pipeline(
1233
+ "reflection-surface",
1234
+ "surfaceVertex",
1235
+ "reflectionSurface",
1236
+ "rgba16float",
1237
+ 1,
1238
+ [uniformLayout, shadowLayout],
1239
+ true
1240
+ );
1241
+ const reflectedSkyPipeline = await pipeline(
1242
+ "reflection-sky",
1243
+ "fullscreenVertex",
1244
+ "reflectionSky",
1245
+ "rgba16float",
1246
+ 1,
1247
+ [uniformLayout]
1248
+ );
1249
+ return {
1250
+ render(input) {
1251
+ if (destroyed) throw new Error("Native scene renderer was destroyed.");
1252
+ if (!available) throw new Error("WebGPU device was lost.");
1253
+ pendingFrame = validateFrame(input, maxVertices);
1254
+ const data = pendingFrame.vertices;
1255
+ ensureTargets();
1256
+ if (data.byteLength > vertexCapacity) {
1257
+ if (vertexBuffer) release(vertexBuffer);
1258
+ vertexCapacity = Math.min(
1259
+ maxVertices * STRIDE * 4,
1260
+ Math.max(4096, 2 ** Math.ceil(Math.log2(data.byteLength)))
1261
+ );
1262
+ vertexBuffer = own(
1263
+ device.createBuffer({
1264
+ label: "native.surface-vertices",
1265
+ size: vertexCapacity,
1266
+ usage: 40
1267
+ })
1268
+ );
1269
+ }
1270
+ vertexCount = data.length / STRIDE;
1271
+ if (data.byteLength) device.queue.writeBuffer(vertexBuffer, 0, data);
1272
+ device.queue.writeBuffer(
1273
+ uniformBuffer,
1274
+ 0,
1275
+ frameUniforms(pendingFrame, targets.width, targets.height)
1276
+ );
1277
+ device.queue.writeBuffer(
1278
+ reflectionBuffer,
1279
+ 0,
1280
+ frameUniforms(
1281
+ pendingFrame,
1282
+ targets.reflectionWidth,
1283
+ targets.reflectionHeight,
1284
+ true
1285
+ )
1286
+ );
1287
+ base.renderOnce(pendingFrame.time * 1e3);
1288
+ submittedFrames++;
1289
+ return this.getSnapshot();
1290
+ },
1291
+ getSnapshot() {
1292
+ return {
1293
+ backend: "webgpu-raster",
1294
+ available: available && !destroyed,
1295
+ submittedFrames,
1296
+ vertexCount,
1297
+ shadowMapSize: SHADOW_SIZE,
1298
+ samples: 4,
1299
+ width: targets?.width ?? 0,
1300
+ height: targets?.height ?? 0
1301
+ };
1302
+ },
1303
+ destroy
1304
+ };
1305
+ } catch (error) {
1306
+ destroy();
1307
+ throw error;
704
1308
  }
705
- return profile.worldDisplacementAllowed === true || profile.motionMode === "root-authored" || profile.motionMode === "calibrated-in-place" || profile.motionMode === "jump";
706
1309
  }
707
- function resolveClipProfiles(clipAssets = []) {
708
- return new Map(clipAssets.map((clip) => [clip.id, clip.movementProfile ?? clip.profile ?? null]));
1310
+
1311
+ // src/canonical-wavefront-meshes.js
1312
+ var STANDARD_TEXTURE_BINDINGS = Object.freeze([
1313
+ ["baseColor", "baseColorTexture"],
1314
+ ["metallicRoughness", "metallicRoughnessTexture"],
1315
+ ["normal", "normalTexture"],
1316
+ ["occlusion", "occlusionTexture"],
1317
+ ["emissive", "emissiveTexture"]
1318
+ ]);
1319
+ var EXTENSION_TEXTURE_BINDINGS = Object.freeze([
1320
+ ["clearcoat", "clearcoat", "clearcoatTexture"],
1321
+ ["clearcoatRoughness", "clearcoat", "clearcoatRoughnessTexture"],
1322
+ ["clearcoatNormal", "clearcoat", "clearcoatNormalTexture"],
1323
+ ["transmission", "transmission", "transmissionTexture"],
1324
+ ["sheenColor", "sheen", "sheenColorTexture"],
1325
+ ["sheenRoughness", "sheen", "sheenRoughnessTexture"]
1326
+ ]);
1327
+ function requireArray(value, label) {
1328
+ if (!Array.isArray(value)) {
1329
+ throw new TypeError(`${label} must be an array.`);
1330
+ }
1331
+ return value;
709
1332
  }
710
- function buildBeatTimeline(beats, route, clipProfiles) {
711
- const routePoints = new Map(route.map((point) => [point.id, point]));
712
- let cursorMs = 0;
713
- let routeCursor = 0;
714
- let currentPosition = route[0]?.position ? [...route[0].position] : [0, 0, 0];
715
- return beats.map((beat) => {
716
- const requirement = beat.movementRequirement ?? beat.movement ?? null;
717
- const movementType = movementRequirementType(requirement, beat);
718
- const movesThroughWorld = movementType === "travel" || movementType === "jump" || movementType === "root-authored";
719
- const explicitTargetPoint = beat.pathPointId ? routePoints.get(beat.pathPointId) : null;
720
- const inferredTargetPoint = movesThroughWorld ? route[Math.min(route.length - 1, routeCursor + 1)] : null;
721
- const targetPoint = explicitTargetPoint ?? inferredTargetPoint ?? null;
722
- const targetPosition = targetPoint?.position ? [...targetPoint.position] : [...currentPosition];
723
- const startPosition = [...currentPosition];
724
- const endPosition = movesThroughWorld ? targetPosition : [...currentPosition];
725
- const segmentDistance = Number(requirement?.distance ?? distance3(startPosition, endPosition));
726
- const profile = clipProfiles.get(beat.clipId) ?? null;
727
- const distancePerLoop = movementDistancePerLoop(profile);
728
- const loopCount = movesThroughWorld && distancePerLoop > 0 ? Math.max(1, Math.ceil(segmentDistance / distancePerLoop)) : 1;
729
- const derivedDurationMs = movesThroughWorld && distancePerLoop > 0 ? loopCount * clipDurationMs(profile, beat.durationMs) : Math.max(1, beat.durationMs ?? 1);
730
- const durationMs = Math.max(1, beat.validatedDurationMs ?? requirement?.validatedDurationMs ?? derivedDurationMs);
731
- const expectedSpeed = Number(profile?.expectedSpeed ?? (durationMs > 0 ? segmentDistance / (durationMs / 1e3) : 0));
732
- const actualSpeed = durationMs > 0 ? segmentDistance / (durationMs / 1e3) : 0;
733
- const warnings = [];
734
- if (movesThroughWorld && !profileAllowsDisplacement(profile, movementType)) {
735
- warnings.push(`clip '${beat.clipId}' does not allow ${movementType} displacement`);
1333
+ function requireMap(value, label) {
1334
+ if (!value || typeof value !== "object" || typeof value.get !== "function") {
1335
+ throw new TypeError(`${label} must provide a ReadonlyMap-compatible get method.`);
1336
+ }
1337
+ return value;
1338
+ }
1339
+ function textureSample(binding, textures, label, extra = {}) {
1340
+ if (binding === void 0 || binding === null) {
1341
+ return void 0;
1342
+ }
1343
+ const sample = textures.get(binding.textureId);
1344
+ if (!sample) {
1345
+ throw new Error(`Missing decoded texture sample ${binding.textureId} for ${label}.`);
1346
+ }
1347
+ const width = Number(sample.width);
1348
+ const height = Number(sample.height);
1349
+ const data = sample.data;
1350
+ const dataLength = data && typeof data.length === "number" ? data.length : -1;
1351
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
1352
+ throw new TypeError(`Decoded texture sample ${binding.textureId} has invalid dimensions.`);
1353
+ }
1354
+ if (width > 4096 || height > 4096 || width * height > 4096 * 4096) {
1355
+ throw new RangeError(`Decoded texture sample ${binding.textureId} exceeds the 4096-pixel resource limit.`);
1356
+ }
1357
+ if (!(Array.isArray(data) || ArrayBuffer.isView(data)) || dataLength < width * height * 4) {
1358
+ throw new TypeError(`Decoded texture sample ${binding.textureId} must contain width \xD7 height \xD7 4 RGBA values.`);
1359
+ }
1360
+ return Object.freeze({
1361
+ ...sample,
1362
+ ...binding.texCoordSet === void 0 ? {} : { texCoord: binding.texCoordSet },
1363
+ ...extra
1364
+ });
1365
+ }
1366
+ function cloneMaterialExtensions(material, textures) {
1367
+ const extensions = { ...material.extensions ?? {} };
1368
+ if (material.workflow === "unlit") {
1369
+ const unlitKey = "KHR_materials_unlit";
1370
+ const unlitValue = extensions[unlitKey] ?? extensions.unlit ?? {};
1371
+ if (typeof unlitValue !== "object" || unlitValue === null || Array.isArray(unlitValue)) {
1372
+ throw new TypeError(`Canonical material extension ${unlitKey} must be an object.`);
736
1373
  }
737
- if (movesThroughWorld && distancePerLoop <= 0 && profile?.motionMode !== "root-authored") {
738
- warnings.push(`clip '${beat.clipId}' has no root or calibrated stride distance`);
1374
+ extensions[unlitKey] = { ...unlitValue, enabled: true };
1375
+ }
1376
+ for (const [bindingName, extensionName, textureKey] of EXTENSION_TEXTURE_BINDINGS) {
1377
+ const binding = material.textures?.[bindingName];
1378
+ if (!binding) {
1379
+ continue;
739
1380
  }
740
- if (movementType === "stationary" && profile?.worldDisplacementAllowed === true) {
741
- warnings.push(`stationary beat '${beat.id}' uses a displacement-capable clip`);
1381
+ const extensionKey = `KHR_materials_${extensionName}`;
1382
+ const extensionValue = extensions[extensionKey] ?? extensions[extensionName] ?? {};
1383
+ if (typeof extensionValue !== "object" || extensionValue === null || Array.isArray(extensionValue)) {
1384
+ throw new TypeError(`Canonical material extension ${extensionKey} must be an object.`);
742
1385
  }
743
- const entry = {
744
- beat,
745
- startMs: cursorMs,
746
- endMs: cursorMs + durationMs,
747
- durationMs,
748
- startPosition,
749
- endPosition,
750
- movementType,
751
- movementProfile: profile,
752
- movementDistance: segmentDistance,
753
- distancePerLoop,
754
- loopCount,
755
- expectedSpeed,
756
- actualSpeed,
757
- footSlideWarning: Number(profile?.footSlideTolerance ?? 0) < 0 ? "invalid-foot-slide-tolerance" : null,
758
- warnings
1386
+ extensions[extensionKey] = {
1387
+ ...extensionValue,
1388
+ [textureKey]: textureSample(binding, textures, `${extensionKey}.${textureKey}`)
759
1389
  };
760
- cursorMs = entry.endMs;
761
- currentPosition = [...endPosition];
762
- if (targetPoint) {
763
- const nextRouteIndex = route.findIndex((point) => point.id === targetPoint.id);
764
- if (nextRouteIndex >= 0) {
765
- routeCursor = nextRouteIndex;
766
- }
767
- }
768
- return entry;
769
- });
770
- }
771
- function resolveTimelineBeat(timeline, loopTimeMs) {
772
- if (!timeline.length) {
773
- return null;
774
1390
  }
775
- return timeline.find((entry) => loopTimeMs < entry.endMs) ?? timeline.at(-1);
1391
+ return extensions;
776
1392
  }
777
- function resolveTimelinePosition(entry, beatTimeMs) {
778
- if (!entry) {
779
- return [0, 0, 0];
1393
+ function mapMaterial(material, materialRefId, textures) {
1394
+ if (!material) {
1395
+ return { materialRefId };
780
1396
  }
781
- if (entry.movementType !== "travel" && entry.movementType !== "jump" && entry.movementType !== "root-authored") {
782
- return [...entry.startPosition];
1397
+ if (material.alphaMode === "mask") {
1398
+ throw new Error("Canonical alphaMode mask is unsupported by WavefrontMeshInput.");
783
1399
  }
784
- return lerp3(entry.startPosition, entry.endPosition, clamp01(beatTimeMs / Math.max(1, entry.durationMs)));
785
- }
786
- function resolveTimelineForward(entry) {
787
- if (!entry) {
788
- return [0, 0, -1];
1400
+ if (material.workflow !== "metallic-roughness" && material.workflow !== "unlit") {
1401
+ throw new Error(`Unsupported canonical material workflow: ${material.workflow}.`);
789
1402
  }
790
- return normalize3([
791
- (entry.endPosition[0] ?? 0) - (entry.startPosition[0] ?? 0),
792
- (entry.endPosition[1] ?? 0) - (entry.startPosition[1] ?? 0),
793
- (entry.endPosition[2] ?? 0) - (entry.startPosition[2] ?? 0)
794
- ]);
795
- }
796
- function resolveCameraViewMode(camera) {
797
- if (camera?.mode === "lagged-follow") {
798
- return camera.viewMode ?? "spectator";
1403
+ const mapped = {
1404
+ materialRefId,
1405
+ ...material.alphaMode === "blend" ? { materialKind: "transparent" } : {},
1406
+ baseColor: material.baseColorFactor,
1407
+ emission: material.emissiveFactor ? [...material.emissiveFactor, 1] : [0, 0, 0, 1],
1408
+ roughness: material.roughnessFactor,
1409
+ metallic: material.metallicFactor,
1410
+ opacity: material.alphaMode === "opaque" ? 1 : material.baseColorFactor?.[3],
1411
+ clearcoat: material.clearcoatFactor,
1412
+ clearcoatRoughness: material.clearcoatRoughnessFactor,
1413
+ transmission: material.transmissionFactor,
1414
+ sheenColor: material.sheenColorFactor,
1415
+ extensions: cloneMaterialExtensions(material, textures)
1416
+ };
1417
+ for (const [bindingName, inputName] of STANDARD_TEXTURE_BINDINGS) {
1418
+ const binding = material.textures?.[bindingName];
1419
+ if (!binding) {
1420
+ continue;
1421
+ }
1422
+ const extra = bindingName === "normal" && material.normalScale !== void 0 ? { scale: material.normalScale } : bindingName === "occlusion" && material.occlusionStrength !== void 0 ? { strength: material.occlusionStrength } : {};
1423
+ mapped[inputName] = textureSample(binding, textures, inputName, extra);
799
1424
  }
800
- return camera?.viewMode ?? camera?.mode ?? "spectator";
1425
+ return mapped;
801
1426
  }
802
- function resolveCanvas(canvas) {
803
- if (typeof canvas === "string") {
804
- const found = globalThis.document?.querySelector?.(canvas);
805
- if (!found) {
806
- throw new Error(`Animated scene canvas '${canvas}' was not found.`);
1427
+ function createCanonicalWavefrontMeshInputs({ document, geometry, textures = /* @__PURE__ */ new Map() } = {}) {
1428
+ if (!document || typeof document !== "object") {
1429
+ throw new TypeError("Canonical model document is required.");
1430
+ }
1431
+ const meshes = requireArray(document.meshes, "Canonical model meshes");
1432
+ const materials = requireArray(document.materials, "Canonical model materials");
1433
+ const geometryInputs = requireArray(geometry, "Renderer-ready geometry");
1434
+ const textureSamples = requireMap(textures, "Decoded texture samples");
1435
+ const geometryByPrimitiveId = /* @__PURE__ */ new Map();
1436
+ for (const entry of geometryInputs) {
1437
+ if (!entry || typeof entry.primitiveId !== "string" || entry.primitiveId.length === 0) {
1438
+ throw new TypeError("Renderer-ready geometry requires a non-empty primitiveId.");
807
1439
  }
808
- return found;
1440
+ if (geometryByPrimitiveId.has(entry.primitiveId)) {
1441
+ throw new Error(`Duplicate renderer-ready geometry for primitive ${entry.primitiveId}.`);
1442
+ }
1443
+ geometryByPrimitiveId.set(entry.primitiveId, entry);
809
1444
  }
810
- if (!canvas) {
811
- throw new Error("Animated scene renderer requires a canvas.");
1445
+ const materialsById = /* @__PURE__ */ new Map();
1446
+ const materialRefIds = /* @__PURE__ */ new Map();
1447
+ materials.forEach((material, index) => {
1448
+ if (!material || typeof material.id !== "string" || material.id.length === 0) {
1449
+ throw new TypeError("Canonical materials require a non-empty id.");
1450
+ }
1451
+ if (materialsById.has(material.id)) {
1452
+ throw new Error(`Duplicate canonical material ${material.id}.`);
1453
+ }
1454
+ materialsById.set(material.id, material);
1455
+ materialRefIds.set(material.id, index + 1);
1456
+ });
1457
+ const output = [];
1458
+ const consumedPrimitiveIds = /* @__PURE__ */ new Set();
1459
+ const canonicalPrimitiveIds = /* @__PURE__ */ new Set();
1460
+ for (const mesh of meshes) {
1461
+ if (!mesh || typeof mesh.id !== "string") {
1462
+ throw new TypeError("Canonical meshes require a string id.");
1463
+ }
1464
+ for (const primitive of requireArray(mesh.primitives, `Primitives for mesh ${mesh.id}`)) {
1465
+ if (!primitive || typeof primitive.id !== "string" || primitive.id.length === 0) {
1466
+ throw new TypeError(`Canonical mesh ${mesh.id} contains a primitive without an id.`);
1467
+ }
1468
+ if (canonicalPrimitiveIds.has(primitive.id)) {
1469
+ throw new Error(`Duplicate canonical primitive ${primitive.id}.`);
1470
+ }
1471
+ canonicalPrimitiveIds.add(primitive.id);
1472
+ if (primitive.topology !== "triangles") {
1473
+ throw new Error(`Unsupported canonical primitive topology: ${primitive.topology}.`);
1474
+ }
1475
+ const source = geometryByPrimitiveId.get(primitive.id);
1476
+ if (!source) {
1477
+ throw new Error(`Missing renderer-ready geometry for primitive ${primitive.id}.`);
1478
+ }
1479
+ consumedPrimitiveIds.add(primitive.id);
1480
+ let material = null;
1481
+ let materialRefId = 0;
1482
+ if (primitive.materialId !== void 0) {
1483
+ material = materialsById.get(primitive.materialId);
1484
+ if (!material) {
1485
+ throw new Error(`Missing canonical material ${primitive.materialId} for primitive ${primitive.id}.`);
1486
+ }
1487
+ materialRefId = materialRefIds.get(primitive.materialId);
1488
+ }
1489
+ output.push(Object.freeze({
1490
+ id: output.length + 1,
1491
+ positions: source.positions,
1492
+ ...source.indices === void 0 ? {} : { indices: source.indices },
1493
+ ...source.normals === void 0 ? {} : { normals: source.normals },
1494
+ ...(source.texcoords ?? source.uvs) === void 0 ? {} : { uvs: source.texcoords ?? source.uvs },
1495
+ ...mapMaterial(material, materialRefId, textureSamples)
1496
+ }));
1497
+ }
812
1498
  }
813
- return canvas;
1499
+ for (const primitiveId of geometryByPrimitiveId.keys()) {
1500
+ if (!consumedPrimitiveIds.has(primitiveId)) {
1501
+ throw new Error(`Renderer-ready geometry references unknown primitive ${primitiveId}.`);
1502
+ }
1503
+ }
1504
+ return Object.freeze(output);
814
1505
  }
815
- function project(position, cameraPosition, width, height) {
816
- const x = position[0] - cameraPosition[0];
817
- const depth = Math.max(0.2, cameraPosition[2] - (position[2] ?? 0));
818
- const scale2 = clamp(34 + 150 / (depth + 1.2), 34, 104);
819
- const groundY = height * 0.82 - depth * height * 0.045 - (position[1] ?? 0) * scale2;
820
- return {
821
- x: width * 0.5 + x * scale2,
822
- y: groundY,
823
- groundY,
824
- scale: scale2,
825
- depth,
826
- visible: groundY > -height * 0.2 && groundY < height * 1.18
827
- };
1506
+
1507
+ // src/animated-scene-renderer.js
1508
+ var import_gpu_camera = require("@plasius/gpu-camera");
1509
+
1510
+ // src/animated-gltf-model.js
1511
+ var GLB_MAGIC = 1179937895;
1512
+ var GLB_VERSION = 2;
1513
+ var GLB_JSON_CHUNK = 1313821514;
1514
+ var GLB_BIN_CHUNK = 5130562;
1515
+ var COMPONENT_READERS = Object.freeze({
1516
+ 5120: { size: 1, read: (view, offset) => view.getInt8(offset), normalizedDivisor: 127 },
1517
+ 5121: { size: 1, read: (view, offset) => view.getUint8(offset), normalizedDivisor: 255 },
1518
+ 5122: { size: 2, read: (view, offset) => view.getInt16(offset, true), normalizedDivisor: 32767 },
1519
+ 5123: { size: 2, read: (view, offset) => view.getUint16(offset, true), normalizedDivisor: 65535 },
1520
+ 5125: { size: 4, read: (view, offset) => view.getUint32(offset, true), normalizedDivisor: 4294967295 },
1521
+ 5126: { size: 4, read: (view, offset) => view.getFloat32(offset, true), normalizedDivisor: 1 }
1522
+ });
1523
+ var ACCESSOR_COMPONENTS = Object.freeze({
1524
+ SCALAR: 1,
1525
+ VEC2: 2,
1526
+ VEC3: 3,
1527
+ VEC4: 4,
1528
+ MAT4: 16
1529
+ });
1530
+ function identityMatrix() {
1531
+ return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
828
1532
  }
829
- function propIdentity(prop, index) {
830
- return prop.id ?? `${prop.kind}-${index}`;
831
- }
832
- function drawGroundShadow(ctx, scale2) {
833
- ctx.fillStyle = "rgba(44, 38, 28, 0.18)";
834
- ctx.fillRect(-scale2 * 0.36, -scale2 * 0.035, scale2 * 0.72, scale2 * 0.07);
835
- }
836
- function drawProp(ctx, prop, cameraPosition, width, height) {
837
- const projected = project(prop.position, cameraPosition, width, height);
838
- const { x, y, scale: scale2 } = projected;
839
- ctx.save();
840
- ctx.translate(x, y);
841
- ctx.lineWidth = Math.max(1, scale2 * 0.04);
842
- if (prop.kind === "crop-row") {
843
- ctx.fillStyle = "#8b633f";
844
- ctx.fillRect(-scale2 * 1.05, -scale2 * 0.08, scale2 * 2.1, scale2 * 0.16);
845
- ctx.fillStyle = "#6f4a30";
846
- ctx.fillRect(-scale2 * 0.92, -scale2 * 0.035, scale2 * 1.84, scale2 * 0.07);
847
- ctx.strokeStyle = "#4f6f32";
848
- ctx.beginPath();
849
- ctx.moveTo(-scale2 * 0.8, 0);
850
- ctx.lineTo(scale2 * 0.8, 0);
851
- ctx.stroke();
852
- ctx.fillStyle = "#6f9f43";
853
- for (let i = -3; i <= 3; i += 1) {
854
- ctx.fillRect(i * scale2 * 0.22, -scale2 * 0.18, scale2 * 0.06, scale2 * 0.2);
855
- }
856
- } else if (prop.kind === "fence-segment") {
857
- ctx.strokeStyle = "#896b4d";
858
- ctx.beginPath();
859
- ctx.moveTo(-scale2 * 0.5, 0);
860
- ctx.lineTo(scale2 * 0.5, 0);
861
- ctx.moveTo(-scale2 * 0.35, -scale2 * 0.25);
862
- ctx.lineTo(-scale2 * 0.35, scale2 * 0.1);
863
- ctx.moveTo(scale2 * 0.35, -scale2 * 0.25);
864
- ctx.lineTo(scale2 * 0.35, scale2 * 0.1);
865
- ctx.stroke();
866
- } else if (prop.kind === "cart") {
867
- drawGroundShadow(ctx, scale2);
868
- ctx.fillStyle = "#7b5238";
869
- ctx.fillRect(-scale2 * 0.52, -scale2 * 0.38, scale2 * 1.04, scale2 * 0.36);
870
- ctx.fillStyle = "#a4774c";
871
- ctx.fillRect(-scale2 * 0.44, -scale2 * 0.32, scale2 * 0.88, scale2 * 0.08);
872
- ctx.fillStyle = "#25201c";
873
- ctx.beginPath();
874
- ctx.arc(-scale2 * 0.32, scale2 * 0.02, scale2 * 0.1, 0, Math.PI * 2);
875
- ctx.arc(scale2 * 0.32, scale2 * 0.02, scale2 * 0.1, 0, Math.PI * 2);
876
- ctx.fill();
877
- } else if (prop.kind === "small-tree") {
878
- drawGroundShadow(ctx, scale2);
879
- ctx.fillStyle = "#5b3b24";
880
- ctx.fillRect(-scale2 * 0.07, -scale2 * 0.56, scale2 * 0.14, scale2 * 0.56);
881
- ctx.fillStyle = "#2f6d3b";
882
- ctx.beginPath();
883
- ctx.arc(-scale2 * 0.16, -scale2 * 0.72, scale2 * 0.25, 0, Math.PI * 2);
884
- ctx.fill();
885
- ctx.fillStyle = "#3f8746";
886
- ctx.beginPath();
887
- ctx.arc(scale2 * 0.13, -scale2 * 0.76, scale2 * 0.28, 0, Math.PI * 2);
888
- ctx.fill();
889
- ctx.fillStyle = "#4f9a50";
890
- ctx.beginPath();
891
- ctx.arc(0, -scale2 * 0.98, scale2 * 0.26, 0, Math.PI * 2);
892
- ctx.fill();
893
- } else {
894
- drawGroundShadow(ctx, scale2);
895
- ctx.fillStyle = prop.kind === "crate" ? "#9a6a3d" : "#d0c39f";
896
- ctx.fillRect(-scale2 * 0.2, -scale2 * 0.32, scale2 * 0.4, scale2 * 0.32);
897
- if (prop.kind === "crate") {
898
- ctx.strokeStyle = "#6e4829";
899
- ctx.beginPath();
900
- ctx.moveTo(-scale2 * 0.2, -scale2 * 0.16);
901
- ctx.lineTo(scale2 * 0.2, -scale2 * 0.16);
902
- ctx.moveTo(0, -scale2 * 0.32);
903
- ctx.lineTo(0, 0);
904
- ctx.stroke();
1533
+ function multiplyMatrix(a, b) {
1534
+ const out = new Array(16).fill(0);
1535
+ for (let column = 0; column < 4; column += 1) {
1536
+ for (let row = 0; row < 4; row += 1) {
1537
+ out[column * 4 + row] = a[0 * 4 + row] * b[column * 4 + 0] + a[1 * 4 + row] * b[column * 4 + 1] + a[2 * 4 + row] * b[column * 4 + 2] + a[3 * 4 + row] * b[column * 4 + 3];
905
1538
  }
906
1539
  }
907
- ctx.restore();
908
- return projected;
1540
+ return out;
909
1541
  }
910
- function drawCharacter(ctx, characterPosition, cameraPosition, width, height, gaitPhase, headLook) {
911
- const projected = project(characterPosition, cameraPosition, width, height);
912
- const { x, y, scale: scale2 } = projected;
913
- const stride = Math.sin(gaitPhase * Math.PI * 2);
914
- const headYaw = (headLook?.yaw ?? 0) * (headLook?.weight ?? 0);
915
- ctx.save();
916
- ctx.translate(x, y);
917
- ctx.lineWidth = Math.max(2, scale2 * 0.05);
918
- ctx.lineCap = "round";
919
- drawGroundShadow(ctx, scale2);
920
- ctx.strokeStyle = "#4c2f25";
921
- ctx.beginPath();
922
- ctx.moveTo(-scale2 * 0.12, -scale2 * 0.34);
923
- ctx.lineTo(-scale2 * (0.18 + stride * 0.04), -scale2 * 0.02);
924
- ctx.moveTo(scale2 * 0.12, -scale2 * 0.34);
925
- ctx.lineTo(scale2 * (0.18 - stride * 0.04), -scale2 * 0.02);
926
- ctx.stroke();
927
- ctx.fillStyle = "#6f8f62";
928
- ctx.beginPath();
929
- ctx.moveTo(-scale2 * 0.3, -scale2 * 0.86);
930
- ctx.lineTo(scale2 * 0.3, -scale2 * 0.86);
931
- ctx.lineTo(scale2 * 0.42, -scale2 * 0.24);
932
- ctx.lineTo(-scale2 * 0.42, -scale2 * 0.24);
933
- ctx.lineTo(-scale2 * 0.3, -scale2 * 0.86);
934
- ctx.fill();
935
- ctx.fillStyle = "#d8b18c";
936
- ctx.beginPath();
937
- ctx.arc(0, -scale2 * 1.16, scale2 * 0.18, 0, Math.PI * 2);
938
- ctx.fill();
939
- ctx.fillStyle = "#5a3a2b";
940
- ctx.fillRect(-scale2 * 0.2, -scale2 * 1.28, scale2 * 0.4, scale2 * 0.16);
941
- ctx.fillRect(-scale2 * 0.23, -scale2 * 1.14, scale2 * 0.1, scale2 * 0.28);
942
- ctx.fillRect(scale2 * 0.13, -scale2 * 1.14, scale2 * 0.1, scale2 * 0.28);
943
- if (headLook?.status === "active" || headLook?.status === "returning") {
944
- ctx.strokeStyle = "rgba(255, 246, 210, 0.74)";
945
- ctx.lineWidth = Math.max(1, scale2 * 0.025);
946
- ctx.beginPath();
947
- ctx.moveTo(0, -scale2 * 1.16);
948
- ctx.lineTo(Math.sin(headYaw) * scale2 * 0.32, -scale2 * 1.16 - scale2 * 0.08);
949
- ctx.stroke();
950
- }
951
- ctx.strokeStyle = "#6b4935";
952
- ctx.beginPath();
953
- ctx.moveTo(-scale2 * 0.24, -scale2 * 0.76);
954
- ctx.lineTo(-scale2 * (0.46 + stride * 0.04), -scale2 * (0.5 + stride * 0.06));
955
- ctx.moveTo(scale2 * 0.24, -scale2 * 0.76);
956
- ctx.lineTo(scale2 * (0.46 - stride * 0.04), -scale2 * (0.5 - stride * 0.06));
957
- ctx.stroke();
958
- ctx.fillStyle = "#d6c09a";
959
- ctx.fillRect(-scale2 * 0.16, -scale2 * 0.86, scale2 * 0.32, scale2 * 0.18);
960
- ctx.restore();
961
- return projected;
1542
+ function transformPoint(matrix, point) {
1543
+ const [x, y, z] = point;
1544
+ return [
1545
+ matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12],
1546
+ matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13],
1547
+ matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14]
1548
+ ];
962
1549
  }
963
- function pointForModelVertex(vertex, projection, sample) {
964
- const bounds = sample.bounds;
965
- const height = Math.max(0.1, bounds.size[1] || sample.bindBounds.size[1] || 1);
966
- const modelScale = projection.scale * 1.3 / height;
967
- const centerX = (bounds.min[0] + bounds.max[0]) * 0.5;
968
- const centerZ = (bounds.min[2] + bounds.max[2]) * 0.5;
1550
+ function composeMatrix(translation = [0, 0, 0], rotation = [0, 0, 0, 1], scale2 = [1, 1, 1]) {
1551
+ const [x, y, z, w] = normalizeQuaternion(rotation);
1552
+ const [sx, sy, sz] = scale2;
1553
+ const x2 = x + x;
1554
+ const y2 = y + y;
1555
+ const z2 = z + z;
1556
+ const xx = x * x2;
1557
+ const xy = x * y2;
1558
+ const xz = x * z2;
1559
+ const yy = y * y2;
1560
+ const yz = y * z2;
1561
+ const zz = z * z2;
1562
+ const wx = w * x2;
1563
+ const wy = w * y2;
1564
+ const wz = w * z2;
969
1565
  return [
970
- projection.x + (vertex[0] - centerX) * modelScale,
971
- projection.groundY - (vertex[1] - bounds.min[1]) * modelScale + (vertex[2] - centerZ) * modelScale * 0.16
1566
+ (1 - (yy + zz)) * sx,
1567
+ (xy + wz) * sx,
1568
+ (xz - wy) * sx,
1569
+ 0,
1570
+ (xy - wz) * sy,
1571
+ (1 - (xx + zz)) * sy,
1572
+ (yz + wx) * sy,
1573
+ 0,
1574
+ (xz + wy) * sz,
1575
+ (yz - wx) * sz,
1576
+ (1 - (xx + yy)) * sz,
1577
+ 0,
1578
+ translation[0] ?? 0,
1579
+ translation[1] ?? 0,
1580
+ translation[2] ?? 0,
1581
+ 1
972
1582
  ];
973
1583
  }
974
- function modelFillForHeight(vertex, sample) {
975
- const height = Math.max(0.1, sample.bounds.size[1] || 1);
976
- const t = (vertex[1] - sample.bounds.min[1]) / height;
977
- if (t > 0.82) {
978
- return "#6d4a36";
1584
+ function normalizeQuaternion(quaternion = [0, 0, 0, 1]) {
1585
+ const length = Math.hypot(quaternion[0] ?? 0, quaternion[1] ?? 0, quaternion[2] ?? 0, quaternion[3] ?? 1) || 1;
1586
+ return [
1587
+ (quaternion[0] ?? 0) / length,
1588
+ (quaternion[1] ?? 0) / length,
1589
+ (quaternion[2] ?? 0) / length,
1590
+ (quaternion[3] ?? 1) / length
1591
+ ];
1592
+ }
1593
+ function lerp(a, b, t) {
1594
+ return a + (b - a) * t;
1595
+ }
1596
+ function lerpArray(a, b, t) {
1597
+ return a.map((value, index) => lerp(value, b[index] ?? value, t));
1598
+ }
1599
+ function nlerpQuaternion(a, b, t) {
1600
+ let end = b;
1601
+ const dot2 = a.reduce((total, value, index) => total + value * (b[index] ?? 0), 0);
1602
+ if (dot2 < 0) {
1603
+ end = b.map((value) => -value);
979
1604
  }
980
- if (t > 0.58) {
981
- return "#d7b28f";
1605
+ return normalizeQuaternion(lerpArray(a, end, t));
1606
+ }
1607
+ function parseGlb(buffer) {
1608
+ if (!(buffer instanceof ArrayBuffer)) {
1609
+ return null;
982
1610
  }
983
- if (t > 0.25) {
984
- return "#6f8f62";
1611
+ const view = new DataView(buffer);
1612
+ if (view.byteLength < 20 || view.getUint32(0, true) !== GLB_MAGIC || view.getUint32(4, true) !== GLB_VERSION) {
1613
+ return null;
985
1614
  }
986
- return "#5e4636";
987
- }
988
- function drawSkinnedGltfCharacter(ctx, model, snapshot, width, height) {
989
- const projection = project(snapshot.characterPosition, snapshot.cameraPosition, width, height);
990
- const sample = model.sample(snapshot.activeClipId, snapshot.clipTimeMs);
991
- const maxTriangles = 12e3;
992
- const triangleCount = Math.floor(sample.indices.length / 3);
993
- const stride = Math.max(1, Math.ceil(triangleCount / maxTriangles));
994
- const triangles = [];
995
- for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += stride) {
996
- const base = triangleIndex * 3;
997
- const a = sample.vertices[sample.indices[base]];
998
- const b = sample.vertices[sample.indices[base + 1]];
999
- const c = sample.vertices[sample.indices[base + 2]];
1000
- if (!a || !b || !c) {
1001
- continue;
1615
+ let offset = 12;
1616
+ let json = null;
1617
+ let binary = null;
1618
+ while (offset + 8 <= view.byteLength) {
1619
+ const length = view.getUint32(offset, true);
1620
+ const type = view.getUint32(offset + 4, true);
1621
+ const start = offset + 8;
1622
+ const end = start + length;
1623
+ if (end > view.byteLength) {
1624
+ return null;
1002
1625
  }
1003
- triangles.push({
1004
- a,
1005
- b,
1006
- c,
1007
- depth: (a[2] + b[2] + c[2]) / 3,
1008
- fill: modelFillForHeight(a, sample)
1009
- });
1010
- }
1011
- triangles.sort((left, right) => left.depth - right.depth);
1012
- ctx.save();
1013
- drawGroundShadow(ctx, projection.scale);
1014
- for (const triangle of triangles) {
1015
- const [ax, ay] = pointForModelVertex(triangle.a, projection, sample);
1016
- const [bx, by] = pointForModelVertex(triangle.b, projection, sample);
1017
- const [cx, cy] = pointForModelVertex(triangle.c, projection, sample);
1018
- ctx.fillStyle = triangle.fill;
1019
- ctx.beginPath();
1020
- ctx.moveTo(ax, ay);
1021
- ctx.lineTo(bx, by);
1022
- ctx.lineTo(cx, cy);
1023
- ctx.closePath?.();
1024
- ctx.fill();
1626
+ if (type === GLB_JSON_CHUNK) {
1627
+ const text = new TextDecoder().decode(new Uint8Array(buffer, start, length)).trim();
1628
+ json = JSON.parse(text);
1629
+ } else if (type === GLB_BIN_CHUNK) {
1630
+ binary = new Uint8Array(buffer, start, length);
1631
+ }
1632
+ offset = end;
1025
1633
  }
1026
- ctx.restore();
1027
- return {
1028
- projection,
1029
- sample
1030
- };
1634
+ if (!json || !binary) {
1635
+ return null;
1636
+ }
1637
+ return { json, binary };
1031
1638
  }
1032
- function renderScene(canvas, ctx, snapshot, props, characterModel) {
1033
- const width = canvas.width || 1;
1034
- const height = canvas.height || 1;
1035
- ctx.clearRect(0, 0, width, height);
1036
- ctx.fillStyle = "#d8e8d0";
1037
- ctx.fillRect(0, 0, width, height);
1038
- ctx.fillStyle = "#9fc47a";
1039
- ctx.fillRect(0, height * 0.52, width, height * 0.48);
1040
- ctx.strokeStyle = "#caa66d";
1041
- ctx.lineWidth = Math.max(2, width * 6e-3);
1042
- ctx.beginPath();
1043
- ctx.moveTo(width * 0.35, height);
1044
- ctx.quadraticCurveTo(width * 0.53, height * 0.58, width * 0.62, 0);
1045
- ctx.stroke();
1046
- const projectedProps = props.map((prop, index) => ({
1047
- prop,
1048
- id: propIdentity(prop, index),
1049
- projected: project(prop.position, snapshot.cameraPosition, width, height)
1050
- })).sort((a, b) => b.projected.depth - a.projected.depth);
1051
- const propGroundAnchors = [];
1052
- for (const { prop, id } of projectedProps) {
1053
- const projected = drawProp(ctx, prop, snapshot.cameraPosition, width, height);
1054
- propGroundAnchors.push({
1055
- id,
1056
- kind: prop.kind,
1057
- groundY: projected.groundY,
1058
- depth: projected.depth,
1059
- visible: projected.visible
1060
- });
1639
+ function readAccessor(document, accessorIndex) {
1640
+ const accessor = document.json.accessors?.[accessorIndex];
1641
+ if (!accessor) {
1642
+ return [];
1061
1643
  }
1062
- let characterProjection;
1063
- let modelRenderable = false;
1064
- let fallbackProxyActive = true;
1065
- let skinnedVertexCount = 0;
1066
- let skinnedTriangleCount = 0;
1067
- let skinnedJointCount = 0;
1068
- let skinnedAnimatedNodeCount = 0;
1069
- let skinnedClipCount = 0;
1070
- let activeClipRenderable = false;
1071
- if (characterModel) {
1072
- const drawn = drawSkinnedGltfCharacter(ctx, characterModel, snapshot, width, height);
1073
- characterProjection = drawn.projection;
1074
- modelRenderable = true;
1075
- fallbackProxyActive = false;
1076
- skinnedVertexCount = characterModel.vertexCount;
1077
- skinnedTriangleCount = characterModel.triangleCount;
1078
- skinnedJointCount = characterModel.jointCount;
1079
- skinnedAnimatedNodeCount = characterModel.animatedNodeCount;
1080
- skinnedClipCount = characterModel.clipCount;
1081
- activeClipRenderable = drawn.sample.activeClipRenderable;
1082
- } else {
1083
- characterProjection = drawCharacter(
1084
- ctx,
1085
- snapshot.characterPosition,
1086
- snapshot.cameraPosition,
1087
- width,
1088
- height,
1089
- snapshot.clipTimeMs / 900,
1090
- snapshot.headLook
1091
- );
1644
+ const bufferView = document.json.bufferViews?.[accessor.bufferView];
1645
+ const component = COMPONENT_READERS[accessor.componentType];
1646
+ const componentCount = ACCESSOR_COMPONENTS[accessor.type] ?? 1;
1647
+ if (!bufferView || !component) {
1648
+ return [];
1649
+ }
1650
+ const view = new DataView(
1651
+ document.binary.buffer,
1652
+ document.binary.byteOffset + (bufferView.byteOffset ?? 0) + (accessor.byteOffset ?? 0),
1653
+ bufferView.byteLength ?? 0
1654
+ );
1655
+ const stride = bufferView.byteStride ?? component.size * componentCount;
1656
+ const values = [];
1657
+ for (let index = 0; index < accessor.count; index += 1) {
1658
+ const item = [];
1659
+ const baseOffset = index * stride;
1660
+ for (let componentIndex = 0; componentIndex < componentCount; componentIndex += 1) {
1661
+ const raw = component.read(view, baseOffset + componentIndex * component.size);
1662
+ item.push(accessor.normalized && accessor.componentType !== 5126 ? raw / component.normalizedDivisor : raw);
1663
+ }
1664
+ values.push(componentCount === 1 ? item[0] : item);
1665
+ }
1666
+ return values;
1667
+ }
1668
+ function readMatrices(document, accessorIndex) {
1669
+ return readAccessor(document, accessorIndex).map((matrix) => Array.from(matrix));
1670
+ }
1671
+ function textureImage(document, textureIndex) {
1672
+ if (typeof textureIndex !== "number") {
1673
+ return null;
1674
+ }
1675
+ const texture = document.json.textures?.[textureIndex];
1676
+ return typeof texture?.source === "number" ? document.json.images?.[texture.source] ?? null : null;
1677
+ }
1678
+ function imageHasBuffer(document, image) {
1679
+ if (!image) {
1680
+ return false;
1681
+ }
1682
+ if (typeof image.uri === "string" && image.uri.trim().length > 0) {
1683
+ return true;
1684
+ }
1685
+ return typeof image.bufferView === "number" && Boolean(document.json.bufferViews?.[image.bufferView]);
1686
+ }
1687
+ function extractMaterialTextureMetadata(document, primitive) {
1688
+ const material = document.json.materials?.[primitive?.material];
1689
+ const baseColorTextureIndex = material?.pbrMetallicRoughness?.baseColorTexture?.index;
1690
+ const normalTextureIndex = material?.normalTexture?.index;
1691
+ const missingTextureReferences = [];
1692
+ if (typeof baseColorTextureIndex === "number" && !imageHasBuffer(document, textureImage(document, baseColorTextureIndex))) {
1693
+ missingTextureReferences.push("baseColorTexture");
1694
+ }
1695
+ if (typeof normalTextureIndex === "number" && !imageHasBuffer(document, textureImage(document, normalTextureIndex))) {
1696
+ missingTextureReferences.push("normalTexture");
1697
+ }
1698
+ return Object.freeze({
1699
+ materialCount: document.json.materials?.length ?? 0,
1700
+ textureCount: document.json.textures?.length ?? 0,
1701
+ imageCount: document.json.images?.length ?? 0,
1702
+ embeddedImageCount: (document.json.images ?? []).filter((image) => typeof image.bufferView === "number").length,
1703
+ baseColorTextureCount: typeof baseColorTextureIndex === "number" ? 1 : 0,
1704
+ normalTextureCount: typeof normalTextureIndex === "number" ? 1 : 0,
1705
+ hasBaseColorTexture: typeof baseColorTextureIndex === "number",
1706
+ hasNormalTexture: typeof normalTextureIndex === "number",
1707
+ missingTextureReferences: Object.freeze(missingTextureReferences),
1708
+ materialName: material?.name ?? ""
1709
+ });
1710
+ }
1711
+ function nodeLocalMatrix(node, override = {}) {
1712
+ if (node.matrix && !override.translation && !override.rotation && !override.scale) {
1713
+ return [...node.matrix];
1714
+ }
1715
+ return composeMatrix(
1716
+ override.translation ?? node.translation ?? [0, 0, 0],
1717
+ override.rotation ?? node.rotation ?? [0, 0, 0, 1],
1718
+ override.scale ?? node.scale ?? [1, 1, 1]
1719
+ );
1720
+ }
1721
+ function computeWorldMatrices(nodes, overridesByName = /* @__PURE__ */ new Map()) {
1722
+ const parents = /* @__PURE__ */ new Set();
1723
+ for (const node of nodes) {
1724
+ for (const child of node.children ?? []) {
1725
+ parents.add(child);
1726
+ }
1727
+ }
1728
+ const roots = nodes.map((_, index) => index).filter((index) => !parents.has(index));
1729
+ const worldMatrices = nodes.map(() => identityMatrix());
1730
+ function visit(nodeIndex, parentMatrix) {
1731
+ const node = nodes[nodeIndex];
1732
+ if (!node) {
1733
+ return;
1734
+ }
1735
+ const worldMatrix = multiplyMatrix(parentMatrix, nodeLocalMatrix(node, overridesByName.get(node.name) ?? {}));
1736
+ worldMatrices[nodeIndex] = worldMatrix;
1737
+ for (const child of node.children ?? []) {
1738
+ visit(child, worldMatrix);
1739
+ }
1740
+ }
1741
+ for (const root of roots) {
1742
+ visit(root, identityMatrix());
1743
+ }
1744
+ return worldMatrices;
1745
+ }
1746
+ function normalizeWeights(weights) {
1747
+ const total = weights.reduce((sum, value) => sum + Math.max(0, value), 0);
1748
+ if (total <= 0) {
1749
+ return [1, 0, 0, 0];
1750
+ }
1751
+ return weights.map((value) => Math.max(0, value) / total);
1752
+ }
1753
+ function deriveBounds(vertices) {
1754
+ const min = [Infinity, Infinity, Infinity];
1755
+ const max = [-Infinity, -Infinity, -Infinity];
1756
+ for (const vertex of vertices) {
1757
+ for (let axis = 0; axis < 3; axis += 1) {
1758
+ min[axis] = Math.min(min[axis], vertex[axis]);
1759
+ max[axis] = Math.max(max[axis], vertex[axis]);
1760
+ }
1761
+ }
1762
+ if (!Number.isFinite(min[0])) {
1763
+ return { min: [0, 0, 0], max: [0, 1, 0], size: [1, 1, 1] };
1092
1764
  }
1093
1765
  return {
1094
- characterGroundY: characterProjection.groundY,
1095
- characterVisible: characterProjection.visible,
1096
- modelLoaded: Boolean(characterModel),
1097
- modelRenderable,
1098
- fallbackProxyActive,
1099
- skinnedVertexCount,
1100
- skinnedTriangleCount,
1101
- skinnedJointCount,
1102
- skinnedAnimatedNodeCount,
1103
- skinnedClipCount,
1104
- activeClipRenderable,
1105
- propGroundAnchors
1766
+ min,
1767
+ max,
1768
+ size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]]
1106
1769
  };
1107
1770
  }
1108
- function resolveCharacterModel(options) {
1109
- try {
1110
- return createAnimatedGltfModel(
1111
- options.modelAsset ?? options.animationAdventure?.modelAsset,
1112
- options.clipAssets ?? options.animationAdventure?.clipAssets ?? []
1113
- );
1114
- } catch {
1771
+ function sampleChannel(channel, sampler, document, timeSeconds) {
1772
+ const input = readAccessor(document, sampler.input);
1773
+ const output = readAccessor(document, sampler.output);
1774
+ if (!input.length || !output.length) {
1115
1775
  return null;
1116
1776
  }
1777
+ const duration = input.at(-1) || 0;
1778
+ const sampleTime = duration > 0 ? timeSeconds % duration : 0;
1779
+ let frame = 0;
1780
+ while (frame < input.length - 2 && sampleTime > input[frame + 1]) {
1781
+ frame += 1;
1782
+ }
1783
+ const start = input[frame] ?? 0;
1784
+ const end = input[frame + 1] ?? start;
1785
+ const t = end > start ? (sampleTime - start) / (end - start) : 0;
1786
+ const startValue = Array.isArray(output[frame]) ? output[frame] : [output[frame] ?? 0];
1787
+ const endValue = Array.isArray(output[frame + 1]) ? output[frame + 1] : startValue;
1788
+ return channel.target.path === "rotation" ? nlerpQuaternion(startValue, endValue, t) : lerpArray(startValue, endValue, t);
1117
1789
  }
1118
- function createAnimatedSceneRenderer(options = {}) {
1119
- const canvas = resolveCanvas(options.canvas);
1120
- const ctx = canvas.getContext?.("2d");
1121
- if (!ctx) {
1122
- throw new Error("Animated scene renderer requires a 2D canvas context.");
1790
+ function rootTranslationDistance(clipPayload) {
1791
+ if (!clipPayload) {
1792
+ return 0;
1123
1793
  }
1124
- const route = [...options.route ?? options.animationAdventure?.route ?? []];
1125
- const beats = [...options.beats ?? options.animationAdventure?.beats ?? []].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
1126
- const props = [...options.props ?? options.animationAdventure?.props ?? []];
1127
- const clipAssets = options.clipAssets ?? options.animationAdventure?.clipAssets ?? [];
1128
- const clipProfiles = resolveClipProfiles(clipAssets);
1129
- const beatTimeline = buildBeatTimeline(beats, route, clipProfiles);
1130
- const characterModel = resolveCharacterModel(options);
1131
- let camera = {
1132
- ...DEFAULT_CAMERA,
1133
- ...definedCameraOverrides(options.camera ?? options.animationAdventure?.camera)
1134
- };
1135
- const loopDurationMs = Math.max(beatTimeline.at(-1)?.endMs ?? durationFromBeats(beats), 1);
1136
- const movementWarnings = beatTimeline.flatMap((entry) => entry.warnings);
1137
- const requestFrame = options.requestAnimationFrame ?? globalThis.requestAnimationFrame?.bind(globalThis);
1138
- const cancelFrame = options.cancelAnimationFrame ?? globalThis.cancelAnimationFrame?.bind(globalThis);
1139
- let frameHandle;
1140
- let running = false;
1141
- let frame = 0;
1142
- let startedAtMs;
1143
- let lastTimestamp;
1144
- let pendingCameraControl = null;
1145
- let activeCameraControl = false;
1146
- let headLookWeight = 0;
1147
- let cameraPosition = route[0] ? [
1148
- route[0].position[0] + (camera.offset?.[0] ?? 0),
1149
- route[0].position[1] + (camera.offset?.[1] ?? 0),
1150
- route[0].position[2] + (camera.offset?.[2] ?? 0)
1151
- ] : [0, 2.4, 5.5];
1152
- let cameraRigFrame = (0, import_gpu_camera.resolveCameraRigFrame)({
1153
- viewMode: "editor",
1154
- anchors: {
1155
- target: route[0]?.position ? [...route[0].position] : [0, 0, 0],
1156
- head: route[0]?.position ? [route[0].position[0], route[0].position[1] + 1.65, route[0].position[2]] : [0, 1.65, 0],
1157
- forward: [0, 0, -1]
1158
- },
1159
- camera: {
1160
- id: "animation-adventure",
1161
- transform: {
1162
- position: [...cameraPosition],
1163
- target: route[0]?.position ? [...route[0].position] : [0, 0, 0]
1164
- }
1165
- },
1166
- constraints: camera.constraints
1167
- });
1168
- let snapshot = {
1169
- frame,
1170
- running,
1171
- activeClipId: beats[0]?.clipId ?? "",
1172
- activeBeatId: beats[0]?.id ?? "",
1173
- activeMovementMode: beatTimeline[0]?.movementType ?? "stationary",
1174
- blendProgress: 0,
1175
- clipTimeMs: 0,
1176
- characterPosition: route[0]?.position ? [...route[0].position] : [0, 0, 0],
1177
- cameraPosition: [...cameraPosition],
1178
- cameraViewMode: resolveCameraViewMode(camera),
1179
- cameraTransform: cameraRigFrame.transform,
1180
- targetDistance: cameraRigFrame.targetDistance,
1181
- headLook: cameraRigFrame.headLook,
1182
- characterGroundY: 0,
1183
- characterVisible: false,
1184
- modelLoaded: Boolean(characterModel),
1185
- modelRenderable: false,
1186
- fallbackProxyActive: true,
1187
- skinnedVertexCount: characterModel?.vertexCount ?? 0,
1188
- skinnedTriangleCount: characterModel?.triangleCount ?? 0,
1189
- skinnedJointCount: characterModel?.jointCount ?? 0,
1190
- skinnedAnimatedNodeCount: characterModel?.animatedNodeCount ?? 0,
1191
- skinnedClipCount: characterModel?.clipCount ?? 0,
1192
- activeClipRenderable: false,
1193
- propGroundAnchors: [],
1194
- movementValidation: {
1195
- status: movementWarnings.length ? "warning" : "passed",
1196
- warnings: movementWarnings,
1197
- activeBeatId: beats[0]?.id ?? "",
1198
- activeClipId: beats[0]?.clipId ?? "",
1199
- motionMode: beatTimeline[0]?.movementType ?? "stationary",
1200
- rootMotionSource: beatTimeline[0]?.movementProfile?.motionMode ?? "none",
1201
- expectedSpeed: beatTimeline[0]?.expectedSpeed ?? 0,
1202
- actualSpeed: beatTimeline[0]?.actualSpeed ?? 0,
1203
- movementDistance: beatTimeline[0]?.movementDistance ?? 0,
1204
- loopCount: beatTimeline[0]?.loopCount ?? 1,
1205
- footSlideWarning: beatTimeline[0]?.footSlideWarning ?? null
1206
- },
1207
- frameState: "initialized"
1208
- };
1209
- function renderOnce(timestamp = nowMs()) {
1210
- if (startedAtMs === void 0) {
1211
- startedAtMs = timestamp;
1794
+ let distance = 0;
1795
+ for (const channel of clipPayload.animation.channels ?? []) {
1796
+ if (channel.target?.path !== "translation") {
1797
+ continue;
1212
1798
  }
1213
- const elapsedMs = Math.max(0, timestamp - startedAtMs);
1214
- const loopTimeMs = elapsedMs % loopDurationMs;
1215
- const frameTimeMs = lastTimestamp === void 0 ? 16.67 : Math.max(0, timestamp - lastTimestamp);
1216
- lastTimestamp = timestamp;
1217
- frame += 1;
1218
- const timelineEntry = resolveTimelineBeat(beatTimeline, loopTimeMs);
1219
- const fallbackBeat = resolveBeat(beats, loopTimeMs);
1220
- const beat = timelineEntry?.beat ?? fallbackBeat.beat;
1221
- const beatTimeMs = timelineEntry ? loopTimeMs - timelineEntry.startMs : fallbackBeat.beatTimeMs;
1222
- const durationMs = timelineEntry?.durationMs ?? fallbackBeat.durationMs;
1223
- const blend = beat?.blend ?? { inMs: 0, outMs: 0 };
1224
- const blendIn = blend.inMs > 0 ? clamp01(beatTimeMs / blend.inMs) : 1;
1225
- const blendOut = blend.outMs > 0 ? clamp01((durationMs - beatTimeMs) / blend.outMs) : 1;
1226
- const blendProgress = clamp01(Math.min(blendIn, blendOut));
1227
- const characterPosition = timelineEntry ? resolveTimelinePosition(timelineEntry, beatTimeMs) : resolveRoutePosition(route, loopTimeMs);
1228
- const characterForward = timelineEntry ? resolveTimelineForward(timelineEntry) : resolveRouteForward(route, loopTimeMs);
1229
- const headAnchor = camera.headBoneAvailable === false ? void 0 : [
1230
- characterPosition[0],
1231
- characterPosition[1] + (camera.headHeight ?? 1.65),
1232
- characterPosition[2]
1233
- ];
1234
- const lookAheadTimeMs = (loopTimeMs + camera.lookAheadMs) % loopDurationMs;
1235
- const lookAheadEntry = resolveTimelineBeat(beatTimeline, lookAheadTimeMs);
1236
- const lookAheadPosition = lookAheadEntry ? resolveTimelinePosition(lookAheadEntry, lookAheadTimeMs - lookAheadEntry.startMs) : resolveRoutePosition(route, lookAheadTimeMs);
1237
- const desiredCamera = [
1238
- lookAheadPosition[0] + (camera.offset?.[0] ?? 0),
1239
- lookAheadPosition[1] + (camera.offset?.[1] ?? 0),
1240
- lookAheadPosition[2] + (camera.offset?.[2] ?? 0)
1241
- ];
1242
- const smoothing = bezierY(camera.cubicBezier, clamp01(frameTimeMs / Math.max(1, camera.lagMs)));
1243
- cameraPosition = lerp3(cameraPosition, desiredCamera, smoothing);
1244
- const cameraViewMode = resolveCameraViewMode(camera);
1245
- const rigViewMode = cameraViewMode === "spectator" ? "editor" : cameraViewMode;
1246
- cameraRigFrame = (0, import_gpu_camera.resolveCameraRigFrame)({
1247
- viewMode: rigViewMode,
1248
- anchors: {
1249
- target: characterPosition,
1250
- ...headAnchor ? { head: headAnchor } : {},
1251
- forward: characterForward
1252
- },
1253
- camera: {
1254
- id: "animation-adventure",
1255
- transform: {
1256
- position: [...cameraPosition],
1257
- target: lookAheadPosition
1258
- }
1259
- },
1260
- constraints: camera.constraints,
1261
- control: pendingCameraControl,
1262
- activeControl: activeCameraControl
1263
- });
1264
- if (activeCameraControl && cameraRigFrame.headLook.status === "active") {
1265
- headLookWeight = cameraRigFrame.headLook.weight;
1266
- } else {
1267
- const returnMs = Math.max(1, camera.headLook?.returnMs ?? DEFAULT_CAMERA.headLook.returnMs);
1268
- headLookWeight = lerp2(headLookWeight, 0, clamp01(frameTimeMs / returnMs));
1799
+ const nodeName = clipPayload.document.json.nodes?.[channel.target.node]?.name ?? "";
1800
+ if (!/(^|:)Hips$/u.test(nodeName) && nodeName !== "mixamorigHips") {
1801
+ continue;
1269
1802
  }
1270
- const headLook = {
1271
- ...cameraRigFrame.headLook,
1272
- status: cameraRigFrame.headLook.status === "unavailable" ? "unavailable" : headLookWeight > 1e-3 && !activeCameraControl ? "returning" : cameraRigFrame.headLook.status,
1273
- weight: headLookWeight
1274
- };
1275
- cameraPosition = [...cameraRigFrame.transform.position];
1276
- pendingCameraControl = null;
1277
- activeCameraControl = false;
1278
- snapshot = {
1279
- frame,
1280
- running,
1281
- activeClipId: beat?.clipId ?? "",
1282
- activeBeatId: beat?.id ?? "",
1283
- activeMovementMode: timelineEntry?.movementType ?? "stationary",
1284
- blendProgress,
1285
- clipTimeMs: beatTimeMs,
1286
- characterPosition,
1287
- cameraPosition: [...cameraPosition],
1288
- cameraViewMode,
1289
- cameraTransform: cameraRigFrame.transform,
1290
- targetDistance: cameraRigFrame.targetDistance,
1291
- headLook,
1292
- characterGroundY: 0,
1293
- characterVisible: false,
1294
- modelLoaded: Boolean(characterModel),
1295
- modelRenderable: false,
1296
- fallbackProxyActive: true,
1297
- skinnedVertexCount: characterModel?.vertexCount ?? 0,
1298
- skinnedTriangleCount: characterModel?.triangleCount ?? 0,
1299
- skinnedJointCount: characterModel?.jointCount ?? 0,
1300
- skinnedAnimatedNodeCount: characterModel?.animatedNodeCount ?? 0,
1301
- skinnedClipCount: characterModel?.clipCount ?? 0,
1302
- activeClipRenderable: false,
1303
- propGroundAnchors: [],
1304
- movementValidation: {
1305
- status: movementWarnings.length ? "warning" : "passed",
1306
- warnings: [...movementWarnings],
1307
- activeBeatId: beat?.id ?? "",
1308
- activeClipId: beat?.clipId ?? "",
1309
- motionMode: timelineEntry?.movementType ?? "stationary",
1310
- rootMotionSource: timelineEntry?.movementProfile?.motionMode ?? "none",
1311
- expectedSpeed: timelineEntry?.expectedSpeed ?? 0,
1312
- actualSpeed: timelineEntry?.actualSpeed ?? 0,
1313
- movementDistance: timelineEntry?.movementDistance ?? 0,
1314
- loopCount: timelineEntry?.loopCount ?? 1,
1315
- footSlideWarning: timelineEntry?.footSlideWarning ?? null
1316
- },
1317
- frameState: running ? "running" : "rendered-once"
1318
- };
1319
- const visibility = renderScene(canvas, ctx, snapshot, props, characterModel);
1320
- snapshot = {
1321
- ...snapshot,
1322
- ...visibility
1323
- };
1324
- return snapshot;
1325
- }
1326
- function tick(timestamp) {
1327
- if (!running) {
1328
- return;
1803
+ const sampler = clipPayload.animation.samplers?.[channel.sampler];
1804
+ const output = sampler ? readAccessor(clipPayload.document, sampler.output) : [];
1805
+ if (output.length < 2) {
1806
+ continue;
1329
1807
  }
1330
- renderOnce(timestamp);
1331
- if (running && requestFrame) {
1332
- frameHandle = requestFrame(tick);
1808
+ const start = Array.isArray(output[0]) ? output[0] : [0, 0, 0];
1809
+ const end = Array.isArray(output.at(-1)) ? output.at(-1) : start;
1810
+ distance = Math.max(distance, Math.hypot((end[0] ?? 0) - (start[0] ?? 0), (end[2] ?? 0) - (start[2] ?? 0)));
1811
+ }
1812
+ return distance;
1813
+ }
1814
+ function createClipPayload(clipRef) {
1815
+ const document = parseGlb(clipRef?.asset);
1816
+ const animation = document?.json.animations?.[0];
1817
+ if (!document || !animation) {
1818
+ return null;
1819
+ }
1820
+ const nodeNames = /* @__PURE__ */ new Set();
1821
+ let durationSeconds = 0;
1822
+ for (const channel of animation.channels ?? []) {
1823
+ const nodeName = document.json.nodes?.[channel.target?.node]?.name;
1824
+ if (nodeName) {
1825
+ nodeNames.add(nodeName);
1333
1826
  }
1827
+ const sampler = animation.samplers?.[channel.sampler];
1828
+ const input = sampler ? readAccessor(document, sampler.input) : [];
1829
+ durationSeconds = Math.max(durationSeconds, input.at(-1) ?? 0);
1334
1830
  }
1335
1831
  return {
1336
- start() {
1337
- if (running) {
1338
- return;
1339
- }
1340
- running = true;
1341
- snapshot = { ...snapshot, running: true, frameState: "running" };
1342
- if (requestFrame) {
1343
- frameHandle = requestFrame(tick);
1344
- } else {
1345
- renderOnce();
1346
- }
1347
- },
1348
- resize(width, height, devicePixelRatio = 1) {
1349
- const ratio = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1;
1350
- canvas.width = Math.max(1, Math.floor(width * ratio));
1351
- canvas.height = Math.max(1, Math.floor(height * ratio));
1352
- if (canvas.style) {
1353
- canvas.style.width = `${width}px`;
1354
- canvas.style.height = `${height}px`;
1355
- }
1356
- const visibility = renderScene(canvas, ctx, snapshot, props, characterModel);
1357
- snapshot = {
1358
- ...snapshot,
1359
- ...visibility
1360
- };
1361
- },
1362
- renderOnce,
1363
- getSnapshot() {
1832
+ id: clipRef.id,
1833
+ document,
1834
+ animation,
1835
+ durationSeconds,
1836
+ nodeNames
1837
+ };
1838
+ }
1839
+ function sampleClipOverrides(clipPayload, timeSeconds) {
1840
+ const overrides = /* @__PURE__ */ new Map();
1841
+ if (!clipPayload) {
1842
+ return overrides;
1843
+ }
1844
+ for (const channel of clipPayload.animation.channels ?? []) {
1845
+ const targetNode = clipPayload.document.json.nodes?.[channel.target?.node];
1846
+ const sampler = clipPayload.animation.samplers?.[channel.sampler];
1847
+ if (!targetNode?.name || !sampler) {
1848
+ continue;
1849
+ }
1850
+ const value = sampleChannel(channel, sampler, clipPayload.document, timeSeconds);
1851
+ if (!value) {
1852
+ continue;
1853
+ }
1854
+ const existing = overrides.get(targetNode.name) ?? {};
1855
+ overrides.set(targetNode.name, {
1856
+ ...existing,
1857
+ [channel.target.path]: value
1858
+ });
1859
+ }
1860
+ return overrides;
1861
+ }
1862
+ function createAnimatedGltfModel(modelAsset, clipRefs = []) {
1863
+ const document = parseGlb(modelAsset);
1864
+ if (!document) {
1865
+ return null;
1866
+ }
1867
+ const nodes = document.json.nodes ?? [];
1868
+ const meshNodeIndex = nodes.findIndex((node) => node.mesh !== void 0 && node.skin !== void 0);
1869
+ const meshNode = nodes[meshNodeIndex];
1870
+ const mesh = document.json.meshes?.[meshNode?.mesh];
1871
+ const primitive = mesh?.primitives?.find((candidate) => candidate.attributes?.POSITION !== void 0);
1872
+ const skin = document.json.skins?.[meshNode?.skin];
1873
+ if (!meshNode || !primitive || !skin) {
1874
+ return null;
1875
+ }
1876
+ const positions = readAccessor(document, primitive.attributes.POSITION);
1877
+ const normals = primitive.attributes.NORMAL === void 0 ? [] : readAccessor(document, primitive.attributes.NORMAL);
1878
+ const texcoords = primitive.attributes.TEXCOORD_0 === void 0 ? [] : readAccessor(document, primitive.attributes.TEXCOORD_0);
1879
+ const joints = primitive.attributes.JOINTS_0 === void 0 ? positions.map(() => [0, 0, 0, 0]) : readAccessor(document, primitive.attributes.JOINTS_0);
1880
+ const weights = primitive.attributes.WEIGHTS_0 === void 0 ? positions.map(() => [1, 0, 0, 0]) : readAccessor(document, primitive.attributes.WEIGHTS_0).map(normalizeWeights);
1881
+ const indices = primitive.indices === void 0 ? positions.map((_, index) => index) : readAccessor(document, primitive.indices);
1882
+ const inverseBindMatrices = skin.inverseBindMatrices === void 0 ? skin.joints.map(() => identityMatrix()) : readMatrices(document, skin.inverseBindMatrices);
1883
+ const clips = clipRefs.map(createClipPayload).filter(Boolean);
1884
+ const bindWorldMatrices = computeWorldMatrices(nodes);
1885
+ const bindVertices = skinVertices({
1886
+ positions,
1887
+ joints,
1888
+ weights,
1889
+ skin,
1890
+ inverseBindMatrices,
1891
+ worldMatrices: bindWorldMatrices
1892
+ });
1893
+ const bounds = deriveBounds(bindVertices);
1894
+ const materialTextureMetadata = extractMaterialTextureMetadata(document, primitive);
1895
+ const clipRootMotionDistances = new Map(clips.map((clip) => [clip.id, rootTranslationDistance(clip)]));
1896
+ return {
1897
+ name: mesh.name ?? meshNode.name ?? "skinned-gltf-model",
1898
+ vertexCount: positions.length,
1899
+ triangleCount: Math.floor(indices.length / 3),
1900
+ jointCount: skin.joints.length,
1901
+ animatedNodeCount: new Set(clips.flatMap((clip) => [...clip.nodeNames])).size,
1902
+ clipCount: clips.length,
1903
+ materialTextureMetadata,
1904
+ textureCount: materialTextureMetadata.textureCount,
1905
+ materialCount: materialTextureMetadata.materialCount,
1906
+ hasBaseColorTexture: materialTextureMetadata.hasBaseColorTexture,
1907
+ hasNormalTexture: materialTextureMetadata.hasNormalTexture,
1908
+ hasUv: texcoords.length === positions.length,
1909
+ hasNormals: normals.length === positions.length,
1910
+ professionalRenderable: materialTextureMetadata.hasBaseColorTexture && materialTextureMetadata.hasNormalTexture && materialTextureMetadata.missingTextureReferences.length === 0 && texcoords.length === positions.length && normals.length === positions.length && skin.joints.length > 0,
1911
+ clips,
1912
+ sample(activeClipId, clipTimeMs) {
1913
+ const clip = clips.find((candidate) => candidate.id === activeClipId) ?? clips[0] ?? null;
1914
+ const overrides = sampleClipOverrides(clip, Math.max(0, clipTimeMs) / 1e3);
1915
+ const worldMatrices = computeWorldMatrices(nodes, overrides);
1916
+ const vertices = skinVertices({
1917
+ positions,
1918
+ joints,
1919
+ weights,
1920
+ skin,
1921
+ inverseBindMatrices,
1922
+ worldMatrices
1923
+ });
1364
1924
  return {
1365
- ...snapshot,
1366
- characterPosition: [...snapshot.characterPosition],
1367
- cameraPosition: [...snapshot.cameraPosition],
1368
- cameraTransform: {
1369
- position: [...snapshot.cameraTransform.position],
1370
- target: [...snapshot.cameraTransform.target],
1371
- up: [...snapshot.cameraTransform.up]
1372
- },
1373
- headLook: {
1374
- ...snapshot.headLook,
1375
- target: [...snapshot.headLook.target]
1376
- },
1377
- movementValidation: {
1378
- ...snapshot.movementValidation,
1379
- warnings: [...snapshot.movementValidation?.warnings ?? []]
1380
- },
1381
- propGroundAnchors: snapshot.propGroundAnchors.map((anchor) => ({ ...anchor }))
1382
- };
1383
- },
1384
- setCamera(nextCamera = {}) {
1385
- camera = { ...camera, ...nextCamera };
1386
- snapshot = {
1387
- ...snapshot,
1388
- cameraViewMode: resolveCameraViewMode(camera)
1389
- };
1390
- },
1391
- setCameraViewMode(viewMode) {
1392
- camera = { ...camera, viewMode };
1393
- snapshot = {
1394
- ...snapshot,
1395
- cameraViewMode: resolveCameraViewMode(camera)
1925
+ vertices,
1926
+ indices,
1927
+ bounds: deriveBounds(vertices),
1928
+ bindBounds: bounds,
1929
+ activeClipRenderable: Boolean(clip),
1930
+ activeClipDurationSeconds: clip?.durationSeconds ?? 0,
1931
+ activeClipRootTranslationDistance: clipRootMotionDistances.get(clip?.id) ?? 0
1396
1932
  };
1397
- },
1398
- applyCameraControl(control, options2 = {}) {
1399
- pendingCameraControl = control;
1400
- activeCameraControl = options2.activeControl !== false;
1401
- },
1402
- destroy() {
1403
- running = false;
1404
- if (frameHandle !== void 0 && cancelFrame) {
1405
- cancelFrame(frameHandle);
1406
- }
1407
- frameHandle = void 0;
1408
- snapshot = { ...snapshot, running: false, frameState: "destroyed" };
1409
1933
  }
1410
1934
  };
1411
1935
  }
1936
+ function skinVertices({ positions, joints, weights, skin, inverseBindMatrices, worldMatrices }) {
1937
+ return positions.map((position, vertexIndex) => {
1938
+ const jointIndices = joints[vertexIndex] ?? [0, 0, 0, 0];
1939
+ const jointWeights = weights[vertexIndex] ?? [1, 0, 0, 0];
1940
+ const skinned = [0, 0, 0];
1941
+ for (let influence = 0; influence < 4; influence += 1) {
1942
+ const weight = jointWeights[influence] ?? 0;
1943
+ if (weight <= 0) {
1944
+ continue;
1945
+ }
1946
+ const jointNodeIndex = skin.joints[jointIndices[influence] ?? 0] ?? skin.joints[0];
1947
+ const jointMatrix = multiplyMatrix(worldMatrices[jointNodeIndex] ?? identityMatrix(), inverseBindMatrices[jointIndices[influence] ?? 0] ?? identityMatrix());
1948
+ const transformed = transformPoint(jointMatrix, position);
1949
+ skinned[0] += transformed[0] * weight;
1950
+ skinned[1] += transformed[1] * weight;
1951
+ skinned[2] += transformed[2] * weight;
1952
+ }
1953
+ return skinned;
1954
+ });
1955
+ }
1412
1956
 
1413
- // src/renderer-constants.js
1414
- var DEFAULT_CLEAR_COLOR = Object.freeze([0.07, 0.11, 0.18, 1]);
1415
- var DEFAULT_CANVAS_SELECTOR = "canvas[data-plasius-gpu-renderer]";
1416
- var rendererDebugOwner = "renderer";
1417
- var rendererWorkerQueueClass = "render";
1418
- var defaultRendererWorkerProfile = "realtime";
1419
- var rendererRepresentationBands = Object.freeze([
1420
- "near",
1421
- "mid",
1422
- "far",
1423
- "horizon"
1424
- ]);
1425
- var rendererAccelerationStructureUpdateClasses = Object.freeze([
1426
- "static",
1427
- "rigid-dynamic",
1428
- "deforming",
1429
- "proxy"
1430
- ]);
1431
- var rendererRayTracingStageOrder = Object.freeze([
1432
- "primaryVisibility",
1433
- "shadowAssist",
1434
- "opaqueFoundation",
1435
- "rtDirectLighting",
1436
- "rtReflections",
1437
- "rtGlobalIllumination",
1438
- "denoiseTemporal",
1439
- "transparents",
1440
- "composition",
1441
- "present"
1442
- ]);
1443
- var rendererWavefrontBufferSchemaVersion = 1;
1444
- var rendererWavefrontQueuePairStrategy = "ping-pong-active-next";
1445
- var rendererWavefrontHitTypes = Object.freeze([
1446
- "surface",
1447
- "emissive",
1448
- "environment",
1449
- "transparent",
1450
- "miss"
1451
- ]);
1452
- var rendererWavefrontPassOrder = Object.freeze([
1453
- "generatePrimaryRays",
1454
- "intersectActiveQueue",
1455
- "resolveSurfaceRecords",
1456
- "accumulateTerminalRadiance",
1457
- "scatterContinuations",
1458
- "compactAndSwapQueues"
1459
- ]);
1460
-
1461
- // src/renderer-validation.js
1462
- function readPositiveNumber(name, value) {
1463
- if (value === void 0) {
1464
- return void 0;
1957
+ // src/animated-scene-renderer.js
1958
+ var DEFAULT_CAMERA = Object.freeze({
1959
+ mode: "lagged-follow",
1960
+ viewMode: "spectator",
1961
+ cubicBezier: [0.22, 0.61, 0.36, 1],
1962
+ lagMs: 240,
1963
+ lookAheadMs: 320,
1964
+ offset: [0, 2.4, 5.5],
1965
+ constraints: Object.freeze({
1966
+ maxDistance: 10,
1967
+ firstPersonHeadOffset: 0.05
1968
+ }),
1969
+ headLook: Object.freeze({
1970
+ enabled: true,
1971
+ returnMs: 240
1972
+ })
1973
+ });
1974
+ function nowMs() {
1975
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
1976
+ }
1977
+ function clamp012(value) {
1978
+ return Math.min(1, Math.max(0, value));
1979
+ }
1980
+ function lerp2(a, b, t) {
1981
+ return a + (b - a) * t;
1982
+ }
1983
+ function lerp3(a, b, t) {
1984
+ return [
1985
+ lerp2(a[0] ?? 0, b[0] ?? 0, t),
1986
+ lerp2(a[1] ?? 0, b[1] ?? 0, t),
1987
+ lerp2(a[2] ?? 0, b[2] ?? 0, t)
1988
+ ];
1989
+ }
1990
+ function normalize3(value, fallback = [0, 0, -1]) {
1991
+ if (!Array.isArray(value) || value.length < 3) {
1992
+ return [...fallback];
1993
+ }
1994
+ const vector = [
1995
+ Number.isFinite(Number(value[0])) ? Number(value[0]) : fallback[0],
1996
+ Number.isFinite(Number(value[1])) ? Number(value[1]) : fallback[1],
1997
+ Number.isFinite(Number(value[2])) ? Number(value[2]) : fallback[2]
1998
+ ];
1999
+ const length = Math.hypot(vector[0], vector[1], vector[2]);
2000
+ if (length <= 1e-6) {
2001
+ return [...fallback];
2002
+ }
2003
+ return [vector[0] / length, vector[1] / length, vector[2] / length];
2004
+ }
2005
+ function clamp(value, min, max) {
2006
+ return Math.min(max, Math.max(min, value));
2007
+ }
2008
+ function bezierY([, y1, , y2], t) {
2009
+ const u = 1 - t;
2010
+ return 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t;
2011
+ }
2012
+ function durationFromBeats(beats) {
2013
+ return beats.reduce((total, beat) => total + Math.max(0, beat.durationMs ?? 0), 0);
2014
+ }
2015
+ function definedCameraOverrides(camera) {
2016
+ if (!camera || typeof camera !== "object") {
2017
+ return {};
2018
+ }
2019
+ return Object.fromEntries(Object.entries(camera).filter(([, value]) => value !== void 0));
2020
+ }
2021
+ function resolveBeat(beats, loopTimeMs) {
2022
+ let cursor = 0;
2023
+ for (const beat of beats) {
2024
+ const durationMs = Math.max(1, beat.durationMs ?? 1);
2025
+ if (loopTimeMs < cursor + durationMs) {
2026
+ return { beat, beatTimeMs: loopTimeMs - cursor, durationMs };
2027
+ }
2028
+ cursor += durationMs;
2029
+ }
2030
+ const fallback = beats.at(-1);
2031
+ return { beat: fallback, beatTimeMs: fallback?.durationMs ?? 0, durationMs: fallback?.durationMs ?? 1 };
2032
+ }
2033
+ function resolveRoutePosition(route, timeMs) {
2034
+ if (!route.length) {
2035
+ return [0, 0, 0];
2036
+ }
2037
+ if (route.length === 1 || timeMs <= (route[0].arriveMs ?? 0)) {
2038
+ return [...route[0].position];
2039
+ }
2040
+ for (let index = 1; index < route.length; index += 1) {
2041
+ const previous = route[index - 1];
2042
+ const next = route[index];
2043
+ const start = previous.arriveMs ?? 0;
2044
+ const end = next.arriveMs ?? start + 1;
2045
+ if (timeMs <= end) {
2046
+ return lerp3(previous.position, next.position, clamp012((timeMs - start) / Math.max(1, end - start)));
2047
+ }
2048
+ }
2049
+ return [...route.at(-1).position];
2050
+ }
2051
+ function resolveRouteForward(route, timeMs) {
2052
+ if (route.length < 2) {
2053
+ return [0, 0, -1];
2054
+ }
2055
+ let previous = route[0];
2056
+ let next = route[1];
2057
+ for (let index = 1; index < route.length; index += 1) {
2058
+ next = route[index];
2059
+ if (timeMs <= (next.arriveMs ?? 0)) {
2060
+ previous = route[index - 1];
2061
+ break;
2062
+ }
2063
+ }
2064
+ return normalize3([
2065
+ (next.position?.[0] ?? 0) - (previous.position?.[0] ?? 0),
2066
+ (next.position?.[1] ?? 0) - (previous.position?.[1] ?? 0),
2067
+ (next.position?.[2] ?? 0) - (previous.position?.[2] ?? 0)
2068
+ ]);
2069
+ }
2070
+ function distance3(a = [0, 0, 0], b = [0, 0, 0]) {
2071
+ return Math.hypot(
2072
+ (b[0] ?? 0) - (a[0] ?? 0),
2073
+ (b[1] ?? 0) - (a[1] ?? 0),
2074
+ (b[2] ?? 0) - (a[2] ?? 0)
2075
+ );
2076
+ }
2077
+ function movementRequirementType(requirement, beat) {
2078
+ if (requirement?.type) {
2079
+ return requirement.type;
2080
+ }
2081
+ if (beat?.kind === "locomotion") {
2082
+ return beat?.rootMotion === "in-place" ? "stationary" : "travel";
2083
+ }
2084
+ if (beat?.kind === "action" || beat?.kind === "idle") {
2085
+ return "stationary";
2086
+ }
2087
+ const clipId = String(beat?.clipId ?? "").toLowerCase();
2088
+ if (/(idle|gesture|dig|plant|water|pick|serve|swing|fire|block|hoe)/iu.test(clipId)) {
2089
+ return "stationary";
2090
+ }
2091
+ if (/(walk|run|jump|climb|crawl|locomotion)/iu.test(clipId)) {
2092
+ return clipId.includes("jump") ? "jump" : "travel";
2093
+ }
2094
+ return beat?.pathPointId ? "travel" : "stationary";
2095
+ }
2096
+ function movementDistancePerLoop(profile = {}) {
2097
+ profile ??= {};
2098
+ const rootDistance = Number(profile.rootTranslationDistance ?? profile.rootTranslation?.distance ?? 0);
2099
+ if (Number.isFinite(rootDistance) && rootDistance > 0) {
2100
+ return rootDistance;
2101
+ }
2102
+ const strideLength = Number(profile.strideLength ?? profile.strideLengthMeters ?? 0);
2103
+ if (Number.isFinite(strideLength) && strideLength > 0) {
2104
+ return strideLength;
2105
+ }
2106
+ return 0;
2107
+ }
2108
+ function clipDurationMs(profile = {}, fallbackMs = 1) {
2109
+ profile ??= {};
2110
+ const duration = Number(profile.durationMs ?? 0);
2111
+ return Number.isFinite(duration) && duration > 0 ? duration : Math.max(1, fallbackMs);
2112
+ }
2113
+ function profileAllowsDisplacement(profile = {}, movementType) {
2114
+ profile ??= {};
2115
+ if (movementType === "stationary") {
2116
+ return profile.worldDisplacementAllowed !== true;
2117
+ }
2118
+ return profile.worldDisplacementAllowed === true || profile.motionMode === "root-authored" || profile.motionMode === "calibrated-in-place" || profile.motionMode === "jump";
2119
+ }
2120
+ function resolveClipProfiles(clipAssets = []) {
2121
+ return new Map(clipAssets.map((clip) => [clip.id, clip.movementProfile ?? clip.profile ?? null]));
2122
+ }
2123
+ function buildBeatTimeline(beats, route, clipProfiles) {
2124
+ const routePoints = new Map(route.map((point) => [point.id, point]));
2125
+ let cursorMs = 0;
2126
+ let routeCursor = 0;
2127
+ let currentPosition = route[0]?.position ? [...route[0].position] : [0, 0, 0];
2128
+ return beats.map((beat) => {
2129
+ const requirement = beat.movementRequirement ?? beat.movement ?? null;
2130
+ const movementType = movementRequirementType(requirement, beat);
2131
+ const movesThroughWorld = movementType === "travel" || movementType === "jump" || movementType === "root-authored";
2132
+ const explicitTargetPoint = beat.pathPointId ? routePoints.get(beat.pathPointId) : null;
2133
+ const inferredTargetPoint = movesThroughWorld ? route[Math.min(route.length - 1, routeCursor + 1)] : null;
2134
+ const targetPoint = explicitTargetPoint ?? inferredTargetPoint ?? null;
2135
+ const targetPosition = targetPoint?.position ? [...targetPoint.position] : [...currentPosition];
2136
+ const startPosition = [...currentPosition];
2137
+ const endPosition = movesThroughWorld ? targetPosition : [...currentPosition];
2138
+ const segmentDistance = Number(requirement?.distance ?? distance3(startPosition, endPosition));
2139
+ const profile = clipProfiles.get(beat.clipId) ?? null;
2140
+ const distancePerLoop = movementDistancePerLoop(profile);
2141
+ const loopCount = movesThroughWorld && distancePerLoop > 0 ? Math.max(1, Math.ceil(segmentDistance / distancePerLoop)) : 1;
2142
+ const derivedDurationMs = movesThroughWorld && distancePerLoop > 0 ? loopCount * clipDurationMs(profile, beat.durationMs) : Math.max(1, beat.durationMs ?? 1);
2143
+ const durationMs = Math.max(1, beat.validatedDurationMs ?? requirement?.validatedDurationMs ?? derivedDurationMs);
2144
+ const expectedSpeed = Number(profile?.expectedSpeed ?? (durationMs > 0 ? segmentDistance / (durationMs / 1e3) : 0));
2145
+ const actualSpeed = durationMs > 0 ? segmentDistance / (durationMs / 1e3) : 0;
2146
+ const warnings = [];
2147
+ if (movesThroughWorld && !profileAllowsDisplacement(profile, movementType)) {
2148
+ warnings.push(`clip '${beat.clipId}' does not allow ${movementType} displacement`);
2149
+ }
2150
+ if (movesThroughWorld && distancePerLoop <= 0 && profile?.motionMode !== "root-authored") {
2151
+ warnings.push(`clip '${beat.clipId}' has no root or calibrated stride distance`);
2152
+ }
2153
+ if (movementType === "stationary" && profile?.worldDisplacementAllowed === true) {
2154
+ warnings.push(`stationary beat '${beat.id}' uses a displacement-capable clip`);
2155
+ }
2156
+ const entry = {
2157
+ beat,
2158
+ startMs: cursorMs,
2159
+ endMs: cursorMs + durationMs,
2160
+ durationMs,
2161
+ startPosition,
2162
+ endPosition,
2163
+ movementType,
2164
+ movementProfile: profile,
2165
+ movementDistance: segmentDistance,
2166
+ distancePerLoop,
2167
+ loopCount,
2168
+ expectedSpeed,
2169
+ actualSpeed,
2170
+ footSlideWarning: Number(profile?.footSlideTolerance ?? 0) < 0 ? "invalid-foot-slide-tolerance" : null,
2171
+ warnings
2172
+ };
2173
+ cursorMs = entry.endMs;
2174
+ currentPosition = [...endPosition];
2175
+ if (targetPoint) {
2176
+ const nextRouteIndex = route.findIndex((point) => point.id === targetPoint.id);
2177
+ if (nextRouteIndex >= 0) {
2178
+ routeCursor = nextRouteIndex;
2179
+ }
2180
+ }
2181
+ return entry;
2182
+ });
2183
+ }
2184
+ function resolveTimelineBeat(timeline, loopTimeMs) {
2185
+ if (!timeline.length) {
2186
+ return null;
1465
2187
  }
1466
- if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
1467
- throw new Error(`${name} must be a finite number greater than zero.`);
2188
+ return timeline.find((entry) => loopTimeMs < entry.endMs) ?? timeline.at(-1);
2189
+ }
2190
+ function resolveTimelinePosition(entry, beatTimeMs) {
2191
+ if (!entry) {
2192
+ return [0, 0, 0];
1468
2193
  }
1469
- return value;
2194
+ if (entry.movementType !== "travel" && entry.movementType !== "jump" && entry.movementType !== "root-authored") {
2195
+ return [...entry.startPosition];
2196
+ }
2197
+ return lerp3(entry.startPosition, entry.endPosition, clamp012(beatTimeMs / Math.max(1, entry.durationMs)));
1470
2198
  }
1471
- function readPositiveInteger(name, value) {
1472
- if (!Number.isInteger(value) || value <= 0) {
1473
- throw new Error(`${name} must be a positive integer.`);
2199
+ function resolveTimelineForward(entry) {
2200
+ if (!entry) {
2201
+ return [0, 0, -1];
1474
2202
  }
1475
- return value;
2203
+ return normalize3([
2204
+ (entry.endPosition[0] ?? 0) - (entry.startPosition[0] ?? 0),
2205
+ (entry.endPosition[1] ?? 0) - (entry.startPosition[1] ?? 0),
2206
+ (entry.endPosition[2] ?? 0) - (entry.startPosition[2] ?? 0)
2207
+ ]);
1476
2208
  }
1477
- function readNonNegativeInteger(name, value) {
1478
- if (!Number.isInteger(value) || value < 0) {
1479
- throw new Error(`${name} must be a non-negative integer.`);
2209
+ function resolveCameraViewMode(camera) {
2210
+ if (camera?.mode === "lagged-follow") {
2211
+ return camera.viewMode ?? "spectator";
1480
2212
  }
1481
- return value;
2213
+ return camera?.viewMode ?? camera?.mode ?? "spectator";
1482
2214
  }
1483
- function assertRendererIdentifier(name, value) {
1484
- if (typeof value !== "string" || value.trim().length === 0) {
1485
- throw new Error(`${name} must be a non-empty string.`);
2215
+ function resolveCanvas2(canvas) {
2216
+ if (typeof canvas === "string") {
2217
+ const found = globalThis.document?.querySelector?.(canvas);
2218
+ if (!found) {
2219
+ throw new Error(`Animated scene canvas '${canvas}' was not found.`);
2220
+ }
2221
+ return found;
1486
2222
  }
1487
- return value.trim();
2223
+ if (!canvas) {
2224
+ throw new Error("Animated scene renderer requires a canvas.");
2225
+ }
2226
+ return canvas;
1488
2227
  }
1489
-
1490
- // src/renderer-webgpu-runtime.js
1491
- function clamp012(value) {
1492
- return Math.min(1, Math.max(0, value));
2228
+ function project(position, cameraPosition, width, height) {
2229
+ const x = position[0] - cameraPosition[0];
2230
+ const depth = Math.max(0.2, cameraPosition[2] - (position[2] ?? 0));
2231
+ const scale2 = clamp(34 + 150 / (depth + 1.2), 34, 104);
2232
+ const groundY = height * 0.82 - depth * height * 0.045 - (position[1] ?? 0) * scale2;
2233
+ return {
2234
+ x: width * 0.5 + x * scale2,
2235
+ y: groundY,
2236
+ groundY,
2237
+ scale: scale2,
2238
+ depth,
2239
+ visible: groundY > -height * 0.2 && groundY < height * 1.18
2240
+ };
1493
2241
  }
1494
- function parseHexChannel(channel) {
1495
- return parseInt(channel, 16) / 255;
2242
+ function propIdentity(prop, index) {
2243
+ return prop.id ?? `${prop.kind}-${index}`;
1496
2244
  }
1497
- function normalizeColor(value) {
1498
- if (Array.isArray(value)) {
1499
- const [r = 0, g = 0, b = 0, a = 1] = value;
1500
- return [clamp012(Number(r) || 0), clamp012(Number(g) || 0), clamp012(Number(b) || 0), clamp012(Number(a) || 0)];
1501
- }
1502
- if (typeof value === "string") {
1503
- const trimmed = value.trim();
1504
- if (/^#[0-9a-f]{3}$/i.test(trimmed)) {
1505
- const r = trimmed[1];
1506
- const g = trimmed[2];
1507
- const b = trimmed[3];
1508
- return [
1509
- parseHexChannel(r + r),
1510
- parseHexChannel(g + g),
1511
- parseHexChannel(b + b),
1512
- 1
1513
- ];
2245
+ function drawGroundShadow(ctx, scale2) {
2246
+ ctx.fillStyle = "rgba(44, 38, 28, 0.18)";
2247
+ ctx.fillRect(-scale2 * 0.36, -scale2 * 0.035, scale2 * 0.72, scale2 * 0.07);
2248
+ }
2249
+ function drawProp(ctx, prop, cameraPosition, width, height) {
2250
+ const projected = project(prop.position, cameraPosition, width, height);
2251
+ const { x, y, scale: scale2 } = projected;
2252
+ ctx.save();
2253
+ ctx.translate(x, y);
2254
+ ctx.lineWidth = Math.max(1, scale2 * 0.04);
2255
+ if (prop.kind === "crop-row") {
2256
+ ctx.fillStyle = "#8b633f";
2257
+ ctx.fillRect(-scale2 * 1.05, -scale2 * 0.08, scale2 * 2.1, scale2 * 0.16);
2258
+ ctx.fillStyle = "#6f4a30";
2259
+ ctx.fillRect(-scale2 * 0.92, -scale2 * 0.035, scale2 * 1.84, scale2 * 0.07);
2260
+ ctx.strokeStyle = "#4f6f32";
2261
+ ctx.beginPath();
2262
+ ctx.moveTo(-scale2 * 0.8, 0);
2263
+ ctx.lineTo(scale2 * 0.8, 0);
2264
+ ctx.stroke();
2265
+ ctx.fillStyle = "#6f9f43";
2266
+ for (let i = -3; i <= 3; i += 1) {
2267
+ ctx.fillRect(i * scale2 * 0.22, -scale2 * 0.18, scale2 * 0.06, scale2 * 0.2);
1514
2268
  }
1515
- if (/^#[0-9a-f]{6}$/i.test(trimmed)) {
1516
- return [
1517
- parseHexChannel(trimmed.slice(1, 3)),
1518
- parseHexChannel(trimmed.slice(3, 5)),
1519
- parseHexChannel(trimmed.slice(5, 7)),
1520
- 1
1521
- ];
2269
+ } else if (prop.kind === "fence-segment") {
2270
+ ctx.strokeStyle = "#896b4d";
2271
+ ctx.beginPath();
2272
+ ctx.moveTo(-scale2 * 0.5, 0);
2273
+ ctx.lineTo(scale2 * 0.5, 0);
2274
+ ctx.moveTo(-scale2 * 0.35, -scale2 * 0.25);
2275
+ ctx.lineTo(-scale2 * 0.35, scale2 * 0.1);
2276
+ ctx.moveTo(scale2 * 0.35, -scale2 * 0.25);
2277
+ ctx.lineTo(scale2 * 0.35, scale2 * 0.1);
2278
+ ctx.stroke();
2279
+ } else if (prop.kind === "cart") {
2280
+ drawGroundShadow(ctx, scale2);
2281
+ ctx.fillStyle = "#7b5238";
2282
+ ctx.fillRect(-scale2 * 0.52, -scale2 * 0.38, scale2 * 1.04, scale2 * 0.36);
2283
+ ctx.fillStyle = "#a4774c";
2284
+ ctx.fillRect(-scale2 * 0.44, -scale2 * 0.32, scale2 * 0.88, scale2 * 0.08);
2285
+ ctx.fillStyle = "#25201c";
2286
+ ctx.beginPath();
2287
+ ctx.arc(-scale2 * 0.32, scale2 * 0.02, scale2 * 0.1, 0, Math.PI * 2);
2288
+ ctx.arc(scale2 * 0.32, scale2 * 0.02, scale2 * 0.1, 0, Math.PI * 2);
2289
+ ctx.fill();
2290
+ } else if (prop.kind === "small-tree") {
2291
+ drawGroundShadow(ctx, scale2);
2292
+ ctx.fillStyle = "#5b3b24";
2293
+ ctx.fillRect(-scale2 * 0.07, -scale2 * 0.56, scale2 * 0.14, scale2 * 0.56);
2294
+ ctx.fillStyle = "#2f6d3b";
2295
+ ctx.beginPath();
2296
+ ctx.arc(-scale2 * 0.16, -scale2 * 0.72, scale2 * 0.25, 0, Math.PI * 2);
2297
+ ctx.fill();
2298
+ ctx.fillStyle = "#3f8746";
2299
+ ctx.beginPath();
2300
+ ctx.arc(scale2 * 0.13, -scale2 * 0.76, scale2 * 0.28, 0, Math.PI * 2);
2301
+ ctx.fill();
2302
+ ctx.fillStyle = "#4f9a50";
2303
+ ctx.beginPath();
2304
+ ctx.arc(0, -scale2 * 0.98, scale2 * 0.26, 0, Math.PI * 2);
2305
+ ctx.fill();
2306
+ } else {
2307
+ drawGroundShadow(ctx, scale2);
2308
+ ctx.fillStyle = prop.kind === "crate" ? "#9a6a3d" : "#d0c39f";
2309
+ ctx.fillRect(-scale2 * 0.2, -scale2 * 0.32, scale2 * 0.4, scale2 * 0.32);
2310
+ if (prop.kind === "crate") {
2311
+ ctx.strokeStyle = "#6e4829";
2312
+ ctx.beginPath();
2313
+ ctx.moveTo(-scale2 * 0.2, -scale2 * 0.16);
2314
+ ctx.lineTo(scale2 * 0.2, -scale2 * 0.16);
2315
+ ctx.moveTo(0, -scale2 * 0.32);
2316
+ ctx.lineTo(0, 0);
2317
+ ctx.stroke();
1522
2318
  }
1523
2319
  }
1524
- return [...DEFAULT_CLEAR_COLOR];
2320
+ ctx.restore();
2321
+ return projected;
1525
2322
  }
1526
- function now() {
1527
- if (typeof performance !== "undefined" && typeof performance.now === "function") {
1528
- return performance.now();
2323
+ function drawCharacter(ctx, characterPosition, cameraPosition, width, height, gaitPhase, headLook) {
2324
+ const projected = project(characterPosition, cameraPosition, width, height);
2325
+ const { x, y, scale: scale2 } = projected;
2326
+ const stride = Math.sin(gaitPhase * Math.PI * 2);
2327
+ const headYaw = (headLook?.yaw ?? 0) * (headLook?.weight ?? 0);
2328
+ ctx.save();
2329
+ ctx.translate(x, y);
2330
+ ctx.lineWidth = Math.max(2, scale2 * 0.05);
2331
+ ctx.lineCap = "round";
2332
+ drawGroundShadow(ctx, scale2);
2333
+ ctx.strokeStyle = "#4c2f25";
2334
+ ctx.beginPath();
2335
+ ctx.moveTo(-scale2 * 0.12, -scale2 * 0.34);
2336
+ ctx.lineTo(-scale2 * (0.18 + stride * 0.04), -scale2 * 0.02);
2337
+ ctx.moveTo(scale2 * 0.12, -scale2 * 0.34);
2338
+ ctx.lineTo(scale2 * (0.18 - stride * 0.04), -scale2 * 0.02);
2339
+ ctx.stroke();
2340
+ ctx.fillStyle = "#6f8f62";
2341
+ ctx.beginPath();
2342
+ ctx.moveTo(-scale2 * 0.3, -scale2 * 0.86);
2343
+ ctx.lineTo(scale2 * 0.3, -scale2 * 0.86);
2344
+ ctx.lineTo(scale2 * 0.42, -scale2 * 0.24);
2345
+ ctx.lineTo(-scale2 * 0.42, -scale2 * 0.24);
2346
+ ctx.lineTo(-scale2 * 0.3, -scale2 * 0.86);
2347
+ ctx.fill();
2348
+ ctx.fillStyle = "#d8b18c";
2349
+ ctx.beginPath();
2350
+ ctx.arc(0, -scale2 * 1.16, scale2 * 0.18, 0, Math.PI * 2);
2351
+ ctx.fill();
2352
+ ctx.fillStyle = "#5a3a2b";
2353
+ ctx.fillRect(-scale2 * 0.2, -scale2 * 1.28, scale2 * 0.4, scale2 * 0.16);
2354
+ ctx.fillRect(-scale2 * 0.23, -scale2 * 1.14, scale2 * 0.1, scale2 * 0.28);
2355
+ ctx.fillRect(scale2 * 0.13, -scale2 * 1.14, scale2 * 0.1, scale2 * 0.28);
2356
+ if (headLook?.status === "active" || headLook?.status === "returning") {
2357
+ ctx.strokeStyle = "rgba(255, 246, 210, 0.74)";
2358
+ ctx.lineWidth = Math.max(1, scale2 * 0.025);
2359
+ ctx.beginPath();
2360
+ ctx.moveTo(0, -scale2 * 1.16);
2361
+ ctx.lineTo(Math.sin(headYaw) * scale2 * 0.32, -scale2 * 1.16 - scale2 * 0.08);
2362
+ ctx.stroke();
1529
2363
  }
1530
- return Date.now();
2364
+ ctx.strokeStyle = "#6b4935";
2365
+ ctx.beginPath();
2366
+ ctx.moveTo(-scale2 * 0.24, -scale2 * 0.76);
2367
+ ctx.lineTo(-scale2 * (0.46 + stride * 0.04), -scale2 * (0.5 + stride * 0.06));
2368
+ ctx.moveTo(scale2 * 0.24, -scale2 * 0.76);
2369
+ ctx.lineTo(scale2 * (0.46 - stride * 0.04), -scale2 * (0.5 - stride * 0.06));
2370
+ ctx.stroke();
2371
+ ctx.fillStyle = "#d6c09a";
2372
+ ctx.fillRect(-scale2 * 0.16, -scale2 * 0.86, scale2 * 0.32, scale2 * 0.18);
2373
+ ctx.restore();
2374
+ return projected;
1531
2375
  }
1532
- function normalizeFrameId(value) {
1533
- if (typeof value !== "string" || value.trim().length === 0) {
1534
- throw new Error("frameIdFactory must return a non-empty string.");
1535
- }
1536
- return value.trim();
2376
+ function pointForModelVertex(vertex, projection, sample) {
2377
+ const bounds = sample.bounds;
2378
+ const height = Math.max(0.1, bounds.size[1] || sample.bindBounds.size[1] || 1);
2379
+ const modelScale = projection.scale * 1.3 / height;
2380
+ const centerX = (bounds.min[0] + bounds.max[0]) * 0.5;
2381
+ const centerZ = (bounds.min[2] + bounds.max[2]) * 0.5;
2382
+ return [
2383
+ projection.x + (vertex[0] - centerX) * modelScale,
2384
+ projection.groundY - (vertex[1] - bounds.min[1]) * modelScale + (vertex[2] - centerZ) * modelScale * 0.16
2385
+ ];
1537
2386
  }
1538
- function resolveTargetFrameTimeMs(options, event) {
1539
- const {
1540
- targetFrameTimeMs: fixedTargetFrameTimeMs,
1541
- targetFrameRate,
1542
- getTargetFrameTimeMs
1543
- } = options;
1544
- if (typeof getTargetFrameTimeMs === "function") {
1545
- const resolved = getTargetFrameTimeMs(event);
1546
- return readPositiveNumber("getTargetFrameTimeMs()", resolved);
2387
+ function modelFillForHeight(vertex, sample) {
2388
+ const height = Math.max(0.1, sample.bounds.size[1] || 1);
2389
+ const t = (vertex[1] - sample.bounds.min[1]) / height;
2390
+ if (t > 0.82) {
2391
+ return "#6d4a36";
1547
2392
  }
1548
- if (fixedTargetFrameTimeMs !== void 0) {
1549
- return fixedTargetFrameTimeMs;
2393
+ if (t > 0.58) {
2394
+ return "#d7b28f";
1550
2395
  }
1551
- if (targetFrameRate !== void 0) {
1552
- return 1e3 / targetFrameRate;
2396
+ if (t > 0.25) {
2397
+ return "#6f8f62";
1553
2398
  }
1554
- return void 0;
2399
+ return "#5e4636";
1555
2400
  }
1556
- function createRendererDebugHooks(options = {}) {
1557
- const {
1558
- debugSession,
1559
- targetFrameTimeMs,
1560
- targetFrameRate,
1561
- getTargetFrameTimeMs,
1562
- onFrameStart,
1563
- onFrameComplete
1564
- } = options;
1565
- if (!debugSession || typeof debugSession.recordFrame !== "function") {
1566
- throw new Error(
1567
- "debugSession must expose recordFrame(sample). Use @plasius/gpu-debug createGpuDebugSession()."
1568
- );
1569
- }
1570
- const fixedTargetFrameTimeMs = readPositiveNumber(
1571
- "targetFrameTimeMs",
1572
- targetFrameTimeMs
1573
- );
1574
- const fixedTargetFrameRate = readPositiveNumber(
1575
- "targetFrameRate",
1576
- targetFrameRate
1577
- );
1578
- if (fixedTargetFrameTimeMs !== void 0 && fixedTargetFrameRate !== void 0) {
1579
- throw new Error(
1580
- "Provide either targetFrameTimeMs or targetFrameRate, not both."
1581
- );
2401
+ function drawSkinnedGltfCharacter(ctx, model, snapshot, width, height) {
2402
+ const projection = project(snapshot.characterPosition, snapshot.cameraPosition, width, height);
2403
+ const sample = model.sample(snapshot.activeClipId, snapshot.clipTimeMs);
2404
+ const maxTriangles = 12e3;
2405
+ const triangleCount = Math.floor(sample.indices.length / 3);
2406
+ const stride = Math.max(1, Math.ceil(triangleCount / maxTriangles));
2407
+ const triangles = [];
2408
+ for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += stride) {
2409
+ const base = triangleIndex * 3;
2410
+ const a = sample.vertices[sample.indices[base]];
2411
+ const b = sample.vertices[sample.indices[base + 1]];
2412
+ const c = sample.vertices[sample.indices[base + 2]];
2413
+ if (!a || !b || !c) {
2414
+ continue;
2415
+ }
2416
+ triangles.push({
2417
+ a,
2418
+ b,
2419
+ c,
2420
+ depth: (a[2] + b[2] + c[2]) / 3,
2421
+ fill: modelFillForHeight(a, sample)
2422
+ });
1582
2423
  }
1583
- if (getTargetFrameTimeMs !== void 0 && typeof getTargetFrameTimeMs !== "function") {
1584
- throw new Error("getTargetFrameTimeMs must be a function when provided.");
2424
+ triangles.sort((left, right) => left.depth - right.depth);
2425
+ ctx.save();
2426
+ drawGroundShadow(ctx, projection.scale);
2427
+ for (const triangle of triangles) {
2428
+ const [ax, ay] = pointForModelVertex(triangle.a, projection, sample);
2429
+ const [bx, by] = pointForModelVertex(triangle.b, projection, sample);
2430
+ const [cx, cy] = pointForModelVertex(triangle.c, projection, sample);
2431
+ ctx.fillStyle = triangle.fill;
2432
+ ctx.beginPath();
2433
+ ctx.moveTo(ax, ay);
2434
+ ctx.lineTo(bx, by);
2435
+ ctx.lineTo(cx, cy);
2436
+ ctx.closePath?.();
2437
+ ctx.fill();
1585
2438
  }
1586
- const resolvedOptions = {
1587
- targetFrameTimeMs: fixedTargetFrameTimeMs,
1588
- targetFrameRate: fixedTargetFrameRate,
1589
- getTargetFrameTimeMs
1590
- };
2439
+ ctx.restore();
1591
2440
  return {
1592
- onFrameStart(event) {
1593
- if (typeof onFrameStart === "function") {
1594
- onFrameStart({
1595
- ...event,
1596
- owner: rendererDebugOwner
1597
- });
1598
- }
1599
- },
1600
- onFrameComplete(event) {
1601
- const resolvedTargetFrameTimeMs = resolveTargetFrameTimeMs(
1602
- resolvedOptions,
1603
- event
1604
- );
1605
- if (typeof event.frameTimeMs === "number" && Number.isFinite(event.frameTimeMs) && event.frameTimeMs > 0) {
1606
- debugSession.recordFrame({
1607
- frameId: event.frameId,
1608
- frameTimeMs: event.frameTimeMs,
1609
- targetFrameTimeMs: resolvedTargetFrameTimeMs
1610
- });
1611
- }
1612
- if (typeof onFrameComplete === "function") {
1613
- onFrameComplete({
1614
- ...event,
1615
- owner: rendererDebugOwner,
1616
- targetFrameTimeMs: resolvedTargetFrameTimeMs
1617
- });
1618
- }
1619
- }
2441
+ projection,
2442
+ sample
1620
2443
  };
1621
2444
  }
1622
- function readNavigator(navigatorOverride) {
1623
- const currentNavigator = navigatorOverride ?? globalThis.navigator;
1624
- if (!currentNavigator || typeof currentNavigator !== "object") {
1625
- throw new Error("Navigator unavailable. Provide a browser-like navigator object.");
1626
- }
1627
- return currentNavigator;
1628
- }
1629
- function readDocument(documentOverride) {
1630
- const doc = documentOverride ?? globalThis.document;
1631
- if (!doc || typeof doc !== "object") {
1632
- throw new Error("Document unavailable. Provide a browser-like document object.");
1633
- }
1634
- return doc;
1635
- }
1636
- function resolveCanvas2(canvasOrSelector, documentOverride) {
1637
- if (canvasOrSelector && typeof canvasOrSelector === "object") {
1638
- return canvasOrSelector;
1639
- }
1640
- const doc = readDocument(documentOverride);
1641
- const selector = typeof canvasOrSelector === "string" && canvasOrSelector.trim().length > 0 ? canvasOrSelector : DEFAULT_CANVAS_SELECTOR;
1642
- const resolved = doc.querySelector(selector);
1643
- if (!resolved) {
1644
- throw new Error(`Unable to find canvas for selector "${selector}".`);
1645
- }
1646
- return resolved;
1647
- }
1648
- function readGpu(navigatorOverride) {
1649
- const currentNavigator = readNavigator(navigatorOverride);
1650
- const gpu = currentNavigator.gpu;
1651
- if (!gpu || typeof gpu.requestAdapter !== "function") {
1652
- throw new Error("WebGPU runtime unavailable. navigator.gpu is missing.");
2445
+ function renderScene(canvas, ctx, snapshot, props, characterModel) {
2446
+ const width = canvas.width || 1;
2447
+ const height = canvas.height || 1;
2448
+ ctx.clearRect(0, 0, width, height);
2449
+ ctx.fillStyle = "#d8e8d0";
2450
+ ctx.fillRect(0, 0, width, height);
2451
+ ctx.fillStyle = "#9fc47a";
2452
+ ctx.fillRect(0, height * 0.52, width, height * 0.48);
2453
+ ctx.strokeStyle = "#caa66d";
2454
+ ctx.lineWidth = Math.max(2, width * 6e-3);
2455
+ ctx.beginPath();
2456
+ ctx.moveTo(width * 0.35, height);
2457
+ ctx.quadraticCurveTo(width * 0.53, height * 0.58, width * 0.62, 0);
2458
+ ctx.stroke();
2459
+ const projectedProps = props.map((prop, index) => ({
2460
+ prop,
2461
+ id: propIdentity(prop, index),
2462
+ projected: project(prop.position, snapshot.cameraPosition, width, height)
2463
+ })).sort((a, b) => b.projected.depth - a.projected.depth);
2464
+ const propGroundAnchors = [];
2465
+ for (const { prop, id } of projectedProps) {
2466
+ const projected = drawProp(ctx, prop, snapshot.cameraPosition, width, height);
2467
+ propGroundAnchors.push({
2468
+ id,
2469
+ kind: prop.kind,
2470
+ groundY: projected.groundY,
2471
+ depth: projected.depth,
2472
+ visible: projected.visible
2473
+ });
1653
2474
  }
1654
- return gpu;
1655
- }
1656
- function configureContext(context, device, format, alphaMode) {
1657
- if (typeof context.configure !== "function") {
1658
- throw new Error("Canvas WebGPU context does not support configure().");
2475
+ let characterProjection;
2476
+ let modelRenderable = false;
2477
+ let fallbackProxyActive = true;
2478
+ let skinnedVertexCount = 0;
2479
+ let skinnedTriangleCount = 0;
2480
+ let skinnedJointCount = 0;
2481
+ let skinnedAnimatedNodeCount = 0;
2482
+ let skinnedClipCount = 0;
2483
+ let activeClipRenderable = false;
2484
+ if (characterModel) {
2485
+ const drawn = drawSkinnedGltfCharacter(ctx, characterModel, snapshot, width, height);
2486
+ characterProjection = drawn.projection;
2487
+ modelRenderable = true;
2488
+ fallbackProxyActive = false;
2489
+ skinnedVertexCount = characterModel.vertexCount;
2490
+ skinnedTriangleCount = characterModel.triangleCount;
2491
+ skinnedJointCount = characterModel.jointCount;
2492
+ skinnedAnimatedNodeCount = characterModel.animatedNodeCount;
2493
+ skinnedClipCount = characterModel.clipCount;
2494
+ activeClipRenderable = drawn.sample.activeClipRenderable;
2495
+ } else {
2496
+ characterProjection = drawCharacter(
2497
+ ctx,
2498
+ snapshot.characterPosition,
2499
+ snapshot.cameraPosition,
2500
+ width,
2501
+ height,
2502
+ snapshot.clipTimeMs / 900,
2503
+ snapshot.headLook
2504
+ );
1659
2505
  }
1660
- context.configure({
1661
- device,
1662
- format,
1663
- alphaMode
1664
- });
1665
- }
1666
- function createRenderPassDescriptor(view, clearColor) {
1667
2506
  return {
1668
- colorAttachments: [
1669
- {
1670
- view,
1671
- loadOp: "clear",
1672
- clearValue: {
1673
- r: clearColor[0],
1674
- g: clearColor[1],
1675
- b: clearColor[2],
1676
- a: clearColor[3]
1677
- },
1678
- storeOp: "store"
1679
- }
1680
- ]
2507
+ characterGroundY: characterProjection.groundY,
2508
+ characterVisible: characterProjection.visible,
2509
+ modelLoaded: Boolean(characterModel),
2510
+ modelRenderable,
2511
+ fallbackProxyActive,
2512
+ skinnedVertexCount,
2513
+ skinnedTriangleCount,
2514
+ skinnedJointCount,
2515
+ skinnedAnimatedNodeCount,
2516
+ skinnedClipCount,
2517
+ activeClipRenderable,
2518
+ propGroundAnchors
1681
2519
  };
1682
2520
  }
1683
- function supportsWebGpu(options = {}) {
2521
+ function resolveCharacterModel(options) {
1684
2522
  try {
1685
- const gpu = readGpu(options.navigator);
1686
- return Boolean(gpu);
2523
+ return createAnimatedGltfModel(
2524
+ options.modelAsset ?? options.animationAdventure?.modelAsset,
2525
+ options.clipAssets ?? options.animationAdventure?.clipAssets ?? []
2526
+ );
1687
2527
  } catch {
1688
- return false;
2528
+ return null;
1689
2529
  }
1690
2530
  }
1691
- async function createGpuRenderer(options = {}) {
1692
- const {
1693
- canvas,
1694
- navigator: navigatorOverride,
1695
- document: documentOverride,
1696
- powerPreference = "high-performance",
1697
- alpha = true,
1698
- format,
1699
- clearColor = DEFAULT_CLEAR_COLOR,
1700
- requestAnimationFrame = globalThis.requestAnimationFrame?.bind(globalThis),
1701
- cancelAnimationFrame = globalThis.cancelAnimationFrame?.bind(globalThis),
1702
- frameIdFactory,
1703
- onFrameStart,
1704
- onBeforeEncode,
1705
- onAfterSubmit,
1706
- onFrameComplete
1707
- } = options;
1708
- const gpu = readGpu(navigatorOverride);
1709
- const adapter = await gpu.requestAdapter({ powerPreference });
1710
- if (!adapter) {
1711
- throw new Error("Unable to obtain GPU adapter.");
1712
- }
1713
- const device = await adapter.requestDevice();
1714
- const targetCanvas = resolveCanvas2(canvas, documentOverride);
1715
- const context = targetCanvas.getContext?.("webgpu");
1716
- if (!context) {
1717
- throw new Error("Unable to obtain WebGPU canvas context.");
2531
+ function createAnimatedSceneRenderer(options = {}) {
2532
+ const canvas = resolveCanvas2(options.canvas);
2533
+ const ctx = canvas.getContext?.("2d");
2534
+ if (!ctx) {
2535
+ throw new Error("Animated scene renderer requires a 2D canvas context.");
1718
2536
  }
1719
- const resolvedFormat = format || (typeof gpu.getPreferredCanvasFormat === "function" ? gpu.getPreferredCanvasFormat() : "bgra8unorm");
1720
- configureContext(context, device, resolvedFormat, alpha ? "premultiplied" : "opaque");
2537
+ const route = [...options.route ?? options.animationAdventure?.route ?? []];
2538
+ const beats = [...options.beats ?? options.animationAdventure?.beats ?? []].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
2539
+ const props = [...options.props ?? options.animationAdventure?.props ?? []];
2540
+ const clipAssets = options.clipAssets ?? options.animationAdventure?.clipAssets ?? [];
2541
+ const clipProfiles = resolveClipProfiles(clipAssets);
2542
+ const beatTimeline = buildBeatTimeline(beats, route, clipProfiles);
2543
+ const characterModel = resolveCharacterModel(options);
2544
+ let camera = {
2545
+ ...DEFAULT_CAMERA,
2546
+ ...definedCameraOverrides(options.camera ?? options.animationAdventure?.camera)
2547
+ };
2548
+ const loopDurationMs = Math.max(beatTimeline.at(-1)?.endMs ?? durationFromBeats(beats), 1);
2549
+ const movementWarnings = beatTimeline.flatMap((entry) => entry.warnings);
2550
+ const requestFrame = options.requestAnimationFrame ?? globalThis.requestAnimationFrame?.bind(globalThis);
2551
+ const cancelFrame = options.cancelAnimationFrame ?? globalThis.cancelAnimationFrame?.bind(globalThis);
2552
+ let frameHandle;
1721
2553
  let running = false;
1722
- let destroyed = false;
1723
2554
  let frame = 0;
1724
- let lastTimestamp = 0;
1725
- let rafId = null;
1726
- let clear = normalizeColor(clearColor);
1727
- let xrActive = false;
1728
- let detachXrBinding = null;
1729
- const renderOnce = (timestamp = now()) => {
1730
- if (destroyed) {
1731
- throw new Error("Renderer was destroyed.");
1732
- }
1733
- const frameNumber = frame + 1;
1734
- const frameId = normalizeFrameId(
1735
- typeof frameIdFactory === "function" ? frameIdFactory({
1736
- frame: frameNumber,
1737
- timestamp,
1738
- canvas: targetCanvas,
1739
- xrActive
1740
- }) : `renderer.frame.${frameNumber}`
1741
- );
1742
- const frameTimeMs = lastTimestamp > 0 ? Math.max(0, timestamp - lastTimestamp) : void 0;
1743
- if (typeof onFrameStart === "function") {
1744
- onFrameStart({
1745
- frame: frameNumber,
1746
- frameId,
1747
- frameTimeMs,
1748
- timestamp,
1749
- device,
1750
- context,
1751
- canvas: targetCanvas,
1752
- xrActive
1753
- });
1754
- }
1755
- const texture = context.getCurrentTexture?.();
1756
- if (!texture || typeof texture.createView !== "function") {
1757
- throw new Error("WebGPU context returned an invalid current texture.");
1758
- }
1759
- const encoder = device.createCommandEncoder({
1760
- label: `plasius.gpu-renderer.frame.${frame}`
1761
- });
1762
- const view = texture.createView();
1763
- const pass = encoder.beginRenderPass(createRenderPassDescriptor(view, clear));
1764
- if (typeof onBeforeEncode === "function") {
1765
- onBeforeEncode({
1766
- frame,
1767
- frameNumber,
1768
- frameId,
1769
- frameTimeMs,
1770
- timestamp,
1771
- device,
1772
- context,
1773
- encoder,
1774
- pass,
1775
- canvas: targetCanvas,
1776
- xrActive
1777
- });
1778
- }
1779
- if (typeof pass.end === "function") {
1780
- pass.end();
1781
- }
1782
- const commandBuffer = encoder.finish();
1783
- device.queue.submit([commandBuffer]);
1784
- frame = frameNumber;
1785
- lastTimestamp = timestamp;
1786
- if (typeof onAfterSubmit === "function") {
1787
- onAfterSubmit({
1788
- frame: frameNumber,
1789
- frameNumber,
1790
- frameId,
1791
- frameTimeMs,
1792
- timestamp,
1793
- device,
1794
- context,
1795
- canvas: targetCanvas,
1796
- xrActive
1797
- });
1798
- }
1799
- if (typeof onFrameComplete === "function") {
1800
- onFrameComplete({
1801
- frame: frameNumber,
1802
- frameId,
1803
- frameTimeMs,
1804
- timestamp,
1805
- device,
1806
- context,
1807
- canvas: targetCanvas,
1808
- xrActive
1809
- });
1810
- }
1811
- return {
1812
- frame: frameNumber,
1813
- frameId,
1814
- frameTimeMs,
1815
- timestamp
1816
- };
1817
- };
1818
- const tick = (timestamp) => {
1819
- if (!running || destroyed) {
1820
- return;
1821
- }
1822
- renderOnce(timestamp);
1823
- if (typeof requestAnimationFrame === "function") {
1824
- rafId = requestAnimationFrame(tick);
1825
- }
2555
+ let startedAtMs;
2556
+ let lastTimestamp;
2557
+ let pendingCameraControl = null;
2558
+ let activeCameraControl = false;
2559
+ let headLookWeight = 0;
2560
+ let cameraPosition = route[0] ? [
2561
+ route[0].position[0] + (camera.offset?.[0] ?? 0),
2562
+ route[0].position[1] + (camera.offset?.[1] ?? 0),
2563
+ route[0].position[2] + (camera.offset?.[2] ?? 0)
2564
+ ] : [0, 2.4, 5.5];
2565
+ let cameraRigFrame = (0, import_gpu_camera.resolveCameraRigFrame)({
2566
+ viewMode: "editor",
2567
+ anchors: {
2568
+ target: route[0]?.position ? [...route[0].position] : [0, 0, 0],
2569
+ head: route[0]?.position ? [route[0].position[0], route[0].position[1] + 1.65, route[0].position[2]] : [0, 1.65, 0],
2570
+ forward: [0, 0, -1]
2571
+ },
2572
+ camera: {
2573
+ id: "animation-adventure",
2574
+ transform: {
2575
+ position: [...cameraPosition],
2576
+ target: route[0]?.position ? [...route[0].position] : [0, 0, 0]
2577
+ }
2578
+ },
2579
+ constraints: camera.constraints
2580
+ });
2581
+ let snapshot = {
2582
+ frame,
2583
+ running,
2584
+ activeClipId: beats[0]?.clipId ?? "",
2585
+ activeBeatId: beats[0]?.id ?? "",
2586
+ activeMovementMode: beatTimeline[0]?.movementType ?? "stationary",
2587
+ blendProgress: 0,
2588
+ clipTimeMs: 0,
2589
+ characterPosition: route[0]?.position ? [...route[0].position] : [0, 0, 0],
2590
+ cameraPosition: [...cameraPosition],
2591
+ cameraViewMode: resolveCameraViewMode(camera),
2592
+ cameraTransform: cameraRigFrame.transform,
2593
+ targetDistance: cameraRigFrame.targetDistance,
2594
+ headLook: cameraRigFrame.headLook,
2595
+ characterGroundY: 0,
2596
+ characterVisible: false,
2597
+ modelLoaded: Boolean(characterModel),
2598
+ modelRenderable: false,
2599
+ fallbackProxyActive: true,
2600
+ skinnedVertexCount: characterModel?.vertexCount ?? 0,
2601
+ skinnedTriangleCount: characterModel?.triangleCount ?? 0,
2602
+ skinnedJointCount: characterModel?.jointCount ?? 0,
2603
+ skinnedAnimatedNodeCount: characterModel?.animatedNodeCount ?? 0,
2604
+ skinnedClipCount: characterModel?.clipCount ?? 0,
2605
+ activeClipRenderable: false,
2606
+ propGroundAnchors: [],
2607
+ movementValidation: {
2608
+ status: movementWarnings.length ? "warning" : "passed",
2609
+ warnings: movementWarnings,
2610
+ activeBeatId: beats[0]?.id ?? "",
2611
+ activeClipId: beats[0]?.clipId ?? "",
2612
+ motionMode: beatTimeline[0]?.movementType ?? "stationary",
2613
+ rootMotionSource: beatTimeline[0]?.movementProfile?.motionMode ?? "none",
2614
+ expectedSpeed: beatTimeline[0]?.expectedSpeed ?? 0,
2615
+ actualSpeed: beatTimeline[0]?.actualSpeed ?? 0,
2616
+ movementDistance: beatTimeline[0]?.movementDistance ?? 0,
2617
+ loopCount: beatTimeline[0]?.loopCount ?? 1,
2618
+ footSlideWarning: beatTimeline[0]?.footSlideWarning ?? null
2619
+ },
2620
+ frameState: "initialized"
1826
2621
  };
1827
- const start = () => {
1828
- if (destroyed) {
1829
- throw new Error("Renderer was destroyed.");
1830
- }
1831
- if (running) {
1832
- return false;
2622
+ function renderOnce(timestamp = nowMs()) {
2623
+ if (startedAtMs === void 0) {
2624
+ startedAtMs = timestamp;
1833
2625
  }
1834
- running = true;
1835
- if (typeof requestAnimationFrame === "function") {
1836
- rafId = requestAnimationFrame(tick);
2626
+ const elapsedMs = Math.max(0, timestamp - startedAtMs);
2627
+ const loopTimeMs = elapsedMs % loopDurationMs;
2628
+ const frameTimeMs = lastTimestamp === void 0 ? 16.67 : Math.max(0, timestamp - lastTimestamp);
2629
+ lastTimestamp = timestamp;
2630
+ frame += 1;
2631
+ const timelineEntry = resolveTimelineBeat(beatTimeline, loopTimeMs);
2632
+ const fallbackBeat = resolveBeat(beats, loopTimeMs);
2633
+ const beat = timelineEntry?.beat ?? fallbackBeat.beat;
2634
+ const beatTimeMs = timelineEntry ? loopTimeMs - timelineEntry.startMs : fallbackBeat.beatTimeMs;
2635
+ const durationMs = timelineEntry?.durationMs ?? fallbackBeat.durationMs;
2636
+ const blend = beat?.blend ?? { inMs: 0, outMs: 0 };
2637
+ const blendIn = blend.inMs > 0 ? clamp012(beatTimeMs / blend.inMs) : 1;
2638
+ const blendOut = blend.outMs > 0 ? clamp012((durationMs - beatTimeMs) / blend.outMs) : 1;
2639
+ const blendProgress = clamp012(Math.min(blendIn, blendOut));
2640
+ const characterPosition = timelineEntry ? resolveTimelinePosition(timelineEntry, beatTimeMs) : resolveRoutePosition(route, loopTimeMs);
2641
+ const characterForward = timelineEntry ? resolveTimelineForward(timelineEntry) : resolveRouteForward(route, loopTimeMs);
2642
+ const headAnchor = camera.headBoneAvailable === false ? void 0 : [
2643
+ characterPosition[0],
2644
+ characterPosition[1] + (camera.headHeight ?? 1.65),
2645
+ characterPosition[2]
2646
+ ];
2647
+ const lookAheadTimeMs = (loopTimeMs + camera.lookAheadMs) % loopDurationMs;
2648
+ const lookAheadEntry = resolveTimelineBeat(beatTimeline, lookAheadTimeMs);
2649
+ const lookAheadPosition = lookAheadEntry ? resolveTimelinePosition(lookAheadEntry, lookAheadTimeMs - lookAheadEntry.startMs) : resolveRoutePosition(route, lookAheadTimeMs);
2650
+ const desiredCamera = [
2651
+ lookAheadPosition[0] + (camera.offset?.[0] ?? 0),
2652
+ lookAheadPosition[1] + (camera.offset?.[1] ?? 0),
2653
+ lookAheadPosition[2] + (camera.offset?.[2] ?? 0)
2654
+ ];
2655
+ const smoothing = bezierY(camera.cubicBezier, clamp012(frameTimeMs / Math.max(1, camera.lagMs)));
2656
+ cameraPosition = lerp3(cameraPosition, desiredCamera, smoothing);
2657
+ const cameraViewMode = resolveCameraViewMode(camera);
2658
+ const rigViewMode = cameraViewMode === "spectator" ? "editor" : cameraViewMode;
2659
+ cameraRigFrame = (0, import_gpu_camera.resolveCameraRigFrame)({
2660
+ viewMode: rigViewMode,
2661
+ anchors: {
2662
+ target: characterPosition,
2663
+ ...headAnchor ? { head: headAnchor } : {},
2664
+ forward: characterForward
2665
+ },
2666
+ camera: {
2667
+ id: "animation-adventure",
2668
+ transform: {
2669
+ position: [...cameraPosition],
2670
+ target: lookAheadPosition
2671
+ }
2672
+ },
2673
+ constraints: camera.constraints,
2674
+ control: pendingCameraControl,
2675
+ activeControl: activeCameraControl
2676
+ });
2677
+ if (activeCameraControl && cameraRigFrame.headLook.status === "active") {
2678
+ headLookWeight = cameraRigFrame.headLook.weight;
1837
2679
  } else {
1838
- renderOnce();
2680
+ const returnMs = Math.max(1, camera.headLook?.returnMs ?? DEFAULT_CAMERA.headLook.returnMs);
2681
+ headLookWeight = lerp2(headLookWeight, 0, clamp012(frameTimeMs / returnMs));
1839
2682
  }
1840
- return true;
1841
- };
1842
- const stop = () => {
2683
+ const headLook = {
2684
+ ...cameraRigFrame.headLook,
2685
+ status: cameraRigFrame.headLook.status === "unavailable" ? "unavailable" : headLookWeight > 1e-3 && !activeCameraControl ? "returning" : cameraRigFrame.headLook.status,
2686
+ weight: headLookWeight
2687
+ };
2688
+ cameraPosition = [...cameraRigFrame.transform.position];
2689
+ pendingCameraControl = null;
2690
+ activeCameraControl = false;
2691
+ snapshot = {
2692
+ frame,
2693
+ running,
2694
+ activeClipId: beat?.clipId ?? "",
2695
+ activeBeatId: beat?.id ?? "",
2696
+ activeMovementMode: timelineEntry?.movementType ?? "stationary",
2697
+ blendProgress,
2698
+ clipTimeMs: beatTimeMs,
2699
+ characterPosition,
2700
+ cameraPosition: [...cameraPosition],
2701
+ cameraViewMode,
2702
+ cameraTransform: cameraRigFrame.transform,
2703
+ targetDistance: cameraRigFrame.targetDistance,
2704
+ headLook,
2705
+ characterGroundY: 0,
2706
+ characterVisible: false,
2707
+ modelLoaded: Boolean(characterModel),
2708
+ modelRenderable: false,
2709
+ fallbackProxyActive: true,
2710
+ skinnedVertexCount: characterModel?.vertexCount ?? 0,
2711
+ skinnedTriangleCount: characterModel?.triangleCount ?? 0,
2712
+ skinnedJointCount: characterModel?.jointCount ?? 0,
2713
+ skinnedAnimatedNodeCount: characterModel?.animatedNodeCount ?? 0,
2714
+ skinnedClipCount: characterModel?.clipCount ?? 0,
2715
+ activeClipRenderable: false,
2716
+ propGroundAnchors: [],
2717
+ movementValidation: {
2718
+ status: movementWarnings.length ? "warning" : "passed",
2719
+ warnings: [...movementWarnings],
2720
+ activeBeatId: beat?.id ?? "",
2721
+ activeClipId: beat?.clipId ?? "",
2722
+ motionMode: timelineEntry?.movementType ?? "stationary",
2723
+ rootMotionSource: timelineEntry?.movementProfile?.motionMode ?? "none",
2724
+ expectedSpeed: timelineEntry?.expectedSpeed ?? 0,
2725
+ actualSpeed: timelineEntry?.actualSpeed ?? 0,
2726
+ movementDistance: timelineEntry?.movementDistance ?? 0,
2727
+ loopCount: timelineEntry?.loopCount ?? 1,
2728
+ footSlideWarning: timelineEntry?.footSlideWarning ?? null
2729
+ },
2730
+ frameState: running ? "running" : "rendered-once"
2731
+ };
2732
+ const visibility = renderScene(canvas, ctx, snapshot, props, characterModel);
2733
+ snapshot = {
2734
+ ...snapshot,
2735
+ ...visibility
2736
+ };
2737
+ return snapshot;
2738
+ }
2739
+ function tick(timestamp) {
1843
2740
  if (!running) {
1844
- return false;
1845
- }
1846
- running = false;
1847
- if (rafId !== null && typeof cancelAnimationFrame === "function") {
1848
- cancelAnimationFrame(rafId);
2741
+ return;
1849
2742
  }
1850
- rafId = null;
1851
- return true;
1852
- };
1853
- const resize = (cssWidth, cssHeight, devicePixelRatio = globalThis.devicePixelRatio ?? 1) => {
1854
- const width = Math.max(1, Math.floor(cssWidth * devicePixelRatio));
1855
- const height = Math.max(1, Math.floor(cssHeight * devicePixelRatio));
1856
- targetCanvas.width = width;
1857
- targetCanvas.height = height;
1858
- if (targetCanvas.style) {
1859
- targetCanvas.style.width = `${Math.max(1, Math.floor(cssWidth))}px`;
1860
- targetCanvas.style.height = `${Math.max(1, Math.floor(cssHeight))}px`;
2743
+ renderOnce(timestamp);
2744
+ if (running && requestFrame) {
2745
+ frameHandle = requestFrame(tick);
1861
2746
  }
1862
- return { width, height };
1863
- };
1864
- const setClearColor = (value) => {
1865
- clear = normalizeColor(value);
1866
- return [...clear];
1867
- };
1868
- const setXrActive = (active) => {
1869
- xrActive = Boolean(active);
1870
- };
1871
- const getSnapshot = () => {
1872
- const width = Number(targetCanvas.width) || 0;
1873
- const height = Number(targetCanvas.height) || 0;
1874
- return {
1875
- running,
1876
- frame,
1877
- lastTimestamp,
1878
- format: resolvedFormat,
1879
- width,
1880
- height,
1881
- xrActive
1882
- };
1883
- };
1884
- const renderer = {
1885
- canvas: targetCanvas,
1886
- context,
1887
- device,
1888
- format: resolvedFormat,
1889
- renderOnce,
1890
- start,
1891
- stop,
1892
- resize,
1893
- setClearColor,
1894
- setXrActive,
1895
- getSnapshot,
1896
- bindXrManager(xrManager, bindOptions = {}) {
1897
- if (detachXrBinding) {
1898
- detachXrBinding();
2747
+ }
2748
+ return {
2749
+ start() {
2750
+ if (running) {
2751
+ return;
2752
+ }
2753
+ running = true;
2754
+ snapshot = { ...snapshot, running: true, frameState: "running" };
2755
+ if (requestFrame) {
2756
+ frameHandle = requestFrame(tick);
2757
+ } else {
2758
+ renderOnce();
1899
2759
  }
1900
- detachXrBinding = bindRendererToXrManager(renderer, xrManager, bindOptions);
1901
- return detachXrBinding;
1902
2760
  },
1903
- destroy() {
1904
- stop();
1905
- destroyed = true;
1906
- if (detachXrBinding) {
1907
- detachXrBinding();
1908
- detachXrBinding = null;
2761
+ resize(width, height, devicePixelRatio = 1) {
2762
+ const ratio = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1;
2763
+ canvas.width = Math.max(1, Math.floor(width * ratio));
2764
+ canvas.height = Math.max(1, Math.floor(height * ratio));
2765
+ if (canvas.style) {
2766
+ canvas.style.width = `${width}px`;
2767
+ canvas.style.height = `${height}px`;
1909
2768
  }
1910
- if (typeof context.unconfigure === "function") {
1911
- context.unconfigure();
2769
+ const visibility = renderScene(canvas, ctx, snapshot, props, characterModel);
2770
+ snapshot = {
2771
+ ...snapshot,
2772
+ ...visibility
2773
+ };
2774
+ },
2775
+ renderOnce,
2776
+ getSnapshot() {
2777
+ return {
2778
+ ...snapshot,
2779
+ characterPosition: [...snapshot.characterPosition],
2780
+ cameraPosition: [...snapshot.cameraPosition],
2781
+ cameraTransform: {
2782
+ position: [...snapshot.cameraTransform.position],
2783
+ target: [...snapshot.cameraTransform.target],
2784
+ up: [...snapshot.cameraTransform.up]
2785
+ },
2786
+ headLook: {
2787
+ ...snapshot.headLook,
2788
+ target: [...snapshot.headLook.target]
2789
+ },
2790
+ movementValidation: {
2791
+ ...snapshot.movementValidation,
2792
+ warnings: [...snapshot.movementValidation?.warnings ?? []]
2793
+ },
2794
+ propGroundAnchors: snapshot.propGroundAnchors.map((anchor) => ({ ...anchor }))
2795
+ };
2796
+ },
2797
+ setCamera(nextCamera = {}) {
2798
+ camera = { ...camera, ...nextCamera };
2799
+ snapshot = {
2800
+ ...snapshot,
2801
+ cameraViewMode: resolveCameraViewMode(camera)
2802
+ };
2803
+ },
2804
+ setCameraViewMode(viewMode) {
2805
+ camera = { ...camera, viewMode };
2806
+ snapshot = {
2807
+ ...snapshot,
2808
+ cameraViewMode: resolveCameraViewMode(camera)
2809
+ };
2810
+ },
2811
+ applyCameraControl(control, options2 = {}) {
2812
+ pendingCameraControl = control;
2813
+ activeCameraControl = options2.activeControl !== false;
2814
+ },
2815
+ destroy() {
2816
+ running = false;
2817
+ if (frameHandle !== void 0 && cancelFrame) {
2818
+ cancelFrame(frameHandle);
1912
2819
  }
2820
+ frameHandle = void 0;
2821
+ snapshot = { ...snapshot, running: false, frameState: "destroyed" };
1913
2822
  }
1914
2823
  };
1915
- return renderer;
1916
- }
1917
- function snapshotFromXrManager(xrManager) {
1918
- if (xrManager && typeof xrManager.getState === "function") {
1919
- return xrManager.getState();
1920
- }
1921
- if (xrManager?.store && typeof xrManager.store.getSnapshot === "function") {
1922
- return xrManager.store.getSnapshot();
1923
- }
1924
- return null;
1925
- }
1926
- function bindRendererToXrManager(renderer, xrManager, options = {}) {
1927
- if (!xrManager || typeof xrManager.subscribe !== "function") {
1928
- throw new Error("XR manager must expose subscribe(listener). Use @plasius/gpu-xr createXrManager().");
1929
- }
1930
- const { onSessionStart, onSessionEnd } = options;
1931
- let previousSession = null;
1932
- const applyState = (state) => {
1933
- const session = state?.activeSession ?? null;
1934
- if (session === previousSession) {
1935
- return;
1936
- }
1937
- previousSession = session;
1938
- if (typeof renderer.setXrActive === "function") {
1939
- renderer.setXrActive(Boolean(session));
1940
- }
1941
- if (session && typeof onSessionStart === "function") {
1942
- onSessionStart(session, renderer);
1943
- }
1944
- if (!session && typeof onSessionEnd === "function") {
1945
- onSessionEnd(renderer);
1946
- }
1947
- };
1948
- applyState(snapshotFromXrManager(xrManager));
1949
- return xrManager.subscribe(applyState);
1950
2824
  }
1951
- var defaultRendererClearColor = DEFAULT_CLEAR_COLOR;
1952
2825
 
1953
2826
  // src/professional-animated-scene-renderer.js
1954
2827
  function nowMs2() {
@@ -3054,7 +3927,7 @@ function scale(a, scalar) {
3054
3927
  function dot(a, b) {
3055
3928
  return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
3056
3929
  }
3057
- function cross(a, b) {
3930
+ function cross2(a, b) {
3058
3931
  return [
3059
3932
  a[1] * b[2] - a[2] * b[1],
3060
3933
  a[2] * b[0] - a[0] * b[2],
@@ -3675,7 +4548,7 @@ function createMeshTriangleRecords(meshes, gpuMaterialSource = null) {
3675
4548
  const v0 = readVector(mesh.positions, a, 3, [0, 0, 0]);
3676
4549
  const v1 = readVector(mesh.positions, b, 3, [0, 0, 0]);
3677
4550
  const v2 = readVector(mesh.positions, c, 3, [0, 0, 0]);
3678
- const faceNormal = normalize(cross(subtract(v1, v0), subtract(v2, v0)), [0, 1, 0]);
4551
+ const faceNormal = normalize(cross2(subtract(v1, v0), subtract(v2, v0)), [0, 1, 0]);
3679
4552
  const n0 = mesh.normals ? normalize(readVector(mesh.normals, a, 3, faceNormal), faceNormal) : faceNormal;
3680
4553
  const n1 = mesh.normals ? normalize(readVector(mesh.normals, b, 3, faceNormal), faceNormal) : faceNormal;
3681
4554
  const n2 = mesh.normals ? normalize(readVector(mesh.normals, c, 3, faceNormal), faceNormal) : faceNormal;
@@ -4716,9 +5589,9 @@ function resolveEnvironmentPortalMode(value, hasPortals) {
4716
5589
  }
4717
5590
  function orthogonalPortalTangent(normal) {
4718
5591
  if (Math.abs(normal[1]) < 0.92) {
4719
- return normalize(cross([0, 1, 0], normal), [1, 0, 0]);
5592
+ return normalize(cross2([0, 1, 0], normal), [1, 0, 0]);
4720
5593
  }
4721
- return normalize(cross([1, 0, 0], normal), [0, 0, 1]);
5594
+ return normalize(cross2([1, 0, 0], normal), [0, 0, 1]);
4722
5595
  }
4723
5596
  function resolvePortalTangent(value, normal) {
4724
5597
  const fallback = orthogonalPortalTangent(normal);
@@ -4750,7 +5623,7 @@ function normalizeEnvironmentPortal(portal, index) {
4750
5623
  const position = asVec3(portal.position ?? portal.center, [0, 0, 0]);
4751
5624
  const normal = asUnitVec3(portal.normal, [0, 0, 1]);
4752
5625
  const tangent = resolvePortalTangent(portal.tangent, normal);
4753
- const bitangent = normalize(cross(normal, tangent), [0, 1, 0]);
5626
+ const bitangent = normalize(cross2(normal, tangent), [0, 1, 0]);
4754
5627
  const width = readPortalExtent(
4755
5628
  `environmentPortals[${index}].width`,
4756
5629
  portal.width,
@@ -4828,8 +5701,8 @@ function resolveCamera(input, width, height) {
4828
5701
  const target = asVec3(camera.target, DEFAULT_CAMERA2.target);
4829
5702
  const upInput = normalize(asVec3(camera.up, DEFAULT_CAMERA2.up), DEFAULT_CAMERA2.up);
4830
5703
  const forward = normalize(subtract(target, position), [0, 0, -1]);
4831
- const right = normalize(cross(forward, upInput), [1, 0, 0]);
4832
- const up = normalize(cross(right, forward), [0, 1, 0]);
5704
+ const right = normalize(cross2(forward, upInput), [1, 0, 0]);
5705
+ const up = normalize(cross2(right, forward), [0, 1, 0]);
4833
5706
  const fovYDegrees = clamp2(
4834
5707
  readFiniteNumber("camera.fovYDegrees", camera.fovYDegrees ?? camera.fov, DEFAULT_CAMERA2.fovYDegrees),
4835
5708
  10,
@@ -5198,8 +6071,8 @@ function supportsWavefrontPathTracingCompute(options = {}) {
5198
6071
  // src/wavefront-sampling.js
5199
6072
  function buildOrthonormalBasis(normal) {
5200
6073
  const tangentFallback = Math.abs(normal[1]) < 0.999 ? [0, 1, 0] : [1, 0, 0];
5201
- const tangent = normalize(cross(tangentFallback, normal), [1, 0, 0]);
5202
- const bitangent = normalize(cross(normal, tangent), [0, 0, 1]);
6074
+ const tangent = normalize(cross2(tangentFallback, normal), [1, 0, 0]);
6075
+ const bitangent = normalize(cross2(normal, tangent), [0, 0, 1]);
5203
6076
  return { tangent, bitangent };
5204
6077
  }
5205
6078
  function localToWorld(local, normal) {
@@ -11428,7 +12301,7 @@ function intersectWavefrontReferenceTriangle(ray, triangle, options = {}) {
11428
12301
  const triangleIndex = readNonNegativeInteger2("triangleIndex", options.triangleIndex, 0);
11429
12302
  const edge1 = subtract(triangle.v1, triangle.v0);
11430
12303
  const edge2 = subtract(triangle.v2, triangle.v0);
11431
- const pvec = cross(ray.direction, edge2);
12304
+ const pvec = cross2(ray.direction, edge2);
11432
12305
  const determinant = dot(edge1, pvec);
11433
12306
  if (Math.abs(determinant) < 1e-7) {
11434
12307
  return null;
@@ -11439,7 +12312,7 @@ function intersectWavefrontReferenceTriangle(ray, triangle, options = {}) {
11439
12312
  if (u < 0 || u > 1) {
11440
12313
  return null;
11441
12314
  }
11442
- const qvec = cross(tvec, edge1);
12315
+ const qvec = cross2(tvec, edge1);
11443
12316
  const v = dot(ray.direction, qvec) * invDet;
11444
12317
  if (v < 0 || u + v > 1) {
11445
12318
  return null;
@@ -11448,7 +12321,7 @@ function intersectWavefrontReferenceTriangle(ray, triangle, options = {}) {
11448
12321
  if (distance <= 1e-3 || distance > maxDistance) {
11449
12322
  return null;
11450
12323
  }
11451
- const geometric = normalize(cross(edge1, edge2), [0, 1, 0]);
12324
+ const geometric = normalize(cross2(edge1, edge2), [0, 1, 0]);
11452
12325
  const frontFace = dot(ray.direction, geometric) < 0;
11453
12326
  const orientedGeometric = frontFace ? geometric : scale(geometric, -1);
11454
12327
  const w = 1 - u - v;
@@ -11542,7 +12415,7 @@ function triangleAreaForLightSampling(triangle) {
11542
12415
  }
11543
12416
  const edge1 = subtract(triangle.v1, triangle.v0);
11544
12417
  const edge2 = subtract(triangle.v2, triangle.v0);
11545
- return Math.max(Math.hypot(...cross(edge1, edge2)) * 0.5, 1e-6);
12418
+ return Math.max(Math.hypot(...cross2(edge1, edge2)) * 0.5, 1e-6);
11546
12419
  }
11547
12420
  function emissiveTriangleWeight(triangle) {
11548
12421
  if (!triangle) {
@@ -13417,10 +14290,12 @@ function createRayTracingRenderPlan(options = {}) {
13417
14290
  bucketFeedbackGameFrameTime,
13418
14291
  bucketFeedbackGameViewport,
13419
14292
  createAnimatedSceneRenderer,
14293
+ createCanonicalWavefrontMeshInputs,
13420
14294
  createDefaultWavefrontSceneObjects,
13421
14295
  createFeedbackGameDiagnosticSnapshot,
13422
14296
  createGpuRenderer,
13423
14297
  createMediumStack,
14298
+ createNativeSceneRenderer,
13424
14299
  createProfessionalAnimatedSceneRenderer,
13425
14300
  createRayTracingRenderPlan,
13426
14301
  createRendererDebugHooks,