├── data └── .gitkeep ├── src ├── global.d.ts ├── index.ts ├── wasm.env.ts ├── wasm.node.ts ├── wasm.browser.ts ├── wasm.compat.ts ├── constants.ts ├── resampleDebug.ts └── bindings.ts ├── .github ├── FUNDING.yml ├── codecov.yml └── workflows │ └── ci.yml ├── renovate.json ├── .prettierignore ├── assembly ├── tsconfig.json └── resample.ts ├── .gitignore ├── .prettierrc.json ├── tsconfig.json ├── asconfig.json ├── .eslintrc.json ├── benchmark ├── extract.ts └── benchmark.ts ├── test └── index.test.ts ├── LICENSE.md ├── package.json └── README.md /data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | // placeholder 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [donmccurdy] 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | {"extends": ["github>donmccurdy/renovate-config"]} 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/dist 2 | **/*.json 3 | **/*.yml 4 | **/*.lock 5 | **/*.css 6 | -------------------------------------------------------------------------------- /assembly/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "assemblyscript/std/assembly.json", 3 | "include": ["./*.ts"] 4 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Builds 5 | dist 6 | 7 | # Data 8 | data/* 9 | !data/.gitkeep 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Interpolation } from './constants.js'; 2 | export * from './resampleDebug.js'; 3 | export * from './bindings.js'; 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "useTabs": true, 4 | "tabWidth": 4, 5 | "printWidth": 100, 6 | "bracketSpacing": true 7 | } 8 | -------------------------------------------------------------------------------- /src/wasm.env.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Stub file, replaced at compile time with the environment-appropriate 3 | * WASM loader. See 'build' macros in package.json#scripts for details. 4 | */ 5 | const wasm = new Uint8Array(0); 6 | export default wasm; 7 | -------------------------------------------------------------------------------- /src/wasm.node.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'node:fs/promises'; 2 | 3 | /** WASM loader for Node.js environments. */ 4 | 5 | const wasmURL = /* #__PURE__ */ new URL('./release.wasm', import.meta.url); 6 | const wasm: Promise = /* #__PURE__ */ readFile(wasmURL); 7 | export default wasm; 8 | -------------------------------------------------------------------------------- /src/wasm.browser.ts: -------------------------------------------------------------------------------- 1 | /** WASM loader for Web environments. */ 2 | const wasm: Promise = /* #__PURE__ */ fetch( 3 | /* #__PURE__ */ new URL('./release.wasm', import.meta.url) 4 | ) 5 | .then((response) => response.arrayBuffer()) 6 | .then((buffer) => new Uint8Array(buffer)); 7 | export default wasm; 8 | -------------------------------------------------------------------------------- /src/wasm.compat.ts: -------------------------------------------------------------------------------- 1 | /** WASM loader using an inline Data URI. */ 2 | const WASM_BASE64 = ''; 3 | const wasm: Promise = /* #__PURE__ */ fetch( 4 | 'data:application/wasm;base64' + WASM_BASE64 5 | ) 6 | .then((response) => response.arrayBuffer()) 7 | .then((buffer) => new Uint8Array(buffer)); 8 | export default wasm; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "keyframe-resample": ["./"] 5 | }, 6 | "typeRoots": ["node_modules/@types"], 7 | "moduleResolution": "nodenext", 8 | "module": "ESNext", 9 | "lib": ["ESNext", "DOM"], 10 | "target": "ESNext", 11 | "declaration": true, 12 | "strict": true 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export type Interpolation = 'step' | 'lerp' | 'slerp'; 2 | 3 | export enum InterpolationInternal { 4 | STEP = 0, 5 | LERP = 1, 6 | SLERP = 2, 7 | } 8 | 9 | export const TO_INTERPOLATION_INTERNAL: Record = { 10 | step: InterpolationInternal.STEP, 11 | lerp: InterpolationInternal.LERP, 12 | slerp: InterpolationInternal.SLERP, 13 | }; 14 | 15 | export const EPSILON = 0.000001; 16 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | # Based on https://docs.codecov.com/docs/codecov-yaml#default-yaml 2 | codecov: 3 | require_ci_to_pass: yes 4 | 5 | coverage: 6 | precision: 2 7 | round: down 8 | range: "50...95" 9 | status: 10 | patch: off 11 | project: 12 | default: 13 | target: 80% 14 | threshold: 1% 15 | 16 | parsers: 17 | gcov: 18 | branch_detection: 19 | conditional: yes 20 | loop: yes 21 | method: no 22 | macro: no 23 | -------------------------------------------------------------------------------- /asconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "targets": { 3 | "debug": { 4 | "outFile": "dist/debug.wasm", 5 | "textFile": "dist/debug.wat", 6 | "sourceMap": true, 7 | "debug": true 8 | }, 9 | "release": { 10 | "outFile": "dist/release.wasm", 11 | "textFile": "dist/release.wat", 12 | "sourceMap": true, 13 | "optimizeLevel": 3, 14 | "shrinkLevel": 0, 15 | "converge": false, 16 | "noAssert": true 17 | } 18 | }, 19 | "options": { 20 | "bindings": "raw", 21 | "runtime": "minimal", 22 | "exportRuntime": true, 23 | "uncheckedBehavior": "always" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [18.x, 20.x] 16 | 17 | env: 18 | CI: true 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: yarn install 27 | - run: yarn dist 28 | - run: yarn test 29 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": [ 4 | "@typescript-eslint", 5 | "prettier" 6 | ], 7 | "env": { 8 | "browser": true, 9 | "es6": true, 10 | "node": true 11 | }, 12 | "extends": [ 13 | "eslint:recommended", 14 | "plugin:@typescript-eslint/eslint-recommended", 15 | "plugin:@typescript-eslint/recommended" 16 | ], 17 | "parserOptions": { 18 | "ecmaVersion": 2018, 19 | "sourceType": "module" 20 | }, 21 | "rules": { 22 | "@typescript-eslint/no-use-before-define": "off", 23 | "@typescript-eslint/no-unused-vars": ["warn", {"argsIgnorePattern": "^_"}], 24 | "quotes": ["warn", "single"], 25 | "max-len": ["warn", {"code": 100, "tabWidth": 4, "ignoreUrls": true, "ignorePattern": "^import|^export"}], 26 | "newline-per-chained-call": [2, {"ignoreChainWithDepth": 3}] 27 | }, 28 | "ignorePatterns": ["**/*.js"] 29 | } 30 | -------------------------------------------------------------------------------- /benchmark/extract.ts: -------------------------------------------------------------------------------- 1 | import { NodeIO } from '@gltf-transform/core'; 2 | import { writeFile } from 'node:fs/promises'; 3 | 4 | const IN_PATH = new URL('../data/Arm.glb', import.meta.url); 5 | const OUT_PATH = new URL('../data/arm_keyframes.json', import.meta.url); 6 | 7 | /** 8 | * Utility script used to extract raw keyframe data from GLB files. Keyframe 9 | * data is stored as JSON, then loaded for benchmarks. 10 | */ 11 | 12 | const io = new NodeIO(); 13 | const document = await io.read(IN_PATH); 14 | 15 | const results = []; 16 | 17 | for (const animation of document.getRoot().listAnimations()) { 18 | const paths = new Map(); 19 | for (const channel of animation.listChannels()) { 20 | const sampler = channel.getSampler(); 21 | paths.set(sampler, channel.getTargetPath()); 22 | } 23 | for (const sampler of animation.listSamplers()) { 24 | results.push({ 25 | input: Array.from(sampler.getInput().getArray()), 26 | output: Array.from(sampler.getOutput().getArray()), 27 | normalized: sampler.getOutput().getNormalized(), 28 | path: paths.get(sampler), 29 | interpolation: sampler.getInterpolation(), 30 | }); 31 | } 32 | } 33 | 34 | await writeFile(OUT_PATH, JSON.stringify(results, null, 2), { encoding: 'utf-8' }); 35 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { ready, resample, resampleDebug } from 'keyframe-resample'; 3 | 4 | const round = (value: number) => Math.round(value * 1e6) / 1e6; 5 | 6 | test('init - debug', async (t) => { 7 | t.is(!!resampleDebug, true, 'js build exists'); 8 | }); 9 | 10 | test('init - wasm', async (t) => { 11 | await ready; 12 | t.is(!!resample, true, 'wasm build exists'); 13 | }); 14 | 15 | test('resample - debug', async (t) => { 16 | const srcTimes = new Float32Array([0, 0.1, 0.2, 0.3, 0.4]); 17 | const srcValues = new Float32Array([0, 0, 1, 0, 0, 2, 0, 0, 3, 0, 0, 4, 0, 0, 5]); 18 | 19 | const count = resampleDebug(srcTimes, srcValues, 'lerp'); 20 | 21 | const dstTimes = Array.from(srcTimes.slice(0, count)).map(round); 22 | const dstValues = Array.from(srcValues.slice(0, count * 3)).map(round); 23 | 24 | t.is(count, 2); 25 | t.deepEqual(dstTimes, [0, 0.4], 'times'); 26 | t.deepEqual(dstValues, [0, 0, 1, 0, 0, 5], 'values'); 27 | }); 28 | 29 | test('resample - wasm', async (t) => { 30 | await ready; 31 | 32 | const srcTimes = new Float32Array([0, 0.1, 0.2, 0.3, 0.4]); 33 | const srcValues = new Float32Array([0, 0, 1, 0, 0, 2, 0, 0, 3, 0, 0, 4, 0, 0, 5]); 34 | 35 | const count = resample(srcTimes, srcValues, 'lerp'); 36 | 37 | const dstTimes = Array.from(srcTimes.slice(0, count)).map(round); 38 | const dstValues = Array.from(srcValues.slice(0, count * 3)).map(round); 39 | 40 | t.is(count, 2); 41 | t.deepEqual(dstTimes, [0, 0.4], 'times'); 42 | t.deepEqual(dstValues, [0, 0, 1, 0, 0, 5], 'values'); 43 | }); 44 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Blue Oak Model License 2 | 3 | Version 1.0.0 4 | 5 | ## Purpose 6 | 7 | This license gives everyone as much permission to work with 8 | this software as possible, while protecting contributors 9 | from liability. 10 | 11 | ## Acceptance 12 | 13 | In order to receive this license, you must agree to its 14 | rules. The rules of this license are both obligations 15 | under that agreement and conditions to your license. 16 | You must not do anything with this software that triggers 17 | a rule that you cannot or will not follow. 18 | 19 | ## Copyright 20 | 21 | Each contributor licenses you to do everything with this 22 | software that would otherwise infringe that contributor's 23 | copyright in it. 24 | 25 | ## Notices 26 | 27 | You must ensure that everyone who gets a copy of 28 | any part of this software from you, with or without 29 | changes, also gets the text of this license or a link to 30 | . 31 | 32 | ## Excuse 33 | 34 | If anyone notifies you in writing that you have not 35 | complied with [Notices](#notices), you can keep your 36 | license by taking all practical steps to comply within 30 37 | days after the notice. If you do not do so, your license 38 | ends immediately. 39 | 40 | ## Patent 41 | 42 | Each contributor licenses you to do everything with this 43 | software that would otherwise infringe any patent claims 44 | they can license or become able to license. 45 | 46 | ## Reliability 47 | 48 | No contributor can revoke this license. 49 | 50 | ## No Liability 51 | 52 | ***As far as the law allows, this software comes as is, 53 | without any warranty or condition, and no contributor 54 | will be liable to anyone for any damages related to this 55 | software or this license, under any kind of legal claim.*** 56 | -------------------------------------------------------------------------------- /benchmark/benchmark.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'node:fs/promises'; 2 | import { resample, resampleDebug } from 'keyframe-resample'; 3 | import { performance } from 'node:perf_hooks'; 4 | 5 | /****************************************************************************** 6 | * Setup 7 | */ 8 | 9 | const INPUT_PATH = new URL('../data/arm_keyframes.json', import.meta.url); 10 | 11 | const BYTES_PER_MB = 1024 * 1024; 12 | const MS_PER_S = 1000; 13 | 14 | interface Sampler { 15 | input: number[]; 16 | output: number[]; 17 | interpolation: 'CUBICSPLINE' | 'LINEAR' | 'STEP'; 18 | path: string; 19 | } 20 | 21 | /****************************************************************************** 22 | * Benchmark 23 | */ 24 | 25 | async function run(label: string, resample: Function) { 26 | const samplers = JSON.parse(await readFile(INPUT_PATH, { encoding: 'utf-8' })); 27 | 28 | let srcCount = 0; 29 | let dstCount = 0; 30 | let byteLength = 0; 31 | 32 | for (const sampler of samplers) { 33 | sampler.input = new Float32Array(sampler.input); 34 | sampler.output = new Float32Array(sampler.output); 35 | byteLength += sampler.input.byteLength + sampler.output.byteLength; 36 | } 37 | 38 | let t0 = performance.now(); 39 | for (const sampler of samplers) { 40 | srcCount += sampler.input.length; 41 | dstCount += resample(sampler.input, sampler.output, getInterpolation(sampler)); 42 | } 43 | let t = performance.now() - t0; 44 | 45 | console.log(label); 46 | console.log(dim(` ${formatLong(Math.round(t))}ms`)); 47 | console.log(dim(` ${Math.round(byteLength / BYTES_PER_MB / (t / MS_PER_S))} MB/s`)); 48 | console.log(dim(` ${formatLong(srcCount)} → ${formatLong(dstCount)} keyframes`)); 49 | console.log('\n'); 50 | } 51 | 52 | await run('\nJavaScript', resampleDebug); 53 | await run('WASM', resample); 54 | 55 | /****************************************************************************** 56 | * Utilities 57 | */ 58 | 59 | function getInterpolation(sampler: Sampler): any { 60 | if (sampler.interpolation === 'LINEAR') { 61 | return sampler.path === 'rotation' ? 'slerp' : 'lerp'; 62 | } else if (sampler.interpolation === 'STEP') { 63 | return 'step'; 64 | } else { 65 | throw new Error(`Unexpected interpolation, ${sampler.interpolation}`); 66 | } 67 | } 68 | 69 | function formatLong(x: number): string { 70 | return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); 71 | } 72 | 73 | export function dim(str: string) { 74 | return `\x1b[2m${str}\x1b[0m`; 75 | } 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "keyframe-resample", 3 | "version": "0.1.0", 4 | "description": "Resamples and optimizes keyframe data using WebAssembly", 5 | "type": "module", 6 | "sideEffects": false, 7 | "source": "./src/index.ts", 8 | "types": "./dist/index.d.ts", 9 | "main": "./dist/keyframe-resample-node.cjs", 10 | "module": "./dist/keyframe-resample-browser.modern.js", 11 | "exports": { 12 | "compat": { 13 | "types": "./dist/index.d.ts", 14 | "require": "./dist/keyframe-resample-compat.cjs", 15 | "default": "./dist/keyframe-resample-compat.modern.js" 16 | }, 17 | "node": { 18 | "types": "./dist/index.d.ts", 19 | "require": "./dist/keyframe-resample-node.cjs", 20 | "default": "./dist/keyframe-resample-node.modern.js" 21 | }, 22 | "default": { 23 | "types": "./dist/index.d.ts", 24 | "require": "./dist/keyframe-resample-browser.cjs", 25 | "default": "./dist/keyframe-resample-browser.modern.js" 26 | } 27 | }, 28 | "repository": "github:donmccurdy/keyframe-resample-wasm", 29 | "author": "Don McCurdy ", 30 | "license": "BlueOak-1.0.0", 31 | "scripts": { 32 | "dist": "yarn asbuild && yarn build", 33 | "build": "yarn build:node && yarn build:browser && yarn build:compat", 34 | "build:node": "microbundle build --target node --format modern,cjs --raw --no-compress --no-sourcemap --output dist/keyframe-resample-node.js --external node:fs/promises --alias ./wasm.env.js=./wasm.node.js", 35 | "build:browser": "microbundle build --target web --format modern,cjs --raw --no-compress --output dist/keyframe-resample-browser.js --external ./release.wasm --alias ./wasm.env.js=./wasm.browser.js", 36 | "build:compat": "microbundle build --target web --format modern,cjs --raw --no-compress --no-sourcemap --output dist/keyframe-resample-compat.js --external ./release.wasm --alias ./wasm.env.js=./wasm.compat.js --define WASM_BASE64=`base64 -i dist/release.wasm`", 37 | "asbuild": "npm run asbuild:debug && npm run asbuild:release", 38 | "asbuild:debug": "asc assembly/resample.ts --target debug", 39 | "asbuild:release": "asc assembly/resample.ts --target release", 40 | "clean": "rimraf dist/*", 41 | "test": "ava --no-worker-threads test/*.ts", 42 | "benchmark": "tsx benchmark/benchmark.ts", 43 | "preversion": "yarn dist && yarn test", 44 | "version": "rimraf dist/* && yarn dist && git add -u", 45 | "postversion": "git push && git push --tags && npm publish" 46 | }, 47 | "devDependencies": { 48 | "@typescript-eslint/eslint-plugin": "6.17.0", 49 | "assemblyscript": "^0.27.22", 50 | "ava": "6.0.1", 51 | "eslint": "8.56.0", 52 | "eslint-config-prettier": "9.1.0", 53 | "microbundle": "0.15.1", 54 | "prettier": "3.1.1", 55 | "rimraf": "5.0.5", 56 | "tsx": "^4.7.0", 57 | "typescript": "5.3.3" 58 | }, 59 | "files": [ 60 | "src/", 61 | "assembly/", 62 | "dist/", 63 | "!dist/debug*", 64 | "!dist/*.map", 65 | "README.md", 66 | "LICENSE.md", 67 | "package.json" 68 | ], 69 | "browserslist": [ 70 | "defaults", 71 | "not IE 11", 72 | "node >= 14" 73 | ], 74 | "ava": { 75 | "extensions": { 76 | "ts": "module" 77 | }, 78 | "nodeArguments": [ 79 | "--import=tsx" 80 | ] 81 | }, 82 | "dependencies": {} 83 | } 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # keyframe-resample-wasm 2 | 3 | [![Latest NPM release](https://img.shields.io/npm/v/keyframe-resample.svg)](https://www.npmjs.com/package/keyframe-resample) 4 | [![Build Status](https://github.com/donmccurdy/keyframe-resample-wasm/workflows/CI/badge.svg?branch=main&event=push)](https://github.com/donmccurdy/keyframe-resample-wasm/actions?query=workflow%3ACI) 5 | 6 | Resamples and optimizes keyframe data using WebAssembly. Minzipped size is about 6-7 kb. 7 | 8 | ## Installation 9 | 10 | ``` 11 | npm install --save keyframe-resample 12 | ``` 13 | 14 | ### Compatibility 15 | 16 | The `keyframe-resample` module will load a WebAssembly binary as a static resource, as described in [Web.Dev: Bundling non-JavaScript resources 17 | ](https://web.dev/bundling-non-js-resources/#universal-pattern-for-browsers-and-bundlers). This method in compatible with Node.js, most browsers, and applications compiled with a modern bundler. For older bundlers, a `keyframe-resample/compat` module is also available, loading the WebAssembly binary from an inline Data URI. The compatibility build is a couple kilobytes larger, and requires more time to process the WebAssembly binary. 18 | 19 | ## API 20 | 21 | ```javascript 22 | import { ready, resample } from 'keyframe-resample'; // Node.js, browsers, and modern bundlers 23 | import { ready, resample } from 'keyframe-resample/compat'; // Legacy bundlers 24 | 25 | // wait for WASM to compile 26 | await ready; 27 | 28 | // keyframe times, in seconds 29 | const srcTimes = new Float32Array([0, 0.1, 0.2, 0.3, 0.4]); 30 | 31 | // keyframe values, N-dimensional vectors 32 | const srcValues = new Float32Array([ 33 | 0, 0, 1, 34 | 0, 0, 2, 35 | 0, 0, 3, 36 | 0, 0, 4, 37 | 0, 0, 5, 38 | ]); 39 | 40 | // resample keyframes, remove those unnecessary with interpolation. 41 | const count = resample(srcTimes, srcValues, 'lerp'); 42 | 43 | // results are written to start of source array. 44 | const dstTimes = srcTimes.slice(0, count); // → [0, 0.4] 45 | const dstValues = srcValues.slice(0, count * 3); // → [0, 0, 1, 0, 0, 5] 46 | ``` 47 | 48 | In addition to the `resample(...)` function implemented in WebAssembly, a `resampleDebug(...)` function implemented in plain JavaScript is also exported. The WebAssembly implementation runs considerably faster. 49 | 50 | ### Exports 51 | 52 | | export | description | 53 | |--------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------| 54 | | | | 55 | |
ready: Promise<void>
| Promise resolving when WASM is initialized and module is ready for use. | 56 | | | | 57 | |
resample(
 times: Float32Array,
 values: Float32Array,
 interp: 'step' \| 'lerp' \| 'slerp',
 tolerance = 1e4
)
| WebAssembly implementation of keyframe interpolation. | 58 | | | | 59 | |
resampleDebug(
 times: Float32Array,
 values: Float32Array,
 interp: 'step' \| 'lerp' \| 'slerp',
 tolerance = 1e4
)
| JavaScript implementation of keyframe interpolation. | 60 | 61 | ### Interpolation modes 62 | 63 | | mode | description | 64 | |-----------|-------------------------------------------------------------| 65 | | `'step'` | Step (also called discrete or constant) interpolation. | 66 | | `'lerp'` | Linear, per-component interpolation. | 67 | | `'slerp'` | Spherical linear interpolation, valid only for quaternions. | 68 | 69 | ## Contributing 70 | 71 | To build the project locally, run: 72 | 73 | ``` 74 | npm install 75 | npm run dist 76 | ``` 77 | 78 | To test changes: 79 | 80 | ``` 81 | npm run test 82 | npm run benchmark 83 | ``` 84 | 85 | Optimizations and bug fixes are welcome. Please consider filing an issue to discuss possible 86 | feature additions. 87 | 88 | ## License 89 | 90 | Licensed under [Blue Oak Model License 1.0.0](./LICENSE.md). 91 | -------------------------------------------------------------------------------- /src/resampleDebug.ts: -------------------------------------------------------------------------------- 1 | import { Interpolation, EPSILON } from './constants.js'; 2 | 3 | type quat = [number, number, number, number]; 4 | 5 | /* Implementation */ 6 | 7 | export function resampleDebug( 8 | input: Float32Array, 9 | output: Float32Array, 10 | interpolation: Interpolation, 11 | tolerance = 1e-4 12 | ): number { 13 | const elementSize = output.length / input.length; 14 | const tmp = new Array(elementSize).fill(0); 15 | const value = new Array(elementSize).fill(0); 16 | const valueNext = new Array(elementSize).fill(0); 17 | const valuePrev = new Array(elementSize).fill(0); 18 | 19 | const lastIndex = input.length - 1; 20 | let writeIndex = 1; 21 | 22 | for (let i = 1; i < lastIndex; ++i) { 23 | const timePrev = input[writeIndex - 1]; 24 | const time = input[i]; 25 | const timeNext = input[i + 1]; 26 | const t = (time - timePrev) / (timeNext - timePrev); 27 | 28 | let keep = false; 29 | 30 | // Remove unnecessary adjacent keyframes. 31 | if (time !== timeNext && (i !== 1 || time !== input[0])) { 32 | getElement(output, writeIndex - 1, valuePrev); 33 | getElement(output, i, value); 34 | getElement(output, i + 1, valueNext); 35 | 36 | if (interpolation === 'slerp') { 37 | // Prune keyframes colinear with prev/next keyframes. 38 | const sample = slerp( 39 | tmp as quat, 40 | valuePrev as quat, 41 | valueNext as quat, 42 | t 43 | ) as number[]; 44 | const angle = 45 | getAngle(valuePrev as quat, value as quat) + 46 | getAngle(value as quat, valueNext as quat); 47 | keep = !eq(value, sample, tolerance) || angle + Number.EPSILON >= Math.PI; 48 | } else if (interpolation === 'lerp') { 49 | // Prune keyframes colinear with prev/next keyframes. 50 | const sample = vlerp(tmp, valuePrev, valueNext, t); 51 | keep = !eq(value, sample, tolerance); 52 | } else if (interpolation === 'step') { 53 | // Prune keyframes identical to prev/next keyframes. 54 | keep = !eq(value, valuePrev) || !eq(value, valueNext); 55 | } 56 | } 57 | 58 | // In-place compaction. 59 | if (keep) { 60 | if (i !== writeIndex) { 61 | input[writeIndex] = input[i]; 62 | setElement(output, writeIndex, getElement(output, i, tmp)); 63 | } 64 | writeIndex++; 65 | } 66 | } 67 | 68 | // Flush last keyframe (compaction looks ahead). 69 | if (lastIndex > 0) { 70 | input[writeIndex] = input[lastIndex]; 71 | setElement(output, writeIndex, getElement(output, lastIndex, tmp)); 72 | writeIndex++; 73 | } 74 | 75 | return writeIndex; 76 | } 77 | 78 | /* Utilities */ 79 | 80 | function getElement(array: Float32Array, index: number, target: number[]): number[] { 81 | for (let i = 0, elementSize = target.length; i < elementSize; i++) { 82 | target[i] = array[index * elementSize + i]; 83 | } 84 | return target; 85 | } 86 | 87 | function setElement(array: Float32Array, index: number, value: number[]): void { 88 | for (let i = 0, elementSize = value.length; i < elementSize; i++) { 89 | array[index * elementSize + i] = value[i]; 90 | } 91 | } 92 | 93 | function eq(a: number[], b: number[], tolerance = 0): boolean { 94 | if (a.length !== b.length) { 95 | return false; 96 | } 97 | 98 | for (let i = 0; i < a.length; i++) { 99 | if (Math.abs(a[i] - b[i]) > tolerance) { 100 | return false; 101 | } 102 | } 103 | 104 | return true; 105 | } 106 | 107 | function lerp(v0: number, v1: number, t: number): number { 108 | return v0 * (1 - t) + v1 * t; 109 | } 110 | 111 | function vlerp(out: number[], a: number[], b: number[], t: number): number[] { 112 | for (let i = 0; i < a.length; i++) out[i] = lerp(a[i], b[i], t); 113 | return out; 114 | } 115 | 116 | // From gl-matrix. 117 | function slerp(out: quat, a: quat, b: quat, t: number): quat { 118 | // benchmarks: 119 | // http://jsperf.com/quaternion-slerp-implementations 120 | let ax = a[0], 121 | ay = a[1], 122 | az = a[2], 123 | aw = a[3]; 124 | let bx = b[0], 125 | by = b[1], 126 | bz = b[2], 127 | bw = b[3]; 128 | 129 | let omega, cosom, sinom, scale0, scale1; 130 | 131 | // calc cosine 132 | cosom = ax * bx + ay * by + az * bz + aw * bw; 133 | // adjust signs (if necessary) 134 | if (cosom < 0.0) { 135 | cosom = -cosom; 136 | bx = -bx; 137 | by = -by; 138 | bz = -bz; 139 | bw = -bw; 140 | } 141 | // calculate coefficients 142 | if (1.0 - cosom > EPSILON) { 143 | // standard case (slerp) 144 | omega = Math.acos(cosom); 145 | sinom = Math.sin(omega); 146 | scale0 = Math.sin((1.0 - t) * omega) / sinom; 147 | scale1 = Math.sin(t * omega) / sinom; 148 | } else { 149 | // "from" and "to" quaternions are very close 150 | // ... so we can do a linear interpolation 151 | scale0 = 1.0 - t; 152 | scale1 = t; 153 | } 154 | // calculate final values 155 | out[0] = scale0 * ax + scale1 * bx; 156 | out[1] = scale0 * ay + scale1 * by; 157 | out[2] = scale0 * az + scale1 * bz; 158 | out[3] = scale0 * aw + scale1 * bw; 159 | 160 | return out; 161 | } 162 | 163 | function getAngle(a: quat, b: quat): number { 164 | const dotproduct = dot(a, b); 165 | return Math.acos(2 * dotproduct * dotproduct - 1); 166 | } 167 | 168 | function dot(a: quat, b: quat): number { 169 | return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; 170 | } 171 | -------------------------------------------------------------------------------- /assembly/resample.ts: -------------------------------------------------------------------------------- 1 | /* Types and constants */ 2 | 3 | // TODO(design): Consider tuples. 4 | type quat = StaticArray; 5 | 6 | export enum Interpolation { 7 | STEP = 0, 8 | LERP = 1, 9 | SLERP = 2, 10 | } 11 | 12 | const EPS = 0.000001; 13 | 14 | /* Implementation */ 15 | 16 | export function resample( 17 | input: StaticArray, 18 | output: StaticArray, 19 | interpolation: Interpolation, 20 | tolerance: f32 = 1e-4 21 | ): u32 { 22 | const elementSize: u32 = output.length / input.length; 23 | const tmp = new StaticArray(elementSize); 24 | const value = new StaticArray(elementSize); 25 | const valueNext = new StaticArray(elementSize); 26 | const valuePrev = new StaticArray(elementSize); 27 | 28 | const lastIndex: u32 = input.length - 1; 29 | let writeIndex: u32 = 1; 30 | 31 | for (let i: u32 = 1; i < lastIndex; ++i) { 32 | const timePrev: f32 = input[writeIndex - 1]; 33 | const time: f32 = input[i]; 34 | const timeNext: f32 = input[i + 1]; 35 | const t: f32 = (time - timePrev) / (timeNext - timePrev); 36 | 37 | let keep = false; 38 | 39 | // Remove unnecessary adjacent keyframes. 40 | if (time !== timeNext && (i !== 1 || time !== input[0])) { 41 | getElement(output, writeIndex - 1, valuePrev); 42 | getElement(output, i, value); 43 | getElement(output, i + 1, valueNext); 44 | 45 | if (interpolation === Interpolation.SLERP) { 46 | // Prune keyframes colinear with prev/next keyframes. 47 | const sample = slerp(tmp as quat, valuePrev as quat, valueNext as quat, t); 48 | const angle: f32 = 49 | getAngle(valuePrev as quat, value as quat) + 50 | getAngle(value as quat, valueNext as quat); 51 | keep = !eq(value, sample, tolerance) || angle + Number.EPSILON >= Math.PI; 52 | } else if (interpolation === Interpolation.LERP) { 53 | // Prune keyframes colinear with prev/next keyframes. 54 | const sample = vlerp(tmp, valuePrev, valueNext, t); 55 | keep = !eq(value, sample, tolerance); 56 | } else if (interpolation === Interpolation.STEP) { 57 | // Prune keyframes identical to prev/next keyframes. 58 | keep = !eq(value, valuePrev) || !eq(value, valueNext); 59 | } 60 | } 61 | 62 | // In-place compaction. 63 | if (keep) { 64 | if (i !== writeIndex) { 65 | input[writeIndex] = input[i]; 66 | setElement(output, writeIndex, getElement(output, i, tmp)); 67 | } 68 | writeIndex++; 69 | } 70 | } 71 | 72 | // Flush last keyframe (compaction looks ahead). 73 | if (lastIndex > 0) { 74 | input[writeIndex] = input[lastIndex]; 75 | setElement(output, writeIndex, getElement(output, lastIndex, tmp)); 76 | writeIndex++; 77 | } 78 | 79 | return writeIndex; 80 | } 81 | 82 | /* Utilities */ 83 | 84 | function getElement( 85 | array: StaticArray, 86 | index: u32, 87 | target: StaticArray 88 | ): StaticArray { 89 | for (let i: u32 = 0, elementSize: u32 = target.length; i < elementSize; i++) { 90 | target[i] = array[index * elementSize + i]; 91 | } 92 | return target; 93 | } 94 | 95 | function setElement(array: StaticArray, index: u32, value: StaticArray): void { 96 | for (let i: u32 = 0, elementSize: u32 = value.length; i < elementSize; i++) { 97 | array[index * elementSize + i] = value[i]; 98 | } 99 | } 100 | 101 | function eq(a: StaticArray, b: StaticArray, tolerance: f32 = 0): boolean { 102 | if (a.length !== b.length) { 103 | return false; 104 | } 105 | 106 | for (let i: u32 = 0, il: u32 = a.length; i < il; i++) { 107 | if (Mathf.abs(a[i] - b[i]) > tolerance) { 108 | return false; 109 | } 110 | } 111 | 112 | return true; 113 | } 114 | 115 | function lerp(v0: f32, v1: f32, t: f32): f32 { 116 | return v0 * (1 - t) + v1 * t; 117 | } 118 | 119 | function vlerp( 120 | out: StaticArray, 121 | a: StaticArray, 122 | b: StaticArray, 123 | t: f32 124 | ): StaticArray { 125 | for (let i: u32 = 0, il: u32 = a.length; i < il; i++) { 126 | const va = a[i]; 127 | const vb = b[i]; 128 | out[i] = lerp(va, vb, t); 129 | } 130 | return out; 131 | } 132 | 133 | // From gl-matrix. 134 | function slerp(out: quat, a: quat, b: quat, t: f32): quat { 135 | // benchmarks: 136 | // http://jsperf.com/quaternion-slerp-implementations 137 | let ax: f32 = a[0], 138 | ay: f32 = a[1], 139 | az: f32 = a[2], 140 | aw: f32 = a[3]; 141 | let bx: f32 = b[0], 142 | by: f32 = b[1], 143 | bz: f32 = b[2], 144 | bw: f32 = b[3]; 145 | 146 | let omega: f32, cosom: f32, sinom: f32, scale0: f32, scale1: f32; 147 | 148 | // calc cosine 149 | cosom = ax * bx + ay * by + az * bz + aw * bw; 150 | // adjust signs (if necessary) 151 | if (cosom < 0.0) { 152 | cosom = -cosom; 153 | bx = -bx; 154 | by = -by; 155 | bz = -bz; 156 | bw = -bw; 157 | } 158 | // calculate coefficients 159 | if (1.0 - cosom > EPS) { 160 | // standard case (slerp) 161 | omega = Mathf.acos(cosom); 162 | sinom = Mathf.sin(omega); 163 | scale0 = Mathf.sin((1.0 - t) * omega) / sinom; 164 | scale1 = Mathf.sin(t * omega) / sinom; 165 | } else { 166 | // "from" and "to" quaternions are very close 167 | // ... so we can do a linear interpolation 168 | scale0 = 1.0 - t; 169 | scale1 = t; 170 | } 171 | // calculate final values 172 | out[0] = scale0 * ax + scale1 * bx; 173 | out[1] = scale0 * ay + scale1 * by; 174 | out[2] = scale0 * az + scale1 * bz; 175 | out[3] = scale0 * aw + scale1 * bw; 176 | 177 | return out; 178 | } 179 | 180 | function getAngle(a: quat, b: quat): f32 { 181 | const dotproduct = dot(a, b); 182 | return Mathf.acos(2 * dotproduct * dotproduct - 1); 183 | } 184 | 185 | function dot(a: quat, b: quat): f32 { 186 | return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; 187 | } 188 | -------------------------------------------------------------------------------- /src/bindings.ts: -------------------------------------------------------------------------------- 1 | import wasm from './wasm.env.js'; 2 | import { Interpolation, TO_INTERPOLATION_INTERNAL } from './constants.js'; 3 | 4 | /////////////////////////////////////////////////////////////////////////////// 5 | // WASM API 6 | /////////////////////////////////////////////////////////////////////////////// 7 | 8 | interface Instance extends WebAssembly.WebAssemblyInstantiatedSource { 9 | exports: InstanceExports; 10 | } 11 | 12 | interface InstanceExports { 13 | memory: WebAssembly.Memory; 14 | resample: (input: number, output: number, interpolation: number, tolerance: number) => number; 15 | __setArgumentsLength: (length: number) => void; 16 | __new: (byteLength: number, id: number) => number; 17 | __pin: (ptr: number) => number; 18 | __unpin: (ptr: number) => void; 19 | __collect: () => void; 20 | } 21 | 22 | /////////////////////////////////////////////////////////////////////////////// 23 | // SETUP 24 | /////////////////////////////////////////////////////////////////////////////// 25 | 26 | let exports: InstanceExports; 27 | 28 | export const ready = /* #__PURE__ */ new Promise(async (resolve, reject) => { 29 | try { 30 | const module = await WebAssembly.compile(await wasm); 31 | exports = await instantiate(module as BufferSource, {}); 32 | resolve(); 33 | } catch (e) { 34 | reject(e); 35 | } 36 | }); 37 | 38 | async function instantiate(module: BufferSource, imports = {}): Promise { 39 | const instance = (await WebAssembly.instantiate(module, { 40 | env: Object.assign(Object.create(globalThis), {}, { abort: __abort }), 41 | })) as Instance; 42 | return instance.exports; 43 | } 44 | 45 | /////////////////////////////////////////////////////////////////////////////// 46 | // PUBLIC API 47 | /////////////////////////////////////////////////////////////////////////////// 48 | 49 | const CHUNK_SIZE = 1024; 50 | 51 | // The first and last keyframes cannot be removed in any given step, but we need to 52 | // somehow remove keyframes on chunk boundaries. So after processing each chunk, 53 | // we copy its last two keyframes in front of the next chunk, and run from there. 54 | // 55 | // 🟩 ⬜️ ⬜️ ⬜️ ⬜️ ⬜️ // chunk 1, original 56 | // 🟩 ⬜️ 🟨 🟥 // chunk 1, resampled 57 | // 🟨 🟥 🟩 ⬜️ ⬜️ ⬜️ // chunk 2, original 58 | // 🟨 🟩 ⬜️ ⬜️ // chunk 2, resampled 59 | // ... 60 | export function resample( 61 | input: Float32Array, 62 | output: Float32Array, 63 | interpolation: Interpolation, 64 | tolerance = 1e-4 65 | ): number { 66 | __assert(!!exports, 'Await "ready" before using module.'); 67 | __assert(input instanceof Float32Array, 'Missing Float32Array input.'); 68 | __assert(output instanceof Float32Array, 'Missing Float32Array output.'); 69 | 70 | const outputSize = output.length / input.length; 71 | 72 | __assert(Number.isInteger(outputSize), 'Invalid input/output counts.'); 73 | __assert(interpolation in TO_INTERPOLATION_INTERNAL, 'Invalid interpolation.'); 74 | __assert(Number.isFinite(tolerance), 'Invalid tolerance'); 75 | 76 | const interpVal = TO_INTERPOLATION_INTERNAL[interpolation]; 77 | const srcCount = input.length; 78 | let dstCount = 0; 79 | 80 | for (let chunkStart = 0; chunkStart < input.length; chunkStart += CHUNK_SIZE) { 81 | const chunkCount = Math.min(srcCount - chunkStart, CHUNK_SIZE); 82 | 83 | // Allocate a two-keyframe prefix for all chunks after the first. 84 | const prefixCount = chunkStart > 0 ? 2 : 0; 85 | const chunkInput = new Float32Array( 86 | input.buffer, 87 | input.byteOffset + (chunkStart - prefixCount) * Float32Array.BYTES_PER_ELEMENT, 88 | chunkCount + prefixCount 89 | ); 90 | const chunkOutput = new Float32Array( 91 | output.buffer, 92 | output.byteOffset + 93 | (chunkStart - prefixCount) * outputSize * Float32Array.BYTES_PER_ELEMENT, 94 | (chunkCount + prefixCount) * outputSize 95 | ); 96 | 97 | // Copy prefix to start of next chunk. 98 | if (prefixCount > 0) { 99 | input.copyWithin(chunkStart - prefixCount, dstCount - prefixCount, dstCount); 100 | output.copyWithin( 101 | (chunkStart - prefixCount) * outputSize, 102 | (dstCount - prefixCount) * outputSize, 103 | dstCount * outputSize 104 | ); 105 | } 106 | 107 | const inputPtr = __retain(__lowerStaticArray(chunkInput, 4, 2)); 108 | const outputPtr = __retain(__lowerStaticArray(chunkOutput, 4, 2)); 109 | try { 110 | exports.__setArgumentsLength(4); 111 | const count = exports.resample(inputPtr, outputPtr, interpVal, tolerance) >>> 0; 112 | dstCount -= prefixCount; 113 | __liftStaticArray(inputPtr, input, dstCount, count); 114 | __liftStaticArray(outputPtr, output, dstCount * outputSize, count * outputSize); 115 | dstCount += count; 116 | } finally { 117 | __release(inputPtr); 118 | __release(outputPtr); 119 | exports.__collect(); 120 | } 121 | } 122 | 123 | // console.log(`Memory: ${exports.memory.buffer.byteLength} bytes`); 124 | 125 | return dstCount; 126 | } 127 | 128 | /////////////////////////////////////////////////////////////////////////////// 129 | // INTERNAL 130 | /////////////////////////////////////////////////////////////////////////////// 131 | 132 | function __assert(cond: boolean, msg: string) { 133 | if (!cond) throw new Error(msg); 134 | } 135 | 136 | function __retain(ptr: number): number { 137 | exports.__pin(ptr); 138 | return ptr; 139 | } 140 | 141 | function __release(ptr: number): number { 142 | exports.__unpin(ptr); 143 | return ptr; 144 | } 145 | 146 | function __liftString(ptr: number) { 147 | if (!ptr) return null; 148 | const end = (ptr + new Uint32Array(exports.memory.buffer)[(ptr - 4) >>> 2]) >>> 1, 149 | memoryU16 = new Uint16Array(exports.memory.buffer); 150 | let start = ptr >>> 1, 151 | string = ''; 152 | while (end - start > 1024) 153 | string += String.fromCharCode(...memoryU16.subarray(start, (start += 1024))); 154 | return string + String.fromCharCode(...memoryU16.subarray(start, end)); 155 | } 156 | 157 | function __lowerStaticArray(values: Float32Array, id: number, align: number) { 158 | const ptr = exports.__pin(exports.__new(values.length << align, id)) >>> 0; 159 | new Float32Array(exports.memory.buffer, ptr, values.length).set(values); 160 | exports.__unpin(ptr); 161 | return ptr; 162 | } 163 | 164 | function __liftStaticArray(ptr: number, values: Float32Array, offset: number, count: number) { 165 | values.set(new Float32Array(exports.memory.buffer, ptr, count), offset); 166 | } 167 | 168 | function __abort( 169 | messagePtr: number, 170 | fileNamePtr: number, 171 | lineNumber: number, 172 | columnNumber: number 173 | ): void { 174 | const message = __liftString(messagePtr >>> 0); 175 | const fileName = __liftString(fileNamePtr >>> 0); 176 | lineNumber = lineNumber >>> 0; 177 | columnNumber = columnNumber >>> 0; 178 | (() => { 179 | throw Error(`${message} in ${fileName}:${lineNumber}:${columnNumber}`); 180 | })(); 181 | } 182 | --------------------------------------------------------------------------------