@plasius/gpu-renderer 0.2.45 → 0.2.47

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