diff --git a/apps/typegpu-docs/package.json b/apps/typegpu-docs/package.json
index 7e96f4f435..2b8273217d 100644
--- a/apps/typegpu-docs/package.json
+++ b/apps/typegpu-docs/package.json
@@ -20,6 +20,7 @@
"@babel/standalone": "^7.28.6",
"@dimforge/rapier2d-compat": "^0.19.3",
"@loaders.gl/core": "^4.3.4",
+ "@loaders.gl/gltf": "^4.3.4",
"@loaders.gl/obj": "^4.3.4",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-select": "^2.2.6",
diff --git a/apps/typegpu-docs/public/assets/mesh-skinning/DemoModel.glb b/apps/typegpu-docs/public/assets/mesh-skinning/DemoModel.glb
new file mode 100644
index 0000000000..dc684c2a66
Binary files /dev/null and b/apps/typegpu-docs/public/assets/mesh-skinning/DemoModel.glb differ
diff --git a/apps/typegpu-docs/src/components/stackblitz/openInStackBlitz.ts b/apps/typegpu-docs/src/components/stackblitz/openInStackBlitz.ts
index 4e8420c1ad..971bff0581 100644
--- a/apps/typegpu-docs/src/components/stackblitz/openInStackBlitz.ts
+++ b/apps/typegpu-docs/src/components/stackblitz/openInStackBlitz.ts
@@ -116,6 +116,7 @@ ${example.htmlFile.content}
"wgpu-matrix": "${pnpmWorkspaceYaml.catalogs.example['wgpu-matrix']}",
"@loaders.gl/core": "${typegpuDocsPackageJson.dependencies['@loaders.gl/core']}",
"@loaders.gl/obj": "${typegpuDocsPackageJson.dependencies['@loaders.gl/obj']}",
+ "@loaders.gl/gltf": "${typegpuDocsPackageJson.dependencies['@loaders.gl/gltf']}",
"three": "${pnpmWorkspaceYaml.catalogs.example.three}",
"@typegpu/noise": "${typegpuNoisePackageJson.version}",
"@typegpu/color": "${typegpuColorPackageJson.version}",
diff --git a/apps/typegpu-docs/src/examples/simple/mesh-skinning/animation.ts b/apps/typegpu-docs/src/examples/simple/mesh-skinning/animation.ts
new file mode 100644
index 0000000000..7cfb9b0166
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/simple/mesh-skinning/animation.ts
@@ -0,0 +1,98 @@
+import { quat, vec3 } from 'wgpu-matrix';
+import type { Animation, Quat, Vec3 } from './types.ts';
+
+export interface NodeTransform {
+ translation: Vec3;
+ hasTranslation: boolean;
+ rotation: Quat;
+ hasRotation: boolean;
+ scale: Vec3;
+ hasScale: boolean;
+}
+
+export type NodeTransformState = NodeTransform[];
+
+export function createNodeTransformState(nodeCount: number): NodeTransformState {
+ return Array.from({ length: nodeCount }, () => ({
+ translation: [0, 0, 0],
+ hasTranslation: false,
+ rotation: [0, 0, 0, 1],
+ hasRotation: false,
+ scale: [1, 1, 1],
+ hasScale: false,
+ }));
+}
+
+export function sampleAnimationInto(
+ animation: Animation | undefined,
+ time: number,
+ transforms: NodeTransformState,
+ touchedNodes: number[],
+): NodeTransformState {
+ for (const nodeIndex of touchedNodes) {
+ const transform = transforms[nodeIndex];
+ transform.hasTranslation = false;
+ transform.hasRotation = false;
+ transform.hasScale = false;
+ }
+ touchedNodes.length = 0;
+
+ if (!animation || animation.duration <= 0) {
+ return transforms;
+ }
+
+ const loopedTime = time % animation.duration;
+
+ for (const channel of animation.channels) {
+ const { input: times, output: values } = animation.samplers[channel.samplerIndex];
+ const components = channel.targetPath === 'rotation' ? 4 : 3;
+
+ let keyframeIndex = 0;
+ while (keyframeIndex < times.length - 2 && loopedTime >= times[keyframeIndex + 1]) {
+ keyframeIndex++;
+ }
+
+ const startTime = times[keyframeIndex];
+ const endTime = times[keyframeIndex + 1];
+ const alpha =
+ endTime > startTime
+ ? Math.max(0, Math.min(1, (loopedTime - startTime) / (endTime - startTime)))
+ : 0;
+
+ const start = keyframeIndex * components;
+ const end = (keyframeIndex + 1) * components;
+
+ const transform = transforms[channel.targetNode];
+ if (!transform.hasTranslation && !transform.hasRotation && !transform.hasScale) {
+ touchedNodes.push(channel.targetNode);
+ }
+
+ if (channel.targetPath === 'rotation') {
+ quat.slerp(
+ values.subarray(start, start + components),
+ values.subarray(end, end + components),
+ alpha,
+ transform.rotation,
+ );
+ transform.hasRotation = true;
+ } else if (channel.targetPath === 'translation') {
+ vec3.lerp(
+ values.subarray(start, start + components),
+ values.subarray(end, end + components),
+ alpha,
+ transform.translation,
+ );
+ transform.hasTranslation = true;
+ } else {
+ vec3.lerp(
+ values.subarray(start, start + components),
+ values.subarray(end, end + components),
+ alpha,
+ transform.scale,
+ );
+ transform.hasScale = true;
+ }
+ }
+
+ return transforms;
+}
diff --git a/apps/typegpu-docs/src/examples/simple/mesh-skinning/index.html b/apps/typegpu-docs/src/examples/simple/mesh-skinning/index.html
new file mode 100644
index 0000000000..e14a9c47e5
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/simple/mesh-skinning/index.html
@@ -0,0 +1,37 @@
+
+
+
+
+
diff --git a/apps/typegpu-docs/src/examples/simple/mesh-skinning/index.ts b/apps/typegpu-docs/src/examples/simple/mesh-skinning/index.ts
new file mode 100644
index 0000000000..397903641f
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/simple/mesh-skinning/index.ts
@@ -0,0 +1,565 @@
+import tgpu, { common } from 'typegpu';
+import * as d from 'typegpu/data';
+import * as std from 'typegpu/std';
+import { mat4, vec3 } from 'wgpu-matrix';
+import { defineControls } from '../../common/defineControls.ts';
+import { setupOrbitCamera } from '../../common/setup-orbit-camera.ts';
+import {
+ createNodeTransformState,
+ sampleAnimationInto,
+ type NodeTransformState,
+} from './animation.ts';
+import { loadGLBModel } from './loader.ts';
+import { mat4ToDualQuat } from './math.ts';
+import { generateTube } from './tube.ts';
+import type { Animation, MeshData, SceneVariant } from './types.ts';
+import { VertexData } from './types.ts';
+
+const MODEL_ASSET = {
+ path: '/TypeGPU/assets/mesh-skinning/DemoModel.glb',
+ scale: 1,
+ offset: [0, 0, 0],
+} as const;
+
+const MAX_JOINTS = 128;
+const INITIAL_CAMERA_POSITION = [3, 3, 3, 1] as const;
+const CAMERA_TARGET_SMOOTHING = 0.08;
+const CAMERA_TARGET_Y_OFFSET = 0.9;
+const TWIST_DEMO_ID = 'Twist_Demo';
+const DEMO_MATERIAL_ID = 0;
+const LIGHTING = {
+ key: d.vec3f(0.42, 0.84, 0.33),
+ fill: d.vec3f(-0.8, 0.25, 0.55),
+ specularColor: d.vec3f(1.0, 0.96, 0.9),
+} as const;
+
+const canvas = document.querySelector('canvas') as HTMLCanvasElement;
+const attributionEl = document.getElementById('attribution') as HTMLElement;
+const dismissAttribution = () => {
+ attributionEl.style.opacity = '0';
+ attributionEl.style.pointerEvents = 'none';
+};
+canvas.addEventListener('click', dismissAttribution, { once: true });
+canvas.addEventListener('touchend', dismissAttribution, { once: true });
+const modelData = await loadGLBModel(MODEL_ASSET.path);
+const twistDemoMesh = generateTube(32, 8, 0.25, 2);
+
+const variants: SceneVariant[] = [
+ ...modelData.animations.map((animation) => ({
+ id: animation.name,
+ mesh: modelData,
+ })),
+ {
+ id: TWIST_DEMO_ID,
+ mesh: twistDemoMesh,
+ },
+];
+
+const selectedVariantId =
+ variants.find((variant) => variant.id === 'Yes')?.id ?? variants[0]?.id ?? TWIST_DEMO_ID;
+const selectedVariant = variants.find((variant) => variant.id === selectedVariantId);
+if (!selectedVariant) {
+ throw new Error('Mesh skinning example has no scene variants to render.');
+}
+if (modelData.jointNodes.length > MAX_JOINTS) {
+ throw new Error(
+ `Model has ${modelData.jointNodes.length} joints but MAX_JOINTS is ${MAX_JOINTS}.`,
+ );
+}
+
+const parentByNode = new Int16Array(modelData.nodes.length).fill(-1);
+for (let parent = 0; parent < modelData.nodes.length; parent++) {
+ for (const child of modelData.nodes[parent].children ?? []) {
+ parentByNode[child] = parent;
+ }
+}
+
+const inverseBindViews = modelData.jointNodes.map((_, index) =>
+ modelData.inverseBindMatrices.subarray(index * 16, (index + 1) * 16),
+);
+
+const modelTransform = mat4.identity();
+mat4.translate(modelTransform, MODEL_ASSET.offset, modelTransform);
+mat4.scale(
+ modelTransform,
+ [MODEL_ASSET.scale, MODEL_ASSET.scale, MODEL_ASSET.scale],
+ modelTransform,
+);
+
+const CpuState = {
+ animatedTransforms: createNodeTransformState(modelData.nodes.length),
+ animatedTransformIndices: [] as number[],
+ nodeWorld: modelData.nodes.map(() => new Float32Array(16)),
+ nodeWorldDirty: new Uint8Array(modelData.nodes.length),
+ local: new Float32Array(16),
+ quatMatrix: new Float32Array(16),
+ jointWorld: modelData.jointNodes.map(() => new Float32Array(16)),
+ jointMatrices: new Float32Array(MAX_JOINTS * 16),
+ jointDualQuats: new Float32Array(MAX_JOINTS * 8),
+ quatScratch: new Float32Array(4),
+ rootJointPosition: new Float32Array(3),
+ smoothedTarget: new Float32Array(3),
+ cameraMatrix: new Float32Array(16),
+};
+
+for (let index = modelData.jointNodes.length; index < MAX_JOINTS; index++) {
+ mat4.identity(CpuState.jointMatrices.subarray(index * 16, index * 16 + 16));
+ CpuState.jointDualQuats[index * 8 + 3] = 1;
+}
+
+const root = await tgpu.init();
+const context = root.configureContext({ canvas });
+const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
+
+function createDepthTexture() {
+ return root['~unstable']
+ .createTexture({
+ size: [canvas.width, canvas.height],
+ format: 'depth24plus',
+ sampleCount: 4,
+ })
+ .$usage('render');
+}
+
+function createMsaaTexture() {
+ return root['~unstable']
+ .createTexture({
+ size: [canvas.width, canvas.height],
+ format: presentationFormat,
+ sampleCount: 4,
+ })
+ .$usage('render');
+}
+
+let depthTexture = createDepthTexture();
+let msaaTexture = createMsaaTexture();
+const materialPalette = [
+ d.vec4f(0.82, 0.82, 0.82, 1),
+ ...modelData.materials.map((material) => d.vec4f(...material)),
+];
+
+const cameraUniform = root.createUniform(d.mat4x4f);
+const cameraPositionUniform = root.createUniform(d.vec4f, d.vec4f(...INITIAL_CAMERA_POSITION));
+const materialUniform = root.createReadonly(
+ d.arrayOf(d.vec4f, materialPalette.length),
+ materialPalette,
+);
+const jointMatricesUniform = root.createUniform(
+ d.arrayOf(d.mat4x4f, MAX_JOINTS),
+ CpuState.jointMatrices,
+);
+const jointDualQuatsUniform = root.createUniform(
+ d.arrayOf(d.vec4f, MAX_JOINTS * 2),
+ CpuState.jointDualQuats,
+);
+
+function createRenderMesh(mesh: MeshData, materialIdOffset = 0) {
+ const materialIds =
+ materialIdOffset === 0
+ ? mesh.materialIds
+ : mesh.materialIds.map((materialId) => materialId + materialIdOffset);
+
+ return {
+ vertexBuffer: root
+ .createBuffer(d.arrayOf(VertexData, mesh.vertexCount), (buffer) => {
+ common.writeSoA(buffer, {
+ position: mesh.positions,
+ normal: mesh.normals,
+ materialId: materialIds,
+ joint: mesh.joints,
+ weight: mesh.weights,
+ });
+ })
+ .$usage('vertex'),
+ indexBuffer: root
+ .createBuffer(d.arrayOf(d.u16, mesh.indexCount), Array.from(mesh.indices))
+ .$usage('index'),
+ indexCount: mesh.indexCount,
+ };
+}
+
+const modelRenderMesh = createRenderMesh(modelData, 1);
+const demoRenderMesh = createRenderMesh(twistDemoMesh, DEMO_MATERIAL_ID);
+
+const vertexLayout = tgpu.vertexLayout(d.arrayOf(VertexData));
+
+const vertex = tgpu.vertexFn({
+ in: { position: d.vec3f, normal: d.vec3f, materialId: d.u32, joint: d.vec4u, weight: d.vec4f },
+ out: { pos: d.builtin.position, normal: d.vec3f, color: d.vec3f, worldPos: d.vec3f },
+})(({ position, normal, materialId, joint, weight }) => {
+ 'use gpu';
+ const jointMatrices = jointMatricesUniform.$;
+ const skinMatrix =
+ jointMatrices[joint.x] * weight.x +
+ jointMatrices[joint.y] * weight.y +
+ jointMatrices[joint.z] * weight.z +
+ jointMatrices[joint.w] * weight.w;
+ const skinnedPosition = skinMatrix * d.vec4f(position, 1);
+
+ return {
+ pos: cameraUniform.$ * skinnedPosition,
+ normal: std.normalize((skinMatrix * d.vec4f(normal, 0)).xyz),
+ color: materialUniform.$[materialId].xyz,
+ worldPos: skinnedPosition.xyz,
+ };
+});
+
+const rotateByUnitQuat = (value: d.v3f, quaternion: d.v4f): d.v3f => {
+ 'use gpu';
+ const tangent = 2 * std.cross(quaternion.xyz, value);
+ return value + quaternion.w * tangent + std.cross(quaternion.xyz, tangent);
+};
+
+const dqsVertex = tgpu.vertexFn({
+ in: { position: d.vec3f, normal: d.vec3f, materialId: d.u32, joint: d.vec4u, weight: d.vec4f },
+ out: { pos: d.builtin.position, normal: d.vec3f, color: d.vec3f, worldPos: d.vec3f },
+})(({ position, normal, materialId, joint, weight }) => {
+ 'use gpu';
+ const dualQuats = jointDualQuatsUniform.$;
+ const referenceReal = dualQuats[joint.x * 2];
+ let realAccum = referenceReal * weight.x;
+ let dualAccum = dualQuats[joint.x * 2 + 1] * weight.x;
+
+ for (const index of tgpu.unroll([1, 2, 3])) {
+ const base = joint[index] * 2;
+ const real = dualQuats[base];
+ const signedWeight =
+ weight[index] * std.select(d.f32(-1), 1, std.dot(referenceReal, real) >= 0);
+ realAccum = realAccum + real * signedWeight;
+ dualAccum = dualAccum + dualQuats[base + 1] * signedWeight;
+ }
+
+ const invLength = 1 / std.length(realAccum);
+ const real = realAccum * invLength;
+ const dual = dualAccum * invLength;
+ const translation = 2.0 * (real.w * dual.xyz - dual.w * real.xyz + std.cross(real.xyz, dual.xyz));
+
+ const worldPos = rotateByUnitQuat(position, real) + translation;
+
+ return {
+ pos: cameraUniform.$ * d.vec4f(worldPos, 1),
+ normal: std.normalize(rotateByUnitQuat(normal, real)),
+ color: materialUniform.$[materialId].xyz,
+ worldPos,
+ };
+});
+
+const fragment = tgpu.fragmentFn({
+ in: { normal: d.vec3f, color: d.vec3f, worldPos: d.vec3f },
+ out: d.vec4f,
+})(({ normal, color, worldPos }) => {
+ 'use gpu';
+ const viewDir = std.normalize(cameraPositionUniform.$.xyz - worldPos);
+ const key = std.saturate(std.dot(normal, LIGHTING.key));
+ const fill = std.saturate(std.dot(normal, LIGHTING.fill));
+ const halfVector = std.normalize(LIGHTING.key + viewDir);
+ const specular = std.pow(std.saturate(std.dot(normal, halfVector)), 32);
+ const finalColor = std.saturate(
+ color * (0.2 + key * 0.9 + fill * 0.25) + LIGHTING.specularColor * specular * 0.18,
+ );
+
+ return d.vec4f(finalColor, 1);
+});
+
+const pipelineConfig = {
+ fragment,
+ attribs: vertexLayout.attrib,
+ depthStencil: {
+ format: 'depth24plus' as const,
+ depthWriteEnabled: true,
+ depthCompare: 'less' as const,
+ },
+ multisample: { count: 4 },
+};
+
+const lbsPipeline = root.createRenderPipeline({ vertex, ...pipelineConfig });
+const dqsPipeline = root.createRenderPipeline({ vertex: dqsVertex, ...pipelineConfig });
+
+const resizeObserver = new ResizeObserver(() => {
+ depthTexture = createDepthTexture();
+ msaaTexture = createMsaaTexture();
+});
+resizeObserver.observe(canvas);
+
+const state = {
+ selectedVariantId,
+ isPlaying: true,
+ timeSeconds: 0,
+ lastFrameTimeMs: 0,
+ useDualQuaternions: false,
+ cameraPosition: d.vec4f(...INITIAL_CAMERA_POSITION),
+ cameraTarget: d.vec4f(0, 0, 0, 1),
+};
+
+let activeVariant = selectedVariant;
+let activeAnimation: Animation | undefined =
+ selectedVariant.id !== TWIST_DEMO_ID
+ ? modelData.animations.find((animation) => animation.name === selectedVariant.id)
+ : undefined;
+
+function toLabel(id: string) {
+ return id.replaceAll('_', ' ');
+}
+
+function fromLabel(label: string) {
+ return label.replaceAll(' ', '_');
+}
+
+function getRootJointPosition(): Float32Array {
+ mat4.getTranslation(CpuState.jointWorld[0], CpuState.rootJointPosition);
+ return CpuState.rootJointPosition;
+}
+
+function updateCameraTarget(position: Float32Array): d.v4f {
+ position[1] += CAMERA_TARGET_Y_OFFSET;
+ vec3.lerp(CpuState.smoothedTarget, position, CAMERA_TARGET_SMOOTHING, CpuState.smoothedTarget);
+ return d.vec4f(
+ CpuState.smoothedTarget[0],
+ CpuState.smoothedTarget[1],
+ CpuState.smoothedTarget[2],
+ 1,
+ );
+}
+
+function getAnimationById(id: string): Animation | undefined {
+ return id === TWIST_DEMO_ID
+ ? undefined
+ : modelData.animations.find((animation) => animation.name === id);
+}
+
+function computeWorldTransform(
+ nodeIndex: number,
+ animatedTransforms: NodeTransformState,
+): Float32Array {
+ if (CpuState.nodeWorldDirty[nodeIndex]) {
+ return CpuState.nodeWorld[nodeIndex];
+ }
+
+ const parentIndex = parentByNode[nodeIndex];
+ const parentWorld =
+ parentIndex === -1 ? undefined : computeWorldTransform(parentIndex, animatedTransforms);
+ const node = modelData.nodes[nodeIndex];
+ const animated = animatedTransforms[nodeIndex];
+
+ mat4.identity(CpuState.local);
+ if (animated.hasTranslation || node.translation) {
+ mat4.translate(
+ CpuState.local,
+ animated.hasTranslation ? animated.translation : (node.translation ?? [0, 0, 0]),
+ CpuState.local,
+ );
+ }
+ if (animated.hasRotation || node.rotation) {
+ mat4.mul(
+ CpuState.local,
+ mat4.fromQuat(
+ animated.hasRotation ? animated.rotation : (node.rotation ?? [0, 0, 0, 1]),
+ CpuState.quatMatrix,
+ ),
+ CpuState.local,
+ );
+ }
+ if (animated.hasScale || node.scale) {
+ mat4.scale(
+ CpuState.local,
+ animated.hasScale ? animated.scale : (node.scale ?? [1, 1, 1]),
+ CpuState.local,
+ );
+ }
+
+ const destination = CpuState.nodeWorld[nodeIndex];
+ if (parentWorld) {
+ mat4.mul(parentWorld, CpuState.local, destination);
+ } else {
+ destination.set(CpuState.local);
+ }
+
+ CpuState.nodeWorldDirty[nodeIndex] = 1;
+ return destination;
+}
+
+function writeJointDualQuat(jointIndex: number) {
+ const matrixOffset = jointIndex * 16;
+ mat4ToDualQuat(
+ CpuState.jointMatrices.subarray(matrixOffset, matrixOffset + 16),
+ CpuState.quatScratch,
+ CpuState.jointDualQuats,
+ jointIndex * 8,
+ );
+}
+
+function updateModelSkinning() {
+ const animatedTransforms = sampleAnimationInto(
+ activeAnimation,
+ state.timeSeconds,
+ CpuState.animatedTransforms,
+ CpuState.animatedTransformIndices,
+ );
+
+ CpuState.nodeWorldDirty.fill(0);
+ for (let jointIndex = 0; jointIndex < modelData.jointNodes.length; jointIndex++) {
+ const world = computeWorldTransform(modelData.jointNodes[jointIndex], animatedTransforms);
+ mat4.mul(modelTransform, world, CpuState.jointWorld[jointIndex]);
+
+ const matrixOffset = jointIndex * 16;
+ mat4.mul(
+ CpuState.jointWorld[jointIndex],
+ inverseBindViews[jointIndex],
+ CpuState.jointMatrices.subarray(matrixOffset, matrixOffset + 16),
+ );
+ writeJointDualQuat(jointIndex);
+ }
+
+ state.cameraTarget = updateCameraTarget(getRootJointPosition());
+}
+
+function updateTwistDemo() {
+ const twist = Math.sin(state.timeSeconds * 0.5) * Math.PI;
+
+ mat4.identity(CpuState.jointMatrices.subarray(0, 16));
+ CpuState.quatScratch.set([0, Math.sin(twist / 2), 0, Math.cos(twist / 2)]);
+ mat4.fromQuat(CpuState.quatScratch, CpuState.jointMatrices.subarray(16, 32));
+
+ writeJointDualQuat(0);
+ writeJointDualQuat(1);
+ for (let jointIndex = 2; jointIndex < modelData.jointNodes.length; jointIndex++) {
+ const matrixOffset = jointIndex * 16;
+ mat4.identity(CpuState.jointMatrices.subarray(matrixOffset, matrixOffset + 16));
+ CpuState.jointDualQuats.fill(0, jointIndex * 8, jointIndex * 8 + 8);
+ CpuState.jointDualQuats[jointIndex * 8 + 3] = 1;
+ }
+
+ state.cameraTarget = d.vec4f(0, 0, 0, 1);
+}
+
+function getInitialCameraTarget(): d.v4f {
+ if (activeVariant.id === TWIST_DEMO_ID) {
+ CpuState.smoothedTarget.fill(0);
+ return d.vec4f(0, 0, 0, 1);
+ }
+
+ updateModelSkinning();
+ const rootJointPosition = getRootJointPosition();
+ CpuState.smoothedTarget[0] = rootJointPosition[0];
+ CpuState.smoothedTarget[1] = rootJointPosition[1] + CAMERA_TARGET_Y_OFFSET;
+ CpuState.smoothedTarget[2] = rootJointPosition[2];
+ return d.vec4f(
+ CpuState.smoothedTarget[0],
+ CpuState.smoothedTarget[1],
+ CpuState.smoothedTarget[2],
+ 1,
+ );
+}
+
+function setActiveVariant(variant: SceneVariant) {
+ activeVariant = variant;
+ activeAnimation = getAnimationById(variant.id);
+
+ state.selectedVariantId = variant.id;
+ state.timeSeconds = 0;
+ sampleAnimationInto(
+ activeAnimation,
+ 0,
+ CpuState.animatedTransforms,
+ CpuState.animatedTransformIndices,
+ );
+ state.cameraTarget = getInitialCameraTarget();
+ targetCamera(state.cameraPosition, state.cameraTarget);
+}
+
+function drawFrame() {
+ jointMatricesUniform.write(CpuState.jointMatrices);
+ jointDualQuatsUniform.write(CpuState.jointDualQuats);
+
+ const renderMesh = activeVariant.id === TWIST_DEMO_ID ? demoRenderMesh : modelRenderMesh;
+ const pipeline = state.useDualQuaternions ? dqsPipeline : lbsPipeline;
+
+ pipeline
+ .with(vertexLayout, renderMesh.vertexBuffer)
+ .withIndexBuffer(renderMesh.indexBuffer)
+ .withColorAttachment({ resolveTarget: context, view: msaaTexture })
+ .withDepthStencilAttachment({
+ view: depthTexture,
+ depthClearValue: 1,
+ depthLoadOp: 'clear',
+ depthStoreOp: 'store',
+ })
+ .drawIndexed(renderMesh.indexCount);
+}
+
+state.cameraTarget = getInitialCameraTarget();
+
+const { cleanupCamera, targetCamera } = setupOrbitCamera(
+ canvas,
+ { initPos: state.cameraPosition, target: state.cameraTarget },
+ (camera) => {
+ if (camera.position) {
+ state.cameraPosition = camera.position;
+ cameraPositionUniform.write(camera.position);
+ }
+ if (camera.view && camera.projection) {
+ cameraUniform.write(mat4.mul(camera.projection, camera.view, CpuState.cameraMatrix));
+ }
+ },
+);
+
+function render(frameTimeMs: number) {
+ const deltaTimeMs = Math.max(0, frameTimeMs - state.lastFrameTimeMs);
+ state.lastFrameTimeMs = frameTimeMs;
+
+ if (state.isPlaying) {
+ state.timeSeconds += deltaTimeMs * 0.001;
+ }
+
+ if (activeVariant.id === TWIST_DEMO_ID) {
+ updateTwistDemo();
+ } else {
+ updateModelSkinning();
+ targetCamera(state.cameraPosition, state.cameraTarget);
+ }
+
+ drawFrame();
+ animationId = requestAnimationFrame(render);
+}
+
+let animationId: number | undefined;
+animationId = requestAnimationFrame(render);
+
+export const controls = defineControls({
+ Animation: {
+ initial: toLabel(selectedVariant.id),
+ options: variants.map((variant) => toLabel(variant.id)),
+ onSelectChange: (label: string) => {
+ const variant = variants.find((entry) => entry.id === fromLabel(label));
+ if (variant) {
+ setActiveVariant(variant);
+ }
+ },
+ },
+ 'Play Animation': {
+ initial: true,
+ onToggleChange: (value: boolean) => {
+ state.isPlaying = value;
+ },
+ },
+ 'Reset Animation': {
+ onButtonClick: () => {
+ state.timeSeconds = 0;
+ },
+ },
+ 'Dual Quaternion Skinning': {
+ initial: false,
+ onToggleChange: (value: boolean) => {
+ state.useDualQuaternions = value;
+ },
+ },
+});
+
+export function onCleanup() {
+ if (animationId !== undefined) {
+ cancelAnimationFrame(animationId);
+ }
+ resizeObserver.disconnect();
+ cleanupCamera();
+ root.destroy();
+}
diff --git a/apps/typegpu-docs/src/examples/simple/mesh-skinning/loader.ts b/apps/typegpu-docs/src/examples/simple/mesh-skinning/loader.ts
new file mode 100644
index 0000000000..81de091792
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/simple/mesh-skinning/loader.ts
@@ -0,0 +1,208 @@
+import { load } from '@loaders.gl/core';
+import { GLBLoader } from '@loaders.gl/gltf';
+import type { Animation, AnimationChannel, AnimationSampler, ModelData } from './types.ts';
+
+interface GLTFAnimationSampler {
+ input: number;
+ output: number;
+ interpolation?: string;
+}
+
+interface GLTFAnimationChannel {
+ sampler: number;
+ target: { node: number; path: 'translation' | 'rotation' | 'scale' };
+}
+
+interface GLTFAnimation {
+ name?: string;
+ samplers: GLTFAnimationSampler[];
+ channels: GLTFAnimationChannel[];
+}
+
+interface GLTFMaterial {
+ pbrMetallicRoughness?: {
+ baseColorFactor?: [number, number, number, number];
+ };
+}
+
+const GLTF_COMPONENT_TYPE = {
+ BYTE: 5120,
+ UNSIGNED_BYTE: 5121,
+ SHORT: 5122,
+ UNSIGNED_SHORT: 5123,
+ UNSIGNED_INT: 5125,
+ FLOAT: 5126,
+} as const;
+
+const COMPONENT_SIZES: Record = {
+ [GLTF_COMPONENT_TYPE.BYTE]: 1,
+ [GLTF_COMPONENT_TYPE.UNSIGNED_BYTE]: 1,
+ [GLTF_COMPONENT_TYPE.SHORT]: 2,
+ [GLTF_COMPONENT_TYPE.UNSIGNED_SHORT]: 2,
+ [GLTF_COMPONENT_TYPE.UNSIGNED_INT]: 4,
+ [GLTF_COMPONENT_TYPE.FLOAT]: 4,
+};
+
+const TYPE_COMPONENTS: Record = {
+ SCALAR: 1,
+ VEC2: 2,
+ VEC3: 3,
+ VEC4: 4,
+ MAT4: 16,
+};
+
+const TYPED_ARRAYS: Record<
+ number,
+ | typeof Int8Array
+ | typeof Uint8Array
+ | typeof Int16Array
+ | typeof Uint16Array
+ | typeof Uint32Array
+ | typeof Float32Array
+> = {
+ [GLTF_COMPONENT_TYPE.BYTE]: Int8Array,
+ [GLTF_COMPONENT_TYPE.UNSIGNED_BYTE]: Uint8Array,
+ [GLTF_COMPONENT_TYPE.SHORT]: Int16Array,
+ [GLTF_COMPONENT_TYPE.UNSIGNED_SHORT]: Uint16Array,
+ [GLTF_COMPONENT_TYPE.UNSIGNED_INT]: Uint32Array,
+ [GLTF_COMPONENT_TYPE.FLOAT]: Float32Array,
+};
+
+export async function loadGLBModel(path: string): Promise {
+ const model = await load(path, GLBLoader);
+ const { arrayBuffer, byteOffset, byteLength } = model.binChunks[0];
+ const binChunk = arrayBuffer.slice(byteOffset, byteOffset + byteLength);
+ const {
+ accessors,
+ bufferViews,
+ meshes,
+ skins,
+ nodes,
+ materials,
+ animations: rawAnims,
+ } = model.json;
+
+ const getTypedArray = (idx: number) => {
+ const acc = accessors[idx];
+ const view = bufferViews[acc.bufferView];
+ const offset = (view.byteOffset || 0) + (acc.byteOffset || 0);
+ const length = acc.count * TYPE_COMPONENTS[acc.type] * COMPONENT_SIZES[acc.componentType];
+ return new TYPED_ARRAYS[acc.componentType](binChunk.slice(offset, offset + length));
+ };
+
+ const primitiveData: {
+ pos: Float32Array;
+ norm: Float32Array;
+ materialId: number;
+ joints: Uint8Array | Uint16Array;
+ weights: Float32Array;
+ indices: Uint16Array;
+ }[] = [];
+ let totalVerts = 0;
+ let totalIndices = 0;
+
+ for (const mesh of meshes) {
+ for (const prim of mesh.primitives) {
+ const pos = getTypedArray(prim.attributes.POSITION) as Float32Array;
+ const norm = getTypedArray(prim.attributes.NORMAL) as Float32Array;
+ const joints = getTypedArray(prim.attributes.JOINTS_0) as Uint8Array | Uint16Array;
+ const weights = getTypedArray(prim.attributes.WEIGHTS_0) as Float32Array;
+ const indices = getTypedArray(prim.indices) as Uint16Array;
+ primitiveData.push({
+ pos,
+ norm,
+ materialId: prim.material ?? 0,
+ joints,
+ weights,
+ indices,
+ });
+ totalVerts += pos.length / 3;
+ totalIndices += indices.length;
+ }
+ }
+
+ const positions = new Float32Array(totalVerts * 3);
+ const normals = new Float32Array(totalVerts * 3);
+ const materialIds = new Uint32Array(totalVerts);
+ const joints = new Uint32Array(totalVerts * 4);
+ const weights = new Float32Array(totalVerts * 4);
+ const indices = new Uint16Array(totalIndices);
+
+ let vOff = 0;
+ let iOff = 0;
+ let base = 0;
+
+ for (const { pos, norm, materialId, joints: j, weights: w, indices: idx } of primitiveData) {
+ const count = pos.length / 3;
+ positions.set(pos, vOff * 3);
+ normals.set(norm, vOff * 3);
+ for (let v = 0; v < count; v++) {
+ materialIds[vOff + v] = materialId;
+ }
+ for (let v = 0; v < count; v++) {
+ for (let c = 0; c < 4; c++) {
+ joints[(vOff + v) * 4 + c] = j[v * 4 + c];
+ weights[(vOff + v) * 4 + c] = w[v * 4 + c];
+ }
+ }
+ for (let i = 0; i < idx.length; i++) {
+ indices[iOff + i] = idx[i] + base;
+ }
+ vOff += count;
+ iOff += idx.length;
+ base += count;
+ }
+
+ const skin = skins[0];
+ const inverseBindMatrices = getTypedArray(skin.inverseBindMatrices) as Float32Array;
+ const materialPalette = ((materials || []) as GLTFMaterial[]).map((material) => {
+ const baseColor = material.pbrMetallicRoughness?.baseColorFactor ?? [0.8, 0.8, 0.8, 1];
+ return [baseColor[0], baseColor[1], baseColor[2], baseColor[3]] as [
+ number,
+ number,
+ number,
+ number,
+ ];
+ });
+
+ const animations: Animation[] = ((rawAnims || []) as GLTFAnimation[]).map((anim) => {
+ let duration = 0;
+ const samplers: AnimationSampler[] = anim.samplers.map((s) => {
+ const input = getTypedArray(s.input) as Float32Array;
+ const output = getTypedArray(s.output) as Float32Array;
+
+ if (input.length > 0) {
+ duration = Math.max(duration, input[input.length - 1]);
+ }
+
+ return {
+ input,
+ output,
+ interpolation: (s.interpolation || 'LINEAR') as AnimationSampler['interpolation'],
+ };
+ });
+ const channels: AnimationChannel[] = anim.channels.map((c) => ({
+ samplerIndex: c.sampler,
+ targetNode: c.target.node,
+ targetPath: c.target.path,
+ }));
+ return { name: anim.name || 'Animation', duration, samplers, channels };
+ });
+
+ return {
+ positions,
+ normals,
+ materialIds,
+ joints,
+ weights,
+ indices,
+ indexCount: indices.length,
+ materials: materialPalette,
+ inverseBindMatrices,
+ nodes,
+ jointNodes: skin.joints,
+ vertexCount: totalVerts,
+ animations,
+ jointCount: skin.joints.length,
+ };
+}
diff --git a/apps/typegpu-docs/src/examples/simple/mesh-skinning/math.ts b/apps/typegpu-docs/src/examples/simple/mesh-skinning/math.ts
new file mode 100644
index 0000000000..598f2d4acc
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/simple/mesh-skinning/math.ts
@@ -0,0 +1,40 @@
+import { quat } from 'wgpu-matrix';
+
+/**
+ * Converts a 4x4 rigid transformation matrix to a dual quaternion (8 floats).
+ * Layout: [real.x, real.y, real.z, real.w, dual.x, dual.y, dual.z, dual.w]
+ *
+ * @param m - 4x4 column-major matrix (Float32Array of length 16)
+ * @param rotDst - scratch Float32Array(4) for quaternion extraction (avoids allocation)
+ * @param dst - destination buffer, written at `offset`
+ * @param offset - byte offset into `dst` (in float indices)
+ */
+export const mat4ToDualQuat = (
+ m: Float32Array,
+ rotDst: Float32Array,
+ dst: Float32Array,
+ offset: number,
+): void => {
+ // Extract rotation quaternion q0 = (x, y, z, w)
+ quat.fromMat(m, rotDst);
+ const q0x = rotDst[0];
+ const q0y = rotDst[1];
+ const q0z = rotDst[2];
+ const q0w = rotDst[3];
+
+ // Extract translation
+ const tx = m[12];
+ const ty = m[13];
+ const tz = m[14];
+
+ // Dual part: qe = 0.5 * pure(t) * q0
+ // Hamilton product of (tx, ty, tz, 0) * (q0x, q0y, q0z, q0w):
+ dst[offset] = q0x;
+ dst[offset + 1] = q0y;
+ dst[offset + 2] = q0z;
+ dst[offset + 3] = q0w;
+ dst[offset + 4] = 0.5 * (tx * q0w + ty * q0z - tz * q0y);
+ dst[offset + 5] = 0.5 * (-tx * q0z + ty * q0w + tz * q0x);
+ dst[offset + 6] = 0.5 * (tx * q0y - ty * q0x + tz * q0w);
+ dst[offset + 7] = 0.5 * (-tx * q0x - ty * q0y - tz * q0z);
+};
diff --git a/apps/typegpu-docs/src/examples/simple/mesh-skinning/meta.json b/apps/typegpu-docs/src/examples/simple/mesh-skinning/meta.json
new file mode 100644
index 0000000000..f2b74013f7
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/simple/mesh-skinning/meta.json
@@ -0,0 +1,5 @@
+{
+ "title": "Mesh Skinning",
+ "category": "simple",
+ "tags": ["rendering", "experimental"]
+}
diff --git a/apps/typegpu-docs/src/examples/simple/mesh-skinning/thumbnail.png b/apps/typegpu-docs/src/examples/simple/mesh-skinning/thumbnail.png
new file mode 100644
index 0000000000..00b678b0fc
Binary files /dev/null and b/apps/typegpu-docs/src/examples/simple/mesh-skinning/thumbnail.png differ
diff --git a/apps/typegpu-docs/src/examples/simple/mesh-skinning/tube.ts b/apps/typegpu-docs/src/examples/simple/mesh-skinning/tube.ts
new file mode 100644
index 0000000000..21f61026bb
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/simple/mesh-skinning/tube.ts
@@ -0,0 +1,36 @@
+export function generateTube(rings: number, segs: number, radius: number, height: number) {
+ const vertexCount = rings * segs;
+ const positions = new Float32Array(vertexCount * 3);
+ const normals = new Float32Array(vertexCount * 3);
+ const materialIds = new Uint32Array(vertexCount);
+ const joints = new Uint32Array(vertexCount * 4);
+ const weights = new Float32Array(vertexCount * 4);
+
+ for (let r = 0; r < rings; r++) {
+ const y = (r / (rings - 1)) * height - height / 2;
+ const blend = r / (rings - 1);
+ for (let s = 0; s < segs; s++) {
+ const a = (s / segs) * Math.PI * 2;
+ const i = r * segs + s;
+ positions.set([Math.cos(a) * radius, y, Math.sin(a) * radius], i * 3);
+ normals.set([Math.cos(a), 0, Math.sin(a)], i * 3);
+ materialIds[i] = 0;
+ joints.set([0, 1, 0, 0], i * 4);
+ weights.set([1 - blend, blend, 0, 0], i * 4);
+ }
+ }
+
+ const indexCount = (rings - 1) * segs * 6;
+ const indices = new Uint16Array(indexCount);
+ let idx = 0;
+ for (let r = 0; r < rings - 1; r++) {
+ for (let s = 0; s < segs; s++) {
+ const c = r * segs + s;
+ const n = r * segs + ((s + 1) % segs);
+ indices.set([c, c + segs, n, n, c + segs, n + segs], idx);
+ idx += 6;
+ }
+ }
+
+ return { positions, normals, materialIds, joints, weights, indices, vertexCount, indexCount };
+}
diff --git a/apps/typegpu-docs/src/examples/simple/mesh-skinning/types.ts b/apps/typegpu-docs/src/examples/simple/mesh-skinning/types.ts
new file mode 100644
index 0000000000..3c12ab3654
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/simple/mesh-skinning/types.ts
@@ -0,0 +1,66 @@
+import { d } from 'typegpu';
+
+export type Vec3 = [number, number, number];
+export type Quat = [number, number, number, number];
+
+export const VertexData = d.struct({
+ position: d.vec3f,
+ normal: d.vec3f,
+ materialId: d.u32,
+ joint: d.vec4u,
+ weight: d.vec4f,
+});
+
+export interface GLTFNode {
+ name?: string;
+ translation?: Vec3;
+ rotation?: Quat;
+ scale?: Vec3;
+ children?: number[];
+ mesh?: number;
+ skin?: number;
+}
+
+export interface AnimationSampler {
+ input: Float32Array;
+ output: Float32Array;
+ interpolation: 'LINEAR' | 'STEP' | 'CUBICSPLINE';
+}
+
+export interface AnimationChannel {
+ samplerIndex: number;
+ targetNode: number;
+ targetPath: 'translation' | 'rotation' | 'scale';
+}
+
+export interface Animation {
+ name: string;
+ duration: number;
+ samplers: AnimationSampler[];
+ channels: AnimationChannel[];
+}
+
+export interface MeshData {
+ positions: Float32Array;
+ normals: Float32Array;
+ materialIds: Uint32Array;
+ joints: Uint32Array;
+ weights: Float32Array;
+ indices: Uint16Array;
+ vertexCount: number;
+ indexCount: number;
+}
+
+export interface ModelData extends MeshData {
+ materials: [number, number, number, number][];
+ inverseBindMatrices: Float32Array;
+ nodes: GLTFNode[];
+ jointNodes: number[];
+ animations: Animation[];
+ jointCount: number;
+}
+
+export interface SceneVariant {
+ id: string;
+ mesh: MeshData;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 59e7245db3..3df7fc23ad 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -205,6 +205,9 @@ importers:
'@loaders.gl/core':
specifier: ^4.3.4
version: 4.3.4
+ '@loaders.gl/gltf':
+ specifier: ^4.3.4
+ version: 4.3.4(@loaders.gl/core@4.3.4)
'@loaders.gl/obj':
specifier: ^4.3.4
version: 4.3.4(@loaders.gl/core@4.3.4)
@@ -1940,6 +1943,21 @@ packages:
'@loaders.gl/core@4.3.4':
resolution: {integrity: sha512-cG0C5fMZ1jyW6WCsf4LoHGvaIAJCEVA/ioqKoYRwoSfXkOf+17KupK1OUQyUCw5XoRn+oWA1FulJQOYlXnb9Gw==}
+ '@loaders.gl/draco@4.3.4':
+ resolution: {integrity: sha512-4Lx0rKmYENGspvcgV5XDpFD9o+NamXoazSSl9Oa3pjVVjo+HJuzCgrxTQYD/3JvRrolW/QRehZeWD/L/cEC6mw==}
+ peerDependencies:
+ '@loaders.gl/core': ^4.3.0
+
+ '@loaders.gl/gltf@4.3.4':
+ resolution: {integrity: sha512-EiUTiLGMfukLd9W98wMpKmw+hVRhQ0dJ37wdlXK98XPeGGB+zTQxCcQY+/BaMhsSpYt/OOJleHhTfwNr8RgzRg==}
+ peerDependencies:
+ '@loaders.gl/core': ^4.3.0
+
+ '@loaders.gl/images@4.3.4':
+ resolution: {integrity: sha512-qgc33BaNsqN9cWa/xvcGvQ50wGDONgQQdzHCKDDKhV2w/uptZoR5iofJfuG8UUV2vUMMd82Uk9zbopRx2rS4Ag==}
+ peerDependencies:
+ '@loaders.gl/core': ^4.3.0
+
'@loaders.gl/loader-utils@4.3.4':
resolution: {integrity: sha512-tjMZvlKQSaMl2qmYTAxg+ySR6zd6hQn5n3XaU8+Ehp90TD3WzxvDKOMNDqOa72fFmIV+KgPhcmIJTpq4lAdC4Q==}
peerDependencies:
@@ -1955,11 +1973,22 @@ packages:
peerDependencies:
'@loaders.gl/core': ^4.3.0
+ '@loaders.gl/textures@4.3.4':
+ resolution: {integrity: sha512-arWIDjlE7JaDS6v9by7juLfxPGGnjT9JjleaXx3wq/PTp+psLOpGUywHXm38BNECos3MFEQK3/GFShWI+/dWPw==}
+ peerDependencies:
+ '@loaders.gl/core': ^4.3.0
+
'@loaders.gl/worker-utils@4.3.4':
resolution: {integrity: sha512-EbsszrASgT85GH3B7jkx7YXfQyIYo/rlobwMx6V3ewETapPUwdSAInv+89flnk5n2eu2Lpdeh+2zS6PvqbL2RA==}
peerDependencies:
'@loaders.gl/core': ^4.3.0
+ '@math.gl/core@4.1.0':
+ resolution: {integrity: sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==}
+
+ '@math.gl/types@4.1.0':
+ resolution: {integrity: sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==}
+
'@mdx-js/mdx@3.1.1':
resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==}
@@ -4002,6 +4031,9 @@ packages:
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
+ argparse@1.0.10:
+ resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
+
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -4686,6 +4718,9 @@ packages:
resolution: {integrity: sha512-YJzsFSyEtj88q5eTELg3UWU7TVZkG1dpbF4JDQ3t1b07xuzXmdoGeSz9TKOke1mUuOpWlk4q+pBh+aHzD6GBTg==}
hasBin: true
+ draco3d@1.5.7:
+ resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
+
dset@3.1.4:
resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==}
engines: {node: '>=4'}
@@ -5104,13 +5139,11 @@ packages:
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
- deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@11.1.0:
resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
engines: {node: 20 || >=22}
- deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
globals@14.0.0:
@@ -5255,6 +5288,11 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
+ image-size@0.7.5:
+ resolution: {integrity: sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g==}
+ engines: {node: '>=6.9.0'}
+ hasBin: true
+
imagetools-core@9.0.0:
resolution: {integrity: sha512-LAU2iVl6MuLbARLrZFEOrgqUFGmHij0FqqOR1/mMndUzJoPz2BU4gCXUhjikgwwmfhBPa/1szwiliUy//ZWafw==}
engines: {node: '>=20.0.0'}
@@ -5547,6 +5585,9 @@ packages:
knitwork@1.2.0:
resolution: {integrity: sha512-xYSH7AvuQ6nXkq42x0v5S8/Iry+cfulBz/DJQzhIyESdLD7425jXsPy4vn5cCXU+HhRN2kVw51Vd1K6/By4BQg==}
+ ktx-parse@0.7.1:
+ resolution: {integrity: sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==}
+
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -5833,7 +5874,6 @@ packages:
mathjax-full@3.2.2:
resolution: {integrity: sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==}
- deprecated: Version 4 replaces this package with the scoped package @mathjax/src
mdast-util-definitions@6.0.0:
resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==}
@@ -7131,6 +7171,9 @@ packages:
split2@3.2.2:
resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==}
+ sprintf-js@1.0.3:
+ resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
+
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -7307,6 +7350,10 @@ packages:
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
engines: {node: '>=18'}
+ texture-compressor@1.0.2:
+ resolution: {integrity: sha512-dStVgoaQ11mA5htJ+RzZ51ZxIZqNOgWKAIvtjLrW1AliQQLCmrDqNzQZ8Jh91YealQ95DXt4MEduLzJmbs6lig==}
+ hasBin: true
+
three@0.181.2:
resolution: {integrity: sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==}
@@ -8128,7 +8175,6 @@ packages:
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
- deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-mimetype@4.0.0:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
@@ -9342,6 +9388,29 @@ snapshots:
'@loaders.gl/worker-utils': 4.3.4(@loaders.gl/core@4.3.4)
'@probe.gl/log': 4.1.0
+ '@loaders.gl/draco@4.3.4(@loaders.gl/core@4.3.4)':
+ dependencies:
+ '@loaders.gl/core': 4.3.4
+ '@loaders.gl/loader-utils': 4.3.4(@loaders.gl/core@4.3.4)
+ '@loaders.gl/schema': 4.3.4(@loaders.gl/core@4.3.4)
+ '@loaders.gl/worker-utils': 4.3.4(@loaders.gl/core@4.3.4)
+ draco3d: 1.5.7
+
+ '@loaders.gl/gltf@4.3.4(@loaders.gl/core@4.3.4)':
+ dependencies:
+ '@loaders.gl/core': 4.3.4
+ '@loaders.gl/draco': 4.3.4(@loaders.gl/core@4.3.4)
+ '@loaders.gl/images': 4.3.4(@loaders.gl/core@4.3.4)
+ '@loaders.gl/loader-utils': 4.3.4(@loaders.gl/core@4.3.4)
+ '@loaders.gl/schema': 4.3.4(@loaders.gl/core@4.3.4)
+ '@loaders.gl/textures': 4.3.4(@loaders.gl/core@4.3.4)
+ '@math.gl/core': 4.1.0
+
+ '@loaders.gl/images@4.3.4(@loaders.gl/core@4.3.4)':
+ dependencies:
+ '@loaders.gl/core': 4.3.4
+ '@loaders.gl/loader-utils': 4.3.4(@loaders.gl/core@4.3.4)
+
'@loaders.gl/loader-utils@4.3.4(@loaders.gl/core@4.3.4)':
dependencies:
'@loaders.gl/core': 4.3.4
@@ -9361,10 +9430,27 @@ snapshots:
'@loaders.gl/core': 4.3.4
'@types/geojson': 7946.0.16
+ '@loaders.gl/textures@4.3.4(@loaders.gl/core@4.3.4)':
+ dependencies:
+ '@loaders.gl/core': 4.3.4
+ '@loaders.gl/images': 4.3.4(@loaders.gl/core@4.3.4)
+ '@loaders.gl/loader-utils': 4.3.4(@loaders.gl/core@4.3.4)
+ '@loaders.gl/schema': 4.3.4(@loaders.gl/core@4.3.4)
+ '@loaders.gl/worker-utils': 4.3.4(@loaders.gl/core@4.3.4)
+ '@math.gl/types': 4.1.0
+ ktx-parse: 0.7.1
+ texture-compressor: 1.0.2
+
'@loaders.gl/worker-utils@4.3.4(@loaders.gl/core@4.3.4)':
dependencies:
'@loaders.gl/core': 4.3.4
+ '@math.gl/core@4.1.0':
+ dependencies:
+ '@math.gl/types': 4.1.0
+
+ '@math.gl/types@4.1.0': {}
+
'@mdx-js/mdx@3.1.1':
dependencies:
'@types/estree': 1.0.8
@@ -11218,6 +11304,10 @@ snapshots:
arg@5.0.2: {}
+ argparse@1.0.10:
+ dependencies:
+ sprintf-js: 1.0.3
+
argparse@2.0.1: {}
aria-hidden@1.2.6:
@@ -12012,6 +12102,8 @@ snapshots:
typescript: tsover@5.9.11
yargs: 17.7.2
+ draco3d@1.5.7: {}
+
dset@3.1.4: {}
dts-resolver@2.1.3: {}
@@ -12797,6 +12889,8 @@ snapshots:
ignore@5.3.2: {}
+ image-size@0.7.5: {}
+
imagetools-core@9.0.0: {}
import-fresh@3.3.1:
@@ -13046,6 +13140,8 @@ snapshots:
knitwork@1.2.0: {}
+ ktx-parse@0.7.1: {}
+
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -15165,6 +15261,8 @@ snapshots:
dependencies:
readable-stream: 3.6.2
+ sprintf-js@1.0.3: {}
+
stackback@0.0.2: {}
starlight-blog@0.26.1(@astrojs/starlight@0.38.1(astro@6.0.8(@types/node@24.10.0)(jiti@2.6.1)(rollup@4.60.1)(terser@5.44.1)(tsover@5.9.11)(tsx@4.20.6)(yaml@2.8.3)))(astro@6.0.8(@types/node@24.10.0)(jiti@2.6.1)(rollup@4.60.1)(terser@5.44.1)(tsover@5.9.11)(tsx@4.20.6)(yaml@2.8.3)):
@@ -15360,6 +15458,11 @@ snapshots:
glob: 10.5.0
minimatch: 9.0.5
+ texture-compressor@1.0.2:
+ dependencies:
+ argparse: 1.0.10
+ image-size: 0.7.5
+
three@0.181.2: {}
through2@4.0.2: