diff --git a/examples/codemirror-repl/main.js b/examples/codemirror-repl/main.js index 5b5714fb7..2d360ce5d 100644 --- a/examples/codemirror-repl/main.js +++ b/examples/codemirror-repl/main.js @@ -29,6 +29,7 @@ const editor = new StrudelMirror({ import('@strudel/core'), import('@strudel/draw'), import('@strudel/mini'), + import('@strudel/shader'), import('@strudel/tonal'), import('@strudel/webaudio'), ); diff --git a/packages/repl/prebake.mjs b/packages/repl/prebake.mjs index 9fc1c8819..f0f72687f 100644 --- a/packages/repl/prebake.mjs +++ b/packages/repl/prebake.mjs @@ -12,6 +12,7 @@ export async function prebake() { import('@strudel/webaudio'), import('@strudel/codemirror'), import('@strudel/hydra'), + import('@strudel/shader'), import('@strudel/soundfonts'), import('@strudel/midi'), // import('@strudel/xen'), diff --git a/packages/shader/README.md b/packages/shader/README.md new file mode 100644 index 000000000..42dc3ba0f --- /dev/null +++ b/packages/shader/README.md @@ -0,0 +1,16 @@ +# @strudel/shader + +Helpers for drawing shader. + +## Todos + +Here are the things that needs to be implemented: + +- [ ] Shader compilation error reporting, e.g. to show the line number +- [ ] Shader import from url, like shadertoy or git +- [ ] Display shader author attribution, e.g. to respect CC-BY +- [ ] Handle WebGL context lost by restoring the objects. +- [ ] Multiple instances and custom canvas positions +- [ ] Multiple programs, to be swapped like a pattern +- [ ] Texture inputs +- [ ] Buffer inputs, e.g. to generate a texture diff --git a/packages/shader/index.mjs b/packages/shader/index.mjs new file mode 100644 index 000000000..8045a1724 --- /dev/null +++ b/packages/shader/index.mjs @@ -0,0 +1 @@ +export * from './shader.mjs'; diff --git a/packages/shader/package.json b/packages/shader/package.json new file mode 100644 index 000000000..45ea5c11c --- /dev/null +++ b/packages/shader/package.json @@ -0,0 +1,40 @@ +{ + "name": "@strudel/shader", + "version": "1.0.0", + "description": "Helpers for drawing shader", + "main": "index.mjs", + "type": "module", + "publishConfig": { + "main": "dist/index.mjs" + }, + "scripts": { + "build": "vite build", + "prepublishOnly": "npm run build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/tidalcycles/strudel.git" + }, + "keywords": [ + "titdalcycles", + "strudel", + "pattern", + "livecoding", + "algorave", + "shader" + ], + "author": "Felix Roos ", + "license": "AGPL-3.0-or-later", + "bugs": { + "url": "https://github.com/tidalcycles/strudel/issues" + }, + "homepage": "https://github.com/tidalcycles/strudel#readme", + "dependencies": { + "@strudel/core": "workspace:*", + "picogl": "^0.17.9" + }, + "devDependencies": { + "vite": "^5.0.10", + "vitest": "^2.1.3" + } +} diff --git a/packages/shader/shader.mjs b/packages/shader/shader.mjs new file mode 100644 index 000000000..37d3995d3 --- /dev/null +++ b/packages/shader/shader.mjs @@ -0,0 +1,347 @@ +/* +shader.mjs - implements the `loadShader` function +Copyright (C) 2024 Strudel contributors +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 . +*/ + +/* +/// Here is a feature demo +// Setup a shader +let truchetFTW = fetch('https://raw.githubusercontent.com/TristanCacqueray/shaders/refs/heads/main/shaders/Truchet%20%2B%20Kaleidoscope%20FTW.glsl').then((res) => res.text()) +// This shader provides the following uniforms: +// uniform float icolor; +// uniform float moveFWD; +// uniform float rotations[4]; +// uniform float modulations[6]; + +// Start the instance and binds the uniforms +let {uniforms} = await loadShader(truchetFTW) + +setcpm(96) + +// A smoothing function that is called for each frame +let smooth = (desired, speed) => (value) => value + ((desired - value) / speed) + +// Each kick updates a different rotation value. +let rotationIndex = 0 +$: s("bd").bank("RolandTR808") + .gain(2).dist("<1 .7 .7 .7>") + .mask("<1@30 0@2>") + .onTrigger(() => uniforms.rotations.set( + cur => smooth(cur + 1, 20), rotationIndex++), + false) + +// Each hat increase the icolor value. +$: sound("hh*4").bank("RolandTR808") + .room(.3).gain(".25 .3 .4") + .mask("<0@8 1@32>") + .onTrigger(() => uniforms.icolor.incr(0.1), false) + +// The snare smoothly increase the moveFWD value +$: s("cp/8").bank("RolandTR808") + .hpf(500).hpa(.8).hpenv("<-3 -2 -3 -2 -1>/8") + .room(0.5).roomsize(7).rlp(5000).gain(.2) + .onTrigger(() => uniforms.moveFWD.set(cur => smooth(cur + 1, 30)), false) + +// Each piano note updates a different modulations value +let pianoPitches = {} +$: note("*[2,2.02]") + .clip(1.1) + .transpose("<-12 -24 -12 0>/8") + // .sound("sawtooth") + .sound("triangle") + .cutoff(perlin.slow(5).range(20,1200)) + .room(.8).roomsize(.6) + .gain(.4) + .onTrigger((_, hap) => { + const n = hap.value.note + // assign unique array position for each new notes + if (!pianoPitches[n]) pianoPitches[n] = Object.keys(pianoPitches).length + 1 + const idx = pianoPitches[n] + uniforms.modulations.set(cur => smooth(cur + .5, 55), idx) + }, false) +*/ + +import { logger } from '@strudel/core'; + +// The standard fullscreen vertex shader. +const vertexShader = `#version 300 es +precision highp float; +layout(location=0) in vec2 position; +void main() { + gl_Position = vec4(position, 1, 1); +} +`; + +// Make the fragment source, similar to the one from shadertoy. +function mkFragmentShader(code) { + return `#version 300 es +precision highp float; +out vec4 oColor; +uniform float iTime; +uniform vec2 iResolution; + +#define STRUDEL 1 + +${code} + +void main(void) { + mainImage(oColor, gl_FragCoord.xy); +} +`; +} + +// Helper class to handle uniform updates +class UniformValue { + constructor(count, draw) { + this.draw = draw; + this.value = new Array(count).fill(0); + this.frameModifier = new Array(count).fill(null); + } + + // Helper to perform a simple increment + incr(value, pos = 0) { + const idx = pos % this.value.length; + this.value[idx] += value; + this.frameModifier[idx] = null; + this.draw(); + } + + // The value can be a function that will be called for each rendering frame + set(value, pos = 0) { + const idx = pos % this.value.length; + if (typeof value === 'function') { + this.frameModifier[idx] = value(this.value[idx]); + } else { + this.value[idx] = value; + this.frameModifier[idx] = null; + } + this.draw(); + } + + get(pos = 0) { + return this.value[pos % this.value.length]; + } + + // This function is called for every frame, allowing to run a smooth modifier + _frameUpdate(elapsed) { + this.value = this.value.map((value, idx) => + this.frameModifier[idx] ? this.frameModifier[idx](value, elapsed) : value, + ); + return this.value; + } + + // When the shader is update, this function adjust the number of values, preserving the current one + _resize(count) { + if (count != this.count) { + count = Math.max(1, count); + resizeArray(this.value, count, 0); + resizeArray(this.frameModifier, count, null); + } + } +} + +// Shrink or extend an array +function resizeArray(arr, size, defval) { + if (arr.length > size) arr.length = size; + else arr.push(...new Array(size - arr.length).fill(defval)); +} + +// Setup the instance's uniform after shader compilation. +function setupUniforms(instance) { + const newUniforms = new Set(); + const draw = () => { + // Start the drawing loop + instance.age = 0; + if (!instance.drawing) { + instance.drawing = requestAnimationFrame(instance.update); + } + }; + + // Collect every available uniforms + let gl = instance.gl; + const numUniforms = instance.gl.getProgramParameter(instance.program, gl.ACTIVE_UNIFORMS); + for (let i = 0; i < numUniforms; ++i) { + const inf = gl.getActiveUniform(instance.program, i); + + // Arrays have a `[0]` suffix in their name, drop that + const name = inf.name.replace('[0]', ''); + + // Figure out how many values is this uniform, and how to update it. + let count = inf.size; + let updateFunc = 'uniform1fv'; + switch (inf.type) { + case gl.FLOAT_VEC2: + count *= 2; + updateFunc = 'uniform2fv'; + break; + case gl.FLOAT_VEC3: + count *= 3; + updateFunc = 'uniform3fv'; + break; + case gl.FLOAT_VEC4: + count *= 4; + updateFunc = 'uniform4fv'; + break; + } + + // This is a new uniform + if (!instance.uniforms[name]) instance.uniforms[name] = new UniformValue(count, draw); + // This is a known uniform, make sure it's size is correct + else instance.uniforms[name]._resize(count); + + // Record it's location for the 'updateUniforms' below. + instance.uniforms[name].loc = gl.getUniformLocation(instance.program, inf.name); + instance.uniforms[name].updateFunc = updateFunc; + + // Record the name so that unused uniform can be deleted below + newUniforms.add(name); + } + + // Remove deleted uniforms + Object.keys(instance.uniforms).forEach((name) => { + if (!newUniforms.has(name)) delete instance.uniforms[name]; + }); +} + +// Update the uniforms for a given drawFrame call. +function updateUniforms(gl, now, elapsed, uniforms) { + Object.entries(uniforms).forEach(([name, uniform]) => { + try { + if (name == 'iTime') { + gl.uniform1f(uniform.loc, now); + } else if (name == 'iResolution') { + gl.uniform2f(uniform.loc, gl.canvas.width, gl.canvas.height); + } else { + const value = uniform._frameUpdate(elapsed); + // Send the value to the GPU + // console.log('updateUniforms:', name, uniform.updateFunc, value); + gl[uniform.updateFunc](uniform.loc, value); + } + } catch (err) { + console.warn('uniform error'); + console.error(err); + } + }); +} + +// Setup the canvas and return the WebGL context. +function setupCanvas(name) { + // TODO: support custom size + const width = 400; + const height = 300; + const canvas = document.createElement('canvas'); + canvas.id = 'cnv-' + name; + canvas.width = width; + canvas.height = height; + const top = 60 + Object.keys(_instances).length * height; + canvas.style = `pointer-events:none;width:${width}px;height:${height}px;position:fixed;top:${top}px;right:23px`; + document.body.append(canvas); + return canvas.getContext('webgl2'); +} + +function createProgram(gl, vertex, fragment) { + const compile = (type, source) => { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS); + if (!success) { + const err = gl.getShaderInfoLog(shader); + gl.deleteShader(shader); + throw err; + } + return shader; + }; + const program = gl.createProgram(); + gl.attachShader(program, compile(gl.VERTEX_SHADER, vertex)); + gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fragment)); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const err = gl.getProgramInfoLog(program); + gl.deleteProgram(program); + throw err; + } + gl.useProgram(program); + return program; +} + +// Setup the shader instance +function initializeShaderInstance(name, code) { + const gl = setupCanvas(name); + + // Two triangle to cover the whole canvas + const mkPositionArray = () => { + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW); + const vao = gl.createVertexArray(); + gl.bindVertexArray(vao); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(buf, 2, gl.FLOAT, false, 0, 0); + return vao; + }; + + try { + let array = mkPositionArray(); + let program = createProgram(gl, vertexShader, code); + const instance = { gl, code, program, array, uniforms: {} }; + setupUniforms(instance); + // Render frame logic + let prev = performance.now() / 1000; + instance.age = 0; + instance.update = () => { + const now = performance.now() / 1000; + const elapsed = instance.age == 0 ? 1 / 60 : now - prev; + prev = now; + // console.log('drawing!'); + + gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); + + // Clear the canvas + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.bindVertexArray(array); + + // Send the uniform values to the GPU + updateUniforms(instance.gl, now, elapsed, instance.uniforms); + + // Draw the quad + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + + // After sometime, if no update happened, stop the animation loop to save cpu cycles + if (instance.age++ < 100) requestAnimationFrame(instance.update); + else instance.drawing = false; + }; + instance.update(); + return instance; + } catch (err) { + gl.canvas.remove(); + throw err; + } +} + +// Update the instance program +function reloadShaderInstanceCode(instance, code) { + const program = createProgram(instance.gl, vertexShader, code); + instance.gl.deleteProgram(instance.program); + instance.program = program; + instance.code = code; + setupUniforms(instance); +} + +// Keep track of the running shader instances +let _instances = {}; +export function loadShader(code = '', name = 'default') { + if (code) { + code = mkFragmentShader(code); + } + if (!_instances[name]) { + _instances[name] = initializeShaderInstance(name, code); + logger('[shader] ready'); + } else if (_instances[name].code != code) { + reloadShaderInstanceCode(_instances[name], code); + logger('[shader] reloaded'); + } + return _instances[name]; +} diff --git a/packages/shader/vite.config.js b/packages/shader/vite.config.js new file mode 100644 index 000000000..5df3edc1b --- /dev/null +++ b/packages/shader/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import { dependencies } from './package.json'; +import { resolve } from 'path'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [], + build: { + lib: { + entry: resolve(__dirname, 'index.mjs'), + formats: ['es'], + fileName: (ext) => ({ es: 'index.mjs' })[ext], + }, + rollupOptions: { + external: [...Object.keys(dependencies)], + }, + target: 'esnext', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a18f06e73..c12f2445e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -406,6 +406,22 @@ importers: specifier: ^5.0.10 version: 5.4.9(@types/node@22.7.6)(terser@5.36.0) + packages/shader: + dependencies: + '@strudel/core': + specifier: workspace:* + version: link:../core + picogl: + specifier: ^0.17.9 + version: 0.17.9 + devDependencies: + 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: '@strudel/core': @@ -629,6 +645,9 @@ importers: '@strudel/serial': specifier: workspace:* version: link:../packages/serial + '@strudel/shader': + specifier: workspace:* + version: link:../packages/shader '@strudel/soundfonts': specifier: workspace:* version: link:../packages/soundfonts @@ -6084,6 +6103,9 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picogl@0.17.9: + resolution: {integrity: sha512-TfqB7jlD5FTO4a/Rp9wnMhVjPA0XZ/xbtLS2f8eHtOmVtIOhFD7Wf0jUL6kDjww3bgF/REEV9oPreBeGvWFlUQ==} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -7740,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==} @@ -14789,6 +14812,8 @@ snapshots: picocolors@1.1.1: {} + picogl@0.17.9: {} + picomatch@2.3.1: {} picomatch@4.0.2: {} diff --git a/website/package.json b/website/package.json index 942fee9c6..176454660 100644 --- a/website/package.json +++ b/website/package.json @@ -35,6 +35,7 @@ "@strudel/mini": "workspace:*", "@strudel/osc": "workspace:*", "@strudel/serial": "workspace:*", + "@strudel/shader": "workspace:*", "@strudel/soundfonts": "workspace:*", "@strudel/tonal": "workspace:*", "@strudel/transpiler": "workspace:*", diff --git a/website/src/repl/util.mjs b/website/src/repl/util.mjs index 905e16b09..901f3323f 100644 --- a/website/src/repl/util.mjs +++ b/website/src/repl/util.mjs @@ -78,6 +78,7 @@ export function loadModules() { import('@strudel/codemirror'), import('@strudel/hydra'), import('@strudel/serial'), + import('@strudel/shader'), import('@strudel/soundfonts'), import('@strudel/csound'), import('@strudel/tidal'),