Skip to content

Instantly share code, notes, and snippets.

@danielchasehooper
Created February 4, 2025 20:33
Show Gist options
  • Save danielchasehooper/72da5d9c286e5e94fdfb8e82bea288cc to your computer and use it in GitHub Desktop.
Save danielchasehooper/72da5d9c286e5e94fdfb8e82bea288cc to your computer and use it in GitHub Desktop.
the code that creates the interactive shader editor for danielchasehooper.com
"use strict";
// I'm posting at the request of this lobsters comment: https://lobste.rs/s/ycbpnz/animating_rick_morty_one_pixel_at_time#c_wonfh3
// this code sets up the live shader editor on http://danielchasehooper.com/posts/code-animated-rick/
(function() {
let gl;
let program;
let cached_vertex_shader;
let positionAttributeLocation;
let timeUniformLocation;
let resolutionUniformLocation;
let animationId;
let vao;
let canvas = document.createElement("canvas");
canvas.width = 400;
canvas.height = 400;
let active_bundle = undefined;
function createProgram(vert, frag, opt_attribs, opt_locations) {
const program = gl.createProgram();
gl.attachShader(program, vert);
gl.attachShader(program, frag);
if (opt_attribs) {
opt_attribs.forEach(function(attrib, ndx) {
gl.bindAttribLocation(
program,
opt_locations ? opt_locations[ndx] : ndx,
attrib);
});
}
gl.linkProgram(program);
const linked = gl.getProgramParameter(program, gl.LINK_STATUS);
if (!linked) {
const lastError = gl.getProgramInfoLog(program);
gl.deleteProgram(program);
return lastError;
}
return program;
}
function loadShader(shaderSource, shaderType) {
const shader = gl.createShader(shaderType);
gl.shaderSource(shader, shaderSource);
gl.compileShader(shader);
const compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
if (!compiled) {
const lastError = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
return lastError;
}
return shader;
}
function setup_vao() {
vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1,
1, -1,
-1, 1,
-1, 1,
1, -1,
1, 1,
]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(positionAttributeLocation);
gl.vertexAttribPointer(
positionAttributeLocation,
2, // 2 components per iteration
gl.FLOAT, // the data is 32bit floats
false, // don't normalize the data
0, // 0 = move forward size * sizeof(type) each iteration to get the next position
0, // start at the beginning of the buffer
);
}
function show_errors(bundle, error_string, line_offset) {
const lines = error_string.trim().split("\n");
const editor_line_count = bundle?.editor?.lineCount?.() || 1;
for (let line of lines) {
try {
const parts = line.split(":");
if (parts.length <= 2) continue;
let error_message = parts.slice(3).join(":").trim();
let error_line = parseInt(parts[2], 10);
if (Number.isNaN(error_line)) {
error_line = 0;
} else {
error_line += line_offset;
}
const clamped_error_line = Math.max(0, Math.min(editor_line_count-1, error_line));
if (clamped_error_line != error_line) {
error_message = "error glsl wrapper: " + error_message;
}
const marker = document.createElement("div");
marker.className = "errorLine";
marker.textContent = error_message;
bundle.error_widgets.push(bundle.editor.addLineWidget(clamped_error_line, marker, {coverGutter:true,noHScroll:true}));
} catch (e) {
console.error(e);
}
}
}
const fragment_prefix = `#version 300 es
precision highp float;
uniform float time;
uniform vec2 resolution;
out vec4 outColor;
`;
const fragment_prefix_line_count = fragment_prefix.split('\n').length;
const fragment_postfix = `
#undef AA
#undef color_for_pixel
#undef m
#undef n
#undef outColor
#undef resolution
#undef sum
#undef time
#undef for
#undef gl_FragCoord
#undef main
#undef float
#undef int
#undef vec2
#undef vec4
#undef void
#ifndef ANTIALIAS_LEVEL
# define ANTIALIAS_LEVEL 3
#endif
void main() {
vec3 sum = vec3(0);
int AA = ANTIALIAS_LEVEL;
for( int m=0; m<AA; m++ ) {
for( int n=0; n<AA; n++ ) {
vec2 o = (vec2(m,n) + 0.5) / float(AA);
vec2 st = (2.0*(gl_FragCoord.xy+o)-resolution)/resolution.y;
sum += color_for_pixel(st, time);
}
}
outColor = vec4(sum / float(AA*AA), 1);
}`;
function rebuild_program(bundle) {
try {
let {editor} = bundle;
let fragment_source = bundle.editor.getValue();
let line_offset = -1;
if (fragment_source.indexOf("#version 300 es") == -1) {
line_offset = -fragment_prefix_line_count;
fragment_source = fragment_prefix + fragment_source + fragment_postfix;
}
for (let widget of bundle.error_widgets) {
widget.clear();
}
bundle.error_widgets.length = 0;
let fragment_shader = loadShader(fragment_source, gl.FRAGMENT_SHADER);
if (typeof fragment_shader === "string") {
show_errors(bundle, fragment_shader, line_offset);
throw new Error("fragment_shader error:" + fragment_shader);
}
let new_program = createProgram(cached_vertex_shader,
fragment_shader);
if (typeof new_program === "string") {
show_errors(bundle, fragment_shader, line_offset);
throw new Error("typeof new_program !== 'string'");
}
if (!new_program) {
throw new Error("new_program is falsy");
}
window.localStorage.code = editor.getValue();
program = new_program;
positionAttributeLocation = gl.getAttribLocation(program, "a_position");
timeUniformLocation = gl.getUniformLocation(program, "time");
resolutionUniformLocation = gl.getUniformLocation(program, "resolution");
} catch(e) {
console.error(e);
return false;
}
return true;
}
function draw(){
let width = 500;
let height = 500;
const pixelsPerPoint = window.devicePixelRatio;
if (canvas.parentNode) {
width = Math.max(1,Math.round(pixelsPerPoint*canvas.clientWidth));
const unscaled_height = Math.max(1,Math.round(canvas.clientWidth*9/16));
height = unscaled_height*pixelsPerPoint;
canvas.style.height=unscaled_height+"px";
}
if (canvas.width != width) canvas.width = width;
if (canvas.height != height) canvas.height = height;
gl.viewport(0, 0, width, height);
gl.clearColor(0,0,0,1);
gl.clear(gl.COLOR_BUFFER_BIT);
if (program) {
gl.useProgram(program);
gl.uniform1f(timeUniformLocation, active_bundle.time/1000);
gl.uniform2f(resolutionUniformLocation, width, height);
gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES,0,6);
}
}
function set_active_bundle(bundle, force_reload) {
try {
if (active_bundle && active_bundle != bundle) {
draw();
// const dataURL = canvas.toDataURL();
// if (dataURL) active_bundle.image.src = dataURL;
const image_to_update = active_bundle.image;
canvas.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
image_to_update.onload = ()=> {
URL.revokeObjectURL(url);
};
image_to_update.src = url;
}
});
}
if (bundle != active_bundle || force_reload) {
if (rebuild_program(bundle)) {
active_bundle = bundle;
draw();
start_animating();
bundle.playback_container.appendChild(canvas);
} else {
active_bundle = undefined;
program = undefined;
}
}
} catch(e) {
console.error(e);
}
}
const foldRegex = /{ *\/\/ *fold *\n/g;
function foldMarkedBlocks(editor) {
editor.operation(()=> {
let match;
let editor_value = editor.getValue();
while ((match = foldRegex.exec(editor_value)) != null) {
editor.foldCode(editor.posFromIndex(match.index+1));
}
});
}
function encodeRFC3986URIComponent(str) {
try {
return encodeURIComponent(str).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
} catch(e) {
console.error(e);
return undefined;
}
}
function writeToClipboardFallback(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.float = 'left';
document.body.appendChild(textarea);
textarea.select();
let success = false;
try {
success = document.execCommand('copy');
} catch (error) {
console.error('Failed to copy text to clipboard:', error);
} finally {
document.body.removeChild(textarea);
}
return success;
}
// Example usage
writeToClipboardFallback('Hello, this text will be copied to the clipboard!');
function share_code(code) {
const currentUrlWithoutQuery = window.location.origin + window.location.pathname;
const encoded = encodeRFC3986URIComponent(code);
if (!encoded) {
alert("Couldn't encode the shader for sharing. See console.");
return;
}
const sharing_url = `${currentUrlWithoutQuery}?code=${encoded}`;
async function writeToClipboard(text) {
try {
await navigator.clipboard.writeText(sharing_url);
alert("A link to your shader has been copied to the clipboard!");
} catch (error) {
console.error('Failed to copy text to clipboard:', error);
if (!writeToClipboardFallback(sharing_url)) {
alert(`Share this link:\n\n${sharing_url}`);
} else {
alert("A link to your shader has been copied to the clipboard!");
}
}
}
writeToClipboard();
}
const keyCodes = {
"Ctrl-Q": cm => cm.foldCode(cm.getCursor()),
"Ctrl-/": cm => cm.execCommand("toggleComment"),
"Ctrl-S": cm => share_code(cm.getValue()),
};
function setup_editor(block, theme) {
const container = block.parentNode;
const share_button = document.createElement('a');
{
share_button.textContent = 'Copy Shader Link';
share_button.href = '#';
share_button.classList.add("share_button");
share_button.addEventListener('click', function (event) {
event.preventDefault();
share_code(editor.getValue());
});
share_button.style.display = 'none';
}
const original_code = block.textContent.trim();
let code = original_code;
const original_persistence_id = container.previousElementSibling?.getAttribute('persistence-id');
let persistence_id = original_persistence_id;
if (persistence_id) {
persistence_id = `saved-code-id-${persistence_id}`;
const saved_code = localStorage.getItem(persistence_id);
if (saved_code?.length > 0) {
if (saved_code === code) {
localStorage.removeItem(persistence_id);
} else {
code = saved_code;
share_button.style.display = '';
}
}
}
const playback_container = document.createElement('div');
playback_container.classList.add("playback_container");
container.appendChild(playback_container);
const image = new Image();
image.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
playback_container.appendChild(image);
if (original_persistence_id !== "flash-example") {
const pause_label = document.createElement('div');
pause_label.innerText = "Paused";
pause_label.classList.add("paused_label");
playback_container.appendChild(pause_label);
}
const editor = CodeMirror(function(elt) {
container.appendChild(elt);
elt.style.height="auto";
},{
mode: "x-shader/x-fragment",
lineWrapping: true,
extraKeys: keyCodes,
// lineNumbers: true,
// foldGutter: true,
// gutters: [ "CodeMirror-foldgutter"]
foldOptions: {
widget: () => "..."
},
theme,
value: code
});
foldMarkedBlocks(editor);
if (original_persistence_id === "flash-example") {
const reference = new Image();
reference.src = "rick.png";
reference.classList.add("flashing_overlay");
playback_container.appendChild(reference);
}
container.appendChild(share_button);
// const reset_button = document.createElement('a');
// {
// reset_button.textContent = 'Reset';
// reset_button.href = '#';
// reset_button.classList.add("reset_button");
// reset_button.addEventListener('click', function (event) {
// event.preventDefault();
// share_code(editor.getValue());
// });
// reset_button.style.display = 'none';
// container.appendChild(reset_button);
// }
const bundle = {
editor,
image,
error_widgets: [],
playback_container,
intersectionRatio : 0,
time: 0,
persistence_id,
original_code,
};
editor.on("changes", function () {
bundle.time = 0;
share_button.style.display = "";
let editor_value = editor.getValue();
if (editor_value.length == 0) {
if (editor_value !== original_code) editor.setValue(original_code);
foldMarkedBlocks(editor);
if (persistence_id) {
localStorage.removeItem(persistence_id);
}
share_button.style.display = 'none';
} else if (persistence_id) {
localStorage.setItem(persistence_id, editor_value);
}
set_active_bundle(bundle, true);
if (active_bundle) active_bundle.time = 0;
});
editor.on('focus', function(){
playback_container.style.position = "sticky";
set_active_bundle(bundle);
});
editor.on('blur', function(){
playback_container.style.position = "";
});
function make_active(){
set_active_bundle(bundle);
}
container.addEventListener('pointerenter', make_active);
container.addEventListener('pointerdown', make_active);
block.remove();
return bundle;
}
let previousTimeStamp = undefined;
function animation_frame(timestamp) {
window.cancelAnimationFrame(animationId);
animationId = window.requestAnimationFrame(animation_frame);
if (!canvas.parentNode) return;
if (typeof timestamp === "number") {
if (typeof previousTimeStamp === "number" && active_bundle) {
active_bundle.time += timestamp-previousTimeStamp;
}
previousTimeStamp = timestamp;
}
draw();
}
function stop_animating() {
window.cancelAnimationFrame(animationId);
animationId = undefined;
previousTimeStamp = undefined;
}
function start_animating() {
if (!animationId) {
previousTimeStamp = undefined;
animationId = window.requestAnimationFrame(animation_frame);
}
}
const main_setup = () => {
let code_blocks = document.querySelectorAll("code.language-glsl");
{ // check persistence ids
let used_persistence_ids = new Set();
for (let block of code_blocks) {
block = block.parentNode;
const container = block.parentNode;
const original_code = block.textContent.trim();
const persistence_id = container.previousElementSibling?.getAttribute('persistence-id');
if (!persistence_id) {
alert(`missing persistence id for:\n${original_code}`);
break;
} else if (used_persistence_ids.has(persistence_id)) {
alert(`persistence id '${persistence_id}' is used multiple times`);
break;
}
used_persistence_ids.add(persistence_id);
}
}
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
const share_editor = document.getElementById('share_editor');
if (share_editor && urlParams.has('code')) {
const encodedValue = urlParams.get('code');
const decodedValue = decodeURIComponent(encodedValue);
console.log('Decoded query parameter value:', decodedValue);
if (decodedValue?.length > 0) {
const codeElement = document.createElement('code');
codeElement.classList.add('language-glsl');
codeElement.textContent = decodedValue;
share_editor?.appendChild(codeElement);
code_blocks= [codeElement, ...code_blocks];
const newParagraph = document.createElement('p');
newParagraph.textContent = 'This shader was written by the person that sent you the link. The original article follows below it';
share_editor.parentNode.before(newParagraph);
}
}
let bundles = [];
const containerToBundleMap = new WeakMap();
const switch_active_observer = new IntersectionObserver((entries) => {
for (let entry of entries) {
const bundle = containerToBundleMap.get(entry.target);
bundle.intersectionRatio = entry.intersectionRatio;
}
let best_bundle = undefined;
let best_ratio = 0;
for (let bundle of bundles) {
if (bundle.intersectionRatio > best_ratio) {
best_bundle = bundle;
best_ratio = bundle.intersectionRatio;
}
}
if (best_bundle && active_bundle?.intersectionRatio < best_ratio) {
set_active_bundle(best_bundle);
}
}, {
threshold: [0, 0.25, 0.5, 0.75, 1],
});
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const setup_observer = new IntersectionObserver((entries) => {
const old_active_bundle = active_bundle;
const theme = mediaQuery.matches? "lucario" : "default";
for (let entry of entries) {
if (!entry.isIntersecting) continue;
setup_observer.unobserve(entry.target);
let bundle = setup_editor(entry.target, theme);
bundles.push(bundle);
containerToBundleMap.set(bundle.playback_container, bundle);
switch_active_observer.observe(bundle.playback_container);
set_active_bundle(bundle);
}
if (old_active_bundle) {
set_active_bundle(old_active_bundle);
}
}, {
rootMargin: "200px",
});
const render_image_observer = new IntersectionObserver((entries) => {
const old_active_bundle = active_bundle;
for (let entry of entries) {
if (!entry.isIntersecting) continue;
const bundle = containerToBundleMap.get(entry.target);
render_image_observer.unobserve(entry.target);
set_active_bundle(bundle);
}
if (old_active_bundle) {
set_active_bundle(old_active_bundle);
}
}, {
rootMargin: "200px",
});
{
const theme = mediaQuery.matches? "lucario" : "default";
let first_visible_preview_bundle = undefined;
let i = 0;
for (;i < code_blocks.length; i++){
const block = code_blocks[i];
const rect = block.getBoundingClientRect();
if (rect.top > window.innerHeight) {
break;
}
let bundle = setup_editor(block.parentNode, theme);
bundles.push(bundle);
containerToBundleMap.set(bundle.playback_container, bundle);
switch_active_observer.observe(bundle.playback_container);
const preview_rect = bundle.playback_container.getBoundingClientRect();
if (preview_rect.bottom >= 0 && preview_rect.top <= window.innerHeight) {
if (!first_visible_preview_bundle) {
first_visible_preview_bundle = bundle;
} else {
set_active_bundle(bundle);
}
} else {
render_image_observer.observe(bundle.playback_container);
}
}
if (first_visible_preview_bundle) set_active_bundle(first_visible_preview_bundle);
for (;i < code_blocks.length; i++){
const block = code_blocks[i];
setup_observer.observe(block.parentNode);
}
}
mediaQuery.addEventListener('change', function() {
const theme = mediaQuery.matches? "lucario" : "default";
for (let bundle of bundles) {
bundle.editor.setOption("theme", theme);
}
});
start_animating();
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
stop_animating();
} else {
start_animating();
}
});
window.addEventListener('blur', function(){stop_animating();});
window.addEventListener('focus', function() {start_animating();});
};
{
gl = canvas.getContext("webgl2");
if (!gl) return;
const vs_source = `#version 300 es
in vec4 a_position;
void main() {
gl_Position = a_position;
}
`;
cached_vertex_shader = loadShader(vs_source, gl.VERTEX_SHADER);
if (typeof cached_vertex_shader === "string") {
console.error(cached_vertex_shader);
return;
}
setup_vao();
}
window.addEventListener('load', () => {
setTimeout(() => {
// we call main_setup in a timer from "load" so that the browser
// has restored the scroll position by this time and we can
// setup the editors above our scroll position
main_setup();
}, 0);
});
window.restore_all_code = function() {
localStorage.clear();
window.location.reload();
};
}());
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment