Skip to content

Instantly share code, notes, and snippets.

@haxiomic
Last active March 23, 2023 20:50
Show Gist options
  • Save haxiomic/b2b66a21d4ae26a37104b9416b67485c to your computer and use it in GitHub Desktop.
Save haxiomic/b2b66a21d4ae26a37104b9416b67485c to your computer and use it in GitHub Desktop.
BodgeShaderEditor. Many of the WebGL shader editor tooling out there is broken or doesn't support WebGL2 and interesting setups like wasm. I created this bodge in a few hours to help solve an issue. See comments for usage. Feel free to do what you like with the code (please build a real shader editor <3)
/**
* BodgeShaderEditor
*
* Many of the WebGL shader editor tooling out there is broken or doesn't support WebGL2 and interesting setups like wasm.
* I created this bodge in a few hours to help solve an issue
*
* @author haxiomic
*/
// replace getContext with our own function
let _getContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, ...args) {
let ctx = _getContext.apply(this, [type, ...args]);
// check if type includes 'webgl'
if (type.includes('webgl') && ctx) {
let gl = ctx;
// check if __shaderEditorEnabled is set
if (!gl.__shaderEditorEnabled) {
setupWebGLIntercept(gl);
setupEditor(gl);
}
}
return ctx;
};
function setupShaderEditor(canvas) {
if (typeof window === "undefined") {
return;
}
// patch into canvas.getContext to intercept the WebGL context as soon as it's created
let _getContext = canvas.getContext;
canvas.getContext = function () {
let gl = _getContext.apply(this, arguments);
console.log('getContext', gl, arguments);
if (gl) {
// check if __shaderEditorEnabled is set
if (!gl.__shaderEditorEnabled) {
setupWebGLIntercept(gl);
setupEditor(gl);
}
}
return gl;
};
}
const patchFunction = (obj, functionName, replacement) => {
/** @type {Function} */
let original = obj[functionName];
obj[functionName] = function () {
// call replacement with first argument being the original function
let ret = replacement.apply(this, [original.bind(obj)].concat(Array.from(arguments)));
return ret;
};
};
let shaderSourceMap = new Map();
let programsMap = new Map();
let replacedPrograms = new Map();
let replacedUniformLocations = new Map();
window.shadersMap = shaderSourceMap;
window.programsMap = programsMap;
let onProgramAttachShaderCallbacks = [];
let onProgramAttachShader = (program, shader) => {
let shaderSource = shaderSourceMap.get(shader);
onProgramAttachShaderCallbacks.forEach(cb => cb(program, shader, shaderSource));
}
let onProgramAddedCallbacks = [];
let onProgramAdded = (program) => {
onProgramAddedCallbacks.forEach(cb => cb(program, programsMap));
}
/**
*
* @param {WebGL2RenderingContext} gl
*/
function setupWebGLIntercept(gl) {
gl.__shaderEditorEnabled = true;
console.log('setupWebGLIntercept', gl);
patchFunction(gl, 'shaderSource', (original, shader, source) => {
shaderSourceMap.set(shader, source);
// console.log(this, shader, source);
return original(shader, source);
});
patchFunction(gl, 'attachShader', (original, program, shader) => {
let programShaders = programsMap.get(program);
if (!programShaders) {
programShaders = new Set();
programsMap.set(program, programShaders);
onProgramAdded(program);
}
programShaders.add(shader);
onProgramAttachShader(program, shader);
return original(program, shader);
});
// patch useProgram to switch to replacement program if it exists
patchFunction(gl, 'useProgram', (original, program) => {
let replacement = replacedPrograms.get(program);
if (replacement) {
program = replacement;
}
return original(program);
});
// patch all uniform functions to switch to replacement program if it exists
['uniform1f', 'uniform2f', 'uniform3f', 'uniform4f', 'uniform1i', 'uniform2i', 'uniform3i', 'uniform4i', 'uniform1fv', 'uniform2fv', 'uniform3fv', 'uniform4fv', 'uniform1iv', 'uniform2iv', 'uniform3iv', 'uniform4iv', 'uniformMatrix2fv', 'uniformMatrix3fv', 'uniformMatrix4fv', 'uniformMatrix2x3fv', 'uniformMatrix3x2fv', 'uniformMatrix2x4fv', 'uniformMatrix4x2fv', 'uniformMatrix3x4fv', 'uniformMatrix4x3fv'].forEach(uniformFunctionName => {
patchFunction(gl, uniformFunctionName, (original, location, ...args) => {
let program = gl.getParameter(gl.CURRENT_PROGRAM);
let replacement = replacedPrograms.get(program);
if (replacement) {
program = replacement;
// check if we've already replaced this uniform location
if (replacedUniformLocations.has(location)) {
location = replacedUniformLocations.get(location);
} else {
console.log('replacing uniform location', location, gl.getActiveUniform(program, location.index).name);
let newLocation = gl.getUniformLocation(program, gl.getActiveUniform(program, location.index).name);
// cache the uniform location so we don't have to look it up again
replacedUniformLocations.set(location, newLocation);
location = newLocation;
}
}
return original(location, ...args);
});
});
// patch attribute functions to switch to replacement program if it exists
['vertexAttrib1f', 'vertexAttrib2f', 'vertexAttrib3f', 'vertexAttrib4f', 'vertexAttrib1fv', 'vertexAttrib2fv', 'vertexAttrib3fv', 'vertexAttrib4fv'].forEach(attributeFunctionName => {
patchFunction(gl, attributeFunctionName, (original, index, ...args) => {
let program = gl.getParameter(gl.CURRENT_PROGRAM);
let replacement = replacedPrograms.get(program);
if (replacement) {
program = replacement;
console.log('replacing attribute location', index, gl.getActiveAttrib(program, index).name);
index = gl.getAttribLocation(program, gl.getActiveAttrib(program, index).name);
}
return original(index, ...args);
});
});
patchFunction(gl, 'enableExtension', (original, name) => {
console.log('enableExtension', name);
return original(name);
});
// log all texture functions
[
// 'texImage2D',
'texSubImage2D',
'compressedTexImage2D',
'compressedTexSubImage2D',
'texStorage2D',
'texStorage3D',
'texImage3D',
'texSubImage3D',
'copyTexSubImage3D',
'compressedTexImage3D',
'compressedTexSubImage3D',
// set parameter functions
'texParameterf',
'texParameteri',
'texParameterfv',
'texParameteriv',
].forEach(textureFunctionName => {
patchFunction(gl, textureFunctionName, (original, ...args) => {
let stack = new Error().stack;
console.log(textureFunctionName, args.map(
(arg) => {
if (typeof arg === 'number') {
let constantName = getGLConstantName(arg);
if (constantName != null) {
return `${arg} (${constantName})`;
} else {
return arg;
}
} else {
return arg;
}
}
),
stack);
return original(...args);
});
});
}
let programIdCounter = 0;
let shaderIdCounter = 0;
function onShader(gl, editor, programHandle, shaderHandle, shaderSource) {
let programId = programHandle.__programId ?? (programHandle.__programId = programIdCounter++);
let shaderId = shaderHandle.__shaderId ?? (shaderHandle.__shaderId = shaderIdCounter++);
let shaderType = gl.getShaderParameter(shaderHandle, gl.SHADER_TYPE);
// shaderType to string
let shaderTypeName = shaderType === gl.VERTEX_SHADER ? 'vertex' : 'fragment';
let filename = `program${programId}/${shaderTypeName}.glsl`;
let fileBrowser = editor.fileBrowser;
if (!fileBrowser.hasFile(filename)) {
// create a monaco model for the shader and add it to the file browser
let model = monaco.editor.createModel(shaderSource, "glsl", monaco.Uri.parse(`inmemory://shader-folder/${filename}`));
fileBrowser.addFile(filename, model);
// when the shader is changed, update the shader source in the shaders map
model.onDidChangeContent(() => {
let newSource = model.getValue();
// create a new shader from the new source
const newShader = gl.createShader(shaderType);
newShader.__shaderId = shaderId;
gl.shaderSource(newShader, newSource);
gl.compileShader(newShader);
if (!gl.getShaderParameter(newShader, gl.COMPILE_STATUS)) {
console.error("An error occurred compiling the shaders: " + gl.getShaderInfoLog(newShader));
gl.deleteShader(newShader);
return;
}
let otherShader = gl.getAttachedShaders(programHandle).find(s => s !== shaderHandle);
const newProgram = gl.createProgram();
newProgram.__programId = programId;
gl.attachShader(newProgram, newShader);
gl.attachShader(newProgram, otherShader);
gl.linkProgram(newProgram);
if (!gl.getProgramParameter(newProgram, gl.LINK_STATUS)) {
console.error("Unable to initialize the shader program: " + gl.getProgramInfoLog(newProgram));
gl.deleteProgram(newProgram);
return;
}
let currentProgram = gl.getParameter(gl.CURRENT_PROGRAM);
// read all uniform values from the old program and set them on the new program
gl.useProgram(newProgram);
const numUniforms = gl.getProgramParameter(programHandle, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < numUniforms; i++) {
const uniformInfo = gl.getActiveUniform(programHandle, i);
const oldLocation = gl.getUniformLocation(programHandle, uniformInfo.name);
const newLocation = gl.getUniformLocation(newProgram, uniformInfo.name);
if (oldLocation === null || newLocation === null) {
continue;
}
// console.log('uniform', uniformInfo.name, uniformInfo.type, oldLocation, newLocation);
switch (uniformInfo.type) {
case gl.FLOAT:
gl.uniform1f(newLocation, gl.getUniform(programHandle, oldLocation));
break;
case gl.FLOAT_VEC2:
gl.uniform2fv(newLocation, gl.getUniform(programHandle, oldLocation));
break;
case gl.FLOAT_VEC3:
gl.uniform3fv(newLocation, gl.getUniform(programHandle, oldLocation));
break;
case gl.FLOAT_VEC4:
gl.uniform4fv(newLocation, gl.getUniform(programHandle, oldLocation));
break;
case gl.INT:
gl.uniform1i(newLocation, gl.getUniform(programHandle, oldLocation));
break;
case gl.INT_VEC2:
gl.uniform2iv(newLocation, gl.getUniform(programHandle, oldLocation));
break;
case gl.INT_VEC3:
gl.uniform3iv(newLocation, gl.getUniform(programHandle, oldLocation));
break;
case gl.INT_VEC4:
gl.uniform4iv(newLocation, gl.getUniform(programHandle, oldLocation));
break;
case gl.BOOL:
gl.uniform1i(newLocation, gl.getUniform(programHandle, oldLocation));
break;
case gl.BOOL_VEC2:
gl.uniform2iv(newLocation, gl.getUniform(programHandle, oldLocation));
break;
case gl.BOOL_VEC3:
gl.uniform3iv(newLocation, gl.getUniform(programHandle, oldLocation));
break;
case gl.BOOL_VEC4:
gl.uniform4iv(newLocation, gl.getUniform(programHandle, oldLocation));
break;
case gl.FLOAT_MAT2:
gl.uniformMatrix2fv(newLocation, false, gl.getUniform(programHandle, oldLocation));
break;
case gl.FLOAT_MAT3:
gl.uniformMatrix3fv(newLocation, false, gl.getUniform(programHandle, oldLocation));
break;
case gl.FLOAT_MAT4:
gl.uniformMatrix4fv(newLocation, false, gl.getUniform(programHandle, oldLocation));
break;
case gl.FLOAT_MAT2x3:
gl.uniformMatrix2x3fv(newLocation, false, gl.getUniform(programHandle, oldLocation));
break;
case gl.FLOAT_MAT2x4:
gl.uniformMatrix2x4fv(newLocation, false, gl.getUniform(programHandle, oldLocation));
break;
case gl.FLOAT_MAT3x2:
gl.uniformMatrix3x2fv(newLocation, false, gl.getUniform(programHandle, oldLocation));
break;
case gl.FLOAT_MAT3x4:
gl.uniformMatrix3x4fv(newLocation, false, gl.getUniform(programHandle, oldLocation));
break;
case gl.FLOAT_MAT4x2:
gl.uniformMatrix4x2fv(newLocation, false, gl.getUniform(programHandle, oldLocation));
break;
case gl.FLOAT_MAT4x3:
gl.uniformMatrix4x3fv(newLocation, false, gl.getUniform(programHandle, oldLocation));
break;
case gl.SAMPLER_2D:
case gl.SAMPLER_3D:
case gl.SAMPLER_CUBE:
case gl.SAMPLER_2D_SHADOW:
case gl.SAMPLER_2D_ARRAY:
case gl.SAMPLER_2D_ARRAY_SHADOW:
case gl.SAMPLER_CUBE_SHADOW:
case gl.SAMPLER_3D_SHADOW:
case gl.SAMPLER_2D_MULTISAMPLE:
case gl.SAMPLER_2D_MULTISAMPLE_ARRAY:
case gl.INT_SAMPLER_2D:
case gl.INT_SAMPLER_3D:
case gl.INT_SAMPLER_CUBE:
case gl.INT_SAMPLER_2D_ARRAY:
case gl.INT_SAMPLER_2D_MULTISAMPLE:
case gl.INT_SAMPLER_2D_MULTISAMPLE_ARRAY:
case gl.UNSIGNED_INT_SAMPLER_2D:
case gl.UNSIGNED_INT_SAMPLER_3D:
case gl.UNSIGNED_INT_SAMPLER_CUBE:
case gl.UNSIGNED_INT_SAMPLER_2D_ARRAY:
case gl.UNSIGNED_INT_SAMPLER_2D_MULTISAMPLE:
case gl.UNSIGNED_INT_SAMPLER_2D_MULTISAMPLE_ARRAY:
const textureUnit = gl.getUniform(programHandle, oldLocation);
gl.uniform1i(newLocation, textureUnit);
break;
}
}
// read uniform block bindings from the old program and set them on the new program
const numUniformBlocks = gl.getProgramParameter(programHandle, gl.ACTIVE_UNIFORM_BLOCKS);
for (let i = 0; i < numUniformBlocks; i++) {
const blockInfo = gl.getActiveUniformBlockName(programHandle, i);
const blockIndexOld = gl.getUniformBlockIndex(programHandle, blockInfo);
const blockIndexNew = gl.getUniformBlockIndex(newProgram, blockInfo);
const blockBinding = gl.getActiveUniformBlockParameter(programHandle, blockIndexOld, gl.UNIFORM_BLOCK_BINDING);
// console.log('uniform block', blockInfo, blockIndexOld, blockIndexNew, blockBinding);
gl.uniformBlockBinding(newProgram, blockIndexNew, blockBinding);
}
// NOTE: Attribute locations are defined at the shader level, not the program level.
// If you use the same attribute names and types, the locations should remain the same.
// However, if you still want to ensure that the attribute locations match,
// you can use glBindAttribLocation before linking the new program.
//
// Keep in mind that this method is not necessary for WebGL 2 since attribute
// locations can be explicitly specified in the vertex shader using the 'location' layout qualifier.
// restore the current program
gl.useProgram(currentProgram);
// console.log('newProgram', newProgram, shaderType, newShader, otherShader);
replacedPrograms.set(programHandle, newProgram);
});
}
}
function createFileBrowser(editor, container) {
const fileBrowser = document.createElement('ul');
container.appendChild(fileBrowser);
fileBrowser.style.position = 'absolute';
fileBrowser.style.left = '0';
fileBrowser.style.top = '0';
fileBrowser.style.transform = 'translateX(-100%)';
fileBrowser.style.listStyleType = 'none';
fileBrowser.style.padding = '0';
fileBrowser.style.margin = '0';
fileBrowser.style.backgroundColor = '#1e1e1e';
fileBrowser.style.overflowY = 'auto';
fileBrowser.style.color = 'white';
return {
hasFile(filename) {
for (let i = 0; i < fileBrowser.children.length; i++) {
let child = fileBrowser.children[i];
if (child.textContent === filename) {
return true;
}
}
return false;
},
addFile(filename, model) {
const fileItem = document.createElement('li');
fileBrowser.appendChild(fileItem);
fileItem.textContent = filename;
fileItem.style.padding = '4px 8px';
fileItem.style.cursor = 'pointer';
fileItem.addEventListener('click', () => {
editor.setModel(model);
});
return fileItem;
},
};
}
function onEditorReady(gl, editor) {
console.log('onEditorReady', gl, editor);
// enumerate all programs and add them to the file browser
for (let [programHandle, shaders] of programsMap) {
for (let shaderHandle of shaders) {
let shaderSource = shaderSourceMap.get(shaderHandle);
onShader(gl, editor, programHandle, shaderHandle, shaderSource);
}
}
onProgramAttachShaderCallbacks.push((programHandle, shaderHandle, shaderSource) => {
onShader(gl, editor, programHandle, shaderHandle, shaderSource);
});
}
function getGLConstantName(constant) {
for (let key in WebGL2RenderingContext) {
if (WebGL2RenderingContext[key] === constant) {
return key;
}
}
return null;
}
let insertedEditorDependencies = false;
function setupEditor(gl) {
console.log('setupEditor')
if (!insertedEditorDependencies) {
addScript('https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.30.1/min/vs/loader.js')
.then(() => {
return new Promise((resolve, reject) => {
// load and configure the Monaco Editor
window.require.config({ paths: { "vs": "https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.30.1/min/vs" } });
window.require(["vs/editor/editor.main"], function () {
let shaderEditorContainer = document.createElement('div');
shaderEditorContainer.id = 'shader-editor-container';
document.body.appendChild(shaderEditorContainer);
shaderEditorContainer.style.position = 'absolute';
shaderEditorContainer.style.top = '0';
shaderEditorContainer.style.right = '0';
shaderEditorContainer.style.width = '40vw';
shaderEditorContainer.style.height = '100vh';
shaderEditorContainer.style.zIndex = '1000';
// hide and show shader with 'h' key
document.addEventListener('keydown', (e) => {
if (e.key === 'h' && e.target === document.body) {
shaderEditorContainer.style.display = shaderEditorContainer.style.display === 'none' ? 'block' : 'none';
}
});
// register glsl language
monaco.languages.register({ id: "glsl" });
monaco.languages.setMonarchTokensProvider("glsl", glslLanguage);
monaco.languages.setLanguageConfiguration("glsl", glslConf);
const editor = monaco.editor.create(shaderEditorContainer, {
language: "glsl",
theme: "vs-dark",
});
window.shaderEditor = editor;
const fileBrowser = createFileBrowser(editor, shaderEditorContainer);
window.shaderEditorFileBrowser = fileBrowser;
editor.fileBrowser = fileBrowser;
resolve(editor);
});
});
}).then((editor) => onEditorReady(gl, editor));
insertedEditorDependencies = true;
}
}
async function addScript(src) {
let head = document.head;
// check if there's already a script with this src
let existingScript = head.querySelector(`script[src="${src}"]`);
if (existingScript) {
return Promise.resolve();
}
let el = document.createElement('script');
el.src = src;
head.appendChild(el);
return new Promise((resolve, reject) => {
el.onload = resolve;
el.onerror = reject;
});
}
const glslConf = {
comments: {
lineComment: '//',
blockComment: ['/*', '*/']
},
brackets: [
['{', '}'],
['[', ']'],
['(', ')']
],
autoClosingPairs: [
{ open: '[', close: ']' },
{ open: '{', close: '}' },
{ open: '(', close: ')' },
{ open: "'", close: "'", notIn: ['string', 'comment'] },
{ open: '"', close: '"', notIn: ['string'] }
],
surroundingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" }
]
}
const glslKeywords = [
'const', 'uniform', 'break', 'continue',
'do', 'for', 'while', 'if', 'else', 'switch', 'case', 'in', 'out', 'inout', 'true', 'false',
'invariant', 'discard', 'return', 'sampler2D', 'samplerCube', 'sampler3D', 'struct',
'radians', 'degrees', 'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'pow', 'sinh', 'cosh', 'tanh', 'asinh', 'acosh', 'atanh',
'exp', 'log', 'exp2', 'log2', 'sqrt', 'inversesqrt', 'abs', 'sign', 'floor', 'ceil', 'round', 'roundEven', 'trunc', 'fract', 'mod', 'modf',
'min', 'max', 'clamp', 'mix', 'step', 'smoothstep', 'length', 'distance', 'dot', 'cross ',
'determinant', 'inverse', 'normalize', 'faceforward', 'reflect', 'refract', 'matrixCompMult', 'outerProduct', 'transpose', 'lessThan ',
'lessThanEqual', 'greaterThan', 'greaterThanEqual', 'equal', 'notEqual', 'any', 'all', 'not', 'packUnorm2x16', 'unpackUnorm2x16', 'packSnorm2x16', 'unpackSnorm2x16', 'packHalf2x16', 'unpackHalf2x16',
'dFdx', 'dFdy', 'fwidth', 'textureSize', 'texture', 'textureProj', 'textureLod', 'textureGrad', 'texelFetch', 'texelFetchOffset',
'textureProjLod', 'textureLodOffset', 'textureGradOffset', 'textureProjLodOffset', 'textureProjGrad', 'intBitsToFloat', 'uintBitsToFloat', 'floatBitsToInt', 'floatBitsToUint', 'isnan', 'isinf',
'vec2', 'vec3', 'vec4', 'ivec2', 'ivec3', 'ivec4', 'uvec2', 'uvec3', 'uvec4', 'bvec2', 'bvec3', 'bvec4',
'mat2', 'mat3', 'mat2x2', 'mat2x3', 'mat2x4', 'mat3x2', 'mat3x3', 'mat3x4', 'mat4x2', 'mat4x3', 'mat4x4', 'mat4',
'float', 'int', 'uint', 'void', 'bool',
]
const glslLanguage = {
tokenPostfix: '.glsl',
// Set defaultToken to invalid to see what you do not tokenize yet
defaultToken: 'invalid',
keywords: glslKeywords,
operators: [
'=',
'>',
'<',
'!',
'~',
'?',
':',
'==',
'<=',
'>=',
'!=',
'&&',
'||',
'++',
'--',
'+',
'-',
'*',
'/',
'&',
'|',
'^',
'%',
'<<',
'>>',
'>>>',
'+=',
'-=',
'*=',
'/=',
'&=',
'|=',
'^=',
'%=',
'<<=',
'>>=',
'>>>='
],
symbols: /[=><!~?:&|+\-*\/\^%]+/,
escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
integersuffix: /([uU](ll|LL|l|L)|(ll|LL|l|L)?[uU]?)/,
floatsuffix: /[fFlL]?/,
encoding: /u|u8|U|L/,
tokenizer: {
root: [
// identifiers and keywords
[
/[a-zA-Z_]\w*/,
{
cases: {
'@keywords': { token: 'keyword.$0' },
'@default': 'identifier'
}
}
],
// Preprocessor directive (#define)
[/^\s*#\s*\w+/, 'keyword.directive'],
// whitespace
{ include: '@whitespace' },
// delimiters and operators
[/[{}()\[\]]/, '@brackets'],
[/@symbols/, {
cases: {
'@operators': 'operator',
'@default': ''
}
}],
// numbers
[/\d*\d+[eE]([\-+]?\d+)?(@floatsuffix)/, 'number.float'],
[/\d*\.\d+([eE][\-+]?\d+)?(@floatsuffix)/, 'number.float'],
[/0[xX][0-9a-fA-F']*[0-9a-fA-F](@integersuffix)/, 'number.hex'],
[/0[0-7']*[0-7](@integersuffix)/, 'number.octal'],
[/0[bB][0-1']*[0-1](@integersuffix)/, 'number.binary'],
[/\d[\d']*\d(@integersuffix)/, 'number'],
[/\d(@integersuffix)/, 'number'],
// delimiter: after number because of .\d floats
[/[;,.]/, 'delimiter']
],
comment: [
[/[^\/*]+/, 'comment'],
[/\/\*/, 'comment', '@push'],
['\\*/', 'comment', '@pop'],
[/[\/*]/, 'comment']
],
// Does it have strings?
string: [
[/[^\\"]+/, 'string'],
[/@escapes/, 'string.escape'],
[/\\./, 'string.escape.invalid'],
[/"/, {
token: 'string.quote',
bracket: '@close',
next: '@pop'
}]
],
whitespace: [
[/[ \t\r\n]+/, 'white'],
[/\/\*/, 'comment', '@comment'],
[/\/\/.*$/, 'comment']
]
}
}
@haxiomic
Copy link
Author

haxiomic commented Mar 22, 2023

Usage

import { setupShaderEditor } from './shader-editor';

let canvas = document.getElementById('canvas');

setupShaderEditor(canvas);

Must be called before canvas.getContext() is used

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment