From 1d06470ccd2918214df01b8e06bccfeeb46de4d3 Mon Sep 17 00:00:00 2001 From: Emile Clark-Boman Date: Mon, 2 Feb 2026 01:12:53 +1000 Subject: [PATCH] created ShadeMyCanvas (smc) --- www/index.html | 2 +- www/js/main.js | 19 ++ www/js/smc/README.md | 4 + .../{draw-scene.js => smc/draw-scene.js.bak} | 0 www/js/smc/errors.js | 10 + .../init-buffers.js.bak} | 0 www/js/smc/lib.js | 4 + www/js/{webgl-demo.js => smc/lib.js.bak} | 25 +- www/js/smc/progbuilder.js | 127 +++++++ www/js/smc/smc.js | 311 ++++++++++++++++++ www/js/smc/smc.js.bak | 37 +++ www/js/smc/util.js | 24 ++ 12 files changed, 539 insertions(+), 24 deletions(-) create mode 100644 www/js/main.js create mode 100644 www/js/smc/README.md rename www/js/{draw-scene.js => smc/draw-scene.js.bak} (100%) create mode 100644 www/js/smc/errors.js rename www/js/{init-buffers.js => smc/init-buffers.js.bak} (100%) create mode 100644 www/js/smc/lib.js rename www/js/{webgl-demo.js => smc/lib.js.bak} (83%) create mode 100644 www/js/smc/progbuilder.js create mode 100644 www/js/smc/smc.js create mode 100644 www/js/smc/smc.js.bak create mode 100644 www/js/smc/util.js diff --git a/www/index.html b/www/index.html index 7af52bb..f8308c9 100644 --- a/www/index.html +++ b/www/index.html @@ -9,7 +9,7 @@ - + diff --git a/www/js/main.js b/www/js/main.js new file mode 100644 index 0000000..a2aa9b7 --- /dev/null +++ b/www/js/main.js @@ -0,0 +1,19 @@ +import { Smc } from "./smc/smc.js" + +main(); + +function main() { + const canvas = document.querySelector("#gl-canvas"); + canvas.setAttribute('width', window.innerWidth); + canvas.setAttribute('height', window.innerHeight); + + new Smc(canvas) + .setMaxFps(30) + .setProgram(builder => + builder + // .fetchVertexShader("../shaders/segfault.glsl") + // .fetchFragmentShader("../shaders/segfault.glsl")) + ) + .run(); +} + diff --git a/www/js/smc/README.md b/www/js/smc/README.md new file mode 100644 index 0000000..c013131 --- /dev/null +++ b/www/js/smc/README.md @@ -0,0 +1,4 @@ +# Shade My Canvas +An easy to use and purely declarative wrapper for WebGL written in Javascript. +The main idea is to remove all the boilerplate required to render shader +programs, so you can focus on writing GLSL and not debugging WebGL. diff --git a/www/js/draw-scene.js b/www/js/smc/draw-scene.js.bak similarity index 100% rename from www/js/draw-scene.js rename to www/js/smc/draw-scene.js.bak diff --git a/www/js/smc/errors.js b/www/js/smc/errors.js new file mode 100644 index 0000000..3900885 --- /dev/null +++ b/www/js/smc/errors.js @@ -0,0 +1,10 @@ +export { SmcErr }; + +const SmcErr = { + UNSUPPORTED: 0, // unused + SHADER_COMPILATION: 1, + PROGRAM_INIT: 2, + ATTRIBUTE_MISSING: 3, + UNIFORM_MISSING: 4, + FETCH_SHADER: 5, +} diff --git a/www/js/init-buffers.js b/www/js/smc/init-buffers.js.bak similarity index 100% rename from www/js/init-buffers.js rename to www/js/smc/init-buffers.js.bak diff --git a/www/js/smc/lib.js b/www/js/smc/lib.js new file mode 100644 index 0000000..cbff55e --- /dev/null +++ b/www/js/smc/lib.js @@ -0,0 +1,4 @@ +import { Smc } from "./smc.js"; +import { SmcErr } from "./errors.js"; + +export { Smc, SmcErr }; diff --git a/www/js/webgl-demo.js b/www/js/smc/lib.js.bak similarity index 83% rename from www/js/webgl-demo.js rename to www/js/smc/lib.js.bak index c8c8217..4f08661 100644 --- a/www/js/webgl-demo.js +++ b/www/js/smc/lib.js.bak @@ -1,7 +1,7 @@ import { initBuffers } from "./init-buffers.js"; import { drawScene } from "./draw-scene.js"; -main(); +export { run }; // Initialize a shader program, so WebGL knows how to draw our data function initShaderProgram(gl, vsSource, fsSource) { @@ -27,25 +27,6 @@ function initShaderProgram(gl, vsSource, fsSource) { return program; } -// Creates a shader of the given type, uploads the source and compiles -function loadShader(gl, type, source) { - const shader = gl.createShader(type); - - // Send the source to the shader object - gl.shaderSource(shader, source); - - // Compile the shader program - gl.compileShader(shader); - - // See if it compiled successfully - if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { - gl.deleteShader(shader); - throw new Error(`An error occurred compiling the shaders: ${gl.getShaderInfoLog(shader)}`); - } - - return shader; -} - function renderShader(gl, vsSource, fsSource) { const shaderProgram = initShaderProgram(gl, vsSource, fsSource); @@ -101,9 +82,7 @@ function fetchShader(name) { } -function main() { - const canvas = document.querySelector("#gl-canvas"); - // Initialize the GL context +function run(canvas) { const gl = canvas.getContext("webgl"); // XXX: TODO: use `window.addEventListener('resize', ...);` diff --git a/www/js/smc/progbuilder.js b/www/js/smc/progbuilder.js new file mode 100644 index 0000000..da86049 --- /dev/null +++ b/www/js/smc/progbuilder.js @@ -0,0 +1,127 @@ +import { SmcErr } from './errors.js'; + +export { SmcProgramBuilder }; + +class SmcProgramBuilder { + #gl; + #program; + + #isBuilt = false; + #hasVertexShader = false; + #hasFragmentShader = false; + + #defaultVertexShader = ` + attribute vec4 aVertex; + + void main() { + gl_Position = aVertex; + } + `; + + // TODO: reset the sample fragment shader back to the rainbow + #sampleFragmentShader = ` + precision mediump float; + + // uniform float uTime; + // uniform vec2 uResolution; + + void main() { + // vec2 uv = gl_FragCoord.xy / uResolution; + // vec3 col = 0.5 + 0.5 * cos(uTime + uv.xyx + vec3(0, 2, 4)); + // gl_FragColor = vec4(col, 1.0); + // gl_FragColor = vec4(216., 43., 72., 255.) / 255.; + + + float maxfc = max(gl_FragCoord.x, gl_FragCoord.y); + gl_FragColor = vec4(gl_FragCoord.xy, maxfc, maxfc) / maxfc; + } + `; + + + constructor(gl, raiseError) { + this.#gl = gl; + this.#program = this.#gl.createProgram(); + this.raiseError = raiseError; + } + + addVertexShader(source) { + this.#gl.attachShader( + this.#program, + this.#newShader( + this.#gl.VERTEX_SHADER, + source + ) + ) + this.#hasVertexShader = true; + return this; + } + + addFragmentShader(source) { + this.#gl.attachShader( + this.#program, + this.#newShader( + this.#gl.FRAGMENT_SHADER, + source + ) + ) + this.#hasFragmentShader = true; + return this; + } + + fetchVertexShader(uri) { + this.#fetchShader(uri, (source) => this.addVertexShader(source)); + return this; + } + + fetchFragmentShader(uri) { + this.#fetchShader(uri, (source) => this.addFragmentShader(source)); + return this; + } + + build() { + // avoid user accidental calls to build() + if (!this.#isBuilt) { + if (!this.#hasVertexShader) + this.addVertexShader(this.#defaultVertexShader) + if (!this.#hasFragmentShader) + this.addFragmentShader(this.#sampleFragmentShader); + + this.#gl.linkProgram(this.#program); + this.#gl.useProgram(this.#program); + } + return this.#program; + } + + // Creates a shader of the given type, uploads the source and compiles + #newShader(type, source) { + const shader = this.#gl.createShader(type); + this.#gl.shaderSource(shader, source); + this.#gl.compileShader(shader); + + if (!this.#gl.getShaderParameter(shader, this.#gl.COMPILE_STATUS)) { + this.#gl.deleteShader(shader); + const infoLog = this.#gl.getShaderInfoLog(shader); + this.raiseError( + SmcErr.SHADER_COMPILATION, + new Error(`An error occurred while compiling the shader: ${infoLog}`) + ); + } + + return shader; + } + + #fetchShader(uri, delegate) { + return fetch(uri) + .then(res => { + if (res.ok) + delegate(res.text()); + else { + this.raiseError( + SmcErr.FETCH_SHADER, + `Failed to load shader source ${url}: ${res.status} ${res.json()}`); + } + }); + } + +} + diff --git a/www/js/smc/smc.js b/www/js/smc/smc.js new file mode 100644 index 0000000..2f77955 --- /dev/null +++ b/www/js/smc/smc.js @@ -0,0 +1,311 @@ +import { SmcErr } from "./errors.js"; +import { SmcProgramBuilder } from "./progbuilder.js"; +import { hexToRgba } from "./util.js"; + +export { Smc, UniformType }; + +const UniformType = { + Float1: 0, + Float2: 1, + Float3: 2, + Float4: 3, + Int1: 4, + Int2: 5, + Int3: 6, + Int4: 7, +}; + +class Smc { + #canvas; + #gl; + + // Position array of a "full-screen" quad (encoded as TRIANGLE_STRIP) + // Ref: https://en.wikipedia.org/wiki/Triangle_strip + // NOTE: +x,+y is top-right & -x,-y is bottom-left + #verticesFullscreen = [ + -1.0, 1.0, + -1.0, -1.0, + 1.0, 1.0, + 1.0, -1.0, + ]; + #vertices = this.#verticesFullscreen; + #attributes = new Map(); + #uniforms = new Map(); + #program = null; + #clearBitFlags; + + #maxFps; + #minDeltaTimeMs; // in milliseconds + #prevTimeMs = 0; + + #errorDelegate = (_, error) => { throw error }; + #initDelegate = (_) => { }; + #resizeDelegate = (_) => { }; + + constructor(canvas) { + this.raiseError = this.raiseError.bind(this); + this.render = this.render.bind(this); + this.renderLoop = this.renderLoop.bind(this); + + this.#canvas = canvas; + this.#gl = Smc.#getWebGlContext(canvas); + // NOTE: smc.isWebGlSupported() should be queried prior + if (this.#gl == null) + throw new Error("Unable to initialize WebGL. Your browser or machine may not support it."); + + // clear the entire depth buffer when this.#gl.clear is called + this.#gl.clearDepth(1.0); + this.#clearBitFlags = this.#gl.COLOR_BUFFER_BIT | this.#gl.DEPTH_BUFFER_BIT; + // set WebGL's render context (number of pixels to draw) + this.#gl.viewport(0, 0, this.#gl.canvas.width, this.#gl.canvas.height); + + // set defaults + this.setMaxFps(30); + this.setClearColor(0., 0., 0., 255.); + } + + static #getWebGlContext(canvas) { + try { + return canvas.getContext("webgl") ?? canvas.getContext("experimental-webgl"); + } catch { + return null; + }; + } + + static isWebGlSupported() { + try { + const canvas = document.createElement('canvas'); + return !!window.WebGLRenderingContext && Smc.#getWebGlContext(canvas) != null; + } catch (e) { + return false; + } + } + + onError(delegate) { + this.#errorDelegate = delegate; + return this; + } + + onInit(delegate) { + this.#initDelegate = delegate; + return this; + } + + onResize(delegate) { + this.#resizeDelegate = delegate; + return this; + } + + setClearColorHex(color) { + color = hexToRgba(color); + if (color == null) { + // this.raiseError isn't needed because this should + // be treated as a "compilation" error not a "runtime" error + throw new Error(`setClearColorHex expects an RGB/RGBA hex value, got "${color}"`); + } + return this.setClearColor(color.r, color.g, color.b, color.a); + } + + setClearColor(r, g, b, a) { + this.#gl.clearColor(r / 255., g / 255., b / 255., a / 255.); + return this; + } + + setVertices(positions) { + this.#vertices = positions; + return this; + } + + setMaxFps(fps) { + this.#maxFps = fps; + this.#minDeltaTimeMs = fps ? 1000 / fps : null; + return this; + } + + setProgram(delegate) { + const builder = new SmcProgramBuilder(this.#gl, this.raiseError); + delegate(builder); // i pray js passes by ref well... + + this.#program = builder.build(); + if (!this.#gl.getProgramParameter(this.#program, this.#gl.LINK_STATUS)) { + const infoLog = this.#gl.getProgramInfoLog(this.#program); + this.raiseError( + SmcErr.PROGRAM_INIT, + new Error(`Unable to initialize the shader program: ${infoLog}`) + ) + } + + this.#addAttribute("aVertex", this.#setVerticesAttribute.bind(this)); + // DEBUG: uncomment afterwards + // this.#addUniform("uResolution", UniformType.Float2); + // this.#addUniform("uTime", UniformType.Float1); + // this.#addUniform("uDelta", UniformType.Float1); + return this; + } + + run() { + this.#initDelegate() + this.setAttribute("aVertex", this.#vertices); + // DEBUG: uncomment afterwards + // this.setUniform("uResolution", new Float32Array([this.#gl.canvas.width, this.#gl.canvas.height])); + + if (this.#maxFps == 0) + requestAnimationFrame(this.render) + else + requestAnimationFrame(this.renderLoop); + } + + // requestAnimationFrame requests the browser to call the renderLoop + // callback function before the next repaint. + // `time` is the milliseconds elapsed since the page loaded. + renderLoop(time) { + var delta = time - this.#prevTimeMs; + this.render(time, delta); + + setTimeout( + () => requestAnimationFrame(this.renderLoop), + Math.max(0, delta - this.#minDeltaTimeMs) + ); + this.#prevTimeMs = time; + } + + render(time, delta) { + // DEBUG: uncomment afterwards + // this.setUniform("uTime", time * 0.001); + // this.setUniform("uDelta", delta); + + // DEBUG: START (remove if not necessary) + this.#gl.viewport(0, 0, this.#gl.canvas.width, this.#gl.canvas.height); + this.#gl.clear(this.#gl.COLOR_BUFFER_BIT | this.#gl.DEPTH_BUFFER_BIT); + + this.setAttribute("aVertex", this.#vertices); + + this.#gl.useProgram(this.#program); + + // DEBUG: uncomment afterwards + // this.setUniform("uTime", time * 0.001); + // this.setUniform("uDelta", delta); + // this.setUniform("uResolution", new Float32Array([this.#gl.canvas.width, this.#gl.canvas.height])); + + this.#gl.drawArrays(this.#gl.TRIANGLE_STRIP, 0, this.#vertices.length / 2); + // DEBUG: END (remove if not necessary) + + // DEBUG: uncomment afterwards + // this.#gl.clear(this.#clearBitFlags); + // this.#gl.drawArrays(this.#gl.TRIANGLE_STRIP, 0, this.#vertices.length); + } + + #addAttribute(name, setDelegate, required = false) { + var location = this.#gl.getAttribLocation(this.#program, name); + if (location == -1) { + if (required) { + this.raiseError( + SmcErr.ATTRIBUTE_MISSING, + `Linked program missing required attribute: "${name}"` + ); + } + location = null; + } + this.#attributes.set( + name, + { + setDelegate: setDelegate, + location: location, + }); + + } + + #addUniform(name, type, setEachFrame, setCallback, required = false) { + const location = this.#gl.getUniformLocation(this.#program, name); + if (location == -1) { + if (required) { + this.raiseError( + SmcErr.UNIFORM_MISSING, + `Linked program missing required uniform: "${name}"` + ) + } + location = null; + } + + if (type == UniformType.Float1) + var uniformfv = this.#gl.uniform1f; + else if (type == UniformType.Float2) + var uniformfv = this.#gl.uniform2fv; + else if (type == UniformType.Float3) + var uniformfv = this.#gl.uniform3fv; + else if (type == UniformType.Float4) + var uniformfv = this.#gl.uniform4fv; + else if (type == UniformType.Int1) + var uniformfv = this.#gl.uniform1i; + else if (type == UniformType.Int2) + var uniformfv = this.#gl.uniform2iv; + else if (type == UniformType.Int3) + var uniformfv = this.#gl.uniform3iv; + else if (type == UniformType.Int4) + var uniformfv = this.#gl.uniform4iv; + else { + // this.raiseError isn't needed because this should + // be treated as a "compilation" error not a "runtime" error + throw new Error(`Expected type from enum UniformType, but got "${type}"`); + } + + const setDelegate = value => uniformfv(location, value); + + // simplify function call to a single argument + this.#uniforms.set( + name, + { + setDelegate: setDelegate, + location: location, + setEachFrame: setEachFrame, + setCallback, + } + ); + } + + #getAttributeLocation(name) { + return this.#attributes.get(name).location; + } + + #getUniformLocation(name) { + return this.#uniforms.get(name).location; + } + + setAttribute(name, value) { + if (this.#getAttributeLocation(name) != null) + this.#attributes.get(name).setDelegate(value); + } + + setUniform(name, value) { + if (this.#getUniformLocation(name) != null) + this.#uniforms.get(name).setDelegate(value); + } + + #setVerticesAttribute(vertices) { + this.#vertices = vertices; + + const buffer = this.#gl.createBuffer(); + this.#gl.bindBuffer(this.#gl.ARRAY_BUFFER, buffer); + this.#gl.bufferData( + this.#gl.ARRAY_BUFFER, + new Float32Array(vertices), + this.#gl.STATIC_DRAW + ); + + this.#gl.vertexAttribPointer( + this.#getAttributeLocation("aVertex"), + 2, // (size) one vertex == 2 floats + this.#gl.FLOAT, // (type) vertex positions given as 32bit floats + false, // (normalized) don't normalize + 0, // (stride) buffer offset pointer BETWEEN elements (0 => packed) + 0, // (offset) buffer offset pointer from START to first element + ) + this.#gl.enableVertexAttribArray(this.#getAttributeLocation("aVertex")); + + return buffer; + } + + raiseError(type, error) { + this.#errorDelegate(type, error); + } +} diff --git a/www/js/smc/smc.js.bak b/www/js/smc/smc.js.bak new file mode 100644 index 0000000..a542864 --- /dev/null +++ b/www/js/smc/smc.js.bak @@ -0,0 +1,37 @@ +import { SmcErr } from "./errors.js"; +import { SmcBuilder } from "./builder.js"; + +export { SmcErr }; + +// XXX: TODO: merge SmcBuilder into smc +class smc { + #canvas; + #builderDelegate = _ => { }; + + constructor(canvas) { + this.#canvas = canvas; + } + + build(delegate) { + this.#builderDelegate = delegate; + return this; + } + + onError(delegate) { + this.#errorDelegate = delegate; + return this; + } + + run() { + this.#canvas = canvas; + const gl = this.#canvas.getContext("webgl"); + if (gl == null) { + this.#raiseError( + SmcErr.UNSUPPORTED, + Error("Unable to initialize WebGL. Your browser or machine may not support it."), + ); + } + const builder = this.#builderDelegate(new SmcBuilder(gl, this.#raiseError)) + builder.render() + } +} diff --git a/www/js/smc/util.js b/www/js/smc/util.js new file mode 100644 index 0000000..a786d02 --- /dev/null +++ b/www/js/smc/util.js @@ -0,0 +1,24 @@ +export { hexToRgba, hexToRgbaNormal }; + +/* Converts a string of the form "#XXXXXX" + * +*/ +function hexToRgba(hex) { + var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(hex.toLowerCase()); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + a: result.length == 4 ? parseInt(result[4], 16) : 255., + } : null; +} + +function hexToRgbaNormal(hex) { + var result = hexToRgba(hex); + return result ? { + r: result.r / 255., + g: result.g / 255., + b: result.b / 255., + a: result.a / 255., + } : null; +}