@plasius/gpu-renderer 0.2.44 → 0.2.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,1861 +1,2536 @@
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/animated-scene-renderer.js
1217
+ import { resolveCameraRigFrame } from "@plasius/gpu-camera";
1218
+
1219
+ // src/animated-gltf-model.js
1220
+ var GLB_MAGIC = 1179937895;
1221
+ var GLB_VERSION = 2;
1222
+ var GLB_JSON_CHUNK = 1313821514;
1223
+ var GLB_BIN_CHUNK = 5130562;
1224
+ var COMPONENT_READERS = Object.freeze({
1225
+ 5120: { size: 1, read: (view, offset) => view.getInt8(offset), normalizedDivisor: 127 },
1226
+ 5121: { size: 1, read: (view, offset) => view.getUint8(offset), normalizedDivisor: 255 },
1227
+ 5122: { size: 2, read: (view, offset) => view.getInt16(offset, true), normalizedDivisor: 32767 },
1228
+ 5123: { size: 2, read: (view, offset) => view.getUint16(offset, true), normalizedDivisor: 65535 },
1229
+ 5125: { size: 4, read: (view, offset) => view.getUint32(offset, true), normalizedDivisor: 4294967295 },
1230
+ 5126: { size: 4, read: (view, offset) => view.getFloat32(offset, true), normalizedDivisor: 1 }
1231
+ });
1232
+ var ACCESSOR_COMPONENTS = Object.freeze({
1233
+ SCALAR: 1,
1234
+ VEC2: 2,
1235
+ VEC3: 3,
1236
+ VEC4: 4,
1237
+ MAT4: 16
1238
+ });
1239
+ function identityMatrix() {
1240
+ return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
616
1241
  }
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`);
643
- }
644
- if (movesThroughWorld && distancePerLoop <= 0 && profile?.motionMode !== "root-authored") {
645
- warnings.push(`clip '${beat.clipId}' has no root or calibrated stride distance`);
646
- }
647
- if (movementType === "stationary" && profile?.worldDisplacementAllowed === true) {
648
- warnings.push(`stationary beat '${beat.id}' uses a displacement-capable clip`);
649
- }
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
666
- };
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
- }
1242
+ function multiplyMatrix(a, b) {
1243
+ const out = new Array(16).fill(0);
1244
+ for (let column = 0; column < 4; column += 1) {
1245
+ for (let row = 0; row < 4; row += 1) {
1246
+ 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];
674
1247
  }
675
- return entry;
676
- });
677
- }
678
- function resolveTimelineBeat(timeline, loopTimeMs) {
679
- if (!timeline.length) {
680
- return null;
681
1248
  }
682
- return timeline.find((entry) => loopTimeMs < entry.endMs) ?? timeline.at(-1);
1249
+ return out;
683
1250
  }
684
- function resolveTimelinePosition(entry, beatTimeMs) {
685
- if (!entry) {
686
- return [0, 0, 0];
687
- }
688
- if (entry.movementType !== "travel" && entry.movementType !== "jump" && entry.movementType !== "root-authored") {
689
- return [...entry.startPosition];
690
- }
691
- return lerp3(entry.startPosition, entry.endPosition, clamp01(beatTimeMs / Math.max(1, entry.durationMs)));
1251
+ function transformPoint(matrix, point) {
1252
+ const [x, y, z] = point;
1253
+ return [
1254
+ matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12],
1255
+ matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13],
1256
+ matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14]
1257
+ ];
692
1258
  }
693
- function resolveTimelineForward(entry) {
694
- if (!entry) {
695
- return [0, 0, -1];
696
- }
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
- ]);
1259
+ function composeMatrix(translation = [0, 0, 0], rotation = [0, 0, 0, 1], scale2 = [1, 1, 1]) {
1260
+ const [x, y, z, w] = normalizeQuaternion(rotation);
1261
+ const [sx, sy, sz] = scale2;
1262
+ const x2 = x + x;
1263
+ const y2 = y + y;
1264
+ const z2 = z + z;
1265
+ const xx = x * x2;
1266
+ const xy = x * y2;
1267
+ const xz = x * z2;
1268
+ const yy = y * y2;
1269
+ const yz = y * z2;
1270
+ const zz = z * z2;
1271
+ const wx = w * x2;
1272
+ const wy = w * y2;
1273
+ const wz = w * z2;
1274
+ return [
1275
+ (1 - (yy + zz)) * sx,
1276
+ (xy + wz) * sx,
1277
+ (xz - wy) * sx,
1278
+ 0,
1279
+ (xy - wz) * sy,
1280
+ (1 - (xx + zz)) * sy,
1281
+ (yz + wx) * sy,
1282
+ 0,
1283
+ (xz + wy) * sz,
1284
+ (yz - wx) * sz,
1285
+ (1 - (xx + yy)) * sz,
1286
+ 0,
1287
+ translation[0] ?? 0,
1288
+ translation[1] ?? 0,
1289
+ translation[2] ?? 0,
1290
+ 1
1291
+ ];
702
1292
  }
703
- function resolveCameraViewMode(camera) {
704
- if (camera?.mode === "lagged-follow") {
705
- return camera.viewMode ?? "spectator";
706
- }
707
- return camera?.viewMode ?? camera?.mode ?? "spectator";
1293
+ function normalizeQuaternion(quaternion = [0, 0, 0, 1]) {
1294
+ const length = Math.hypot(quaternion[0] ?? 0, quaternion[1] ?? 0, quaternion[2] ?? 0, quaternion[3] ?? 1) || 1;
1295
+ return [
1296
+ (quaternion[0] ?? 0) / length,
1297
+ (quaternion[1] ?? 0) / length,
1298
+ (quaternion[2] ?? 0) / length,
1299
+ (quaternion[3] ?? 1) / length
1300
+ ];
708
1301
  }
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.`);
714
- }
715
- return found;
716
- }
717
- if (!canvas) {
718
- throw new Error("Animated scene renderer requires a canvas.");
719
- }
720
- return canvas;
721
- }
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
- };
1302
+ function lerp(a, b, t) {
1303
+ return a + (b - a) * t;
735
1304
  }
736
- function propIdentity(prop, index) {
737
- return prop.id ?? `${prop.kind}-${index}`;
1305
+ function lerpArray(a, b, t) {
1306
+ return a.map((value, index) => lerp(value, b[index] ?? value, t));
738
1307
  }
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);
1308
+ function nlerpQuaternion(a, b, t) {
1309
+ let end = b;
1310
+ const dot2 = a.reduce((total, value, index) => total + value * (b[index] ?? 0), 0);
1311
+ if (dot2 < 0) {
1312
+ end = b.map((value) => -value);
1313
+ }
1314
+ return normalizeQuaternion(lerpArray(a, end, t));
742
1315
  }
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);
1316
+ function parseGlb(buffer) {
1317
+ if (!(buffer instanceof ArrayBuffer)) {
1318
+ return null;
1319
+ }
1320
+ const view = new DataView(buffer);
1321
+ if (view.byteLength < 20 || view.getUint32(0, true) !== GLB_MAGIC || view.getUint32(4, true) !== GLB_VERSION) {
1322
+ return null;
1323
+ }
1324
+ let offset = 12;
1325
+ let json = null;
1326
+ let binary = null;
1327
+ while (offset + 8 <= view.byteLength) {
1328
+ const length = view.getUint32(offset, true);
1329
+ const type = view.getUint32(offset + 4, true);
1330
+ const start = offset + 8;
1331
+ const end = start + length;
1332
+ if (end > view.byteLength) {
1333
+ return null;
762
1334
  }
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();
1335
+ if (type === GLB_JSON_CHUNK) {
1336
+ const text = new TextDecoder().decode(new Uint8Array(buffer, start, length)).trim();
1337
+ json = JSON.parse(text);
1338
+ } else if (type === GLB_BIN_CHUNK) {
1339
+ binary = new Uint8Array(buffer, start, length);
812
1340
  }
1341
+ offset = end;
813
1342
  }
814
- ctx.restore();
815
- return projected;
1343
+ if (!json || !binary) {
1344
+ return null;
1345
+ }
1346
+ return { json, binary };
816
1347
  }
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();
1348
+ function readAccessor(document, accessorIndex) {
1349
+ const accessor = document.json.accessors?.[accessorIndex];
1350
+ if (!accessor) {
1351
+ return [];
857
1352
  }
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;
1353
+ const bufferView = document.json.bufferViews?.[accessor.bufferView];
1354
+ const component = COMPONENT_READERS[accessor.componentType];
1355
+ const componentCount = ACCESSOR_COMPONENTS[accessor.type] ?? 1;
1356
+ if (!bufferView || !component) {
1357
+ return [];
1358
+ }
1359
+ const view = new DataView(
1360
+ document.binary.buffer,
1361
+ document.binary.byteOffset + (bufferView.byteOffset ?? 0) + (accessor.byteOffset ?? 0),
1362
+ bufferView.byteLength ?? 0
1363
+ );
1364
+ const stride = bufferView.byteStride ?? component.size * componentCount;
1365
+ const values = [];
1366
+ for (let index = 0; index < accessor.count; index += 1) {
1367
+ const item = [];
1368
+ const baseOffset = index * stride;
1369
+ for (let componentIndex = 0; componentIndex < componentCount; componentIndex += 1) {
1370
+ const raw = component.read(view, baseOffset + componentIndex * component.size);
1371
+ item.push(accessor.normalized && accessor.componentType !== 5126 ? raw / component.normalizedDivisor : raw);
1372
+ }
1373
+ values.push(componentCount === 1 ? item[0] : item);
1374
+ }
1375
+ return values;
869
1376
  }
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;
876
- return [
877
- projection.x + (vertex[0] - centerX) * modelScale,
878
- projection.groundY - (vertex[1] - bounds.min[1]) * modelScale + (vertex[2] - centerZ) * modelScale * 0.16
879
- ];
1377
+ function readMatrices(document, accessorIndex) {
1378
+ return readAccessor(document, accessorIndex).map((matrix) => Array.from(matrix));
880
1379
  }
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";
1380
+ function textureImage(document, textureIndex) {
1381
+ if (typeof textureIndex !== "number") {
1382
+ return null;
886
1383
  }
887
- if (t > 0.58) {
888
- return "#d7b28f";
1384
+ const texture = document.json.textures?.[textureIndex];
1385
+ return typeof texture?.source === "number" ? document.json.images?.[texture.source] ?? null : null;
1386
+ }
1387
+ function imageHasBuffer(document, image) {
1388
+ if (!image) {
1389
+ return false;
889
1390
  }
890
- if (t > 0.25) {
891
- return "#6f8f62";
1391
+ if (typeof image.uri === "string" && image.uri.trim().length > 0) {
1392
+ return true;
892
1393
  }
893
- return "#5e4636";
1394
+ return typeof image.bufferView === "number" && Boolean(document.json.bufferViews?.[image.bufferView]);
894
1395
  }
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;
1396
+ function extractMaterialTextureMetadata(document, primitive) {
1397
+ const material = document.json.materials?.[primitive?.material];
1398
+ const baseColorTextureIndex = material?.pbrMetallicRoughness?.baseColorTexture?.index;
1399
+ const normalTextureIndex = material?.normalTexture?.index;
1400
+ const missingTextureReferences = [];
1401
+ if (typeof baseColorTextureIndex === "number" && !imageHasBuffer(document, textureImage(document, baseColorTextureIndex))) {
1402
+ missingTextureReferences.push("baseColorTexture");
1403
+ }
1404
+ if (typeof normalTextureIndex === "number" && !imageHasBuffer(document, textureImage(document, normalTextureIndex))) {
1405
+ missingTextureReferences.push("normalTexture");
1406
+ }
1407
+ return Object.freeze({
1408
+ materialCount: document.json.materials?.length ?? 0,
1409
+ textureCount: document.json.textures?.length ?? 0,
1410
+ imageCount: document.json.images?.length ?? 0,
1411
+ embeddedImageCount: (document.json.images ?? []).filter((image) => typeof image.bufferView === "number").length,
1412
+ baseColorTextureCount: typeof baseColorTextureIndex === "number" ? 1 : 0,
1413
+ normalTextureCount: typeof normalTextureIndex === "number" ? 1 : 0,
1414
+ hasBaseColorTexture: typeof baseColorTextureIndex === "number",
1415
+ hasNormalTexture: typeof normalTextureIndex === "number",
1416
+ missingTextureReferences: Object.freeze(missingTextureReferences),
1417
+ materialName: material?.name ?? ""
1418
+ });
1419
+ }
1420
+ function nodeLocalMatrix(node, override = {}) {
1421
+ if (node.matrix && !override.translation && !override.rotation && !override.scale) {
1422
+ return [...node.matrix];
1423
+ }
1424
+ return composeMatrix(
1425
+ override.translation ?? node.translation ?? [0, 0, 0],
1426
+ override.rotation ?? node.rotation ?? [0, 0, 0, 1],
1427
+ override.scale ?? node.scale ?? [1, 1, 1]
1428
+ );
1429
+ }
1430
+ function computeWorldMatrices(nodes, overridesByName = /* @__PURE__ */ new Map()) {
1431
+ const parents = /* @__PURE__ */ new Set();
1432
+ for (const node of nodes) {
1433
+ for (const child of node.children ?? []) {
1434
+ parents.add(child);
909
1435
  }
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
1436
  }
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();
1437
+ const roots = nodes.map((_, index) => index).filter((index) => !parents.has(index));
1438
+ const worldMatrices = nodes.map(() => identityMatrix());
1439
+ function visit(nodeIndex, parentMatrix) {
1440
+ const node = nodes[nodeIndex];
1441
+ if (!node) {
1442
+ return;
1443
+ }
1444
+ const worldMatrix = multiplyMatrix(parentMatrix, nodeLocalMatrix(node, overridesByName.get(node.name) ?? {}));
1445
+ worldMatrices[nodeIndex] = worldMatrix;
1446
+ for (const child of node.children ?? []) {
1447
+ visit(child, worldMatrix);
1448
+ }
932
1449
  }
933
- ctx.restore();
934
- return {
935
- projection,
936
- sample
937
- };
1450
+ for (const root of roots) {
1451
+ visit(root, identityMatrix());
1452
+ }
1453
+ return worldMatrices;
938
1454
  }
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
- });
1455
+ function normalizeWeights(weights) {
1456
+ const total = weights.reduce((sum, value) => sum + Math.max(0, value), 0);
1457
+ if (total <= 0) {
1458
+ return [1, 0, 0, 0];
968
1459
  }
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
- );
1460
+ return weights.map((value) => Math.max(0, value) / total);
1461
+ }
1462
+ function deriveBounds(vertices) {
1463
+ const min = [Infinity, Infinity, Infinity];
1464
+ const max = [-Infinity, -Infinity, -Infinity];
1465
+ for (const vertex of vertices) {
1466
+ for (let axis = 0; axis < 3; axis += 1) {
1467
+ min[axis] = Math.min(min[axis], vertex[axis]);
1468
+ max[axis] = Math.max(max[axis], vertex[axis]);
1469
+ }
1470
+ }
1471
+ if (!Number.isFinite(min[0])) {
1472
+ return { min: [0, 0, 0], max: [0, 1, 0], size: [1, 1, 1] };
999
1473
  }
1000
1474
  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
1475
+ min,
1476
+ max,
1477
+ size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]]
1013
1478
  };
1014
1479
  }
1015
- function resolveCharacterModel(options) {
1016
- try {
1017
- return createAnimatedGltfModel(
1018
- options.modelAsset ?? options.animationAdventure?.modelAsset,
1019
- options.clipAssets ?? options.animationAdventure?.clipAssets ?? []
1020
- );
1021
- } catch {
1480
+ function sampleChannel(channel, sampler, document, timeSeconds) {
1481
+ const input = readAccessor(document, sampler.input);
1482
+ const output = readAccessor(document, sampler.output);
1483
+ if (!input.length || !output.length) {
1022
1484
  return null;
1023
1485
  }
1486
+ const duration = input.at(-1) || 0;
1487
+ const sampleTime = duration > 0 ? timeSeconds % duration : 0;
1488
+ let frame = 0;
1489
+ while (frame < input.length - 2 && sampleTime > input[frame + 1]) {
1490
+ frame += 1;
1491
+ }
1492
+ const start = input[frame] ?? 0;
1493
+ const end = input[frame + 1] ?? start;
1494
+ const t = end > start ? (sampleTime - start) / (end - start) : 0;
1495
+ const startValue = Array.isArray(output[frame]) ? output[frame] : [output[frame] ?? 0];
1496
+ const endValue = Array.isArray(output[frame + 1]) ? output[frame + 1] : startValue;
1497
+ return channel.target.path === "rotation" ? nlerpQuaternion(startValue, endValue, t) : lerpArray(startValue, endValue, t);
1024
1498
  }
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.");
1499
+ function rootTranslationDistance(clipPayload) {
1500
+ if (!clipPayload) {
1501
+ return 0;
1030
1502
  }
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;
1503
+ let distance = 0;
1504
+ for (const channel of clipPayload.animation.channels ?? []) {
1505
+ if (channel.target?.path !== "translation") {
1506
+ continue;
1119
1507
  }
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));
1508
+ const nodeName = clipPayload.document.json.nodes?.[channel.target.node]?.name ?? "";
1509
+ if (!/(^|:)Hips$/u.test(nodeName) && nodeName !== "mixamorigHips") {
1510
+ continue;
1176
1511
  }
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;
1512
+ const sampler = clipPayload.animation.samplers?.[channel.sampler];
1513
+ const output = sampler ? readAccessor(clipPayload.document, sampler.output) : [];
1514
+ if (output.length < 2) {
1515
+ continue;
1516
+ }
1517
+ const start = Array.isArray(output[0]) ? output[0] : [0, 0, 0];
1518
+ const end = Array.isArray(output.at(-1)) ? output.at(-1) : start;
1519
+ distance = Math.max(distance, Math.hypot((end[0] ?? 0) - (start[0] ?? 0), (end[2] ?? 0) - (start[2] ?? 0)));
1232
1520
  }
1233
- function tick(timestamp) {
1234
- if (!running) {
1235
- return;
1521
+ return distance;
1522
+ }
1523
+ function createClipPayload(clipRef) {
1524
+ const document = parseGlb(clipRef?.asset);
1525
+ const animation = document?.json.animations?.[0];
1526
+ if (!document || !animation) {
1527
+ return null;
1528
+ }
1529
+ const nodeNames = /* @__PURE__ */ new Set();
1530
+ let durationSeconds = 0;
1531
+ for (const channel of animation.channels ?? []) {
1532
+ const nodeName = document.json.nodes?.[channel.target?.node]?.name;
1533
+ if (nodeName) {
1534
+ nodeNames.add(nodeName);
1236
1535
  }
1237
- renderOnce(timestamp);
1238
- if (running && requestFrame) {
1239
- frameHandle = requestFrame(tick);
1536
+ const sampler = animation.samplers?.[channel.sampler];
1537
+ const input = sampler ? readAccessor(document, sampler.input) : [];
1538
+ durationSeconds = Math.max(durationSeconds, input.at(-1) ?? 0);
1539
+ }
1540
+ return {
1541
+ id: clipRef.id,
1542
+ document,
1543
+ animation,
1544
+ durationSeconds,
1545
+ nodeNames
1546
+ };
1547
+ }
1548
+ function sampleClipOverrides(clipPayload, timeSeconds) {
1549
+ const overrides = /* @__PURE__ */ new Map();
1550
+ if (!clipPayload) {
1551
+ return overrides;
1552
+ }
1553
+ for (const channel of clipPayload.animation.channels ?? []) {
1554
+ const targetNode = clipPayload.document.json.nodes?.[channel.target?.node];
1555
+ const sampler = clipPayload.animation.samplers?.[channel.sampler];
1556
+ if (!targetNode?.name || !sampler) {
1557
+ continue;
1558
+ }
1559
+ const value = sampleChannel(channel, sampler, clipPayload.document, timeSeconds);
1560
+ if (!value) {
1561
+ continue;
1240
1562
  }
1563
+ const existing = overrides.get(targetNode.name) ?? {};
1564
+ overrides.set(targetNode.name, {
1565
+ ...existing,
1566
+ [channel.target.path]: value
1567
+ });
1568
+ }
1569
+ return overrides;
1570
+ }
1571
+ function createAnimatedGltfModel(modelAsset, clipRefs = []) {
1572
+ const document = parseGlb(modelAsset);
1573
+ if (!document) {
1574
+ return null;
1575
+ }
1576
+ const nodes = document.json.nodes ?? [];
1577
+ const meshNodeIndex = nodes.findIndex((node) => node.mesh !== void 0 && node.skin !== void 0);
1578
+ const meshNode = nodes[meshNodeIndex];
1579
+ const mesh = document.json.meshes?.[meshNode?.mesh];
1580
+ const primitive = mesh?.primitives?.find((candidate) => candidate.attributes?.POSITION !== void 0);
1581
+ const skin = document.json.skins?.[meshNode?.skin];
1582
+ if (!meshNode || !primitive || !skin) {
1583
+ return null;
1241
1584
  }
1585
+ const positions = readAccessor(document, primitive.attributes.POSITION);
1586
+ const normals = primitive.attributes.NORMAL === void 0 ? [] : readAccessor(document, primitive.attributes.NORMAL);
1587
+ const texcoords = primitive.attributes.TEXCOORD_0 === void 0 ? [] : readAccessor(document, primitive.attributes.TEXCOORD_0);
1588
+ const joints = primitive.attributes.JOINTS_0 === void 0 ? positions.map(() => [0, 0, 0, 0]) : readAccessor(document, primitive.attributes.JOINTS_0);
1589
+ const weights = primitive.attributes.WEIGHTS_0 === void 0 ? positions.map(() => [1, 0, 0, 0]) : readAccessor(document, primitive.attributes.WEIGHTS_0).map(normalizeWeights);
1590
+ const indices = primitive.indices === void 0 ? positions.map((_, index) => index) : readAccessor(document, primitive.indices);
1591
+ const inverseBindMatrices = skin.inverseBindMatrices === void 0 ? skin.joints.map(() => identityMatrix()) : readMatrices(document, skin.inverseBindMatrices);
1592
+ const clips = clipRefs.map(createClipPayload).filter(Boolean);
1593
+ const bindWorldMatrices = computeWorldMatrices(nodes);
1594
+ const bindVertices = skinVertices({
1595
+ positions,
1596
+ joints,
1597
+ weights,
1598
+ skin,
1599
+ inverseBindMatrices,
1600
+ worldMatrices: bindWorldMatrices
1601
+ });
1602
+ const bounds = deriveBounds(bindVertices);
1603
+ const materialTextureMetadata = extractMaterialTextureMetadata(document, primitive);
1604
+ const clipRootMotionDistances = new Map(clips.map((clip) => [clip.id, rootTranslationDistance(clip)]));
1242
1605
  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() {
1606
+ name: mesh.name ?? meshNode.name ?? "skinned-gltf-model",
1607
+ vertexCount: positions.length,
1608
+ triangleCount: Math.floor(indices.length / 3),
1609
+ jointCount: skin.joints.length,
1610
+ animatedNodeCount: new Set(clips.flatMap((clip) => [...clip.nodeNames])).size,
1611
+ clipCount: clips.length,
1612
+ materialTextureMetadata,
1613
+ textureCount: materialTextureMetadata.textureCount,
1614
+ materialCount: materialTextureMetadata.materialCount,
1615
+ hasBaseColorTexture: materialTextureMetadata.hasBaseColorTexture,
1616
+ hasNormalTexture: materialTextureMetadata.hasNormalTexture,
1617
+ hasUv: texcoords.length === positions.length,
1618
+ hasNormals: normals.length === positions.length,
1619
+ professionalRenderable: materialTextureMetadata.hasBaseColorTexture && materialTextureMetadata.hasNormalTexture && materialTextureMetadata.missingTextureReferences.length === 0 && texcoords.length === positions.length && normals.length === positions.length && skin.joints.length > 0,
1620
+ clips,
1621
+ sample(activeClipId, clipTimeMs) {
1622
+ const clip = clips.find((candidate) => candidate.id === activeClipId) ?? clips[0] ?? null;
1623
+ const overrides = sampleClipOverrides(clip, Math.max(0, clipTimeMs) / 1e3);
1624
+ const worldMatrices = computeWorldMatrices(nodes, overrides);
1625
+ const vertices = skinVertices({
1626
+ positions,
1627
+ joints,
1628
+ weights,
1629
+ skin,
1630
+ inverseBindMatrices,
1631
+ worldMatrices
1632
+ });
1271
1633
  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)
1634
+ vertices,
1635
+ indices,
1636
+ bounds: deriveBounds(vertices),
1637
+ bindBounds: bounds,
1638
+ activeClipRenderable: Boolean(clip),
1639
+ activeClipDurationSeconds: clip?.durationSeconds ?? 0,
1640
+ activeClipRootTranslationDistance: clipRootMotionDistances.get(clip?.id) ?? 0
1303
1641
  };
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
1642
  }
1317
1643
  };
1318
1644
  }
1645
+ function skinVertices({ positions, joints, weights, skin, inverseBindMatrices, worldMatrices }) {
1646
+ return positions.map((position, vertexIndex) => {
1647
+ const jointIndices = joints[vertexIndex] ?? [0, 0, 0, 0];
1648
+ const jointWeights = weights[vertexIndex] ?? [1, 0, 0, 0];
1649
+ const skinned = [0, 0, 0];
1650
+ for (let influence = 0; influence < 4; influence += 1) {
1651
+ const weight = jointWeights[influence] ?? 0;
1652
+ if (weight <= 0) {
1653
+ continue;
1654
+ }
1655
+ const jointNodeIndex = skin.joints[jointIndices[influence] ?? 0] ?? skin.joints[0];
1656
+ const jointMatrix = multiplyMatrix(worldMatrices[jointNodeIndex] ?? identityMatrix(), inverseBindMatrices[jointIndices[influence] ?? 0] ?? identityMatrix());
1657
+ const transformed = transformPoint(jointMatrix, position);
1658
+ skinned[0] += transformed[0] * weight;
1659
+ skinned[1] += transformed[1] * weight;
1660
+ skinned[2] += transformed[2] * weight;
1661
+ }
1662
+ return skinned;
1663
+ });
1664
+ }
1319
1665
 
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;
1666
+ // src/animated-scene-renderer.js
1667
+ var DEFAULT_CAMERA = Object.freeze({
1668
+ mode: "lagged-follow",
1669
+ viewMode: "spectator",
1670
+ cubicBezier: [0.22, 0.61, 0.36, 1],
1671
+ lagMs: 240,
1672
+ lookAheadMs: 320,
1673
+ offset: [0, 2.4, 5.5],
1674
+ constraints: Object.freeze({
1675
+ maxDistance: 10,
1676
+ firstPersonHeadOffset: 0.05
1677
+ }),
1678
+ headLook: Object.freeze({
1679
+ enabled: true,
1680
+ returnMs: 240
1681
+ })
1682
+ });
1683
+ function nowMs() {
1684
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
1685
+ }
1686
+ function clamp012(value) {
1687
+ return Math.min(1, Math.max(0, value));
1688
+ }
1689
+ function lerp2(a, b, t) {
1690
+ return a + (b - a) * t;
1691
+ }
1692
+ function lerp3(a, b, t) {
1693
+ return [
1694
+ lerp2(a[0] ?? 0, b[0] ?? 0, t),
1695
+ lerp2(a[1] ?? 0, b[1] ?? 0, t),
1696
+ lerp2(a[2] ?? 0, b[2] ?? 0, t)
1697
+ ];
1698
+ }
1699
+ function normalize3(value, fallback = [0, 0, -1]) {
1700
+ if (!Array.isArray(value) || value.length < 3) {
1701
+ return [...fallback];
1702
+ }
1703
+ const vector = [
1704
+ Number.isFinite(Number(value[0])) ? Number(value[0]) : fallback[0],
1705
+ Number.isFinite(Number(value[1])) ? Number(value[1]) : fallback[1],
1706
+ Number.isFinite(Number(value[2])) ? Number(value[2]) : fallback[2]
1707
+ ];
1708
+ const length = Math.hypot(vector[0], vector[1], vector[2]);
1709
+ if (length <= 1e-6) {
1710
+ return [...fallback];
1711
+ }
1712
+ return [vector[0] / length, vector[1] / length, vector[2] / length];
1713
+ }
1714
+ function clamp(value, min, max) {
1715
+ return Math.min(max, Math.max(min, value));
1716
+ }
1717
+ function bezierY([, y1, , y2], t) {
1718
+ const u = 1 - t;
1719
+ return 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t;
1720
+ }
1721
+ function durationFromBeats(beats) {
1722
+ return beats.reduce((total, beat) => total + Math.max(0, beat.durationMs ?? 0), 0);
1723
+ }
1724
+ function definedCameraOverrides(camera) {
1725
+ if (!camera || typeof camera !== "object") {
1726
+ return {};
1727
+ }
1728
+ return Object.fromEntries(Object.entries(camera).filter(([, value]) => value !== void 0));
1729
+ }
1730
+ function resolveBeat(beats, loopTimeMs) {
1731
+ let cursor = 0;
1732
+ for (const beat of beats) {
1733
+ const durationMs = Math.max(1, beat.durationMs ?? 1);
1734
+ if (loopTimeMs < cursor + durationMs) {
1735
+ return { beat, beatTimeMs: loopTimeMs - cursor, durationMs };
1736
+ }
1737
+ cursor += durationMs;
1738
+ }
1739
+ const fallback = beats.at(-1);
1740
+ return { beat: fallback, beatTimeMs: fallback?.durationMs ?? 0, durationMs: fallback?.durationMs ?? 1 };
1741
+ }
1742
+ function resolveRoutePosition(route, timeMs) {
1743
+ if (!route.length) {
1744
+ return [0, 0, 0];
1745
+ }
1746
+ if (route.length === 1 || timeMs <= (route[0].arriveMs ?? 0)) {
1747
+ return [...route[0].position];
1748
+ }
1749
+ for (let index = 1; index < route.length; index += 1) {
1750
+ const previous = route[index - 1];
1751
+ const next = route[index];
1752
+ const start = previous.arriveMs ?? 0;
1753
+ const end = next.arriveMs ?? start + 1;
1754
+ if (timeMs <= end) {
1755
+ return lerp3(previous.position, next.position, clamp012((timeMs - start) / Math.max(1, end - start)));
1756
+ }
1757
+ }
1758
+ return [...route.at(-1).position];
1759
+ }
1760
+ function resolveRouteForward(route, timeMs) {
1761
+ if (route.length < 2) {
1762
+ return [0, 0, -1];
1763
+ }
1764
+ let previous = route[0];
1765
+ let next = route[1];
1766
+ for (let index = 1; index < route.length; index += 1) {
1767
+ next = route[index];
1768
+ if (timeMs <= (next.arriveMs ?? 0)) {
1769
+ previous = route[index - 1];
1770
+ break;
1771
+ }
1772
+ }
1773
+ return normalize3([
1774
+ (next.position?.[0] ?? 0) - (previous.position?.[0] ?? 0),
1775
+ (next.position?.[1] ?? 0) - (previous.position?.[1] ?? 0),
1776
+ (next.position?.[2] ?? 0) - (previous.position?.[2] ?? 0)
1777
+ ]);
1778
+ }
1779
+ function distance3(a = [0, 0, 0], b = [0, 0, 0]) {
1780
+ return Math.hypot(
1781
+ (b[0] ?? 0) - (a[0] ?? 0),
1782
+ (b[1] ?? 0) - (a[1] ?? 0),
1783
+ (b[2] ?? 0) - (a[2] ?? 0)
1784
+ );
1785
+ }
1786
+ function movementRequirementType(requirement, beat) {
1787
+ if (requirement?.type) {
1788
+ return requirement.type;
1789
+ }
1790
+ if (beat?.kind === "locomotion") {
1791
+ return beat?.rootMotion === "in-place" ? "stationary" : "travel";
1792
+ }
1793
+ if (beat?.kind === "action" || beat?.kind === "idle") {
1794
+ return "stationary";
1795
+ }
1796
+ const clipId = String(beat?.clipId ?? "").toLowerCase();
1797
+ if (/(idle|gesture|dig|plant|water|pick|serve|swing|fire|block|hoe)/iu.test(clipId)) {
1798
+ return "stationary";
1799
+ }
1800
+ if (/(walk|run|jump|climb|crawl|locomotion)/iu.test(clipId)) {
1801
+ return clipId.includes("jump") ? "jump" : "travel";
1802
+ }
1803
+ return beat?.pathPointId ? "travel" : "stationary";
1804
+ }
1805
+ function movementDistancePerLoop(profile = {}) {
1806
+ profile ??= {};
1807
+ const rootDistance = Number(profile.rootTranslationDistance ?? profile.rootTranslation?.distance ?? 0);
1808
+ if (Number.isFinite(rootDistance) && rootDistance > 0) {
1809
+ return rootDistance;
1810
+ }
1811
+ const strideLength = Number(profile.strideLength ?? profile.strideLengthMeters ?? 0);
1812
+ if (Number.isFinite(strideLength) && strideLength > 0) {
1813
+ return strideLength;
1814
+ }
1815
+ return 0;
1816
+ }
1817
+ function clipDurationMs(profile = {}, fallbackMs = 1) {
1818
+ profile ??= {};
1819
+ const duration = Number(profile.durationMs ?? 0);
1820
+ return Number.isFinite(duration) && duration > 0 ? duration : Math.max(1, fallbackMs);
1821
+ }
1822
+ function profileAllowsDisplacement(profile = {}, movementType) {
1823
+ profile ??= {};
1824
+ if (movementType === "stationary") {
1825
+ return profile.worldDisplacementAllowed !== true;
1826
+ }
1827
+ return profile.worldDisplacementAllowed === true || profile.motionMode === "root-authored" || profile.motionMode === "calibrated-in-place" || profile.motionMode === "jump";
1828
+ }
1829
+ function resolveClipProfiles(clipAssets = []) {
1830
+ return new Map(clipAssets.map((clip) => [clip.id, clip.movementProfile ?? clip.profile ?? null]));
1831
+ }
1832
+ function buildBeatTimeline(beats, route, clipProfiles) {
1833
+ const routePoints = new Map(route.map((point) => [point.id, point]));
1834
+ let cursorMs = 0;
1835
+ let routeCursor = 0;
1836
+ let currentPosition = route[0]?.position ? [...route[0].position] : [0, 0, 0];
1837
+ return beats.map((beat) => {
1838
+ const requirement = beat.movementRequirement ?? beat.movement ?? null;
1839
+ const movementType = movementRequirementType(requirement, beat);
1840
+ const movesThroughWorld = movementType === "travel" || movementType === "jump" || movementType === "root-authored";
1841
+ const explicitTargetPoint = beat.pathPointId ? routePoints.get(beat.pathPointId) : null;
1842
+ const inferredTargetPoint = movesThroughWorld ? route[Math.min(route.length - 1, routeCursor + 1)] : null;
1843
+ const targetPoint = explicitTargetPoint ?? inferredTargetPoint ?? null;
1844
+ const targetPosition = targetPoint?.position ? [...targetPoint.position] : [...currentPosition];
1845
+ const startPosition = [...currentPosition];
1846
+ const endPosition = movesThroughWorld ? targetPosition : [...currentPosition];
1847
+ const segmentDistance = Number(requirement?.distance ?? distance3(startPosition, endPosition));
1848
+ const profile = clipProfiles.get(beat.clipId) ?? null;
1849
+ const distancePerLoop = movementDistancePerLoop(profile);
1850
+ const loopCount = movesThroughWorld && distancePerLoop > 0 ? Math.max(1, Math.ceil(segmentDistance / distancePerLoop)) : 1;
1851
+ const derivedDurationMs = movesThroughWorld && distancePerLoop > 0 ? loopCount * clipDurationMs(profile, beat.durationMs) : Math.max(1, beat.durationMs ?? 1);
1852
+ const durationMs = Math.max(1, beat.validatedDurationMs ?? requirement?.validatedDurationMs ?? derivedDurationMs);
1853
+ const expectedSpeed = Number(profile?.expectedSpeed ?? (durationMs > 0 ? segmentDistance / (durationMs / 1e3) : 0));
1854
+ const actualSpeed = durationMs > 0 ? segmentDistance / (durationMs / 1e3) : 0;
1855
+ const warnings = [];
1856
+ if (movesThroughWorld && !profileAllowsDisplacement(profile, movementType)) {
1857
+ warnings.push(`clip '${beat.clipId}' does not allow ${movementType} displacement`);
1858
+ }
1859
+ if (movesThroughWorld && distancePerLoop <= 0 && profile?.motionMode !== "root-authored") {
1860
+ warnings.push(`clip '${beat.clipId}' has no root or calibrated stride distance`);
1861
+ }
1862
+ if (movementType === "stationary" && profile?.worldDisplacementAllowed === true) {
1863
+ warnings.push(`stationary beat '${beat.id}' uses a displacement-capable clip`);
1864
+ }
1865
+ const entry = {
1866
+ beat,
1867
+ startMs: cursorMs,
1868
+ endMs: cursorMs + durationMs,
1869
+ durationMs,
1870
+ startPosition,
1871
+ endPosition,
1872
+ movementType,
1873
+ movementProfile: profile,
1874
+ movementDistance: segmentDistance,
1875
+ distancePerLoop,
1876
+ loopCount,
1877
+ expectedSpeed,
1878
+ actualSpeed,
1879
+ footSlideWarning: Number(profile?.footSlideTolerance ?? 0) < 0 ? "invalid-foot-slide-tolerance" : null,
1880
+ warnings
1881
+ };
1882
+ cursorMs = entry.endMs;
1883
+ currentPosition = [...endPosition];
1884
+ if (targetPoint) {
1885
+ const nextRouteIndex = route.findIndex((point) => point.id === targetPoint.id);
1886
+ if (nextRouteIndex >= 0) {
1887
+ routeCursor = nextRouteIndex;
1888
+ }
1889
+ }
1890
+ return entry;
1891
+ });
1892
+ }
1893
+ function resolveTimelineBeat(timeline, loopTimeMs) {
1894
+ if (!timeline.length) {
1895
+ return null;
1372
1896
  }
1373
- if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
1374
- throw new Error(`${name} must be a finite number greater than zero.`);
1897
+ return timeline.find((entry) => loopTimeMs < entry.endMs) ?? timeline.at(-1);
1898
+ }
1899
+ function resolveTimelinePosition(entry, beatTimeMs) {
1900
+ if (!entry) {
1901
+ return [0, 0, 0];
1375
1902
  }
1376
- return value;
1903
+ if (entry.movementType !== "travel" && entry.movementType !== "jump" && entry.movementType !== "root-authored") {
1904
+ return [...entry.startPosition];
1905
+ }
1906
+ return lerp3(entry.startPosition, entry.endPosition, clamp012(beatTimeMs / Math.max(1, entry.durationMs)));
1377
1907
  }
1378
- function readPositiveInteger(name, value) {
1379
- if (!Number.isInteger(value) || value <= 0) {
1380
- throw new Error(`${name} must be a positive integer.`);
1908
+ function resolveTimelineForward(entry) {
1909
+ if (!entry) {
1910
+ return [0, 0, -1];
1381
1911
  }
1382
- return value;
1912
+ return normalize3([
1913
+ (entry.endPosition[0] ?? 0) - (entry.startPosition[0] ?? 0),
1914
+ (entry.endPosition[1] ?? 0) - (entry.startPosition[1] ?? 0),
1915
+ (entry.endPosition[2] ?? 0) - (entry.startPosition[2] ?? 0)
1916
+ ]);
1383
1917
  }
1384
- function readNonNegativeInteger(name, value) {
1385
- if (!Number.isInteger(value) || value < 0) {
1386
- throw new Error(`${name} must be a non-negative integer.`);
1918
+ function resolveCameraViewMode(camera) {
1919
+ if (camera?.mode === "lagged-follow") {
1920
+ return camera.viewMode ?? "spectator";
1387
1921
  }
1388
- return value;
1922
+ return camera?.viewMode ?? camera?.mode ?? "spectator";
1389
1923
  }
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.`);
1924
+ function resolveCanvas2(canvas) {
1925
+ if (typeof canvas === "string") {
1926
+ const found = globalThis.document?.querySelector?.(canvas);
1927
+ if (!found) {
1928
+ throw new Error(`Animated scene canvas '${canvas}' was not found.`);
1929
+ }
1930
+ return found;
1393
1931
  }
1394
- return value.trim();
1932
+ if (!canvas) {
1933
+ throw new Error("Animated scene renderer requires a canvas.");
1934
+ }
1935
+ return canvas;
1395
1936
  }
1396
-
1397
- // src/renderer-webgpu-runtime.js
1398
- function clamp012(value) {
1399
- return Math.min(1, Math.max(0, value));
1937
+ function project(position, cameraPosition, width, height) {
1938
+ const x = position[0] - cameraPosition[0];
1939
+ const depth = Math.max(0.2, cameraPosition[2] - (position[2] ?? 0));
1940
+ const scale2 = clamp(34 + 150 / (depth + 1.2), 34, 104);
1941
+ const groundY = height * 0.82 - depth * height * 0.045 - (position[1] ?? 0) * scale2;
1942
+ return {
1943
+ x: width * 0.5 + x * scale2,
1944
+ y: groundY,
1945
+ groundY,
1946
+ scale: scale2,
1947
+ depth,
1948
+ visible: groundY > -height * 0.2 && groundY < height * 1.18
1949
+ };
1400
1950
  }
1401
- function parseHexChannel(channel) {
1402
- return parseInt(channel, 16) / 255;
1951
+ function propIdentity(prop, index) {
1952
+ return prop.id ?? `${prop.kind}-${index}`;
1403
1953
  }
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
- ];
1954
+ function drawGroundShadow(ctx, scale2) {
1955
+ ctx.fillStyle = "rgba(44, 38, 28, 0.18)";
1956
+ ctx.fillRect(-scale2 * 0.36, -scale2 * 0.035, scale2 * 0.72, scale2 * 0.07);
1957
+ }
1958
+ function drawProp(ctx, prop, cameraPosition, width, height) {
1959
+ const projected = project(prop.position, cameraPosition, width, height);
1960
+ const { x, y, scale: scale2 } = projected;
1961
+ ctx.save();
1962
+ ctx.translate(x, y);
1963
+ ctx.lineWidth = Math.max(1, scale2 * 0.04);
1964
+ if (prop.kind === "crop-row") {
1965
+ ctx.fillStyle = "#8b633f";
1966
+ ctx.fillRect(-scale2 * 1.05, -scale2 * 0.08, scale2 * 2.1, scale2 * 0.16);
1967
+ ctx.fillStyle = "#6f4a30";
1968
+ ctx.fillRect(-scale2 * 0.92, -scale2 * 0.035, scale2 * 1.84, scale2 * 0.07);
1969
+ ctx.strokeStyle = "#4f6f32";
1970
+ ctx.beginPath();
1971
+ ctx.moveTo(-scale2 * 0.8, 0);
1972
+ ctx.lineTo(scale2 * 0.8, 0);
1973
+ ctx.stroke();
1974
+ ctx.fillStyle = "#6f9f43";
1975
+ for (let i = -3; i <= 3; i += 1) {
1976
+ ctx.fillRect(i * scale2 * 0.22, -scale2 * 0.18, scale2 * 0.06, scale2 * 0.2);
1421
1977
  }
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
- ];
1978
+ } else if (prop.kind === "fence-segment") {
1979
+ ctx.strokeStyle = "#896b4d";
1980
+ ctx.beginPath();
1981
+ ctx.moveTo(-scale2 * 0.5, 0);
1982
+ ctx.lineTo(scale2 * 0.5, 0);
1983
+ ctx.moveTo(-scale2 * 0.35, -scale2 * 0.25);
1984
+ ctx.lineTo(-scale2 * 0.35, scale2 * 0.1);
1985
+ ctx.moveTo(scale2 * 0.35, -scale2 * 0.25);
1986
+ ctx.lineTo(scale2 * 0.35, scale2 * 0.1);
1987
+ ctx.stroke();
1988
+ } else if (prop.kind === "cart") {
1989
+ drawGroundShadow(ctx, scale2);
1990
+ ctx.fillStyle = "#7b5238";
1991
+ ctx.fillRect(-scale2 * 0.52, -scale2 * 0.38, scale2 * 1.04, scale2 * 0.36);
1992
+ ctx.fillStyle = "#a4774c";
1993
+ ctx.fillRect(-scale2 * 0.44, -scale2 * 0.32, scale2 * 0.88, scale2 * 0.08);
1994
+ ctx.fillStyle = "#25201c";
1995
+ ctx.beginPath();
1996
+ ctx.arc(-scale2 * 0.32, scale2 * 0.02, scale2 * 0.1, 0, Math.PI * 2);
1997
+ ctx.arc(scale2 * 0.32, scale2 * 0.02, scale2 * 0.1, 0, Math.PI * 2);
1998
+ ctx.fill();
1999
+ } else if (prop.kind === "small-tree") {
2000
+ drawGroundShadow(ctx, scale2);
2001
+ ctx.fillStyle = "#5b3b24";
2002
+ ctx.fillRect(-scale2 * 0.07, -scale2 * 0.56, scale2 * 0.14, scale2 * 0.56);
2003
+ ctx.fillStyle = "#2f6d3b";
2004
+ ctx.beginPath();
2005
+ ctx.arc(-scale2 * 0.16, -scale2 * 0.72, scale2 * 0.25, 0, Math.PI * 2);
2006
+ ctx.fill();
2007
+ ctx.fillStyle = "#3f8746";
2008
+ ctx.beginPath();
2009
+ ctx.arc(scale2 * 0.13, -scale2 * 0.76, scale2 * 0.28, 0, Math.PI * 2);
2010
+ ctx.fill();
2011
+ ctx.fillStyle = "#4f9a50";
2012
+ ctx.beginPath();
2013
+ ctx.arc(0, -scale2 * 0.98, scale2 * 0.26, 0, Math.PI * 2);
2014
+ ctx.fill();
2015
+ } else {
2016
+ drawGroundShadow(ctx, scale2);
2017
+ ctx.fillStyle = prop.kind === "crate" ? "#9a6a3d" : "#d0c39f";
2018
+ ctx.fillRect(-scale2 * 0.2, -scale2 * 0.32, scale2 * 0.4, scale2 * 0.32);
2019
+ if (prop.kind === "crate") {
2020
+ ctx.strokeStyle = "#6e4829";
2021
+ ctx.beginPath();
2022
+ ctx.moveTo(-scale2 * 0.2, -scale2 * 0.16);
2023
+ ctx.lineTo(scale2 * 0.2, -scale2 * 0.16);
2024
+ ctx.moveTo(0, -scale2 * 0.32);
2025
+ ctx.lineTo(0, 0);
2026
+ ctx.stroke();
1429
2027
  }
1430
2028
  }
1431
- return [...DEFAULT_CLEAR_COLOR];
2029
+ ctx.restore();
2030
+ return projected;
1432
2031
  }
1433
- function now() {
1434
- if (typeof performance !== "undefined" && typeof performance.now === "function") {
1435
- return performance.now();
2032
+ function drawCharacter(ctx, characterPosition, cameraPosition, width, height, gaitPhase, headLook) {
2033
+ const projected = project(characterPosition, cameraPosition, width, height);
2034
+ const { x, y, scale: scale2 } = projected;
2035
+ const stride = Math.sin(gaitPhase * Math.PI * 2);
2036
+ const headYaw = (headLook?.yaw ?? 0) * (headLook?.weight ?? 0);
2037
+ ctx.save();
2038
+ ctx.translate(x, y);
2039
+ ctx.lineWidth = Math.max(2, scale2 * 0.05);
2040
+ ctx.lineCap = "round";
2041
+ drawGroundShadow(ctx, scale2);
2042
+ ctx.strokeStyle = "#4c2f25";
2043
+ ctx.beginPath();
2044
+ ctx.moveTo(-scale2 * 0.12, -scale2 * 0.34);
2045
+ ctx.lineTo(-scale2 * (0.18 + stride * 0.04), -scale2 * 0.02);
2046
+ ctx.moveTo(scale2 * 0.12, -scale2 * 0.34);
2047
+ ctx.lineTo(scale2 * (0.18 - stride * 0.04), -scale2 * 0.02);
2048
+ ctx.stroke();
2049
+ ctx.fillStyle = "#6f8f62";
2050
+ ctx.beginPath();
2051
+ ctx.moveTo(-scale2 * 0.3, -scale2 * 0.86);
2052
+ ctx.lineTo(scale2 * 0.3, -scale2 * 0.86);
2053
+ ctx.lineTo(scale2 * 0.42, -scale2 * 0.24);
2054
+ ctx.lineTo(-scale2 * 0.42, -scale2 * 0.24);
2055
+ ctx.lineTo(-scale2 * 0.3, -scale2 * 0.86);
2056
+ ctx.fill();
2057
+ ctx.fillStyle = "#d8b18c";
2058
+ ctx.beginPath();
2059
+ ctx.arc(0, -scale2 * 1.16, scale2 * 0.18, 0, Math.PI * 2);
2060
+ ctx.fill();
2061
+ ctx.fillStyle = "#5a3a2b";
2062
+ ctx.fillRect(-scale2 * 0.2, -scale2 * 1.28, scale2 * 0.4, scale2 * 0.16);
2063
+ ctx.fillRect(-scale2 * 0.23, -scale2 * 1.14, scale2 * 0.1, scale2 * 0.28);
2064
+ ctx.fillRect(scale2 * 0.13, -scale2 * 1.14, scale2 * 0.1, scale2 * 0.28);
2065
+ if (headLook?.status === "active" || headLook?.status === "returning") {
2066
+ ctx.strokeStyle = "rgba(255, 246, 210, 0.74)";
2067
+ ctx.lineWidth = Math.max(1, scale2 * 0.025);
2068
+ ctx.beginPath();
2069
+ ctx.moveTo(0, -scale2 * 1.16);
2070
+ ctx.lineTo(Math.sin(headYaw) * scale2 * 0.32, -scale2 * 1.16 - scale2 * 0.08);
2071
+ ctx.stroke();
1436
2072
  }
1437
- return Date.now();
2073
+ ctx.strokeStyle = "#6b4935";
2074
+ ctx.beginPath();
2075
+ ctx.moveTo(-scale2 * 0.24, -scale2 * 0.76);
2076
+ ctx.lineTo(-scale2 * (0.46 + stride * 0.04), -scale2 * (0.5 + stride * 0.06));
2077
+ ctx.moveTo(scale2 * 0.24, -scale2 * 0.76);
2078
+ ctx.lineTo(scale2 * (0.46 - stride * 0.04), -scale2 * (0.5 - stride * 0.06));
2079
+ ctx.stroke();
2080
+ ctx.fillStyle = "#d6c09a";
2081
+ ctx.fillRect(-scale2 * 0.16, -scale2 * 0.86, scale2 * 0.32, scale2 * 0.18);
2082
+ ctx.restore();
2083
+ return projected;
1438
2084
  }
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();
2085
+ function pointForModelVertex(vertex, projection, sample) {
2086
+ const bounds = sample.bounds;
2087
+ const height = Math.max(0.1, bounds.size[1] || sample.bindBounds.size[1] || 1);
2088
+ const modelScale = projection.scale * 1.3 / height;
2089
+ const centerX = (bounds.min[0] + bounds.max[0]) * 0.5;
2090
+ const centerZ = (bounds.min[2] + bounds.max[2]) * 0.5;
2091
+ return [
2092
+ projection.x + (vertex[0] - centerX) * modelScale,
2093
+ projection.groundY - (vertex[1] - bounds.min[1]) * modelScale + (vertex[2] - centerZ) * modelScale * 0.16
2094
+ ];
1444
2095
  }
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);
2096
+ function modelFillForHeight(vertex, sample) {
2097
+ const height = Math.max(0.1, sample.bounds.size[1] || 1);
2098
+ const t = (vertex[1] - sample.bounds.min[1]) / height;
2099
+ if (t > 0.82) {
2100
+ return "#6d4a36";
1454
2101
  }
1455
- if (fixedTargetFrameTimeMs !== void 0) {
1456
- return fixedTargetFrameTimeMs;
2102
+ if (t > 0.58) {
2103
+ return "#d7b28f";
1457
2104
  }
1458
- if (targetFrameRate !== void 0) {
1459
- return 1e3 / targetFrameRate;
2105
+ if (t > 0.25) {
2106
+ return "#6f8f62";
1460
2107
  }
1461
- return void 0;
2108
+ return "#5e4636";
1462
2109
  }
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
- );
2110
+ function drawSkinnedGltfCharacter(ctx, model, snapshot, width, height) {
2111
+ const projection = project(snapshot.characterPosition, snapshot.cameraPosition, width, height);
2112
+ const sample = model.sample(snapshot.activeClipId, snapshot.clipTimeMs);
2113
+ const maxTriangles = 12e3;
2114
+ const triangleCount = Math.floor(sample.indices.length / 3);
2115
+ const stride = Math.max(1, Math.ceil(triangleCount / maxTriangles));
2116
+ const triangles = [];
2117
+ for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += stride) {
2118
+ const base = triangleIndex * 3;
2119
+ const a = sample.vertices[sample.indices[base]];
2120
+ const b = sample.vertices[sample.indices[base + 1]];
2121
+ const c = sample.vertices[sample.indices[base + 2]];
2122
+ if (!a || !b || !c) {
2123
+ continue;
2124
+ }
2125
+ triangles.push({
2126
+ a,
2127
+ b,
2128
+ c,
2129
+ depth: (a[2] + b[2] + c[2]) / 3,
2130
+ fill: modelFillForHeight(a, sample)
2131
+ });
1489
2132
  }
1490
- if (getTargetFrameTimeMs !== void 0 && typeof getTargetFrameTimeMs !== "function") {
1491
- throw new Error("getTargetFrameTimeMs must be a function when provided.");
2133
+ triangles.sort((left, right) => left.depth - right.depth);
2134
+ ctx.save();
2135
+ drawGroundShadow(ctx, projection.scale);
2136
+ for (const triangle of triangles) {
2137
+ const [ax, ay] = pointForModelVertex(triangle.a, projection, sample);
2138
+ const [bx, by] = pointForModelVertex(triangle.b, projection, sample);
2139
+ const [cx, cy] = pointForModelVertex(triangle.c, projection, sample);
2140
+ ctx.fillStyle = triangle.fill;
2141
+ ctx.beginPath();
2142
+ ctx.moveTo(ax, ay);
2143
+ ctx.lineTo(bx, by);
2144
+ ctx.lineTo(cx, cy);
2145
+ ctx.closePath?.();
2146
+ ctx.fill();
1492
2147
  }
1493
- const resolvedOptions = {
1494
- targetFrameTimeMs: fixedTargetFrameTimeMs,
1495
- targetFrameRate: fixedTargetFrameRate,
1496
- getTargetFrameTimeMs
1497
- };
2148
+ ctx.restore();
1498
2149
  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
- }
2150
+ projection,
2151
+ sample
1527
2152
  };
1528
2153
  }
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.");
2154
+ function renderScene(canvas, ctx, snapshot, props, characterModel) {
2155
+ const width = canvas.width || 1;
2156
+ const height = canvas.height || 1;
2157
+ ctx.clearRect(0, 0, width, height);
2158
+ ctx.fillStyle = "#d8e8d0";
2159
+ ctx.fillRect(0, 0, width, height);
2160
+ ctx.fillStyle = "#9fc47a";
2161
+ ctx.fillRect(0, height * 0.52, width, height * 0.48);
2162
+ ctx.strokeStyle = "#caa66d";
2163
+ ctx.lineWidth = Math.max(2, width * 6e-3);
2164
+ ctx.beginPath();
2165
+ ctx.moveTo(width * 0.35, height);
2166
+ ctx.quadraticCurveTo(width * 0.53, height * 0.58, width * 0.62, 0);
2167
+ ctx.stroke();
2168
+ const projectedProps = props.map((prop, index) => ({
2169
+ prop,
2170
+ id: propIdentity(prop, index),
2171
+ projected: project(prop.position, snapshot.cameraPosition, width, height)
2172
+ })).sort((a, b) => b.projected.depth - a.projected.depth);
2173
+ const propGroundAnchors = [];
2174
+ for (const { prop, id } of projectedProps) {
2175
+ const projected = drawProp(ctx, prop, snapshot.cameraPosition, width, height);
2176
+ propGroundAnchors.push({
2177
+ id,
2178
+ kind: prop.kind,
2179
+ groundY: projected.groundY,
2180
+ depth: projected.depth,
2181
+ visible: projected.visible
2182
+ });
1560
2183
  }
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().");
2184
+ let characterProjection;
2185
+ let modelRenderable = false;
2186
+ let fallbackProxyActive = true;
2187
+ let skinnedVertexCount = 0;
2188
+ let skinnedTriangleCount = 0;
2189
+ let skinnedJointCount = 0;
2190
+ let skinnedAnimatedNodeCount = 0;
2191
+ let skinnedClipCount = 0;
2192
+ let activeClipRenderable = false;
2193
+ if (characterModel) {
2194
+ const drawn = drawSkinnedGltfCharacter(ctx, characterModel, snapshot, width, height);
2195
+ characterProjection = drawn.projection;
2196
+ modelRenderable = true;
2197
+ fallbackProxyActive = false;
2198
+ skinnedVertexCount = characterModel.vertexCount;
2199
+ skinnedTriangleCount = characterModel.triangleCount;
2200
+ skinnedJointCount = characterModel.jointCount;
2201
+ skinnedAnimatedNodeCount = characterModel.animatedNodeCount;
2202
+ skinnedClipCount = characterModel.clipCount;
2203
+ activeClipRenderable = drawn.sample.activeClipRenderable;
2204
+ } else {
2205
+ characterProjection = drawCharacter(
2206
+ ctx,
2207
+ snapshot.characterPosition,
2208
+ snapshot.cameraPosition,
2209
+ width,
2210
+ height,
2211
+ snapshot.clipTimeMs / 900,
2212
+ snapshot.headLook
2213
+ );
1566
2214
  }
1567
- context.configure({
1568
- device,
1569
- format,
1570
- alphaMode
1571
- });
1572
- }
1573
- function createRenderPassDescriptor(view, clearColor) {
1574
2215
  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
- ]
2216
+ characterGroundY: characterProjection.groundY,
2217
+ characterVisible: characterProjection.visible,
2218
+ modelLoaded: Boolean(characterModel),
2219
+ modelRenderable,
2220
+ fallbackProxyActive,
2221
+ skinnedVertexCount,
2222
+ skinnedTriangleCount,
2223
+ skinnedJointCount,
2224
+ skinnedAnimatedNodeCount,
2225
+ skinnedClipCount,
2226
+ activeClipRenderable,
2227
+ propGroundAnchors
1588
2228
  };
1589
2229
  }
1590
- function supportsWebGpu(options = {}) {
2230
+ function resolveCharacterModel(options) {
1591
2231
  try {
1592
- const gpu = readGpu(options.navigator);
1593
- return Boolean(gpu);
2232
+ return createAnimatedGltfModel(
2233
+ options.modelAsset ?? options.animationAdventure?.modelAsset,
2234
+ options.clipAssets ?? options.animationAdventure?.clipAssets ?? []
2235
+ );
1594
2236
  } catch {
1595
- return false;
2237
+ return null;
1596
2238
  }
1597
2239
  }
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.");
2240
+ function createAnimatedSceneRenderer(options = {}) {
2241
+ const canvas = resolveCanvas2(options.canvas);
2242
+ const ctx = canvas.getContext?.("2d");
2243
+ if (!ctx) {
2244
+ throw new Error("Animated scene renderer requires a 2D canvas context.");
1625
2245
  }
1626
- const resolvedFormat = format || (typeof gpu.getPreferredCanvasFormat === "function" ? gpu.getPreferredCanvasFormat() : "bgra8unorm");
1627
- configureContext(context, device, resolvedFormat, alpha ? "premultiplied" : "opaque");
2246
+ const route = [...options.route ?? options.animationAdventure?.route ?? []];
2247
+ const beats = [...options.beats ?? options.animationAdventure?.beats ?? []].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
2248
+ const props = [...options.props ?? options.animationAdventure?.props ?? []];
2249
+ const clipAssets = options.clipAssets ?? options.animationAdventure?.clipAssets ?? [];
2250
+ const clipProfiles = resolveClipProfiles(clipAssets);
2251
+ const beatTimeline = buildBeatTimeline(beats, route, clipProfiles);
2252
+ const characterModel = resolveCharacterModel(options);
2253
+ let camera = {
2254
+ ...DEFAULT_CAMERA,
2255
+ ...definedCameraOverrides(options.camera ?? options.animationAdventure?.camera)
2256
+ };
2257
+ const loopDurationMs = Math.max(beatTimeline.at(-1)?.endMs ?? durationFromBeats(beats), 1);
2258
+ const movementWarnings = beatTimeline.flatMap((entry) => entry.warnings);
2259
+ const requestFrame = options.requestAnimationFrame ?? globalThis.requestAnimationFrame?.bind(globalThis);
2260
+ const cancelFrame = options.cancelAnimationFrame ?? globalThis.cancelAnimationFrame?.bind(globalThis);
2261
+ let frameHandle;
1628
2262
  let running = false;
1629
- let destroyed = false;
1630
2263
  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
- }
2264
+ let startedAtMs;
2265
+ let lastTimestamp;
2266
+ let pendingCameraControl = null;
2267
+ let activeCameraControl = false;
2268
+ let headLookWeight = 0;
2269
+ let cameraPosition = route[0] ? [
2270
+ route[0].position[0] + (camera.offset?.[0] ?? 0),
2271
+ route[0].position[1] + (camera.offset?.[1] ?? 0),
2272
+ route[0].position[2] + (camera.offset?.[2] ?? 0)
2273
+ ] : [0, 2.4, 5.5];
2274
+ let cameraRigFrame = resolveCameraRigFrame({
2275
+ viewMode: "editor",
2276
+ anchors: {
2277
+ target: route[0]?.position ? [...route[0].position] : [0, 0, 0],
2278
+ head: route[0]?.position ? [route[0].position[0], route[0].position[1] + 1.65, route[0].position[2]] : [0, 1.65, 0],
2279
+ forward: [0, 0, -1]
2280
+ },
2281
+ camera: {
2282
+ id: "animation-adventure",
2283
+ transform: {
2284
+ position: [...cameraPosition],
2285
+ target: route[0]?.position ? [...route[0].position] : [0, 0, 0]
2286
+ }
2287
+ },
2288
+ constraints: camera.constraints
2289
+ });
2290
+ let snapshot = {
2291
+ frame,
2292
+ running,
2293
+ activeClipId: beats[0]?.clipId ?? "",
2294
+ activeBeatId: beats[0]?.id ?? "",
2295
+ activeMovementMode: beatTimeline[0]?.movementType ?? "stationary",
2296
+ blendProgress: 0,
2297
+ clipTimeMs: 0,
2298
+ characterPosition: route[0]?.position ? [...route[0].position] : [0, 0, 0],
2299
+ cameraPosition: [...cameraPosition],
2300
+ cameraViewMode: resolveCameraViewMode(camera),
2301
+ cameraTransform: cameraRigFrame.transform,
2302
+ targetDistance: cameraRigFrame.targetDistance,
2303
+ headLook: cameraRigFrame.headLook,
2304
+ characterGroundY: 0,
2305
+ characterVisible: false,
2306
+ modelLoaded: Boolean(characterModel),
2307
+ modelRenderable: false,
2308
+ fallbackProxyActive: true,
2309
+ skinnedVertexCount: characterModel?.vertexCount ?? 0,
2310
+ skinnedTriangleCount: characterModel?.triangleCount ?? 0,
2311
+ skinnedJointCount: characterModel?.jointCount ?? 0,
2312
+ skinnedAnimatedNodeCount: characterModel?.animatedNodeCount ?? 0,
2313
+ skinnedClipCount: characterModel?.clipCount ?? 0,
2314
+ activeClipRenderable: false,
2315
+ propGroundAnchors: [],
2316
+ movementValidation: {
2317
+ status: movementWarnings.length ? "warning" : "passed",
2318
+ warnings: movementWarnings,
2319
+ activeBeatId: beats[0]?.id ?? "",
2320
+ activeClipId: beats[0]?.clipId ?? "",
2321
+ motionMode: beatTimeline[0]?.movementType ?? "stationary",
2322
+ rootMotionSource: beatTimeline[0]?.movementProfile?.motionMode ?? "none",
2323
+ expectedSpeed: beatTimeline[0]?.expectedSpeed ?? 0,
2324
+ actualSpeed: beatTimeline[0]?.actualSpeed ?? 0,
2325
+ movementDistance: beatTimeline[0]?.movementDistance ?? 0,
2326
+ loopCount: beatTimeline[0]?.loopCount ?? 1,
2327
+ footSlideWarning: beatTimeline[0]?.footSlideWarning ?? null
2328
+ },
2329
+ frameState: "initialized"
1733
2330
  };
1734
- const start = () => {
1735
- if (destroyed) {
1736
- throw new Error("Renderer was destroyed.");
1737
- }
1738
- if (running) {
1739
- return false;
2331
+ function renderOnce(timestamp = nowMs()) {
2332
+ if (startedAtMs === void 0) {
2333
+ startedAtMs = timestamp;
1740
2334
  }
1741
- running = true;
1742
- if (typeof requestAnimationFrame === "function") {
1743
- rafId = requestAnimationFrame(tick);
2335
+ const elapsedMs = Math.max(0, timestamp - startedAtMs);
2336
+ const loopTimeMs = elapsedMs % loopDurationMs;
2337
+ const frameTimeMs = lastTimestamp === void 0 ? 16.67 : Math.max(0, timestamp - lastTimestamp);
2338
+ lastTimestamp = timestamp;
2339
+ frame += 1;
2340
+ const timelineEntry = resolveTimelineBeat(beatTimeline, loopTimeMs);
2341
+ const fallbackBeat = resolveBeat(beats, loopTimeMs);
2342
+ const beat = timelineEntry?.beat ?? fallbackBeat.beat;
2343
+ const beatTimeMs = timelineEntry ? loopTimeMs - timelineEntry.startMs : fallbackBeat.beatTimeMs;
2344
+ const durationMs = timelineEntry?.durationMs ?? fallbackBeat.durationMs;
2345
+ const blend = beat?.blend ?? { inMs: 0, outMs: 0 };
2346
+ const blendIn = blend.inMs > 0 ? clamp012(beatTimeMs / blend.inMs) : 1;
2347
+ const blendOut = blend.outMs > 0 ? clamp012((durationMs - beatTimeMs) / blend.outMs) : 1;
2348
+ const blendProgress = clamp012(Math.min(blendIn, blendOut));
2349
+ const characterPosition = timelineEntry ? resolveTimelinePosition(timelineEntry, beatTimeMs) : resolveRoutePosition(route, loopTimeMs);
2350
+ const characterForward = timelineEntry ? resolveTimelineForward(timelineEntry) : resolveRouteForward(route, loopTimeMs);
2351
+ const headAnchor = camera.headBoneAvailable === false ? void 0 : [
2352
+ characterPosition[0],
2353
+ characterPosition[1] + (camera.headHeight ?? 1.65),
2354
+ characterPosition[2]
2355
+ ];
2356
+ const lookAheadTimeMs = (loopTimeMs + camera.lookAheadMs) % loopDurationMs;
2357
+ const lookAheadEntry = resolveTimelineBeat(beatTimeline, lookAheadTimeMs);
2358
+ const lookAheadPosition = lookAheadEntry ? resolveTimelinePosition(lookAheadEntry, lookAheadTimeMs - lookAheadEntry.startMs) : resolveRoutePosition(route, lookAheadTimeMs);
2359
+ const desiredCamera = [
2360
+ lookAheadPosition[0] + (camera.offset?.[0] ?? 0),
2361
+ lookAheadPosition[1] + (camera.offset?.[1] ?? 0),
2362
+ lookAheadPosition[2] + (camera.offset?.[2] ?? 0)
2363
+ ];
2364
+ const smoothing = bezierY(camera.cubicBezier, clamp012(frameTimeMs / Math.max(1, camera.lagMs)));
2365
+ cameraPosition = lerp3(cameraPosition, desiredCamera, smoothing);
2366
+ const cameraViewMode = resolveCameraViewMode(camera);
2367
+ const rigViewMode = cameraViewMode === "spectator" ? "editor" : cameraViewMode;
2368
+ cameraRigFrame = resolveCameraRigFrame({
2369
+ viewMode: rigViewMode,
2370
+ anchors: {
2371
+ target: characterPosition,
2372
+ ...headAnchor ? { head: headAnchor } : {},
2373
+ forward: characterForward
2374
+ },
2375
+ camera: {
2376
+ id: "animation-adventure",
2377
+ transform: {
2378
+ position: [...cameraPosition],
2379
+ target: lookAheadPosition
2380
+ }
2381
+ },
2382
+ constraints: camera.constraints,
2383
+ control: pendingCameraControl,
2384
+ activeControl: activeCameraControl
2385
+ });
2386
+ if (activeCameraControl && cameraRigFrame.headLook.status === "active") {
2387
+ headLookWeight = cameraRigFrame.headLook.weight;
1744
2388
  } else {
1745
- renderOnce();
2389
+ const returnMs = Math.max(1, camera.headLook?.returnMs ?? DEFAULT_CAMERA.headLook.returnMs);
2390
+ headLookWeight = lerp2(headLookWeight, 0, clamp012(frameTimeMs / returnMs));
1746
2391
  }
1747
- return true;
1748
- };
1749
- const stop = () => {
2392
+ const headLook = {
2393
+ ...cameraRigFrame.headLook,
2394
+ status: cameraRigFrame.headLook.status === "unavailable" ? "unavailable" : headLookWeight > 1e-3 && !activeCameraControl ? "returning" : cameraRigFrame.headLook.status,
2395
+ weight: headLookWeight
2396
+ };
2397
+ cameraPosition = [...cameraRigFrame.transform.position];
2398
+ pendingCameraControl = null;
2399
+ activeCameraControl = false;
2400
+ snapshot = {
2401
+ frame,
2402
+ running,
2403
+ activeClipId: beat?.clipId ?? "",
2404
+ activeBeatId: beat?.id ?? "",
2405
+ activeMovementMode: timelineEntry?.movementType ?? "stationary",
2406
+ blendProgress,
2407
+ clipTimeMs: beatTimeMs,
2408
+ characterPosition,
2409
+ cameraPosition: [...cameraPosition],
2410
+ cameraViewMode,
2411
+ cameraTransform: cameraRigFrame.transform,
2412
+ targetDistance: cameraRigFrame.targetDistance,
2413
+ headLook,
2414
+ characterGroundY: 0,
2415
+ characterVisible: false,
2416
+ modelLoaded: Boolean(characterModel),
2417
+ modelRenderable: false,
2418
+ fallbackProxyActive: true,
2419
+ skinnedVertexCount: characterModel?.vertexCount ?? 0,
2420
+ skinnedTriangleCount: characterModel?.triangleCount ?? 0,
2421
+ skinnedJointCount: characterModel?.jointCount ?? 0,
2422
+ skinnedAnimatedNodeCount: characterModel?.animatedNodeCount ?? 0,
2423
+ skinnedClipCount: characterModel?.clipCount ?? 0,
2424
+ activeClipRenderable: false,
2425
+ propGroundAnchors: [],
2426
+ movementValidation: {
2427
+ status: movementWarnings.length ? "warning" : "passed",
2428
+ warnings: [...movementWarnings],
2429
+ activeBeatId: beat?.id ?? "",
2430
+ activeClipId: beat?.clipId ?? "",
2431
+ motionMode: timelineEntry?.movementType ?? "stationary",
2432
+ rootMotionSource: timelineEntry?.movementProfile?.motionMode ?? "none",
2433
+ expectedSpeed: timelineEntry?.expectedSpeed ?? 0,
2434
+ actualSpeed: timelineEntry?.actualSpeed ?? 0,
2435
+ movementDistance: timelineEntry?.movementDistance ?? 0,
2436
+ loopCount: timelineEntry?.loopCount ?? 1,
2437
+ footSlideWarning: timelineEntry?.footSlideWarning ?? null
2438
+ },
2439
+ frameState: running ? "running" : "rendered-once"
2440
+ };
2441
+ const visibility = renderScene(canvas, ctx, snapshot, props, characterModel);
2442
+ snapshot = {
2443
+ ...snapshot,
2444
+ ...visibility
2445
+ };
2446
+ return snapshot;
2447
+ }
2448
+ function tick(timestamp) {
1750
2449
  if (!running) {
1751
- return false;
1752
- }
1753
- running = false;
1754
- if (rafId !== null && typeof cancelAnimationFrame === "function") {
1755
- cancelAnimationFrame(rafId);
2450
+ return;
1756
2451
  }
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`;
2452
+ renderOnce(timestamp);
2453
+ if (running && requestFrame) {
2454
+ frameHandle = requestFrame(tick);
1768
2455
  }
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();
2456
+ }
2457
+ return {
2458
+ start() {
2459
+ if (running) {
2460
+ return;
2461
+ }
2462
+ running = true;
2463
+ snapshot = { ...snapshot, running: true, frameState: "running" };
2464
+ if (requestFrame) {
2465
+ frameHandle = requestFrame(tick);
2466
+ } else {
2467
+ renderOnce();
1806
2468
  }
1807
- detachXrBinding = bindRendererToXrManager(renderer, xrManager, bindOptions);
1808
- return detachXrBinding;
1809
2469
  },
1810
- destroy() {
1811
- stop();
1812
- destroyed = true;
1813
- if (detachXrBinding) {
1814
- detachXrBinding();
1815
- detachXrBinding = null;
2470
+ resize(width, height, devicePixelRatio = 1) {
2471
+ const ratio = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1;
2472
+ canvas.width = Math.max(1, Math.floor(width * ratio));
2473
+ canvas.height = Math.max(1, Math.floor(height * ratio));
2474
+ if (canvas.style) {
2475
+ canvas.style.width = `${width}px`;
2476
+ canvas.style.height = `${height}px`;
1816
2477
  }
1817
- if (typeof context.unconfigure === "function") {
1818
- context.unconfigure();
2478
+ const visibility = renderScene(canvas, ctx, snapshot, props, characterModel);
2479
+ snapshot = {
2480
+ ...snapshot,
2481
+ ...visibility
2482
+ };
2483
+ },
2484
+ renderOnce,
2485
+ getSnapshot() {
2486
+ return {
2487
+ ...snapshot,
2488
+ characterPosition: [...snapshot.characterPosition],
2489
+ cameraPosition: [...snapshot.cameraPosition],
2490
+ cameraTransform: {
2491
+ position: [...snapshot.cameraTransform.position],
2492
+ target: [...snapshot.cameraTransform.target],
2493
+ up: [...snapshot.cameraTransform.up]
2494
+ },
2495
+ headLook: {
2496
+ ...snapshot.headLook,
2497
+ target: [...snapshot.headLook.target]
2498
+ },
2499
+ movementValidation: {
2500
+ ...snapshot.movementValidation,
2501
+ warnings: [...snapshot.movementValidation?.warnings ?? []]
2502
+ },
2503
+ propGroundAnchors: snapshot.propGroundAnchors.map((anchor) => ({ ...anchor }))
2504
+ };
2505
+ },
2506
+ setCamera(nextCamera = {}) {
2507
+ camera = { ...camera, ...nextCamera };
2508
+ snapshot = {
2509
+ ...snapshot,
2510
+ cameraViewMode: resolveCameraViewMode(camera)
2511
+ };
2512
+ },
2513
+ setCameraViewMode(viewMode) {
2514
+ camera = { ...camera, viewMode };
2515
+ snapshot = {
2516
+ ...snapshot,
2517
+ cameraViewMode: resolveCameraViewMode(camera)
2518
+ };
2519
+ },
2520
+ applyCameraControl(control, options2 = {}) {
2521
+ pendingCameraControl = control;
2522
+ activeCameraControl = options2.activeControl !== false;
2523
+ },
2524
+ destroy() {
2525
+ running = false;
2526
+ if (frameHandle !== void 0 && cancelFrame) {
2527
+ cancelFrame(frameHandle);
1819
2528
  }
2529
+ frameHandle = void 0;
2530
+ snapshot = { ...snapshot, running: false, frameState: "destroyed" };
1820
2531
  }
1821
2532
  };
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
2533
  }
1858
- var defaultRendererClearColor = DEFAULT_CLEAR_COLOR;
1859
2534
 
1860
2535
  // src/professional-animated-scene-renderer.js
1861
2536
  function nowMs2() {
@@ -2965,7 +3640,7 @@ function scale(a, scalar) {
2965
3640
  function dot(a, b) {
2966
3641
  return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
2967
3642
  }
2968
- function cross(a, b) {
3643
+ function cross2(a, b) {
2969
3644
  return [
2970
3645
  a[1] * b[2] - a[2] * b[1],
2971
3646
  a[2] * b[0] - a[0] * b[2],
@@ -3586,7 +4261,7 @@ function createMeshTriangleRecords(meshes, gpuMaterialSource = null) {
3586
4261
  const v0 = readVector(mesh.positions, a, 3, [0, 0, 0]);
3587
4262
  const v1 = readVector(mesh.positions, b, 3, [0, 0, 0]);
3588
4263
  const v2 = readVector(mesh.positions, c, 3, [0, 0, 0]);
3589
- const faceNormal = normalize(cross(subtract(v1, v0), subtract(v2, v0)), [0, 1, 0]);
4264
+ const faceNormal = normalize(cross2(subtract(v1, v0), subtract(v2, v0)), [0, 1, 0]);
3590
4265
  const n0 = mesh.normals ? normalize(readVector(mesh.normals, a, 3, faceNormal), faceNormal) : faceNormal;
3591
4266
  const n1 = mesh.normals ? normalize(readVector(mesh.normals, b, 3, faceNormal), faceNormal) : faceNormal;
3592
4267
  const n2 = mesh.normals ? normalize(readVector(mesh.normals, c, 3, faceNormal), faceNormal) : faceNormal;
@@ -4627,9 +5302,9 @@ function resolveEnvironmentPortalMode(value, hasPortals) {
4627
5302
  }
4628
5303
  function orthogonalPortalTangent(normal) {
4629
5304
  if (Math.abs(normal[1]) < 0.92) {
4630
- return normalize(cross([0, 1, 0], normal), [1, 0, 0]);
5305
+ return normalize(cross2([0, 1, 0], normal), [1, 0, 0]);
4631
5306
  }
4632
- return normalize(cross([1, 0, 0], normal), [0, 0, 1]);
5307
+ return normalize(cross2([1, 0, 0], normal), [0, 0, 1]);
4633
5308
  }
4634
5309
  function resolvePortalTangent(value, normal) {
4635
5310
  const fallback = orthogonalPortalTangent(normal);
@@ -4661,7 +5336,7 @@ function normalizeEnvironmentPortal(portal, index) {
4661
5336
  const position = asVec3(portal.position ?? portal.center, [0, 0, 0]);
4662
5337
  const normal = asUnitVec3(portal.normal, [0, 0, 1]);
4663
5338
  const tangent = resolvePortalTangent(portal.tangent, normal);
4664
- const bitangent = normalize(cross(normal, tangent), [0, 1, 0]);
5339
+ const bitangent = normalize(cross2(normal, tangent), [0, 1, 0]);
4665
5340
  const width = readPortalExtent(
4666
5341
  `environmentPortals[${index}].width`,
4667
5342
  portal.width,
@@ -4739,8 +5414,8 @@ function resolveCamera(input, width, height) {
4739
5414
  const target = asVec3(camera.target, DEFAULT_CAMERA2.target);
4740
5415
  const upInput = normalize(asVec3(camera.up, DEFAULT_CAMERA2.up), DEFAULT_CAMERA2.up);
4741
5416
  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]);
5417
+ const right = normalize(cross2(forward, upInput), [1, 0, 0]);
5418
+ const up = normalize(cross2(right, forward), [0, 1, 0]);
4744
5419
  const fovYDegrees = clamp2(
4745
5420
  readFiniteNumber("camera.fovYDegrees", camera.fovYDegrees ?? camera.fov, DEFAULT_CAMERA2.fovYDegrees),
4746
5421
  10,
@@ -5109,8 +5784,8 @@ function supportsWavefrontPathTracingCompute(options = {}) {
5109
5784
  // src/wavefront-sampling.js
5110
5785
  function buildOrthonormalBasis(normal) {
5111
5786
  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]);
5787
+ const tangent = normalize(cross2(tangentFallback, normal), [1, 0, 0]);
5788
+ const bitangent = normalize(cross2(normal, tangent), [0, 0, 1]);
5114
5789
  return { tangent, bitangent };
5115
5790
  }
5116
5791
  function localToWorld(local, normal) {
@@ -11339,7 +12014,7 @@ function intersectWavefrontReferenceTriangle(ray, triangle, options = {}) {
11339
12014
  const triangleIndex = readNonNegativeInteger2("triangleIndex", options.triangleIndex, 0);
11340
12015
  const edge1 = subtract(triangle.v1, triangle.v0);
11341
12016
  const edge2 = subtract(triangle.v2, triangle.v0);
11342
- const pvec = cross(ray.direction, edge2);
12017
+ const pvec = cross2(ray.direction, edge2);
11343
12018
  const determinant = dot(edge1, pvec);
11344
12019
  if (Math.abs(determinant) < 1e-7) {
11345
12020
  return null;
@@ -11350,7 +12025,7 @@ function intersectWavefrontReferenceTriangle(ray, triangle, options = {}) {
11350
12025
  if (u < 0 || u > 1) {
11351
12026
  return null;
11352
12027
  }
11353
- const qvec = cross(tvec, edge1);
12028
+ const qvec = cross2(tvec, edge1);
11354
12029
  const v = dot(ray.direction, qvec) * invDet;
11355
12030
  if (v < 0 || u + v > 1) {
11356
12031
  return null;
@@ -11359,7 +12034,7 @@ function intersectWavefrontReferenceTriangle(ray, triangle, options = {}) {
11359
12034
  if (distance <= 1e-3 || distance > maxDistance) {
11360
12035
  return null;
11361
12036
  }
11362
- const geometric = normalize(cross(edge1, edge2), [0, 1, 0]);
12037
+ const geometric = normalize(cross2(edge1, edge2), [0, 1, 0]);
11363
12038
  const frontFace = dot(ray.direction, geometric) < 0;
11364
12039
  const orientedGeometric = frontFace ? geometric : scale(geometric, -1);
11365
12040
  const w = 1 - u - v;
@@ -11453,7 +12128,7 @@ function triangleAreaForLightSampling(triangle) {
11453
12128
  }
11454
12129
  const edge1 = subtract(triangle.v1, triangle.v0);
11455
12130
  const edge2 = subtract(triangle.v2, triangle.v0);
11456
- return Math.max(Math.hypot(...cross(edge1, edge2)) * 0.5, 1e-6);
12131
+ return Math.max(Math.hypot(...cross2(edge1, edge2)) * 0.5, 1e-6);
11457
12132
  }
11458
12133
  function emissiveTriangleWeight(triangle) {
11459
12134
  if (!triangle) {
@@ -13331,6 +14006,7 @@ export {
13331
14006
  createFeedbackGameDiagnosticSnapshot,
13332
14007
  createGpuRenderer,
13333
14008
  createMediumStack,
14009
+ createNativeSceneRenderer,
13334
14010
  createProfessionalAnimatedSceneRenderer,
13335
14011
  createRayTracingRenderPlan,
13336
14012
  createRendererDebugHooks,