WebGL shader preview
WebGL itself is not a very good API — conceptually it follows directly from the OpenGL ES API, which was written in C and defined as a state machine. It's familiar to graphics programmers, and that's about all it has going for it. Which is a shame, since hidden behind that intimidating facade is a fascinating creative environment: fragment shaders.
Fragment shaders are small programs that run for every pixel in a WebGL polygon. They're typically used to do things like texturing and lighting within a 3D scene. Your video card is extremely good at running thousands (or even million) of these programs in parallel, with certain tradeoffs: they can't directly share information with each other, and they can't retain state between executions.
Despite these constraints, you can do incredible things just with a fragment shader and some basic math (Inigo Quilez is a well-known master of this). You can see more examples at Shadertoy, and learn how to write your own from The Book of Shaders. But be careful: like a great puzzle game, fragment shaders can be addictive!
I've been hooked for a few years now, but while I love writing shaders (I even made a browser extension that lets you fiddle with them in the new tab page) I don't love the WebGL boilerplate. What I really wanted was an element that would let me load a fragment shader and display it the same way that I would an image: set the "src" attribute and see the results. For an NPR project that needed some visual spice, I finally create it via this custom element.
shader-box.js
// coordinates in GL space for two triangles that take up the whole viewport
const POLYS = [
-1, 1,
1, 1,
1, -1,
-1, 1,
1, -1,
-1, -1
];
// this component uses our base element class
class ShaderBox extends CustomElement {
constructor() {
super();
// visibility and animation
this.observer = new IntersectionObserver(this.onIntersection);
this.observer.observe(this);
this.visible = false;
this.raf = null;
// AbortController for in-flight fetch requests
this.requesting = null;
// set up the WebGL context
this.initGL();
this.shadowElements.canvas.addEventListener("webglcontextlost", this.recover);
// monitor for changes to shader-uniform children
this.mutationObserver = new MutationObserver(this.onMutation);
this.mutationObserver.observe(this, {
childList: true,
subtree: true,
attributes: true
});
}
initGL() {
var gl = this.gl = this.shadowElements.canvas.getContext("webgl");
var vertex = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertex, `
attribute vec2 coord;
void main() {
gl_Position = vec4(coord, 0.0, 1.0);
}
`);
gl.compileShader(vertex);
gl.vertex = vertex;
this.buffer = gl.createBuffer();
this.program = null;
}
static get boundMethods() {
return [
"onIntersection",
"onMutation",
"tick",
"recover"
];
}
static get observedAttributes() {
return [ "src" ]
}
static get mirroredProps() {
return [ "src" ]
}
async attributeChangedCallback(attr, was, value) {
switch (attr) {
case "src":
if (was == value) return;
// cancel any outgoing requests
if (this.requesting) {
this.requesting.abort();
}
var options = {};
// if we can, create a new cancellation token
if ("AbortController" in window) {
this.requesting = new AbortController();
options.signal = this.requesting.signal;
}
// get the new shader
try {
var response = await fetch(value, options);
this.requesting = null;
if (response.status >= 400) throw `Request for ${value} failed`;
var source = await response.text();
this.setShader(source);
} catch (err) {
// abort signals are handled as if the fetch() threw
if (err.name == "AbortError") {
console.log(`Cancelled shader load for ${value}`);
} else {
throw err;
}
}
break;
}
}
recover() {
var uniforms = this.gl.uniforms;
this.initGL();
Object.assign(this.gl.uniforms, uniforms);
if (this.shaderCache) {
this.setShader(this.shaderCache);
}
}
setShader(shader) {
// cancel requests if this was called directly
if (this.requesting) {
this.requesting.abort();
}
// compile and link our fragment shader
var gl = this.gl;
gl.program = null;
var fragment = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragment, shader);
gl.compileShader(fragment);
this.shaderCache = shader;
var error = gl.getShaderInfoLog(fragment);
if (error) {
console.log(error);
return;
}
var program = gl.createProgram();
gl.attachShader(program, fragment);
gl.attachShader(program, gl.vertex);
gl.linkProgram(program);
gl.useProgram(program);
// get attributes and uniforms
gl.program = program;
gl.attributes = {
coord: 0
};
for (var a in gl.attributes) gl.attributes[a] = gl.getAttribLocation(program, a);
gl.uniforms = {
u_time: 0,
u_resolution: 0
};
for (var u in gl.uniforms) gl.uniforms[u] = gl.getUniformLocation(program, u);
this.onMutation();
this.tick();
}
// call GL methods to set the values of uniforms (shader globals)
setUniform(name, ...values) {
var gl = this.gl;
if (!gl.uniforms[name]) {
gl.uniforms[name] = gl.getUniformLocation(gl.program, name);
}
var method = `uniform${values.length}f`;
gl[method](gl.uniforms[name], ...values);
}
// process shader-uniform children and add them to our mapping
onMutation() {
var uniforms = Array.from(this.children).filter(t => t.tagName == "SHADER-UNIFORM");
for (var uniform of uniforms) {
var name = uniform.getAttribute("name");
var values = uniform.getAttribute("values").split(/, */).map(Number);
this.setUniform(name, ...values);
}
}
onIntersection([e]) {
this.visible = e.isIntersecting;
if (this.visible) this.tick();
// seems to prevent cyan flash on some GPUs
this.shadowElements.canvas.style.opacity = this.visible ? 1 : 0;
}
// render loop with visibility/readiness guards
tick(t) {
if (!this.visible) return;
if (this.raf) cancelAnimationFrame(this.raf);
if (this.gl.program) this.render(t);
this.raf = requestAnimationFrame(this.tick);
}
render(t) {
var { buffer, gl } = this;
// require setShader() to be called
if (!gl.program) return;
var canvas = gl.canvas;
// set up our two triangles
gl.enableVertexAttribArray(gl.uniforms.coords);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(POLYS), gl.STATIC_DRAW);
gl.vertexAttribPointer(gl.uniforms.coords, 2, gl.FLOAT, false, 0, 0);
// add a time uniform, with some offset to starting at 0
gl.uniform1f(gl.uniforms.u_time, t + 12581372.5324);
// adjust to the current canvas size and set resolution uniform
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform2f(gl.uniforms.u_resolution, canvas.width, canvas.height);
// clear canvas and render
gl.clearColor(0, 1, 1, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, POLYS.length / 2);
}
static get shadowTemplate() {
return `
<style>
:host {
width: 300px;
height: 150px;
display: block;
position: relative;
}
canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
<canvas data-as="canvas"></canvas>
`
}
}
ShaderBox.define("shader-box");
Demo
Notes
Knowing how WebGL works isn't a prerequisite for talking about this element. However, if you're curious, I've found WebGL Fundamentals to be one of the best tutorials on the subject.
Cancelling requests
Loading shaders from the network by setting the "src" attribute is relatively straightforward: when we get the attributeChangedCallback() notification, we just fetch() the file and feed its contents to setShader(). However, what if we were to set it multiple times in rapid succession? We would expect only the final value to be loaded, since that's how <img> and other tags work, but depending on how fast the network responded (and in which order), we could get very different results as the rest of the callback runs.
To avoid this scenario (and to let us override a remote shader when we call setShader() directly), we need to cancel any pending requests when the attribute is updated. Surprisingly, the fetch() API did not initially ship with a way to cancel requests — it's based on Promises, and there's no way to reject a standard Promise from outside of itself. Several methods were explored, and the eventual winner was the creation of Abort Controllers, which let you pass a signal into one or more requests and then cancel them all at once.
Essentially, we create a new AbortController instance for every fetch, storing it on this.requesting. If there was already a controller there, we call its abort() method to cancel the previous request (if it already completed successfully, nothing will happen). Once the request completes, we nullify this.requesting to clean up a little, and keep setShader() from sending pointless abort signals.
One thing that can trip you up is that aborting a fetch is treated as a thrown exception. This can be surprising at first, because fetch() as an API does not typically throw where you'd expect it to — things like 404 status codes are seen as a "successful" fetch (in that the network request completed), even if the result actually represents an error. To keep us from having one path for "real" errors and one for abort signals, we throw exceptions on 400+ status codes, so that everything can be handled in the catch clause for the fetch.
Observation patterns
This element has a couple of observers connected to it, both set up in the constructor. It uses an Intersection Observer to track its own visibility, and only renders if it's actually in view. WebGL shaders do not have to be strenuous on modern hardware, but there's no reason to do extra work — and since fragment shaders don't have any memory between executions, we don't have to worry about losing anything while the render loop is "paused."
We also define a small domain-specific language for this element, currently composed only of <shader-uniform> elements. These set "uniforms" within the fragment shader, which is WebGL jargon for global values accessible to all fragments. A Mutation Observer watches the custom element and its subtree for node and attribute changes, and runs our onMutation() method if it sees them.
Since there can be multiple uniforms per shader and they can have any name you want, it's easier to represent these as child elements than as attributes on the <shader-box> itself. In the demo above, we're just passing a single u_color uniform through, which tweaks the color of the "lava lamp" animation.
<shader-box class="demo-shader" src="./static/lava.glsl">
<shader-uniform
name="u_color"
values="0.3,0.6,0.6"
></shader-uniform>
</shader-box>
Using the dev tools, you can update the values to see it shift (the colors are an RGB triplet with values from 0 to 1).
Future improvements
One of the reasons that I'm a strong advocate for using patterns that already exist in the platform, besides taking advantage of existing developer knowledge, is that it allows our code to interoperate more easily with the platform. For example, if you have code that is tuned to work with <video> in terms of the basic events and properties it expects, it will also usually work with <audio> (which also implements the MediaElement interface), or with any other media element that browsers might add.
Our WebGL code is not quite the same as a media tag — for one thing, it doesn't have a beginning, end, or current time. However, there are some similarities. It might make sense to add common events, methods, and properties to our tag to permit it a little extra control. For example, we might support the play() method and the paused property, so that controls could toggle the animation and reflect its current state. We might also add loading events, so that other code on the page could know when the animation is ready to go (useful for lazy-loading).
In terms of WebGL, the other natural extension point would be to expand the range of inputs available via child elements. In addition to the uniform values currently supported, we could automatically load <img> tags as textures, or even feed in <video> and <audio> as buffers for shaders to access.
A richer set of tools exposed through a domain-specific language isn't just useful for fragment shader developers. It also expands the potential uses of this element. For example, a shader can easily replicate filters from something like Photoshop or Premiere. By providing a basic manipulation shader and a comprehensive DSL for inputs, it would be possible for people who only know HTML to embed a video with tweakable effects applied.