|
| 1 | +/* |
| 2 | +shader.mjs - implements the `loadShader` helper and `shader` pattern function |
| 3 | +Copyright (C) 2024 Strudel contributors |
| 4 | +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 <https://www.gnu.org/licenses/>. |
| 5 | +*/ |
| 6 | + |
| 7 | +import { PicoGL } from "picogl"; |
| 8 | +import { register, logger } from '@strudel/core'; |
| 9 | + |
| 10 | +// The standard fullscreen vertex shader. |
| 11 | +const vertexShader = `#version 300 es |
| 12 | +precision highp float; |
| 13 | +layout(location=0) in vec2 position; |
| 14 | +void main() { |
| 15 | + gl_Position = vec4(position, 1, 1); |
| 16 | +} |
| 17 | +`; |
| 18 | + |
| 19 | +// Make the fragment source, similar to the one from shadertoy. |
| 20 | +function mkFragmentShader(code) { |
| 21 | + return `#version 300 es |
| 22 | +precision highp float; |
| 23 | +out vec4 oColor; |
| 24 | +uniform float iTime; |
| 25 | +uniform vec2 iResolution; |
| 26 | +
|
| 27 | +${code} |
| 28 | +
|
| 29 | +void main(void) { |
| 30 | + mainImage(oColor, gl_FragCoord.xy); |
| 31 | +} |
| 32 | +` |
| 33 | +} |
| 34 | + |
| 35 | +// Modulation helpers. |
| 36 | +const hardModulation = () => { |
| 37 | + let val = 0; |
| 38 | + return { |
| 39 | + get: () => val, |
| 40 | + set: (v) => { val = v }, |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +const decayModulation = (decay) => { |
| 45 | + let val = 0; |
| 46 | + let desired = 0 |
| 47 | + return { |
| 48 | + get: (ts) => { |
| 49 | + val += (desired - val) / decay |
| 50 | + return val |
| 51 | + }, |
| 52 | + set: (v) => { desired = val + v }, |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +// Set an uniform value (from a pattern). |
| 57 | +function setUniform(instance, name, value, position) { |
| 58 | + const uniform = instance.uniforms[name] |
| 59 | + if (uniform) { |
| 60 | + if (uniform.count == 0) { |
| 61 | + // This is a single value |
| 62 | + uniform.mod.set(value) |
| 63 | + } else { |
| 64 | + // This is an array |
| 65 | + const idx = position % uniform.mod.length |
| 66 | + uniform.mod[idx].set(value) |
| 67 | + } |
| 68 | + } else { |
| 69 | + logger('[shader] unknown uniform: ' + name) |
| 70 | + } |
| 71 | + |
| 72 | + // Ensure the instance is drawn |
| 73 | + instance.age = 0 |
| 74 | + if (!instance.drawing) { |
| 75 | + instance.drawing = requestAnimationFrame(instance.update) |
| 76 | + } |
| 77 | +} |
| 78 | + |
| 79 | +// Update the uniforms for a given drawFrame call. |
| 80 | +function updateUniforms(drawFrame, elapsed, uniforms) { |
| 81 | + Object.values(uniforms).forEach((uniform) => { |
| 82 | + const value = uniform.count == 0 |
| 83 | + ? uniform.mod.get(elapsed) |
| 84 | + : uniform.value.map((_, i) => uniform.mod[i].get(elapsed)) |
| 85 | + // Send the value to the GPU |
| 86 | + drawFrame.uniform(uniform.name, value) |
| 87 | + }) |
| 88 | +} |
| 89 | + |
| 90 | +// Setup the instance's uniform after shader compilation. |
| 91 | +function setupUniforms(uniforms, program) { |
| 92 | + Object.entries(program.uniforms).forEach(([name, uniform]) => { |
| 93 | + if (name != "iTime" && name != "iResolution") { |
| 94 | + // remove array suffix |
| 95 | + const uname = name.replace("[0]", "") |
| 96 | + const count = uniform.count | 0 |
| 97 | + if (!uniforms[uname] || uniforms[uname].count != count) { |
| 98 | + // TODO: keep the previous value when the count change... |
| 99 | + uniforms[uname] = { |
| 100 | + name, |
| 101 | + count, |
| 102 | + value: count == 0 ? 0 : new Float32Array(count), |
| 103 | + mod: count == 0 ? decayModulation(50) : new Array(count).fill().map(() => decayModulation(50)) |
| 104 | + } |
| 105 | + } |
| 106 | + } |
| 107 | + }) |
| 108 | + // TODO: remove previous uniform that are no longer used... |
| 109 | + return uniforms |
| 110 | +} |
| 111 | + |
| 112 | +// Setup the canvas and return the WebGL context. |
| 113 | +function setupCanvas(name) { |
| 114 | + // TODO: support custom size |
| 115 | + const width = 400; const height = 300; |
| 116 | + const canvas = document.createElement('canvas'); |
| 117 | + canvas.id = "cnv-" + name |
| 118 | + canvas.width = width |
| 119 | + canvas.height = height |
| 120 | + const top = 60 + Object.keys(_instances).length * height |
| 121 | + canvas.style = `pointer-events:none;width:${width}px;height:${height}px;position:fixed;top:${top}px;right:23px`; |
| 122 | + document.body.append(canvas) |
| 123 | + return canvas.getContext("webgl2") |
| 124 | +} |
| 125 | + |
| 126 | +// Setup the shader instance |
| 127 | +async function initializeShaderInstance(name, code) { |
| 128 | + // Setup PicoGL app |
| 129 | + const ctx = setupCanvas(name) |
| 130 | + console.log(ctx) |
| 131 | + const app = PicoGL.createApp(ctx); |
| 132 | + app.resize(400, 300) |
| 133 | + |
| 134 | + // Setup buffers |
| 135 | + const resolution = new Float32Array([ctx.canvas.width, ctx.canvas.height]) |
| 136 | + |
| 137 | + // Two triangle to cover the whole canvas |
| 138 | + const positionBuffer = app.createVertexBuffer(PicoGL.FLOAT, 2, new Float32Array([ |
| 139 | + -1, -1, -1, 1, 1, 1, |
| 140 | + 1, 1, 1, -1, -1, -1, |
| 141 | + ])) |
| 142 | + |
| 143 | + // Setup the arrays |
| 144 | + const arrays = app.createVertexArray().vertexAttributeBuffer(0, positionBuffer); |
| 145 | + |
| 146 | + return app.createPrograms([vertexShader, code]).then(([program]) => { |
| 147 | + const drawFrame = app.createDrawCall(program, arrays) |
| 148 | + const instance = {app, code, program, arrays, drawFrame, uniforms: setupUniforms({}, program)} |
| 149 | + |
| 150 | + // Render frame logic |
| 151 | + let prev = performance.now() / 1000; |
| 152 | + instance.age = 0 |
| 153 | + instance.update = () => { |
| 154 | + const now = performance.now() / 1000; |
| 155 | + const elapsed = now - prev |
| 156 | + prev = now |
| 157 | + // console.log("drawing!") |
| 158 | + app.clear() |
| 159 | + instance.drawFrame |
| 160 | + .uniform("iResolution", resolution) |
| 161 | + .uniform("iTime", now) |
| 162 | + |
| 163 | + updateUniforms(instance.drawFrame, elapsed, instance.uniforms) |
| 164 | + |
| 165 | + instance.drawFrame.draw() |
| 166 | + if (instance.age++ < 100) |
| 167 | + requestAnimationFrame(instance.update) |
| 168 | + else |
| 169 | + instance.drawing = false |
| 170 | + } |
| 171 | + return instance |
| 172 | + }).catch((err) => { |
| 173 | + ctx.canvas.remove() |
| 174 | + throw err |
| 175 | + }) |
| 176 | +} |
| 177 | + |
| 178 | +// Update the instance program |
| 179 | +async function reloadShaderInstanceCode(instance, code) { |
| 180 | + return instance.app.createPrograms([vertexShader, code]).then(([program]) => { |
| 181 | + instance.program.delete() |
| 182 | + instance.program = program |
| 183 | + instance.uniforms = setupUniforms(instance.uniforms, program) |
| 184 | + instance.draw = instance.app.createDrawCall(program, instance.arrays) |
| 185 | + }) |
| 186 | +} |
| 187 | + |
| 188 | +// Keep track of the running shader instances |
| 189 | +let _instances = {} |
| 190 | +export async function loadShader(code = '', name = 'default') { |
| 191 | + if (code) { |
| 192 | + code = mkFragmentShader(code) |
| 193 | + } |
| 194 | + if (!_instances[name]) { |
| 195 | + _instances[name] = await initializeShaderInstance(name, code) |
| 196 | + logger('[shader] ready') |
| 197 | + } else if (_instances[name].code != code) { |
| 198 | + await reloadShaderInstanceCode(_instances[name], code) |
| 199 | + logger('[shader] reloaded') |
| 200 | + } |
| 201 | +} |
| 202 | + |
| 203 | +export const shader = register('shader', (options, pat) => { |
| 204 | + // Keep track of the pitches value: Map String Int |
| 205 | + const pitches = {_count: 0}; |
| 206 | + |
| 207 | + return pat.onTrigger((time_deprecate, hap, currentTime, cps, targetTime) => { |
| 208 | + const instance = _instances[options.instance || "default"] |
| 209 | + if (!instance) { |
| 210 | + logger('[shader] not loaded yet', 'warning') |
| 211 | + return |
| 212 | + } |
| 213 | + |
| 214 | + const value = options.gain || 1.0; |
| 215 | + if (options.pitch !== undefined) { |
| 216 | + const note = hap.value.note || hap.value.s; |
| 217 | + if (pitches[note] === undefined) { |
| 218 | + // Assign new value, the first note gets 0, then 1, then 2, ... |
| 219 | + pitches[note] = Object.keys(pitches).length |
| 220 | + } |
| 221 | + setUniform(instance, options.pitch, value, pitches[note]) |
| 222 | + } else if (options.seq !== undefined) { |
| 223 | + setUniform(instance, options.seq, value, pitches._count++) |
| 224 | + } else if (options.uniform !== undefined) { |
| 225 | + setUniform(instance, options.uniform, value) |
| 226 | + } else { |
| 227 | + console.error("Unknown shader options, need either pitch or uniform", options) |
| 228 | + } |
| 229 | + }, false) |
| 230 | +}) |
0 commit comments