diff --git a/apps/typegpu-docs/package.json b/apps/typegpu-docs/package.json index 17abaa8fa8..b642bbc614 100644 --- a/apps/typegpu-docs/package.json +++ b/apps/typegpu-docs/package.json @@ -18,6 +18,7 @@ "@astrojs/tailwind": "^6.0.2", "@babel/standalone": "^7.27.0", "@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/DancingBot.glb b/apps/typegpu-docs/public/assets/mesh-skinning/DancingBot.glb new file mode 100644 index 0000000000..cbf9f48c1a Binary files /dev/null and b/apps/typegpu-docs/public/assets/mesh-skinning/DancingBot.glb differ diff --git a/apps/typegpu-docs/public/assets/mesh-skinning/LongBoi.glb b/apps/typegpu-docs/public/assets/mesh-skinning/LongBoi.glb new file mode 100644 index 0000000000..4efdc233e4 Binary files /dev/null and b/apps/typegpu-docs/public/assets/mesh-skinning/LongBoi.glb differ diff --git a/apps/typegpu-docs/src/components/stackblitz/openInStackBlitz.ts b/apps/typegpu-docs/src/components/stackblitz/openInStackBlitz.ts index f2601780c7..2ad91f5a3c 100644 --- a/apps/typegpu-docs/src/components/stackblitz/openInStackBlitz.ts +++ b/apps/typegpu-docs/src/components/stackblitz/openInStackBlitz.ts @@ -127,6 +127,9 @@ ${example.htmlFile.content} "@loaders.gl/obj": "${ typegpuDocsPackageJson.dependencies['@loaders.gl/obj'] }", + "@loaders.gl/gltf": "${ + typegpuDocsPackageJson.dependencies['@loaders.gl/gltf'] + }", "@typegpu/noise": "${typegpuNoisePackageJson.version}", "@typegpu/color": "${typegpuColorPackageJson.version}", "@typegpu/sdf": "${typegpuSdfPackageJson.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..2966fcb837 --- /dev/null +++ b/apps/typegpu-docs/src/examples/simple/mesh-skinning/animation.ts @@ -0,0 +1,59 @@ +import type { Animation } from './types.ts'; +import { lerp, type Quat, slerp, type Vec3 } from './math.ts'; + +export type NodeTransform = { + translation?: Vec3; + rotation?: Quat; + scale?: Vec3; +}; + +export const sampleAnimation = ( + animation: Animation, + time: number, +): Map => { + const transforms = new Map(); + 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 i = 0; + while (i < times.length - 2 && loopedTime >= times[i + 1]) { + i++; + } + + const t0 = times[i]; + const t1 = times[i + 1]; + const alpha = t1 > t0 + ? Math.max(0, Math.min(1, (loopedTime - t0) / (t1 - t0))) + : 0; + + const start = i * components; + const end = (i + 1) * components; + const v0 = Array.from(values.slice(start, start + components)); + const v1 = Array.from(values.slice(end, end + components)); + + const result = channel.targetPath === 'rotation' + ? slerp(v0 as Quat, v1 as Quat, alpha) + : lerp(v0, v1, alpha); + + if (!transforms.has(channel.targetNode)) { + transforms.set(channel.targetNode, {}); + } + const t = transforms.get(channel.targetNode); + if (!t) { + continue; + } + if (channel.targetPath === 'rotation') { + t.rotation = result as Quat; + } else if (channel.targetPath === 'translation') { + t.translation = result as Vec3; + } else { + t.scale = result as Vec3; + } + } + + 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..aa8cc321b3 --- /dev/null +++ b/apps/typegpu-docs/src/examples/simple/mesh-skinning/index.html @@ -0,0 +1 @@ + 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..a15c7fb80b --- /dev/null +++ b/apps/typegpu-docs/src/examples/simple/mesh-skinning/index.ts @@ -0,0 +1,368 @@ +import tgpu from 'typegpu'; +import * as d from 'typegpu/data'; +import * as std from 'typegpu/std'; +import { mat4 } from 'wgpu-matrix'; +import { loadGLBModel } from './loader.ts'; +import { type NodeTransform, sampleAnimation } from './animation.ts'; +import { type Quat, quatFromAxisAngle, quatMul } from './math.ts'; +import type { ModelData } from './types.ts'; +import { VertexData } from './types.ts'; +import { setupOrbitCamera } from './setup-orbit-camera.ts'; + +const MODELS: Record< + string, + { path: string; scale: number; offset: [number, number, number] } +> = { + LongBoi: { + path: '/TypeGPU/assets/mesh-skinning/LongBoi.glb', + scale: 1, + offset: [0, 0, 0], + }, + DancingBot: { + path: '/TypeGPU/assets/mesh-skinning/DancingBot.glb', + scale: 8, + offset: [0, -8, 0], + }, +}; +type ModelName = keyof typeof MODELS; + +const MAX_JOINTS = 64; + +let currentModelName: ModelName = 'LongBoi'; +let modelData: ModelData = await loadGLBModel(MODELS[currentModelName].path); +let twistEnabled = false; +let bendEnabled = false; +let animationPlaying = true; +let bendTime = 0; +let twistTime = 0; +let animationTime = 0; +let lastFrameTime = 0; + +const root = await tgpu.init(); +const canvas = document.querySelector('canvas') as HTMLCanvasElement; +const context = canvas.getContext('webgpu') as GPUCanvasContext; +const presentationFormat = navigator.gpu.getPreferredCanvasFormat(); + +context.configure({ + device: root.device, + format: presentationFormat, + alphaMode: 'premultiplied', +}); + +let depthTexture = root['~unstable'].createTexture({ + size: [canvas.width, canvas.height], + format: 'depth24plus', + sampleCount: 4, +}).$usage('render'); +let msaaTexture = root['~unstable'].createTexture({ + size: [canvas.width, canvas.height], + format: presentationFormat, + sampleCount: 4, +}).$usage('render'); + +const cameraUniform = root.createUniform(d.mat4x4f); +let viewMatrix = d.mat4x4f(); +let projectionMatrix = d.mat4x4f(); + +const { cleanupCamera } = setupOrbitCamera( + canvas, + { initPos: d.vec4f(15, 15, 15, 1) }, + (camera) => { + if (camera.view) { + viewMatrix = camera.view; + } + if (camera.projection) { + projectionMatrix = camera.projection; + } + cameraUniform.write(mat4.mul(projectionMatrix, viewMatrix, d.mat4x4f())); + }, +); + +const longBoiAnimation = (nodeIndex: number): Quat | null => { + if (nodeIndex !== 0) { + return null; + } + const bendQuat = quatFromAxisAngle( + [0, 0, 1], + Math.sin(bendTime * 0.001) * Math.PI * 0.5, + ); + const twistQuat = quatFromAxisAngle( + [0, 1, 0], + Math.sin(twistTime * 0.0015) * Math.PI * 0.3, + ); + return quatMul(twistQuat, bendQuat); +}; + +const getWorldTransform = ( + nodeIndex: number, + parentTransform?: Float32Array, + animatedTransforms?: Map, + useLongBoi?: boolean, +): Float32Array => { + const node = modelData.nodes[nodeIndex]; + const anim = animatedTransforms?.get(nodeIndex); + const localMatrix = mat4.identity(); + + const translation = anim?.translation ?? node.translation; + const scale = anim?.scale ?? node.scale; + let rotation = anim?.rotation ?? node.rotation; + + if (useLongBoi) { + const animRot = longBoiAnimation(nodeIndex); + if (animRot) { + rotation = animRot; + } + } + + if (translation) { + mat4.translate(localMatrix, translation, localMatrix); + } + if (rotation) { + mat4.mul(localMatrix, mat4.fromQuat(rotation), localMatrix); + } + if (scale) { + mat4.scale(localMatrix, scale, localMatrix); + } + + if (parentTransform) { + return mat4.mul(parentTransform, localMatrix); + } + + for (let i = 0; i < modelData.nodes.length; i++) { + if (modelData.nodes[i].children?.includes(nodeIndex)) { + return mat4.mul( + getWorldTransform(i, undefined, animatedTransforms, useLongBoi), + localMatrix, + ); + } + } + + return localMatrix; +}; + +const getJointMatrices = (): d.m4x4f[] => { + const useLongBoi = currentModelName === 'LongBoi' && + (twistEnabled || bendEnabled); + const animTransforms = + currentModelName === 'DancingBot' && modelData.animations.length > 0 + ? sampleAnimation(modelData.animations[0], animationTime) + : undefined; + + const { scale, offset } = MODELS[currentModelName]; + const modelTransform = mat4.identity(); + mat4.translate(modelTransform, offset, modelTransform); + mat4.scale(modelTransform, [scale, scale, scale], modelTransform); + + const matrices = modelData.jointNodes.map((jointNode: number, i: number) => { + const world = getWorldTransform( + jointNode, + undefined, + animTransforms, + useLongBoi, + ); + const invBind = modelData.inverseBindMatrices.slice(i * 16, (i + 1) * 16); + const jointMatrix = mat4.mul(world, invBind, d.mat4x4f()); + return mat4.mul(modelTransform, jointMatrix, d.mat4x4f()); + }); + + while (matrices.length < MAX_JOINTS) { + matrices.push(d.mat4x4f.identity()); + } + return matrices; +}; + +const createVertexData = (): d.Infer[] => + Array.from({ length: modelData.vertexCount }, (_, i) => ({ + position: d.vec3f( + modelData.positions[i * 3], + modelData.positions[i * 3 + 1], + modelData.positions[i * 3 + 2], + ), + normal: d.vec3f( + modelData.normals[i * 3], + modelData.normals[i * 3 + 1], + modelData.normals[i * 3 + 2], + ), + joint: d.vec4u( + modelData.joints[i * 4], + modelData.joints[i * 4 + 1], + modelData.joints[i * 4 + 2], + modelData.joints[i * 4 + 3], + ), + weight: d.vec4f( + modelData.weights[i * 4], + modelData.weights[i * 4 + 1], + modelData.weights[i * 4 + 2], + modelData.weights[i * 4 + 3], + ), + })); + +let vertexBuffer = root.createBuffer( + d.arrayOf(VertexData, modelData.vertexCount), + createVertexData(), +).$usage('vertex'); +let indexBuffer = root.createBuffer( + d.arrayOf(d.u16, modelData.indices.length), + Array.from(modelData.indices) as number[], +).$usage('index'); +let currentIndexCount = modelData.indices.length; + +const jointMatricesUniform = root.createUniform( + d.arrayOf(d.mat4x4f, MAX_JOINTS), + getJointMatrices(), +); +const vertexLayout = tgpu.vertexLayout(d.arrayOf(VertexData)); + +const vertex = tgpu['~unstable'].vertexFn({ + in: { position: d.vec3f, normal: d.vec3f, joint: d.vec4u, weight: d.vec4f }, + out: { pos: d.builtin.position, normal: d.vec3f }, +})(({ position, normal, joint, weight }) => { + const jm = jointMatricesUniform.$; + const skinMatrix = jm[joint.x].mul(weight.x) + .add(jm[joint.y].mul(weight.y)) + .add(jm[joint.z].mul(weight.z)) + .add(jm[joint.w].mul(weight.w)); + + return { + pos: cameraUniform.$.mul(skinMatrix.mul(d.vec4f(position, 1))), + normal: std.normalize(skinMatrix.mul(d.vec4f(normal, 0)).xyz), + }; +}); + +const fragment = tgpu['~unstable'].fragmentFn({ + in: { normal: d.vec3f }, + out: d.vec4f, +})(({ normal }) => { + const diffuse = std.saturate( + std.dot(normal, std.normalize(d.vec3f(1, 0, 1))), + ); + return d.vec4f(d.vec3f(0.8).mul(diffuse * 0.7 + 0.3), 1.0); +}); + +const pipeline = root['~unstable'] + .withVertex(vertex, vertexLayout.attrib) + .withFragment(fragment, { format: presentationFormat }) + .withDepthStencil({ + format: 'depth24plus', + depthWriteEnabled: true, + depthCompare: 'less', + }) + .withMultisample({ count: 4 }) + .createPipeline() + .withIndexBuffer(indexBuffer); + +const resizeObserver = new ResizeObserver(() => { + depthTexture = root['~unstable'].createTexture({ + size: [canvas.width, canvas.height], + format: 'depth24plus', + sampleCount: 4, + }).$usage('render'); + msaaTexture = root['~unstable'].createTexture({ + size: [canvas.width, canvas.height], + format: presentationFormat, + sampleCount: 4, + }).$usage('render'); +}); +resizeObserver.observe(canvas); + +async function switchModel(name: ModelName) { + if (name === currentModelName) { + return; + } + currentModelName = name; + modelData = await loadGLBModel(MODELS[name].path); + vertexBuffer = root.createBuffer( + d.arrayOf(VertexData, modelData.vertexCount), + createVertexData(), + ).$usage('vertex'); + indexBuffer = root.createBuffer( + d.arrayOf(d.u16, modelData.indices.length), + Array.from(modelData.indices), + ).$usage('index'); + currentIndexCount = modelData.indices.length; + animationTime = 0; +} + +function render(time: number) { + const deltaTime = Math.max(0, time - lastFrameTime); + lastFrameTime = time; + + if (currentModelName === 'LongBoi') { + if (bendEnabled) { + bendTime += deltaTime; + } + if (twistEnabled) { + twistTime += deltaTime; + } + } else if (animationPlaying) { + animationTime += deltaTime * 0.001; + } + + jointMatricesUniform.write(getJointMatrices()); + + pipeline + .with(vertexLayout, vertexBuffer) + .withIndexBuffer(indexBuffer) + .withColorAttachment({ + resolveTarget: context.getCurrentTexture().createView(), + view: root.unwrap(msaaTexture).createView(), + loadOp: 'clear', + storeOp: 'store', + }) + .withDepthStencilAttachment({ + view: depthTexture, + depthClearValue: 1.0, + depthLoadOp: 'clear', + depthStoreOp: 'store', + }) + .drawIndexed(currentIndexCount); + + animationId = requestAnimationFrame(render); +} + +let animationId: number | undefined; +animationId = requestAnimationFrame(render); + +export const controls = { + Model: { + initial: 'LongBoi', + options: Object.keys(MODELS), + onSelectChange: async (v: string) => { + await switchModel(v as ModelName); + }, + }, + Twist: { + initial: true, + onToggleChange: (v: boolean) => { + twistEnabled = v; + }, + }, + Bend: { + initial: true, + onToggleChange: (v: boolean) => { + bendEnabled = v; + }, + }, + 'Play Animation': { + initial: true, + onToggleChange: (v: boolean) => { + animationPlaying = v; + }, + }, + 'Reset Animation': { + onButtonClick: () => { + animationTime = 0; + bendTime = 0; + twistTime = 0; + }, + }, +}; + +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..30c45b3f0f --- /dev/null +++ b/apps/typegpu-docs/src/examples/simple/mesh-skinning/loader.ts @@ -0,0 +1,171 @@ +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[]; +} + +const COMPONENT_SIZES: Record = { + 5120: 1, + 5121: 1, + 5122: 2, + 5123: 2, + 5125: 4, + 5126: 4, +}; + +const TYPE_COMPONENTS: Record = { + SCALAR: 1, + VEC2: 2, + VEC3: 3, + VEC4: 4, + MAT4: 16, +}; + +const TYPED_ARRAYS: Record< + number, + | typeof Uint8Array + | typeof Uint16Array + | typeof Uint32Array + | typeof Float32Array +> = { + 5121: Uint8Array, + 5123: Uint16Array, + 5125: Uint32Array, + 5126: 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, 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; + 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, joints, weights, indices }); + totalVerts += pos.length / 3; + totalIndices += indices.length; + } + } + + const positions = new Float32Array(totalVerts * 3); + const normals = new Float32Array(totalVerts * 3); + 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, 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++) { + 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 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; + 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, + joints, + weights, + indices, + 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..2e01b46653 --- /dev/null +++ b/apps/typegpu-docs/src/examples/simple/mesh-skinning/math.ts @@ -0,0 +1,49 @@ +export type Quat = [number, number, number, number]; +export type Vec3 = [number, number, number]; + +export const quatFromAxisAngle = (axis: Vec3, angle: number): Quat => { + const s = Math.sin(angle / 2); + return [axis[0] * s, axis[1] * s, axis[2] * s, Math.cos(angle / 2)]; +}; + +export const quatMul = (a: Quat, b: Quat): Quat => [ + a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], + a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], + a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], + a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2], +]; + +export const slerp = (q0: Quat, q1: Quat, t: number): Quat => { + let dot = q0[0] * q1[0] + q0[1] * q1[1] + q0[2] * q1[2] + q0[3] * q1[3]; + const q1a: Quat = dot < 0 ? [-q1[0], -q1[1], -q1[2], -q1[3]] : [...q1]; + dot = Math.abs(dot); + + if (dot > 0.9995) { + { + const r: Quat = [ + q0[0] + t * (q1a[0] - q0[0]), + q0[1] + t * (q1a[1] - q0[1]), + q0[2] + t * (q1a[2] - q0[2]), + q0[3] + t * (q1a[3] - q0[3]), + ]; + const len = Math.hypot(...r); + return [r[0] / len, r[1] / len, r[2] / len, r[3] / len]; + } + } + + const theta0 = Math.acos(dot); + const sinTheta0 = Math.sin(theta0); + const theta = theta0 * t; + const s0 = Math.cos(theta) - (dot * Math.sin(theta)) / sinTheta0; + const s1 = Math.sin(theta) / sinTheta0; + + return [ + s0 * q0[0] + s1 * q1a[0], + s0 * q0[1] + s1 * q1a[1], + s0 * q0[2] + s1 * q1a[2], + s0 * q0[3] + s1 * q1a[3], + ]; +}; + +export const lerp = (a: number[], b: number[], t: number): number[] => + a.map((v, i) => v + (b[i] - v) * t); 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/setup-orbit-camera.ts b/apps/typegpu-docs/src/examples/simple/mesh-skinning/setup-orbit-camera.ts new file mode 100644 index 0000000000..b1dbe74493 --- /dev/null +++ b/apps/typegpu-docs/src/examples/simple/mesh-skinning/setup-orbit-camera.ts @@ -0,0 +1,239 @@ +import * as m from 'wgpu-matrix'; +import * as d from 'typegpu/data'; +import * as std from 'typegpu/std'; + +export const Camera = d.struct({ + position: d.vec4f, + targetPos: d.vec4f, + view: d.mat4x4f, + projection: d.mat4x4f, +}); + +export interface CameraOptions { + initPos: d.v4f; + target?: d.v4f; + minZoom?: number; + maxZoom?: number; + invertCamera?: boolean; +} + +const cameraDefaults: Partial = { + target: d.vec4f(0, 0, 0, 1), + minZoom: 1, + maxZoom: 100, + invertCamera: false, +}; + +/** + * Sets up an orbit camera. + * Calls the callback on scroll events, canvas clicks/touches and resizes. + * Also, calls the callback during the setup with an initial camera. + */ +export function setupOrbitCamera( + canvas: HTMLCanvasElement, + partialOptions: CameraOptions, + callback: (updatedProps: Partial>) => void, +) { + const options = { ...cameraDefaults, ...partialOptions } as Required< + CameraOptions + >; + + // orbit variables storing the current camera position + const cameraState = { + target: d.vec4f(), + radius: 0, + pitch: 0, + yaw: 0, + }; + + // initialize the camera + targetCamera(options.initPos, options.target); + + function targetCamera(newPos: d.v4f, newTarget?: d.v4f) { + const tgt = newTarget ?? cameraState.target; + const cameraVector = newPos.sub(tgt); + cameraState.radius = std.length(cameraVector); + cameraState.yaw = Math.atan2(cameraVector.x, cameraVector.z); + cameraState.pitch = Math.asin(cameraVector.y / cameraState.radius); + cameraState.target = tgt; + + callback(Camera({ + position: newPos, + targetPos: cameraState.target, + view: calculateView(newPos, cameraState.target), + projection: calculateProj(canvas.clientWidth / canvas.clientHeight), + })); + } + + function rotateCamera(dx: number, dy: number) { + const orbitSensitivity = 0.005; + cameraState.yaw += -dx * orbitSensitivity * (options.invertCamera ? -1 : 1); + cameraState.pitch += dy * orbitSensitivity * + (options.invertCamera ? -1 : 1); + cameraState.pitch = std.clamp( + cameraState.pitch, + -Math.PI / 2 + 0.01, + Math.PI / 2 - 0.01, + ); + + const newCameraPos = calculatePos( + cameraState.target, + cameraState.radius, + cameraState.pitch, + cameraState.yaw, + ); + + callback({ + view: calculateView(newCameraPos, cameraState.target), + position: newCameraPos, + }); + } + + function zoomCamera(delta: number) { + cameraState.radius += delta * 0.05; + cameraState.radius = std.clamp( + cameraState.radius, + options.minZoom, + options.maxZoom, + ); + + const newPos = calculatePos( + cameraState.target, + cameraState.radius, + cameraState.pitch, + cameraState.yaw, + ); + const newView = calculateView(newPos, cameraState.target); + + callback({ view: newView, position: newPos }); + } + + // resize observer + const resizeObserver = new ResizeObserver(() => { + const projection = calculateProj(canvas.clientWidth / canvas.clientHeight); + callback({ projection }); + }); + resizeObserver.observe(canvas); + + // Variables for mouse/touch interaction. + let isDragging = false; + let prevX = 0; + let prevY = 0; + let lastPinchDist = 0; + + // mouse/touch events + canvas.addEventListener('wheel', (event: WheelEvent) => { + event.preventDefault(); + zoomCamera(event.deltaY); + }, { passive: false }); + + canvas.addEventListener('mousedown', (event) => { + isDragging = true; + prevX = event.clientX; + prevY = event.clientY; + }); + + canvas.addEventListener('touchstart', (event) => { + event.preventDefault(); + if (event.touches.length === 1) { + isDragging = true; + prevX = event.touches[0].clientX; + prevY = event.touches[0].clientY; + } else if (event.touches.length === 2) { + isDragging = false; + const dx = event.touches[0].clientX - event.touches[1].clientX; + const dy = event.touches[0].clientY - event.touches[1].clientY; + lastPinchDist = Math.sqrt(dx * dx + dy * dy); + } + }, { passive: false }); + + const mouseUpEventListener = () => { + isDragging = false; + }; + window.addEventListener('mouseup', mouseUpEventListener); + + const touchEndEventListener = (e: TouchEvent) => { + if (e.touches.length === 1) { + isDragging = true; + prevX = e.touches[0].clientX; + prevY = e.touches[0].clientY; + } else { + isDragging = false; + } + }; + window.addEventListener('touchend', touchEndEventListener); + + const mouseMoveEventListener = (event: MouseEvent) => { + const dx = event.clientX - prevX; + const dy = event.clientY - prevY; + prevX = event.clientX; + prevY = event.clientY; + + if (isDragging) { + rotateCamera(dx, dy); + } + }; + window.addEventListener('mousemove', mouseMoveEventListener); + + const touchMoveEventListener = (event: TouchEvent) => { + if (event.touches.length === 1 && isDragging) { + event.preventDefault(); + const dx = event.touches[0].clientX - prevX; + const dy = event.touches[0].clientY - prevY; + prevX = event.touches[0].clientX; + prevY = event.touches[0].clientY; + + rotateCamera(dx, dy); + } + }; + window.addEventListener('touchmove', touchMoveEventListener, { + passive: false, + }); + + canvas.addEventListener('touchmove', (e) => { + if (e.touches.length === 2) { + e.preventDefault(); + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + const pinchDist = Math.sqrt(dx * dx + dy * dy); + zoomCamera((lastPinchDist - pinchDist) * 0.5); + lastPinchDist = pinchDist; + } + }, { passive: false }); + + function cleanupCamera() { + window.removeEventListener('mouseup', mouseUpEventListener); + window.removeEventListener('mousemove', mouseMoveEventListener); + window.removeEventListener('touchmove', touchMoveEventListener); + window.removeEventListener('touchend', touchEndEventListener); + resizeObserver.unobserve(canvas); + } + + return { cleanupCamera, targetCamera }; +} + +function calculatePos( + target: d.v4f, + radius: number, + pitch: number, + yaw: number, +) { + const newX = radius * Math.sin(yaw) * Math.cos(pitch); + const newY = radius * Math.sin(pitch); + const newZ = radius * Math.cos(yaw) * Math.cos(pitch); + const displacement = d.vec4f(newX, newY, newZ, 0); + return target.add(displacement); +} + +function calculateView(position: d.v4f, target: d.v4f) { + return m.mat4.lookAt( + position, + target, + d.vec3f(0, 1, 0), + d.mat4x4f(), + ); +} + +function calculateProj(aspectRatio: number) { + return m.mat4.perspective(Math.PI / 4, aspectRatio, 0.1, 1000, d.mat4x4f()); +} 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/types.ts b/apps/typegpu-docs/src/examples/simple/mesh-skinning/types.ts new file mode 100644 index 0000000000..84f2f006bf --- /dev/null +++ b/apps/typegpu-docs/src/examples/simple/mesh-skinning/types.ts @@ -0,0 +1,51 @@ +import * as d from 'typegpu/data'; + +export const VertexData = d.struct({ + position: d.vec3f, + normal: d.vec3f, + joint: d.vec4u, + weight: d.vec4f, +}); + +export interface GLTFNode { + name?: string; + translation?: [number, number, number]; + rotation?: [number, number, number, number]; + scale?: [number, number, number]; + 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 ModelData { + positions: Float32Array; + normals: Float32Array; + joints: Uint32Array; + weights: Float32Array; + indices: Uint16Array; + inverseBindMatrices: Float32Array; + nodes: GLTFNode[]; + jointNodes: number[]; + vertexCount: number; + animations: Animation[]; + jointCount: number; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69ebcc5f6c..2ec4fb9db5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,7 +110,7 @@ importers: devDependencies: '@types/bun': specifier: latest - version: 1.3.1(@types/react@19.1.8) + version: 1.3.4 apps/infra-benchmarks: devDependencies: @@ -144,6 +144,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) @@ -1630,6 +1633,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: @@ -1645,11 +1663,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.0': resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==} @@ -2538,8 +2567,8 @@ packages: '@types/bun@1.2.22': resolution: {integrity: sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA==} - '@types/bun@1.3.1': - resolution: {integrity: sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ==} + '@types/bun@1.3.4': + resolution: {integrity: sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA==} '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} @@ -2836,6 +2865,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==} @@ -2964,10 +2996,8 @@ packages: peerDependencies: '@types/react': ^19 - bun-types@1.3.1: - resolution: {integrity: sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw==} - peerDependencies: - '@types/react': ^19 + bun-types@1.3.4: + resolution: {integrity: sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ==} bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} @@ -3483,6 +3513,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'} @@ -3895,6 +3928,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'} @@ -4139,6 +4177,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==} + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -5477,6 +5518,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==} @@ -5621,6 +5665,10 @@ packages: resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} engines: {node: '>=18'} + texture-compressor@1.0.2: + resolution: {integrity: sha512-dStVgoaQ11mA5htJ+RzZ51ZxIZqNOgWKAIvtjLrW1AliQQLCmrDqNzQZ8Jh91YealQ95DXt4MEduLzJmbs6lig==} + hasBin: true + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -7363,6 +7411,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 @@ -7382,10 +7453,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.0(acorn@8.15.0)': dependencies: '@types/estree': 1.0.8 @@ -8228,11 +8316,9 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@types/bun@1.3.1(@types/react@19.1.8)': + '@types/bun@1.3.4': dependencies: - bun-types: 1.3.1(@types/react@19.1.8) - transitivePeerDependencies: - - '@types/react' + bun-types: 1.3.4 '@types/chai@5.2.2': dependencies: @@ -8563,6 +8649,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: @@ -8817,10 +8907,9 @@ snapshots: '@types/node': 24.10.0 '@types/react': 19.1.8 - bun-types@1.3.1(@types/react@19.1.8): + bun-types@1.3.4: dependencies: '@types/node': 24.10.0 - '@types/react': 19.1.8 bundle-require@5.1.0(esbuild@0.25.10): dependencies: @@ -9335,6 +9424,8 @@ snapshots: typescript: 5.9.3 yargs: 17.7.2 + draco3d@1.5.7: {} + dset@3.1.4: {} eastasianwidth@0.2.0: {} @@ -9935,6 +10026,8 @@ snapshots: ignore@5.3.2: {} + image-size@0.7.5: {} + imagetools-core@9.0.0: {} import-meta-resolve@4.2.0: {} @@ -10139,6 +10232,8 @@ snapshots: knitwork@1.2.0: {} + ktx-parse@0.7.1: {} + lightningcss-darwin-arm64@1.30.1: optional: true @@ -11906,6 +12001,8 @@ snapshots: dependencies: readable-stream: 3.6.2 + sprintf-js@1.0.3: {} + stackback@0.0.2: {} starlight-blog@0.23.2(@astrojs/starlight@0.36.1(astro@5.14.5(@types/node@24.10.0)(jiti@2.6.0)(lightningcss@1.30.1)(rollup@4.34.8)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1)))(astro@5.14.5(@types/node@24.10.0)(jiti@2.6.0)(lightningcss@1.30.1)(rollup@4.34.8)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1)): @@ -12080,6 +12177,11 @@ snapshots: glob: 10.4.5 minimatch: 9.0.5 + texture-compressor@1.0.2: + dependencies: + argparse: 1.0.10 + image-size: 0.7.5 + thenify-all@1.6.0: dependencies: thenify: 3.3.1