diff --git a/packages/shader/package.json b/packages/shader/package.json index 55e1250e7..64496b424 100644 --- a/packages/shader/package.json +++ b/packages/shader/package.json @@ -8,6 +8,7 @@ "main": "dist/index.mjs" }, "scripts": { + "test": "vitest run", "build": "vite build", "prepublishOnly": "npm run build" }, @@ -34,6 +35,7 @@ "picogl": "^0.17.9" }, "devDependencies": { - "vite": "^5.0.10" + "vite": "^5.0.10", + "vitest": "^2.1.3" } } diff --git a/packages/shader/shader.mjs b/packages/shader/shader.mjs index 6b1864b38..f65b23f8a 100644 --- a/packages/shader/shader.mjs +++ b/packages/shader/shader.mjs @@ -34,50 +34,47 @@ void main(void) { `; } -// Modulation helpers. -const hardModulation = () => { - let val = 0; - return { - get: () => val, - set: (v) => { - val = v; - }, - }; -}; +// Modulation helpers to smooth the values. +class UniformValue { + constructor() { + this.value = 0; + this.desired = 0; + this.slow = 10; + } -const decayModulation = (decay) => { - let val = 0; - let desired = 0; - return { - get: (ts) => { - val += (desired - val) / decay; - return val; - }, - set: (v) => { - desired = val + v; - }, - }; -}; + get(elapsed) { + // Adjust the value according to the rate of change + const offset = (this.desired - this.value) / (this.slow * Math.min(1, elapsed * 60)); + // Ignore small changes + if (Math.abs(offset) > 1e-3) this.value += offset; + return this.value; + } +} // Set an uniform value (from a pattern). -export function setUniform(instanceName, name, value, position) { +export function setUniform(instanceName, name, value, incr, position, slow) { const instance = _instances[instanceName]; if (!instance) { logger('[shader] not loaded yet', 'warning'); return; } - // console.log("setUniform: ", name, value, position) + // console.log('setUniform: ', name, value, position, slow); const uniform = instance.uniforms[name]; if (uniform) { + let uniformValue; if (uniform.count == 0) { // This is a single value - uniform.mod.set(value); + uniformValue = uniform.value; } else { // This is an array - const idx = position % uniform.mod.length; - uniform.mod[idx].set(value); + const idx = position % uniform.value.length; + uniformValue = uniform.value[idx]; } + uniformValue.slow = slow; + // TODO: handle direct assignment, this is incrementing by default + if (incr) uniformValue.desired += value; + else uniformValue.desired = value; } else { logger('[shader] unknown uniform: ' + name); } @@ -92,11 +89,10 @@ export function setUniform(instanceName, name, value, position) { // Update the uniforms for a given drawFrame call. function updateUniforms(drawFrame, elapsed, uniforms) { Object.values(uniforms).forEach((uniform) => { - const value = - uniform.count == 0 ? uniform.mod.get(elapsed) : uniform.value.map((_, i) => uniform.mod[i].get(elapsed)); + const value = uniform.count == 0 ? uniform.value.get(elapsed) : uniform.value.map((v) => v.get(elapsed)); - // console.log("updateUniforms:", uniform.name, value) // Send the value to the GPU + // console.log('updateUniforms:', uniform.name, value); drawFrame.uniform(uniform.name, value); }); } @@ -114,8 +110,7 @@ function setupUniforms(uniforms, program) { uniforms[uname] = { name, count, - value: count == 0 ? 0 : new Float32Array(count), - mod: count == 0 ? decayModulation(50) : new Array(count).fill().map(() => decayModulation(50)), + value: count == 0 ? new UniformValue() : new Array(count).fill().map(() => new UniformValue()), }; } } @@ -169,7 +164,7 @@ async function initializeShaderInstance(name, code) { instance.age = 0; instance.update = () => { const now = performance.now() / 1000; - const elapsed = now - prev; + const elapsed = instance.age == 0 ? 1 / 60 : now - prev; prev = now; // console.log("drawing!") app.clear(); diff --git a/packages/shader/testSetup.mjs b/packages/shader/testSetup.mjs new file mode 100644 index 000000000..fb6963834 --- /dev/null +++ b/packages/shader/testSetup.mjs @@ -0,0 +1,3 @@ +// Fix `ReferenceError: self is not defined` +// when importing picogl in tests +globalThis.self = {}; diff --git a/packages/shader/uniform.mjs b/packages/shader/uniform.mjs index 12627aa68..4bf364967 100644 --- a/packages/shader/uniform.mjs +++ b/packages/shader/uniform.mjs @@ -7,18 +7,29 @@ This program is free software: you can redistribute it and/or modify it under th import { register, logger } from '@strudel/core'; import { setUniform } from './shader.mjs'; -function parseUniformTarget(name) { - if (typeof name === 'string') return { name, position: 0 }; - else if (name.length == 2) { - let position = null; - if (typeof name[1] === 'number') position = name[1]; - else if (name[1] == 'random') position = Math.floor(Math.random() * 1024); - else if (name[1] != 'seq') throw 'Unknown mapping: ' + name[1]; - return { name: name[0], position }; +// Parse a destination from the mini notation, e.g. `name` or `name:attr:value` +export function parseUniformDest(dest) { + let result = {}; + if (typeof dest === 'string') result.name = dest; + else if (dest.length >= 2) { + result.name = dest[0]; + // Parse the attr:value pairs + for (let i = 1; i < dest.length; i += 2) { + const k = dest[i]; + const v = dest[i + 1]; + const isNum = typeof v === 'number'; + if (k == 'index' && isNum) result.position = v; + else if (k == 'index' && v == 'random') result.position = Math.floor(Math.random() * 1024); + else if (k == 'index' && v == 'seq') result.position = null; + else if (k == 'gain' && isNum) result.gain = v; + else if (k == 'slow' && isNum) result.slow = v; + else throw 'Bad uniform param ' + k + ':' + v; + } } + return result; } -// Keep track of the pitches value per uniform +// Keep track of the last uniforms' array position let _uniforms = {}; function getNextPosition(name, value) { // Initialize uniform state @@ -37,27 +48,31 @@ function getNextPosition(name, value) { * Update a shader. The destination name consist of * * - the uniform name - * - optional array mapping, either a number or an assignment mode ('seq' or 'random') + * - optional 'index' to set array position, either a number or an assignment mode ('seq' or 'random') + * - optional 'gain' to adjust the value: 0 to silence, 2 to double + * - optional 'slow' to adjust the change speed: 1 for instant, 50 for slow changes, default to 10 * * @name uniform * @example * pan(sine.uniform("iColor")) * @example - * gain("<.5 .3>".uniform("rotations:seq")) + * gain("<.5 .3>".uniform("rotations:index:seq")) */ export const uniform = register('uniform', (target, pat) => { // TODO: support multiple shader instance const instance = 'default'; // Decode the uniform target defintion - const uniformTarget = parseUniformTarget(target); + const uniformDest = parseUniformDest(target); + // Set the first value by default + if (uniformDest.position === undefined) uniformDest.position = 0; return pat.withValue((v) => { // TODO: figure out why this is called repeatedly when changing values. For example, on first call, the last value is passed. if (typeof v === 'number') { - const position = - uniformTarget.position === null ? getNextPosition(uniformTarget.name, v) : uniformTarget.position; - setUniform(instance, uniformTarget.name, v, position); + const position = uniformDest.position === null ? getNextPosition(uniformDest.name, v) : uniformDest.position; + const value = v * (uniformDest.gain || 1); + setUniform(instance, uniformDest.name, value, false, position, uniformDest.slow || 10); } else { console.error('Uniform applied to a non number pattern'); } @@ -79,32 +94,28 @@ function getNotePosition(name, value) { } /** - * Update a shader with note-on event. The destination name consist of - * - * - the uniform name - * - optional array position, default to randomly assigned position based on the note or sound. + * Update a shader with note-on event. See the 'uniform' doc. * * @name uniformTrigger * @example - * pan(sine.uniform("iColor")) - * @example - * gain("<.5 .3>".uniform("rotations:seq")) + * s("bd sd").uniformTrigger("iColors:gain:2")) */ export const uniformTrigger = register('uniformTrigger', (target, pat) => { // TODO: support multiple shader instance const instance = 'default'; // Decode the uniform target defintion - const uniformTarget = parseUniformTarget(target); + const uniformDest = parseUniformDest(target); + // Assign pitch position by default + if (uniformDest.position === undefined) uniformDest.position = null; return pat.onTrigger((time_deprecate, hap, currentTime, cps, targetTime) => { const position = - uniformTarget.position === null ? getNotePosition(uniformTarget.name, hap.value) : uniformTarget.position; + uniformDest.position === null ? getNotePosition(uniformDest.name, hap.value) : uniformDest.position; - // TODO: support custom value - const value = 1.0; + const value = (hap.value.gain || 1) * (uniformDest.gain || 1); // Update the uniform - setUniform(instance, uniformTarget.name, value, position); + setUniform(instance, uniformDest.name, value, true, position, uniformDest.slow || 10); }, false); }); diff --git a/packages/shader/uniform.test.mjs b/packages/shader/uniform.test.mjs new file mode 100644 index 000000000..7751ebd56 --- /dev/null +++ b/packages/shader/uniform.test.mjs @@ -0,0 +1,22 @@ +/* +uniform.test.mjs - +Copyright (C) 2024 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +import { describe, it, expect } from 'vitest'; +import { parseUniformDest } from './uniform.mjs'; + +describe('Uniform', () => { + it('Parse simple', () => { + expect(parseUniformDest('iColor')).toStrictEqual({ name: 'iColor' }); + }); + it('Parse param', () => { + expect(parseUniformDest(['iColor', 'index', 2])).toStrictEqual({ name: 'iColor', position: 2 }); + expect(parseUniformDest(['iColor', 'index', 'seq'])).toStrictEqual({ name: 'iColor', position: null }); + expect(parseUniformDest(['iColor', 'gain', 3])).toStrictEqual({ name: 'iColor', gain: 3 }); + }); + it('Parse multi param', () => { + expect(parseUniformDest(['iColor', 'index', 2, 'gain', 3])).toStrictEqual({ name: 'iColor', position: 2, gain: 3 }); + }); +}); diff --git a/packages/shader/vite.config.js b/packages/shader/vite.config.js index 5df3edc1b..b0e119c95 100644 --- a/packages/shader/vite.config.js +++ b/packages/shader/vite.config.js @@ -5,6 +5,9 @@ import { resolve } from 'path'; // https://vitejs.dev/config/ export default defineConfig({ plugins: [], + test: { + setupFiles: './testSetup.mjs', + }, build: { lib: { entry: resolve(__dirname, 'index.mjs'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb7fba514..c12f2445e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -418,6 +418,9 @@ importers: vite: specifier: ^5.0.10 version: 5.4.9(@types/node@22.7.6)(terser@5.36.0) + vitest: + specifier: ^2.1.3 + version: 2.1.3(@types/node@22.7.6)(@vitest/ui@2.1.3)(terser@5.36.0) packages/soundfonts: dependencies: @@ -7759,6 +7762,7 @@ packages: workbox-google-analytics@7.0.0: resolution: {integrity: sha512-MEYM1JTn/qiC3DbpvP2BVhyIH+dV/5BjHk756u9VbwuAhu0QHyKscTnisQuz21lfRpOwiS9z4XdqeVAKol0bzg==} + deprecated: It is not compatible with newer versions of GA starting with v4, as long as you are using GAv3 it should be ok, but the package is not longer being maintained workbox-navigation-preload@7.0.0: resolution: {integrity: sha512-juWCSrxo/fiMz3RsvDspeSLGmbgC0U9tKqcUPZBCf35s64wlaLXyn2KdHHXVQrb2cqF7I0Hc9siQalainmnXJA==}