Created
February 4, 2025 20:33
-
-
Save danielchasehooper/72da5d9c286e5e94fdfb8e82bea288cc to your computer and use it in GitHub Desktop.
the code that creates the interactive shader editor for danielchasehooper.com
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"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