created ShadeMyCanvas (smc)

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

View file

@ -9,7 +9,7 @@
<!-- integrity="sha512-zhHQR0/H5SEBL3Wn6yYSaTTZej12z0hVZKOv3TwCUXT1z5qeqGcXJLLrbERYRScEDDpYIJhPC1fk31gqR783iQ==" --> <!-- integrity="sha512-zhHQR0/H5SEBL3Wn6yYSaTTZej12z0hVZKOv3TwCUXT1z5qeqGcXJLLrbERYRScEDDpYIJhPC1fk31gqR783iQ==" -->
<!-- crossorigin="anonymous" --> <!-- crossorigin="anonymous" -->
<!-- defer></script> --> <!-- defer></script> -->
<script src="js/webgl-demo.js" type="module"></script> <script src="js/main.js" type="module"></script>
<link rel="stylesheet" href="css/shader-style.css"> <link rel="stylesheet" href="css/shader-style.css">
<link rel="stylesheet" href="css/typing.css"> <link rel="stylesheet" href="css/typing.css">
</head> </head>

19
www/js/main.js Normal file
View file

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

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.

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

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

View file

@ -1,7 +1,7 @@
import { initBuffers } from "./init-buffers.js"; import { initBuffers } from "./init-buffers.js";
import { drawScene } from "./draw-scene.js"; import { drawScene } from "./draw-scene.js";
main(); export { run };
// Initialize a shader program, so WebGL knows how to draw our data // Initialize a shader program, so WebGL knows how to draw our data
function initShaderProgram(gl, vsSource, fsSource) { function initShaderProgram(gl, vsSource, fsSource) {
@ -27,25 +27,6 @@ function initShaderProgram(gl, vsSource, fsSource) {
return program; 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) { function renderShader(gl, vsSource, fsSource) {
const shaderProgram = initShaderProgram(gl, vsSource, fsSource); const shaderProgram = initShaderProgram(gl, vsSource, fsSource);
@ -101,9 +82,7 @@ function fetchShader(name) {
} }
function main() { function run(canvas) {
const canvas = document.querySelector("#gl-canvas");
// Initialize the GL context
const gl = canvas.getContext("webgl"); const gl = canvas.getContext("webgl");
// XXX: TODO: use `window.addEventListener('resize', ...);` // XXX: TODO: use `window.addEventListener('resize', ...);`

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