Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,478 changes: 1,438 additions & 40 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"test": "node --test --test-concurrency=1"
},
"dependencies": {
"gl": "^8.1.6",
"grapick": "^0.1.13",
"ws": "^8.18.0"
},
Expand Down
2 changes: 2 additions & 0 deletions src/effects/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import * as solid from './library/solid.mjs';
import * as noise from './library/noise.mjs';
import * as digitalRain from './library/digitalRain.mjs';
import * as diagonalStripes from './library/diagonalStripes.mjs';
import * as exampleShader from './library/exampleShader.mjs';

export const effects = {
[gradient.id]: gradient,
[solid.id]: solid,
[noise.id]: noise,
[digitalRain.id]: digitalRain,
[diagonalStripes.id]: diagonalStripes,
[exampleShader.id]: exampleShader,
};
10 changes: 10 additions & 0 deletions src/effects/library/exampleShader.fragment.glsl
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
precision highp float;
uniform float elapsed;
uniform vec3 colorA;
uniform vec3 colorB;
uniform float strobeSpeed;
void main(){
float phase = step(0.5, fract(elapsed * strobeSpeed));
vec3 resultColor = mix(colorA, colorB, phase);
gl_FragColor = vec4(resultColor, 1.0);
}
122 changes: 122 additions & 0 deletions src/effects/library/exampleShader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
let createGl;
if (typeof document === 'undefined'){
({ default: createGl } = await import('gl'));
}

async function loadShaderSource(shaderFileName){
if (typeof document === 'undefined'){
const { readFile } = await import('node:fs/promises');
const { fileURLToPath } = await import('node:url');
const { dirname, join } = await import('node:path');
const directoryName = dirname(fileURLToPath(import.meta.url));
return readFile(join(directoryName, shaderFileName), 'utf8');
}
const response = await fetch(new URL(shaderFileName, import.meta.url));
return response.text();
}

const vertexShaderSource = await loadShaderSource('exampleShader.vertex.glsl');
const fragmentShaderSource = await loadShaderSource('exampleShader.fragment.glsl');

export const id = 'exampleShader';
export const displayName = 'Example Shader';
export const defaultParams = {
strobeSpeed: 2,
colorA: [0.0, 0.0, 0.0],
colorB: [1.0, 1.0, 1.0],
};
export const paramSchema = {
strobeSpeed: { type: 'number', min: 0.1, max: 30, step: 0.1, label: 'Strobe Speed' },
colorA: { type: 'color', label: 'Color A' },
colorB: { type: 'color', label: 'Color B' },
};

let glContext = null;
let shaderProgram = null;
let positionBuffer = null;

function createWebGlContext(sceneWidth, sceneHeight){
if (typeof document === 'undefined'){
return createGl ? createGl(sceneWidth, sceneHeight, { preserveDrawingBuffer: true }) : null;
}
const canvas = document.createElement('canvas');
canvas.width = sceneWidth;
canvas.height = sceneHeight;
return canvas.getContext('webgl', { preserveDrawingBuffer: true });
}

function initializeContext(sceneWidth, sceneHeight){
glContext = createWebGlContext(sceneWidth, sceneHeight);
if (!glContext){
shaderProgram = null;
positionBuffer = null;
return;
}
const renderingContext = glContext;
const vertexShader = renderingContext.createShader(renderingContext.VERTEX_SHADER);
renderingContext.shaderSource(vertexShader, vertexShaderSource);
renderingContext.compileShader(vertexShader);
const fragmentShader = renderingContext.createShader(renderingContext.FRAGMENT_SHADER);
renderingContext.shaderSource(fragmentShader, fragmentShaderSource);
renderingContext.compileShader(fragmentShader);
shaderProgram = renderingContext.createProgram();
renderingContext.attachShader(shaderProgram, vertexShader);
renderingContext.attachShader(shaderProgram, fragmentShader);
renderingContext.linkProgram(shaderProgram);

positionBuffer = renderingContext.createBuffer();
renderingContext.bindBuffer(renderingContext.ARRAY_BUFFER, positionBuffer);
renderingContext.bufferData(
renderingContext.ARRAY_BUFFER,
new Float32Array([
-1, -1,
1, -1,
-1, 1,
-1, 1,
1, -1,
1, 1,
]),
renderingContext.STATIC_DRAW,
);
}

export function render(sceneFloat32, sceneWidth, sceneHeight, elapsedSeconds, userParams = {}){
const {
strobeSpeed = defaultParams.strobeSpeed,
colorA = defaultParams.colorA,
colorB = defaultParams.colorB,
} = userParams;
if (!glContext || glContext.drawingBufferWidth !== sceneWidth || glContext.drawingBufferHeight !== sceneHeight){
initializeContext(sceneWidth, sceneHeight);
}
if (!glContext){
const phase = (elapsedSeconds * strobeSpeed) % 1;
const chosenColor = phase >= 0.5 ? colorB : colorA;
for (let sceneOffset = 0; sceneOffset < sceneFloat32.length; sceneOffset += 3){
sceneFloat32[sceneOffset] = chosenColor[0];
sceneFloat32[sceneOffset + 1] = chosenColor[1];
sceneFloat32[sceneOffset + 2] = chosenColor[2];
}
return;
}
const renderingContext = glContext;
renderingContext.viewport(0, 0, sceneWidth, sceneHeight);
renderingContext.useProgram(shaderProgram);
renderingContext.bindBuffer(renderingContext.ARRAY_BUFFER, positionBuffer);
const positionLocation = renderingContext.getAttribLocation(shaderProgram, 'position');
renderingContext.enableVertexAttribArray(positionLocation);
renderingContext.vertexAttribPointer(positionLocation, 2, renderingContext.FLOAT, false, 0, 0);
renderingContext.uniform1f(renderingContext.getUniformLocation(shaderProgram, 'elapsed'), elapsedSeconds);
renderingContext.uniform1f(renderingContext.getUniformLocation(shaderProgram, 'strobeSpeed'), strobeSpeed);
renderingContext.uniform3fv(renderingContext.getUniformLocation(shaderProgram, 'colorA'), colorA);
renderingContext.uniform3fv(renderingContext.getUniformLocation(shaderProgram, 'colorB'), colorB);
renderingContext.drawArrays(renderingContext.TRIANGLES, 0, 6);

const pixelBuffer = new Uint8Array(sceneWidth * sceneHeight * 4);
renderingContext.readPixels(0, 0, sceneWidth, sceneHeight, renderingContext.RGBA, renderingContext.UNSIGNED_BYTE, pixelBuffer);
for (let pixelOffset = 0, sceneOffset = 0; sceneOffset < sceneFloat32.length; pixelOffset += 4, sceneOffset += 3){
sceneFloat32[sceneOffset] = pixelBuffer[pixelOffset] / 255;
sceneFloat32[sceneOffset + 1] = pixelBuffer[pixelOffset + 1] / 255;
sceneFloat32[sceneOffset + 2] = pixelBuffer[pixelOffset + 2] / 255;
}
}
4 changes: 4 additions & 0 deletions src/effects/library/exampleShader.vertex.glsl
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
attribute vec2 position;
void main(){
gl_Position = vec4(position, 0.0, 1.0);
}
4 changes: 3 additions & 1 deletion src/effects/library/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@

One file per visual effect. Each module exports the following:
`{ id, displayName, defaultParams, paramSchema, render }`.
Note that this includes its own render function, and parameters for modification.
Note that this includes its own render function, and parameters for modification.

- `exampleShader.mjs` – WebGL fragment shader that alternates between two colors at a configurable speed.
42 changes: 42 additions & 0 deletions test/exampleShader.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'assert/strict';
import * as exampleShader from '../src/effects/library/exampleShader.mjs';

function getPixel(sceneFloat32, sceneWidth, xPosition, yPosition){
const offset = (yPosition * sceneWidth + xPosition) * 3;
return [sceneFloat32[offset], sceneFloat32[offset + 1], sceneFloat32[offset + 2]];
}

function approximately(value, target){
return Math.abs(value - target) < 1e-3;
}

test('example shader toggles colors based on time and speed', () => {
const sceneWidth = 2;
const sceneHeight = 2;
const sceneBuffer = new Float32Array(sceneWidth * sceneHeight * 3);

exampleShader.render(sceneBuffer, sceneWidth, sceneHeight, 0, {});
for (let yPosition = 0; yPosition < sceneHeight; yPosition++){
for (let xPosition = 0; xPosition < sceneWidth; xPosition++){
const [red, green, blue] = getPixel(sceneBuffer, sceneWidth, xPosition, yPosition);
assert(approximately(red, 0) && approximately(green, 0) && approximately(blue, 0));
}
}

exampleShader.render(sceneBuffer, sceneWidth, sceneHeight, 0.3, {});
for (let yPosition = 0; yPosition < sceneHeight; yPosition++){
for (let xPosition = 0; xPosition < sceneWidth; xPosition++){
const [red, green, blue] = getPixel(sceneBuffer, sceneWidth, xPosition, yPosition);
assert(approximately(red, 1) && approximately(green, 1) && approximately(blue, 1));
}
}

exampleShader.render(sceneBuffer, sceneWidth, sceneHeight, 0.3, { strobeSpeed: 1 });
for (let yPosition = 0; yPosition < sceneHeight; yPosition++){
for (let xPosition = 0; xPosition < sceneWidth; xPosition++){
const [red, green, blue] = getPixel(sceneBuffer, sceneWidth, xPosition, yPosition);
assert(approximately(red, 0) && approximately(green, 0) && approximately(blue, 0));
}
}
});