diff --git a/packages/draw/index.mjs b/packages/draw/index.mjs index 506c6151d..98ac989af 100644 --- a/packages/draw/index.mjs +++ b/packages/draw/index.mjs @@ -2,5 +2,6 @@ export * from './animate.mjs'; export * from './color.mjs'; export * from './draw.mjs'; export * from './pianoroll.mjs'; +export * from './shader.mjs'; export * from './spiral.mjs'; export * from './pitchwheel.mjs'; diff --git a/packages/draw/package.json b/packages/draw/package.json index a6a88ecfa..b65817504 100644 --- a/packages/draw/package.json +++ b/packages/draw/package.json @@ -29,7 +29,8 @@ }, "homepage": "https://github.com/tidalcycles/strudel#readme", "dependencies": { - "@strudel/core": "workspace:*" + "@strudel/core": "workspace:*", + "picogl": "^0.17.9" }, "devDependencies": { "vite": "^5.0.10" diff --git a/packages/draw/shader.mjs b/packages/draw/shader.mjs new file mode 100644 index 000000000..9875cf0ec --- /dev/null +++ b/packages/draw/shader.mjs @@ -0,0 +1,230 @@ +/* +shader.mjs - implements the `loadShader` helper and `shader` pattern 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 . +*/ + +import { PicoGL } from "picogl"; +import { register, 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; + +${code} + +void main(void) { + mainImage(oColor, gl_FragCoord.xy); +} +` +} + +// Modulation helpers. +const hardModulation = () => { + let val = 0; + return { + get: () => val, + set: (v) => { val = v }, + } +} + +const decayModulation = (decay) => { + let val = 0; + let desired = 0 + return { + get: (ts) => { + val += (desired - val) / decay + return val + }, + set: (v) => { desired = val + v }, + } +} + +// Set an uniform value (from a pattern). +function setUniform(instance, name, value, position) { + const uniform = instance.uniforms[name] + if (uniform) { + if (uniform.count == 0) { + // This is a single value + uniform.mod.set(value) + } else { + // This is an array + const idx = position % uniform.mod.length + uniform.mod[idx].set(value) + } + } else { + logger('[shader] unknown uniform: ' + name) + } + + // Ensure the instance is drawn + instance.age = 0 + if (!instance.drawing) { + instance.drawing = requestAnimationFrame(instance.update) + } +} + +// 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)) + // Send the value to the GPU + drawFrame.uniform(uniform.name, value) + }) +} + +// Setup the instance's uniform after shader compilation. +function setupUniforms(uniforms, program) { + Object.entries(program.uniforms).forEach(([name, uniform]) => { + if (name != "iTime" && name != "iResolution") { + // remove array suffix + const uname = name.replace("[0]", "") + const count = uniform.count | 0 + if (!uniforms[uname] || uniforms[name].count != count) { + // TODO: keep the previous value when the count change... + uniforms[uname] = { + name, + count, + value: count == 0 ? 0 : new Float32Array(count), + mod: count == 0 ? decayModulation(50) : new Array(count).fill().map(() => decayModulation(50)) + } + } + } + }) + // TODO: remove previous uniform that are no longer used... + return uniforms +} + +// 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") +} + +// Setup the shader instance +async function initializeShaderInstance(name, code) { + // Setup PicoGL app + const ctx = setupCanvas(name) + console.log(ctx) + const app = PicoGL.createApp(ctx); + app.resize(400, 300) + + // Setup buffers + const resolution = new Float32Array([ctx.canvas.width, ctx.canvas.height]) + + // Two triangle to cover the whole canvas + const positionBuffer = app.createVertexBuffer(PicoGL.FLOAT, 2, new Float32Array([ + -1, -1, -1, 1, 1, 1, + 1, 1, 1, -1, -1, -1, + ])) + + // Setup the arrays + const arrays = app.createVertexArray().vertexAttributeBuffer(0, positionBuffer); + + return app.createPrograms([vertexShader, code]).then(([program]) => { + const drawFrame = app.createDrawCall(program, arrays) + const instance = {app, code, program, arrays, drawFrame, uniforms: setupUniforms({}, program)} + + // Render frame logic + let prev = performance.now() / 1000; + instance.age = 0 + instance.update = () => { + const now = performance.now() / 1000; + const elapsed = now - prev + prev = now + // console.log("drawing!") + app.clear() + instance.drawFrame + .uniform("iResolution", resolution) + .uniform("iTime", now) + + updateUniforms(instance.drawFrame, elapsed, instance.uniforms) + + instance.drawFrame.draw() + if (instance.age++ < 100) + requestAnimationFrame(instance.update) + else + instance.drawing = false + } + return instance + }).catch((err) => { + ctx.canvas.remove() + throw err + }) +} + +// Update the instance program +async function reloadShaderInstanceCode(instance, code) { + return instance.app.createPrograms([vertexShader, code]).then(([program]) => { + instance.program.delete() + instance.program = program + instance.uniforms = setupUniforms(instance.uniforms, program) + instance.draw = instance.app.createDrawCall(program, instance.arrays) + }) +} + +// Keep track of the running shader instances +let _instances = {} +export async function loadShader(code = '', name = 'default') { + if (code) { + code = mkFragmentShader(code) + } + if (!_instances[name]) { + _instances[name] = await initializeShaderInstance(name, code) + logger('[shader] ready') + } else if (_instances[name].code != code) { + await reloadShaderInstanceCode(_instances[name], code) + logger('[shader] reloaded') + } +} + +export const shader = register('shader', (options, pat) => { + // Keep track of the pitches value: Map String Int + const pitches = {_count: 0}; + + return pat.onTrigger((time_deprecate, hap, currentTime, cps, targetTime) => { + const instance = _instances[options.instance || "default"] + if (!instance) { + logger('[shader] not loaded yet', 'warning') + return + } + + const value = options.gain || 1.0; + if (options.pitch !== undefined) { + const note = hap.value.note || hap.value.s; + if (pitches[note] === undefined) { + // Assign new value, the first note gets 0, then 1, then 2, ... + pitches[note] = Object.keys(pitches).length + } + setUniform(instance, options.pitch, value, pitches[note]) + } else if (options.seq !== undefined) { + setUniform(instance, options.seq, value, pitches.count++) + } else if (options.uniform !== undefined) { + setUniform(instance, options.uniform, value) + } else { + console.error("Unknown shader options, need either pitch or uniform", options) + } + }, false) +})