created ShadeMyCanvas (smc)

This commit is contained in:
do butterflies cry? 2026-02-02 01:12:53 +10:00
parent 935cc44f66
commit 1d06470ccd
12 changed files with 539 additions and 24 deletions

4
www/js/smc/README.md Normal file
View file

@ -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.

View file

@ -0,0 +1,60 @@
function drawScene(gl, programInfo, buffers, time) {
// Tell WebGL how to convert from clip space to pixels
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clearColor(0.0, 0.0, 0.0, 1.0); // Clear to black, fully opaque
gl.clearDepth(1.0); // Clear everything
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// NOTE: this is how width/height is taken
// const aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
// Tell WebGL how to pull out the positions from the position
// buffer into the vertexPosition attribute.
setPositionAttribute(gl, buffers, programInfo);
gl.useProgram(programInfo.program);
/* --- Set Uniform Variables --- */
// Time since page loaded in seconds
gl.uniform1f(
programInfo.uniformLocations.time,
time,
);
// Viewport resolution in pixels
gl.uniform2f(
programInfo.uniformLocations.resolution,
gl.canvas.width,
gl.canvas.height,
);
{
const offset = 0;
const vertexCount = 4;
gl.drawArrays(gl.TRIANGLE_STRIP, offset, vertexCount);
}
}
// Tell WebGL how to pull out the positions from the position
// buffer into the vertexPosition attribute.
function setPositionAttribute(gl, buffers, programInfo) {
const numComponents = 2; // pull out 2 values per iteration
const type = gl.FLOAT; // the data in the buffer is 32bit floats
const normalize = false; // don't normalize
const stride = 0; // how many bytes to get from one set of values to the next
// 0 = use type and numComponents above
const offset = 0; // how many bytes inside the buffer to start from
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.position);
gl.vertexAttribPointer(
programInfo.attribLocations.vertexPosition,
numComponents,
type,
normalize,
stride,
offset,
);
gl.enableVertexAttribArray(programInfo.attribLocations.vertexPosition);
}
export { drawScene };

10
www/js/smc/errors.js Normal file
View file

@ -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,
}

View file

@ -0,0 +1,34 @@
function initBuffers(gl) {
const positionBuffer = initPositionBuffer(gl);
return {
position: positionBuffer,
};
}
function initPositionBuffer(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
const positions = [
-1.0, 1.0,
-1.0, -1.0,
1.0, 1.0,
1.0, -1.0,
];
// Create a buffer for the square's positions.
const positionBuffer = gl.createBuffer();
// Select the positionBuffer as the one to apply buffer
// operations to from here out.
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
// Now pass the list of positions into WebGL to build the
// shape. We do this by creating a Float32Array from the
// JavaScript array, then use it to fill the current buffer.
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
return positionBuffer;
}
export { initBuffers };

4
www/js/smc/lib.js Normal file
View file

@ -0,0 +1,4 @@
import { Smc } from "./smc.js";
import { SmcErr } from "./errors.js";
export { Smc, SmcErr };

112
www/js/smc/lib.js.bak Normal file
View file

@ -0,0 +1,112 @@
import { initBuffers } from "./init-buffers.js";
import { drawScene } from "./draw-scene.js";
export { run };
// Initialize a shader program, so WebGL knows how to draw our data
function initShaderProgram(gl, vsSource, fsSource) {
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
// Create the shader program
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
// If creating the shader program failed, alert
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
alert(
`Unable to initialize the shader program: ${gl.getProgramInfoLog(
program,
)}`,
);
return null;
}
return program;
}
function renderShader(gl, vsSource, fsSource) {
const shaderProgram = initShaderProgram(gl, vsSource, fsSource);
// Collect all the info needed to use the shader program.
// Look up which attribute our shader program is using
// for aVertexPosition and look up uniform locations.
const programInfo = {
program: shaderProgram,
attribLocations: {
vertexPosition: gl.getAttribLocation(shaderProgram, "aVertexPosition"),
},
uniformLocations: {
resolution: gl.getUniformLocation(shaderProgram, "u_resolution"),
time: gl.getUniformLocation(shaderProgram, "u_time"),
},
};
// Here's where we call the routine that builds all the
// objects we'll be drawing.
const buffers = initBuffers(gl);
const fpsLimit = 30;
const fpsDelta = 1000 / fpsLimit;
// let timePrev = 0;
// requestAnimationFrame asks the browser to call render,
// providing the time in milliseconds since the page loaded
function render(time) {
time *= 0.001; // convert to seconds
// delta = time - timePrev;
drawScene(gl, programInfo, buffers, time);
setTimeout(() => requestAnimationFrame(render), fpsDelta);
}
function update() {
requestAnimationFrame(render);
}
// XXX: TODO: read this guide it's great! https://stackoverflow.com/questions/56998225/why-is-rendering-blurred-in-webgl
// window.addEventListener('resize', render);
requestAnimationFrame(render);
// update();
// setInterval(update, 1000 / fpsLimit);
}
function fetchShader(name) {
return fetch(`../shaders/${name}`)
.then(res => {
if (!res.ok) throw new Error(`Failed to load fragment shader source ${url}: ${res.status}`);
return res.text();
});
}
function run(canvas) {
const gl = canvas.getContext("webgl");
// XXX: TODO: use `window.addEventListener('resize', ...);`
canvas.setAttribute('width', window.innerWidth);
canvas.setAttribute('height', window.innerHeight);
// Only continue if WebGL is available and working
if (gl === null) {
throw new Error("Unable to initialize WebGL. Your browser or machine may not support it.");
}
// Vertex shader program
const vsSource = `
attribute vec4 aVertexPosition;
void main() {
gl_Position = aVertexPosition;
}
`;
// Fetch fragment shader program
fetchShader("fbm.glsl")
.then(fsSource => {
renderShader(gl, vsSource, fsSource);
});
}

127
www/js/smc/progbuilder.js Normal file
View file

@ -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()}`);
}
});
}
}

311
www/js/smc/smc.js Normal file
View file

@ -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);
}
}

37
www/js/smc/smc.js.bak Normal file
View file

@ -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()
}
}

24
www/js/smc/util.js Normal file
View file

@ -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;
}