@plasius/gpu-renderer 0.2.45 → 0.2.46

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