1539 lines
43 KiB
TypeScript
1539 lines
43 KiB
TypeScript
const CELL_SIZE = 10;
|
|
const HUE_PERIOD_MS = 3000;
|
|
const BRUSH_RADIUS = 2;
|
|
const MAX_DPR = 2;
|
|
const PANEL_LIMIT = 3;
|
|
const TAU = Math.PI * 2;
|
|
const PANEL_BORDER_INSET_PX = 10;
|
|
type TextureTarget = {
|
|
texture: WebGLTexture;
|
|
framebuffer: WebGLFramebuffer;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
type ProgramBundle = {
|
|
program: WebGLProgram;
|
|
uniforms: Record<string, WebGLUniformLocation | null>;
|
|
};
|
|
|
|
type PointerState = {
|
|
active: boolean;
|
|
col: number;
|
|
row: number;
|
|
};
|
|
|
|
type StackTuning = {
|
|
blurStrength: number;
|
|
blurRadius: number;
|
|
smallestBlock: number;
|
|
largestBlock: number;
|
|
levels: number;
|
|
detailThreshold: number;
|
|
ditherStrength: number;
|
|
edgeBias: number;
|
|
hueDrift: number;
|
|
streakStrength: number;
|
|
};
|
|
|
|
type TuningState = {
|
|
inside: StackTuning;
|
|
outside: StackTuning;
|
|
outsideGlow: {
|
|
threshold: number;
|
|
softness: number;
|
|
boost: number;
|
|
};
|
|
darkness: number;
|
|
imageEmit: number;
|
|
ansiEmit: number;
|
|
linkEmit: number;
|
|
};
|
|
|
|
const fullscreenVertexSource = `#version 300 es
|
|
precision highp float;
|
|
|
|
out vec2 v_uv;
|
|
|
|
void main() {
|
|
vec2 position;
|
|
if (gl_VertexID == 0) {
|
|
position = vec2(-1.0, -1.0);
|
|
} else if (gl_VertexID == 1) {
|
|
position = vec2(3.0, -1.0);
|
|
} else {
|
|
position = vec2(-1.0, 3.0);
|
|
}
|
|
|
|
v_uv = position * 0.5 + 0.5;
|
|
gl_Position = vec4(position, 0.0, 1.0);
|
|
}`;
|
|
|
|
const simulationFragmentSource = `#version 300 es
|
|
precision highp float;
|
|
precision highp sampler2D;
|
|
|
|
uniform sampler2D u_state;
|
|
uniform ivec2 u_gridSize;
|
|
|
|
out vec4 outColor;
|
|
|
|
int readState(ivec2 cell) {
|
|
ivec2 wrapped = ivec2(
|
|
int(mod(float(cell.x + u_gridSize.x), float(u_gridSize.x))),
|
|
int(mod(float(cell.y + u_gridSize.y), float(u_gridSize.y)))
|
|
);
|
|
return int(round(texelFetch(u_state, wrapped, 0).r * 255.0));
|
|
}
|
|
|
|
float hueAngle(int hue) {
|
|
return float(hue) * ${TAU.toFixed(8)} / 256.0;
|
|
}
|
|
|
|
void main() {
|
|
ivec2 cell = ivec2(gl_FragCoord.xy);
|
|
int current = readState(cell);
|
|
int count = 0;
|
|
float sumSin = 0.0;
|
|
float sumCos = 0.0;
|
|
|
|
for (int dy = -1; dy <= 1; dy++) {
|
|
for (int dx = -1; dx <= 1; dx++) {
|
|
if (dx == 0 && dy == 0) {
|
|
continue;
|
|
}
|
|
|
|
int neighbor = readState(cell + ivec2(dx, dy));
|
|
if (neighbor != 0) {
|
|
count += 1;
|
|
float angle = hueAngle(neighbor);
|
|
sumSin += sin(angle);
|
|
sumCos += cos(angle);
|
|
}
|
|
}
|
|
}
|
|
|
|
int nextState = 0;
|
|
|
|
if (current != 0) {
|
|
if (count == 2 || count == 3) {
|
|
nextState = current;
|
|
}
|
|
} else if (count == 3) {
|
|
float angle = atan(sumSin, sumCos);
|
|
if (angle < 0.0) {
|
|
angle += ${TAU.toFixed(8)};
|
|
}
|
|
nextState = int(floor(angle * 256.0 / ${TAU.toFixed(8)}));
|
|
if (nextState == 0) {
|
|
nextState = 1;
|
|
}
|
|
}
|
|
|
|
outColor = vec4(float(nextState) / 255.0, 0.0, 0.0, 1.0);
|
|
}`;
|
|
|
|
const stampFragmentSource = `#version 300 es
|
|
precision highp float;
|
|
precision highp sampler2D;
|
|
|
|
uniform sampler2D u_state;
|
|
uniform ivec2 u_gridSize;
|
|
uniform vec2 u_center;
|
|
uniform float u_radius;
|
|
uniform float u_hue;
|
|
uniform bool u_active;
|
|
|
|
out vec4 outColor;
|
|
|
|
void main() {
|
|
ivec2 cell = ivec2(gl_FragCoord.xy);
|
|
vec4 state = texelFetch(u_state, cell, 0);
|
|
|
|
if (!u_active) {
|
|
outColor = state;
|
|
return;
|
|
}
|
|
|
|
vec2 position = vec2(cell) + 0.5;
|
|
vec2 delta = abs(position - u_center);
|
|
delta = min(delta, vec2(u_gridSize) - delta);
|
|
|
|
if (dot(delta, delta) <= u_radius * u_radius) {
|
|
outColor = vec4(u_hue, 0.0, 0.0, 1.0);
|
|
return;
|
|
}
|
|
|
|
outColor = state;
|
|
}`;
|
|
|
|
const injectFragmentSource = `#version 300 es
|
|
precision highp float;
|
|
precision highp sampler2D;
|
|
|
|
uniform sampler2D u_state;
|
|
uniform sampler2D u_emitter;
|
|
uniform ivec2 u_gridSize;
|
|
|
|
out vec4 outColor;
|
|
|
|
float hash12(vec2 point) {
|
|
vec3 p3 = fract(vec3(point.xyx) * 0.1031);
|
|
p3 += dot(p3, p3.yzx + 33.33);
|
|
return fract((p3.x + p3.y) * p3.z);
|
|
}
|
|
|
|
vec3 rgbToHsv(vec3 color) {
|
|
vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
|
|
vec4 p = mix(vec4(color.bg, K.wz), vec4(color.gb, K.xy), step(color.b, color.g));
|
|
vec4 q = mix(vec4(p.xyw, color.r), vec4(color.r, p.yzx), step(p.x, color.r));
|
|
float d = q.x - min(q.w, q.y);
|
|
float e = 1.0e-10;
|
|
return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
|
|
}
|
|
|
|
void main() {
|
|
ivec2 cell = ivec2(gl_FragCoord.xy);
|
|
vec4 state = texelFetch(u_state, cell, 0);
|
|
vec4 emitter = texelFetch(u_emitter, cell, 0);
|
|
float strength = clamp(emitter.a, 0.0, 1.0);
|
|
|
|
if (strength <= 0.0) {
|
|
outColor = state;
|
|
return;
|
|
}
|
|
|
|
float noise = hash12(vec2(cell) + emitter.rg * 255.0);
|
|
if (noise > strength) {
|
|
outColor = state;
|
|
return;
|
|
}
|
|
|
|
vec3 hsv = rgbToHsv(emitter.rgb);
|
|
int hue = int(floor(hsv.x * 254.0)) + 1;
|
|
outColor = vec4(float(hue) / 255.0, 0.0, 0.0, 1.0);
|
|
}`;
|
|
|
|
const colorFragmentSource = `#version 300 es
|
|
precision highp float;
|
|
precision highp sampler2D;
|
|
|
|
uniform sampler2D u_state;
|
|
uniform sampler2D u_palette;
|
|
uniform ivec2 u_gridSize;
|
|
uniform vec2 u_resolution;
|
|
|
|
out vec4 outColor;
|
|
|
|
void main() {
|
|
vec2 uv = gl_FragCoord.xy / u_resolution;
|
|
ivec2 cell = ivec2(floor(uv * vec2(u_gridSize)));
|
|
cell = clamp(cell, ivec2(0), u_gridSize - 1);
|
|
float index = texelFetch(u_state, cell, 0).r;
|
|
outColor = texture(u_palette, vec2(index, 0.5));
|
|
}`;
|
|
|
|
const copyFragmentSource = `#version 300 es
|
|
precision highp float;
|
|
precision highp sampler2D;
|
|
|
|
in vec2 v_uv;
|
|
|
|
uniform sampler2D u_image;
|
|
|
|
out vec4 outColor;
|
|
|
|
void main() {
|
|
outColor = texture(u_image, v_uv);
|
|
}`;
|
|
|
|
const blurFragmentSource = `#version 300 es
|
|
precision highp float;
|
|
precision highp sampler2D;
|
|
|
|
in vec2 v_uv;
|
|
|
|
uniform sampler2D u_image;
|
|
uniform vec2 u_direction;
|
|
uniform vec2 u_resolution;
|
|
uniform float u_radius;
|
|
|
|
out vec4 outColor;
|
|
|
|
void main() {
|
|
vec2 texel = (u_direction / u_resolution) * u_radius;
|
|
|
|
vec4 color = texture(u_image, v_uv) * 0.15957691;
|
|
color += texture(u_image, v_uv + texel * 1.0) * 0.14730806;
|
|
color += texture(u_image, v_uv - texel * 1.0) * 0.14730806;
|
|
color += texture(u_image, v_uv + texel * 2.0) * 0.11587662;
|
|
color += texture(u_image, v_uv - texel * 2.0) * 0.11587662;
|
|
color += texture(u_image, v_uv + texel * 3.0) * 0.07767442;
|
|
color += texture(u_image, v_uv - texel * 3.0) * 0.07767442;
|
|
color += texture(u_image, v_uv + texel * 4.0) * 0.04436833;
|
|
color += texture(u_image, v_uv - texel * 4.0) * 0.04436833;
|
|
color += texture(u_image, v_uv + texel * 5.0) * 0.02159639;
|
|
color += texture(u_image, v_uv - texel * 5.0) * 0.02159639;
|
|
color += texture(u_image, v_uv + texel * 6.0) * 0.00895781;
|
|
color += texture(u_image, v_uv - texel * 6.0) * 0.00895781;
|
|
|
|
outColor = color;
|
|
}`;
|
|
|
|
const compositeFragmentSource = `#version 300 es
|
|
precision highp float;
|
|
precision highp sampler2D;
|
|
|
|
uniform sampler2D u_sharp;
|
|
uniform sampler2D u_inBlur;
|
|
uniform sampler2D u_outBlur;
|
|
uniform sampler2D u_outBlur2;
|
|
uniform sampler2D u_palette;
|
|
uniform vec2 u_resolution;
|
|
uniform vec4 u_panelRects[${PANEL_LIMIT}];
|
|
uniform int u_panelCount;
|
|
uniform float u_inMinBlockSize;
|
|
uniform float u_inMaxBlockSize;
|
|
uniform int u_inLevels;
|
|
uniform float u_inDetailThreshold;
|
|
uniform float u_inDitherStrength;
|
|
uniform float u_inEdgeBias;
|
|
uniform float u_inHueDrift;
|
|
uniform float u_inStreakStrength;
|
|
uniform float u_outMinBlockSize;
|
|
uniform float u_outMaxBlockSize;
|
|
uniform int u_outLevels;
|
|
uniform float u_outDetailThreshold;
|
|
uniform float u_outDitherStrength;
|
|
uniform float u_outEdgeBias;
|
|
uniform float u_outHueDrift;
|
|
uniform float u_outStreakStrength;
|
|
uniform float u_outGlowStrength;
|
|
uniform float u_outGlowThreshold;
|
|
uniform float u_outGlowSoftness;
|
|
uniform float u_outGlowBoost;
|
|
out vec4 outColor;
|
|
|
|
bool inRect(vec2 point, vec4 rect) {
|
|
return point.x >= rect.x &&
|
|
point.y >= rect.y &&
|
|
point.x < rect.x + rect.z &&
|
|
point.y < rect.y + rect.w;
|
|
}
|
|
|
|
float rectSignedDistance(vec2 point, vec4 rect) {
|
|
vec2 center = rect.xy + rect.zw * 0.5;
|
|
vec2 halfSize = rect.zw * 0.5;
|
|
vec2 delta = abs(point - center) - halfSize;
|
|
float outside = length(max(delta, 0.0));
|
|
float inside = min(max(delta.x, delta.y), 0.0);
|
|
return outside + inside;
|
|
}
|
|
|
|
float bayer4(vec2 point) {
|
|
vec2 cell = mod(floor(point), 4.0);
|
|
if (cell.y < 1.0) {
|
|
if (cell.x < 1.0) return 0.0 / 16.0;
|
|
if (cell.x < 2.0) return 8.0 / 16.0;
|
|
if (cell.x < 3.0) return 2.0 / 16.0;
|
|
return 10.0 / 16.0;
|
|
}
|
|
if (cell.y < 2.0) {
|
|
if (cell.x < 1.0) return 12.0 / 16.0;
|
|
if (cell.x < 2.0) return 4.0 / 16.0;
|
|
if (cell.x < 3.0) return 14.0 / 16.0;
|
|
return 6.0 / 16.0;
|
|
}
|
|
if (cell.y < 3.0) {
|
|
if (cell.x < 1.0) return 3.0 / 16.0;
|
|
if (cell.x < 2.0) return 11.0 / 16.0;
|
|
if (cell.x < 3.0) return 1.0 / 16.0;
|
|
return 9.0 / 16.0;
|
|
}
|
|
if (cell.x < 1.0) return 15.0 / 16.0;
|
|
if (cell.x < 2.0) return 7.0 / 16.0;
|
|
if (cell.x < 3.0) return 13.0 / 16.0;
|
|
return 5.0 / 16.0;
|
|
}
|
|
|
|
vec3 rgbToHsv(vec3 color) {
|
|
vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
|
|
vec4 p = mix(vec4(color.bg, K.wz), vec4(color.gb, K.xy), step(color.b, color.g));
|
|
vec4 q = mix(vec4(p.xyw, color.r), vec4(color.r, p.yzx), step(p.x, color.r));
|
|
float d = q.x - min(q.w, q.y);
|
|
float e = 1.0e-10;
|
|
return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
|
|
}
|
|
|
|
float luma(vec3 color) {
|
|
return dot(color, vec3(0.299, 0.587, 0.114));
|
|
}
|
|
|
|
float hash12(vec2 point) {
|
|
vec3 p3 = fract(vec3(point.xyx) * 0.1031);
|
|
p3 += dot(p3, p3.yzx + 33.33);
|
|
return fract((p3.x + p3.y) * p3.z);
|
|
}
|
|
|
|
vec3 sampleBlock(vec2 point, float blockSize, float streakStrength, sampler2D blurTex) {
|
|
vec2 blockCoord = floor(point / blockSize);
|
|
vec2 samplePoint = (blockCoord + 0.5) * blockSize;
|
|
float streak = (hash12(vec2(blockCoord.y, blockSize)) - 0.5) * blockSize * streakStrength;
|
|
samplePoint.x += streak;
|
|
samplePoint.x = clamp(samplePoint.x, 0.5, u_resolution.x - 0.5);
|
|
vec2 sampleUv = samplePoint / u_resolution;
|
|
return texture(blurTex, sampleUv).rgb;
|
|
}
|
|
|
|
float blockDetail(vec2 point, float blockSize, float edgeBias, float streakStrength, sampler2D blurTex) {
|
|
vec2 offset = vec2(blockSize * 0.35);
|
|
vec3 center = sampleBlock(point, blockSize, streakStrength, blurTex);
|
|
vec3 right = sampleBlock(point + vec2(offset.x, 0.0), blockSize, streakStrength, blurTex);
|
|
vec3 left = sampleBlock(point + vec2(-offset.x, 0.0), blockSize, streakStrength, blurTex);
|
|
vec3 up = sampleBlock(point + vec2(0.0, offset.y), blockSize, streakStrength, blurTex);
|
|
vec3 down = sampleBlock(point + vec2(0.0, -offset.y), blockSize, streakStrength, blurTex);
|
|
|
|
float edgeX = abs(luma(right) - luma(left));
|
|
float edgeY = abs(luma(up) - luma(down));
|
|
float edgeDetail = max(edgeX, edgeY);
|
|
|
|
float chromaShift = max(
|
|
max(length(right - center), length(left - center)),
|
|
max(length(up - center), length(down - center))
|
|
);
|
|
|
|
return edgeDetail * edgeBias + chromaShift * 0.2;
|
|
}
|
|
|
|
float chooseBlockSize(vec2 point, float minBlockSize, float maxBlockSize, int levels, float detailThreshold, float edgeBias, float streakStrength, sampler2D blurTex) {
|
|
float minSize = max(minBlockSize, 0.25);
|
|
float maxSize = max(minSize, maxBlockSize);
|
|
int sections = clamp(levels, 1, 10);
|
|
int sizeCount = sections + 1;
|
|
float chosen = maxSize;
|
|
float steps = float(max(sizeCount - 1, 1));
|
|
|
|
for (int i = 0; i < 11; i++) {
|
|
if (i >= sizeCount) {
|
|
break;
|
|
}
|
|
float t = float(i) / steps;
|
|
float candidate = exp(mix(log(maxSize), log(minSize), t));
|
|
chosen = candidate;
|
|
if (blockDetail(point, candidate, edgeBias, streakStrength, blurTex) <= detailThreshold) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return chosen;
|
|
}
|
|
|
|
vec4 stylizeBlur(vec2 frag, sampler2D blurTex, float minBlockSize, float maxBlockSize, int levels, float detailThreshold, float ditherStrength, float edgeBias, float hueDrift, float streakStrength) {
|
|
float pixelSize = chooseBlockSize(frag, minBlockSize, maxBlockSize, levels, detailThreshold, edgeBias, streakStrength, blurTex);
|
|
vec2 blockCoord = floor(frag / pixelSize);
|
|
vec3 blurred = sampleBlock(frag, pixelSize, streakStrength, blurTex);
|
|
vec3 hsv = rgbToHsv(blurred);
|
|
float drift = (hash12(blockCoord + vec2(pixelSize, 17.0)) - 0.5) * hueDrift;
|
|
float hueIndex = floor(fract(hsv.x + drift) * 254.0) + 1.0;
|
|
vec3 hueColor = texture(u_palette, vec2(hueIndex / 255.0, 0.5)).rgb;
|
|
float coverage = clamp(hsv.z * mix(0.28, 1.0, hsv.y), 0.0, 1.0);
|
|
float threshold = mix(0.5, bayer4(frag), clamp(ditherStrength, 0.0, 1.0));
|
|
return coverage > threshold ? vec4(hueColor, 1.0) : vec4(0.0, 0.0, 0.0, 1.0);
|
|
}
|
|
|
|
vec3 screenBlend(vec3 base, vec3 blend) {
|
|
return 1.0 - (1.0 - base) * (1.0 - blend);
|
|
}
|
|
|
|
vec4 glowOutside(vec2 frag) {
|
|
vec2 uv = frag / u_resolution;
|
|
vec3 sharp = texture(u_sharp, uv).rgb;
|
|
vec3 blur = texture(u_outBlur, uv).rgb;
|
|
vec3 bloom = max(blur - vec3(u_outGlowThreshold), vec3(0.0));
|
|
bloom = pow(bloom, vec3(1.0 / max(u_outGlowSoftness, 0.01)));
|
|
vec3 glow = bloom * u_outGlowBoost * u_outGlowStrength;
|
|
vec3 color = screenBlend(sharp, glow);
|
|
|
|
return vec4(color, 1.0);
|
|
}
|
|
|
|
void main() {
|
|
vec2 frag = gl_FragCoord.xy;
|
|
bool inside = false;
|
|
for (int i = 0; i < ${PANEL_LIMIT}; i++) {
|
|
if (i >= u_panelCount) {
|
|
break;
|
|
}
|
|
if (inRect(frag, u_panelRects[i])) {
|
|
inside = true;
|
|
break;
|
|
}
|
|
}
|
|
outColor = inside
|
|
? stylizeBlur(frag, u_inBlur, u_inMinBlockSize, u_inMaxBlockSize, u_inLevels, u_inDetailThreshold, u_inDitherStrength, u_inEdgeBias, u_inHueDrift, u_inStreakStrength)
|
|
: glowOutside(frag);
|
|
}`;
|
|
|
|
function compileShader(
|
|
gl: WebGL2RenderingContext,
|
|
type: number,
|
|
source: string,
|
|
) {
|
|
const shader = gl.createShader(type);
|
|
if (!shader) {
|
|
throw new Error("Unable to create WebGL shader");
|
|
}
|
|
|
|
gl.shaderSource(shader, source);
|
|
gl.compileShader(shader);
|
|
|
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
const message = gl.getShaderInfoLog(shader) ?? "Unknown shader error";
|
|
gl.deleteShader(shader);
|
|
throw new Error(message);
|
|
}
|
|
|
|
return shader;
|
|
}
|
|
|
|
function createProgram(
|
|
gl: WebGL2RenderingContext,
|
|
vertexSource: string,
|
|
fragmentSource: string,
|
|
uniformNames: string[],
|
|
): ProgramBundle {
|
|
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource);
|
|
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
|
|
const program = gl.createProgram();
|
|
|
|
if (!program) {
|
|
throw new Error("Unable to create WebGL program");
|
|
}
|
|
|
|
gl.attachShader(program, vertexShader);
|
|
gl.attachShader(program, fragmentShader);
|
|
gl.linkProgram(program);
|
|
gl.deleteShader(vertexShader);
|
|
gl.deleteShader(fragmentShader);
|
|
|
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
const message = gl.getProgramInfoLog(program) ?? "Unknown program error";
|
|
gl.deleteProgram(program);
|
|
throw new Error(message);
|
|
}
|
|
|
|
const uniforms = Object.fromEntries(
|
|
uniformNames.map((name) => [name, gl.getUniformLocation(program, name)]),
|
|
);
|
|
|
|
return { program, uniforms };
|
|
}
|
|
|
|
function createTextureTarget(
|
|
gl: WebGL2RenderingContext,
|
|
width: number,
|
|
height: number,
|
|
filter: number,
|
|
) {
|
|
const texture = gl.createTexture();
|
|
const framebuffer = gl.createFramebuffer();
|
|
|
|
if (!texture || !framebuffer) {
|
|
throw new Error("Unable to allocate WebGL texture target");
|
|
}
|
|
|
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
gl.texImage2D(
|
|
gl.TEXTURE_2D,
|
|
0,
|
|
gl.RGBA8,
|
|
width,
|
|
height,
|
|
0,
|
|
gl.RGBA,
|
|
gl.UNSIGNED_BYTE,
|
|
null,
|
|
);
|
|
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
|
gl.framebufferTexture2D(
|
|
gl.FRAMEBUFFER,
|
|
gl.COLOR_ATTACHMENT0,
|
|
gl.TEXTURE_2D,
|
|
texture,
|
|
0,
|
|
);
|
|
|
|
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
|
|
throw new Error("Incomplete WebGL framebuffer");
|
|
}
|
|
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
|
|
return { texture, framebuffer, width, height };
|
|
}
|
|
|
|
function deleteTextureTarget(
|
|
gl: WebGL2RenderingContext,
|
|
target: TextureTarget | null,
|
|
) {
|
|
if (!target) return;
|
|
gl.deleteFramebuffer(target.framebuffer);
|
|
gl.deleteTexture(target.texture);
|
|
}
|
|
|
|
function createPaletteTexture(gl: WebGL2RenderingContext) {
|
|
const texture = gl.createTexture();
|
|
if (!texture) {
|
|
throw new Error("Unable to create palette texture");
|
|
}
|
|
|
|
const data = new Uint8Array(256 * 4);
|
|
data[3] = 255;
|
|
|
|
for (let i = 1; i < 256; i++) {
|
|
const h = (i / 255) * 6;
|
|
const x = 1 - Math.abs((h % 2) - 1);
|
|
let r = 0;
|
|
let g = 0;
|
|
let b = 0;
|
|
|
|
if (h < 1) {
|
|
r = 1;
|
|
g = x;
|
|
} else if (h < 2) {
|
|
r = x;
|
|
g = 1;
|
|
} else if (h < 3) {
|
|
g = 1;
|
|
b = x;
|
|
} else if (h < 4) {
|
|
g = x;
|
|
b = 1;
|
|
} else if (h < 5) {
|
|
r = x;
|
|
b = 1;
|
|
} else {
|
|
r = 1;
|
|
b = x;
|
|
}
|
|
|
|
const offset = i * 4;
|
|
data[offset] = Math.floor(r * 255);
|
|
data[offset + 1] = Math.floor(g * 255);
|
|
data[offset + 2] = Math.floor(b * 255);
|
|
data[offset + 3] = 255;
|
|
}
|
|
|
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
gl.texImage2D(
|
|
gl.TEXTURE_2D,
|
|
0,
|
|
gl.RGBA8,
|
|
256,
|
|
1,
|
|
0,
|
|
gl.RGBA,
|
|
gl.UNSIGNED_BYTE,
|
|
data,
|
|
);
|
|
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
|
|
return texture;
|
|
}
|
|
|
|
function createInitialState(width: number, height: number) {
|
|
const data = new Uint8Array(width * height * 4);
|
|
const seedBuffer = new Uint32Array(1);
|
|
globalThis.crypto.getRandomValues(seedBuffer);
|
|
let rng = seedBuffer[0] ?? 0x4a455450;
|
|
|
|
const next = () => {
|
|
rng ^= rng << 13;
|
|
rng ^= rng >>> 17;
|
|
rng ^= rng << 5;
|
|
return rng >>> 0;
|
|
};
|
|
|
|
for (let i = 0; i < width * height; i++) {
|
|
const alive = (next() & 1) === 0;
|
|
data[i * 4] = alive ? next() & 0xff || 1 : 0;
|
|
data[i * 4 + 3] = 255;
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
function setTextureUnit(
|
|
gl: WebGL2RenderingContext,
|
|
unit: number,
|
|
texture: WebGLTexture,
|
|
) {
|
|
gl.activeTexture(gl.TEXTURE0 + unit);
|
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
}
|
|
|
|
function uniform(
|
|
bundle: ProgramBundle,
|
|
name: string,
|
|
): WebGLUniformLocation | null {
|
|
return bundle.uniforms[name] ?? null;
|
|
}
|
|
|
|
export function initWebGLBackground() {
|
|
const canvas = document.getElementById("canvas") as HTMLCanvasElement | null;
|
|
if (!canvas) {
|
|
return null;
|
|
}
|
|
|
|
const gl = canvas.getContext("webgl2", {
|
|
alpha: true,
|
|
antialias: false,
|
|
premultipliedAlpha: true,
|
|
depth: false,
|
|
stencil: false,
|
|
preserveDrawingBuffer: false,
|
|
});
|
|
|
|
if (!gl) {
|
|
document.body.dataset.backgroundMode = "failed";
|
|
canvas.hidden = true;
|
|
return null;
|
|
}
|
|
|
|
const vao = gl.createVertexArray();
|
|
if (!vao) {
|
|
throw new Error("Unable to create WebGL vertex array");
|
|
}
|
|
gl.bindVertexArray(vao);
|
|
|
|
const simulationProgram = createProgram(
|
|
gl,
|
|
fullscreenVertexSource,
|
|
simulationFragmentSource,
|
|
["u_state", "u_gridSize"],
|
|
);
|
|
const stampProgram = createProgram(
|
|
gl,
|
|
fullscreenVertexSource,
|
|
stampFragmentSource,
|
|
["u_state", "u_gridSize", "u_center", "u_radius", "u_hue", "u_active"],
|
|
);
|
|
const injectProgram = createProgram(
|
|
gl,
|
|
fullscreenVertexSource,
|
|
injectFragmentSource,
|
|
["u_state", "u_emitter", "u_gridSize"],
|
|
);
|
|
const colorProgram = createProgram(
|
|
gl,
|
|
fullscreenVertexSource,
|
|
colorFragmentSource,
|
|
["u_state", "u_palette", "u_gridSize", "u_resolution"],
|
|
);
|
|
const copyProgram = createProgram(
|
|
gl,
|
|
fullscreenVertexSource,
|
|
copyFragmentSource,
|
|
["u_image"],
|
|
);
|
|
const blurProgram = createProgram(
|
|
gl,
|
|
fullscreenVertexSource,
|
|
blurFragmentSource,
|
|
["u_image", "u_direction", "u_resolution", "u_radius"],
|
|
);
|
|
const compositeProgram = createProgram(
|
|
gl,
|
|
fullscreenVertexSource,
|
|
compositeFragmentSource,
|
|
[
|
|
"u_sharp",
|
|
"u_inBlur",
|
|
"u_outBlur",
|
|
"u_outBlur2",
|
|
"u_palette",
|
|
"u_resolution",
|
|
"u_panelRects",
|
|
"u_panelCount",
|
|
"u_inMinBlockSize",
|
|
"u_inMaxBlockSize",
|
|
"u_inLevels",
|
|
"u_inDetailThreshold",
|
|
"u_inDitherStrength",
|
|
"u_inEdgeBias",
|
|
"u_inHueDrift",
|
|
"u_inStreakStrength",
|
|
"u_outMinBlockSize",
|
|
"u_outMaxBlockSize",
|
|
"u_outLevels",
|
|
"u_outDetailThreshold",
|
|
"u_outDitherStrength",
|
|
"u_outEdgeBias",
|
|
"u_outHueDrift",
|
|
"u_outStreakStrength",
|
|
"u_outGlowStrength",
|
|
"u_outGlowThreshold",
|
|
"u_outGlowSoftness",
|
|
"u_outGlowBoost",
|
|
],
|
|
);
|
|
|
|
const paletteTexture = createPaletteTexture(gl);
|
|
const pointer: PointerState = { active: false, col: 0, row: 0 };
|
|
const tuning: TuningState = {
|
|
inside: {
|
|
blurStrength: 1,
|
|
blurRadius: 20,
|
|
smallestBlock: 0.25,
|
|
largestBlock: 63,
|
|
levels: 5,
|
|
detailThreshold: 0.103,
|
|
ditherStrength: 0.79,
|
|
edgeBias: 3,
|
|
hueDrift: 0.04,
|
|
streakStrength: 1,
|
|
},
|
|
outside: {
|
|
blurStrength: 1,
|
|
blurRadius: 20,
|
|
smallestBlock: 1,
|
|
largestBlock: 20,
|
|
levels: 3,
|
|
detailThreshold: 0.04,
|
|
ditherStrength: 0.75,
|
|
edgeBias: 1.35,
|
|
hueDrift: 0.08,
|
|
streakStrength: 0.35,
|
|
},
|
|
outsideGlow: {
|
|
threshold: 0,
|
|
softness: 1,
|
|
boost: 1,
|
|
},
|
|
darkness: 0.68,
|
|
imageEmit: 1,
|
|
ansiEmit: 1,
|
|
linkEmit: 1,
|
|
};
|
|
const panelRects = new Float32Array(PANEL_LIMIT * 4);
|
|
|
|
let stateTargets: [TextureTarget, TextureTarget] | null = null;
|
|
let colorTarget: TextureTarget | null = null;
|
|
let sharpTarget: TextureTarget | null = null;
|
|
let smoothTarget: TextureTarget | null = null;
|
|
let insideBlurTargets: [TextureTarget, TextureTarget] | null = null;
|
|
let outsideBlurTargets: [TextureTarget, TextureTarget] | null = null;
|
|
let emitterTarget: TextureTarget | null = null;
|
|
let stateIndex = 0;
|
|
let rafId = 0;
|
|
let cssWidth = 0;
|
|
let cssHeight = 0;
|
|
let canvasWidth = 0;
|
|
let canvasHeight = 0;
|
|
let gridWidth = 0;
|
|
let gridHeight = 0;
|
|
const emitterCanvas = document.createElement("canvas");
|
|
const emitterCtx = emitterCanvas.getContext("2d");
|
|
|
|
const renderPass = (
|
|
target: TextureTarget | null,
|
|
width: number,
|
|
height: number,
|
|
program: WebGLProgram,
|
|
) => {
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, target?.framebuffer ?? null);
|
|
gl.viewport(0, 0, width, height);
|
|
gl.useProgram(program);
|
|
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
};
|
|
|
|
const currentState = () => {
|
|
if (!stateTargets) {
|
|
throw new Error("State textures not initialized");
|
|
}
|
|
return stateTargets[stateIndex]!;
|
|
};
|
|
|
|
const nextState = () => {
|
|
if (!stateTargets) {
|
|
throw new Error("State textures not initialized");
|
|
}
|
|
return stateTargets[1 - stateIndex]!;
|
|
};
|
|
|
|
const swapState = () => {
|
|
stateIndex = 1 - stateIndex;
|
|
};
|
|
|
|
const resizeTargets = () => {
|
|
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
|
cssWidth = Math.max(1, Math.round(window.innerWidth));
|
|
cssHeight = Math.max(1, Math.round(window.innerHeight));
|
|
canvasWidth = Math.max(1, Math.round(cssWidth * dpr));
|
|
canvasHeight = Math.max(1, Math.round(cssHeight * dpr));
|
|
gridWidth = Math.max(1, Math.floor(cssWidth / CELL_SIZE));
|
|
gridHeight = Math.max(1, Math.floor(cssHeight / CELL_SIZE));
|
|
|
|
canvas.width = canvasWidth;
|
|
canvas.height = canvasHeight;
|
|
canvas.hidden = false;
|
|
document.body.dataset.backgroundMode = "animated";
|
|
document.documentElement.style.setProperty(
|
|
"--panel-bg-alpha",
|
|
tuning.darkness.toFixed(2),
|
|
);
|
|
|
|
deleteTextureTarget(gl, stateTargets?.[0] ?? null);
|
|
deleteTextureTarget(gl, stateTargets?.[1] ?? null);
|
|
deleteTextureTarget(gl, colorTarget);
|
|
deleteTextureTarget(gl, sharpTarget);
|
|
deleteTextureTarget(gl, smoothTarget);
|
|
deleteTextureTarget(gl, insideBlurTargets?.[0] ?? null);
|
|
deleteTextureTarget(gl, insideBlurTargets?.[1] ?? null);
|
|
deleteTextureTarget(gl, outsideBlurTargets?.[0] ?? null);
|
|
deleteTextureTarget(gl, outsideBlurTargets?.[1] ?? null);
|
|
deleteTextureTarget(gl, emitterTarget);
|
|
|
|
stateTargets = [
|
|
createTextureTarget(gl, gridWidth, gridHeight, gl.NEAREST),
|
|
createTextureTarget(gl, gridWidth, gridHeight, gl.NEAREST),
|
|
];
|
|
colorTarget = createTextureTarget(gl, gridWidth, gridHeight, gl.LINEAR);
|
|
sharpTarget = createTextureTarget(gl, canvasWidth, canvasHeight, gl.LINEAR);
|
|
smoothTarget = createTextureTarget(
|
|
gl,
|
|
canvasWidth,
|
|
canvasHeight,
|
|
gl.LINEAR,
|
|
);
|
|
insideBlurTargets = [
|
|
createTextureTarget(gl, canvasWidth, canvasHeight, gl.LINEAR),
|
|
createTextureTarget(gl, canvasWidth, canvasHeight, gl.LINEAR),
|
|
];
|
|
outsideBlurTargets = [
|
|
createTextureTarget(gl, canvasWidth, canvasHeight, gl.LINEAR),
|
|
createTextureTarget(gl, canvasWidth, canvasHeight, gl.LINEAR),
|
|
];
|
|
emitterTarget = createTextureTarget(gl, gridWidth, gridHeight, gl.LINEAR);
|
|
stateIndex = 0;
|
|
emitterCanvas.width = gridWidth;
|
|
emitterCanvas.height = gridHeight;
|
|
|
|
const initialState = createInitialState(gridWidth, gridHeight);
|
|
setTextureUnit(gl, 0, stateTargets[0].texture);
|
|
gl.texSubImage2D(
|
|
gl.TEXTURE_2D,
|
|
0,
|
|
0,
|
|
0,
|
|
gridWidth,
|
|
gridHeight,
|
|
gl.RGBA,
|
|
gl.UNSIGNED_BYTE,
|
|
initialState,
|
|
);
|
|
setTextureUnit(gl, 0, stateTargets[1].texture);
|
|
gl.texSubImage2D(
|
|
gl.TEXTURE_2D,
|
|
0,
|
|
0,
|
|
0,
|
|
gridWidth,
|
|
gridHeight,
|
|
gl.RGBA,
|
|
gl.UNSIGNED_BYTE,
|
|
initialState,
|
|
);
|
|
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
};
|
|
|
|
const updatePanelRects = () => {
|
|
panelRects.fill(0);
|
|
const dpr = canvasWidth / cssWidth;
|
|
const nodes = Array.from(
|
|
document.querySelectorAll<HTMLElement>(".site-panel-frame"),
|
|
).slice(0, PANEL_LIMIT);
|
|
|
|
nodes.forEach((node, index) => {
|
|
const rect = node.getBoundingClientRect();
|
|
const offset = index * 4;
|
|
const inset = PANEL_BORDER_INSET_PX * dpr;
|
|
panelRects[offset] = rect.left * dpr + inset;
|
|
panelRects[offset + 1] = (cssHeight - rect.bottom) * dpr + inset;
|
|
panelRects[offset + 2] = Math.max(1, rect.width * dpr - inset * 2);
|
|
panelRects[offset + 3] = Math.max(1, rect.height * dpr - inset * 2);
|
|
});
|
|
|
|
return nodes.length;
|
|
};
|
|
|
|
const updatePointer = (clientX: number, clientY: number) => {
|
|
pointer.col = Math.max(
|
|
0,
|
|
Math.min(gridWidth - 1, Math.floor(clientX / CELL_SIZE)),
|
|
);
|
|
pointer.row = Math.max(
|
|
0,
|
|
Math.min(gridHeight - 1, Math.floor((cssHeight - clientY) / CELL_SIZE)),
|
|
);
|
|
pointer.active = true;
|
|
};
|
|
|
|
const TOUCH_DRAG_THRESHOLD = 8;
|
|
let touchDragActive = false;
|
|
let touchStartX = 0;
|
|
let touchStartY = 0;
|
|
|
|
const renderEmitters = () => {
|
|
if (!emitterCtx || !emitterTarget) {
|
|
return;
|
|
}
|
|
|
|
emitterCtx.clearRect(0, 0, gridWidth, gridHeight);
|
|
emitterCtx.imageSmoothingEnabled = true;
|
|
|
|
const scaleX = gridWidth / cssWidth;
|
|
const scaleY = gridHeight / cssHeight;
|
|
const intersectRect = (a: DOMRect, b: DOMRect) => {
|
|
const left = Math.max(a.left, b.left);
|
|
const top = Math.max(a.top, b.top);
|
|
const right = Math.min(a.right, b.right);
|
|
const bottom = Math.min(a.bottom, b.bottom);
|
|
if (right <= left || bottom <= top) return null;
|
|
return new DOMRect(left, top, right - left, bottom - top);
|
|
};
|
|
const getVisibleRect = (element: HTMLElement) => {
|
|
let visible: DOMRect | null = intersectRect(
|
|
element.getBoundingClientRect(),
|
|
new DOMRect(0, 0, cssWidth, cssHeight),
|
|
);
|
|
if (!visible) return null;
|
|
|
|
let current = element.parentElement;
|
|
while (current && current !== document.body) {
|
|
const style = getComputedStyle(current);
|
|
const clipsX = /(auto|scroll|hidden|clip)/.test(style.overflowX);
|
|
const clipsY = /(auto|scroll|hidden|clip)/.test(style.overflowY);
|
|
if (clipsX || clipsY) {
|
|
visible = intersectRect(visible, current.getBoundingClientRect());
|
|
if (!visible) return null;
|
|
}
|
|
current = current.parentElement;
|
|
}
|
|
|
|
return visible;
|
|
};
|
|
const drawRect = (rect: DOMRect, color: string, alpha: number) => {
|
|
if (alpha <= 0 || rect.width <= 0 || rect.height <= 0) return;
|
|
emitterCtx.globalAlpha = alpha;
|
|
emitterCtx.fillStyle = color;
|
|
emitterCtx.fillRect(
|
|
rect.left * scaleX,
|
|
rect.top * scaleY,
|
|
rect.width * scaleX,
|
|
rect.height * scaleY,
|
|
);
|
|
};
|
|
|
|
const drawText = (element: HTMLElement, alpha: number) => {
|
|
const text = element.textContent?.trim();
|
|
if (!text || alpha <= 0) return;
|
|
const rect = getVisibleRect(element);
|
|
if (!rect) return;
|
|
if (rect.width <= 0 || rect.height <= 0) return;
|
|
const style = getComputedStyle(element);
|
|
if (style.visibility === "hidden" || style.display === "none") return;
|
|
emitterCtx.save();
|
|
emitterCtx.beginPath();
|
|
emitterCtx.rect(
|
|
rect.left * scaleX,
|
|
rect.top * scaleY,
|
|
rect.width * scaleX,
|
|
rect.height * scaleY,
|
|
);
|
|
emitterCtx.clip();
|
|
emitterCtx.globalAlpha = alpha;
|
|
emitterCtx.fillStyle = style.color;
|
|
emitterCtx.textBaseline = "top";
|
|
emitterCtx.font = `${style.fontStyle} ${style.fontWeight} ${Math.max(8, parseFloat(style.fontSize) * scaleY)}px ${style.fontFamily}`;
|
|
const x = rect.left * scaleX;
|
|
const y = rect.top * scaleY;
|
|
const lines = text
|
|
.split(/\n+/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
const lineHeight = Math.max(
|
|
8,
|
|
parseFloat(style.lineHeight || style.fontSize) * scaleY ||
|
|
parseFloat(style.fontSize) * scaleY,
|
|
);
|
|
lines.forEach((line, index) => {
|
|
emitterCtx.fillText(
|
|
line,
|
|
x,
|
|
y + index * lineHeight,
|
|
Math.max(1, rect.width * scaleX),
|
|
);
|
|
});
|
|
emitterCtx.restore();
|
|
};
|
|
|
|
if (tuning.imageEmit > 0) {
|
|
document
|
|
.querySelectorAll<HTMLImageElement>("img[data-emitter-image]")
|
|
.forEach((image) => {
|
|
const rect = getVisibleRect(image);
|
|
if (!rect) return;
|
|
if (rect.width <= 0 || rect.height <= 0 || !image.complete) return;
|
|
emitterCtx.save();
|
|
emitterCtx.beginPath();
|
|
emitterCtx.rect(
|
|
rect.left * scaleX,
|
|
rect.top * scaleY,
|
|
rect.width * scaleX,
|
|
rect.height * scaleY,
|
|
);
|
|
emitterCtx.clip();
|
|
emitterCtx.globalAlpha = tuning.imageEmit;
|
|
emitterCtx.drawImage(
|
|
image,
|
|
rect.left * scaleX,
|
|
rect.top * scaleY,
|
|
rect.width * scaleX,
|
|
rect.height * scaleY,
|
|
);
|
|
emitterCtx.restore();
|
|
});
|
|
}
|
|
|
|
if (tuning.ansiEmit > 0) {
|
|
document
|
|
.querySelectorAll<HTMLElement>("[data-emitter-ansi] span")
|
|
.forEach((span) => {
|
|
const rect = getVisibleRect(span);
|
|
if (!rect) return;
|
|
const color = getComputedStyle(span).color;
|
|
drawRect(rect, color, tuning.ansiEmit);
|
|
});
|
|
}
|
|
|
|
if (tuning.linkEmit > 0) {
|
|
document.querySelectorAll<HTMLElement>("a").forEach((link) => {
|
|
drawText(link, tuning.linkEmit);
|
|
});
|
|
}
|
|
|
|
const textEmitStrength = Math.max(tuning.linkEmit, tuning.ansiEmit * 0.8);
|
|
if (textEmitStrength > 0) {
|
|
document
|
|
.querySelectorAll<HTMLElement>(
|
|
"button, p, span, legend, label, [role='button'], .qa-meta, .qa-button, .qa-inline-action",
|
|
)
|
|
.forEach((element) => {
|
|
if (element.closest("[data-emitter-ansi]")) {
|
|
return;
|
|
}
|
|
drawText(element, textEmitStrength);
|
|
});
|
|
|
|
document
|
|
.querySelectorAll<HTMLElement>("button, .qa-button, .qa-inline-action")
|
|
.forEach((element) => {
|
|
const rect = getVisibleRect(element);
|
|
if (!rect) return;
|
|
const color = getComputedStyle(element).color;
|
|
drawRect(rect, color, textEmitStrength * 0.2);
|
|
});
|
|
|
|
document
|
|
.querySelectorAll<HTMLElement>(
|
|
"#char-count, #qa-status, #copy-email-status",
|
|
)
|
|
.forEach((element) => {
|
|
if (element.id === "char-count") {
|
|
const color = getComputedStyle(element).color.replace(/\s+/g, "");
|
|
if (color !== "rgb(255,85,85)") {
|
|
return;
|
|
}
|
|
}
|
|
drawText(element, Math.max(textEmitStrength, 0.75));
|
|
});
|
|
}
|
|
|
|
emitterCtx.globalAlpha = 1;
|
|
setTextureUnit(gl, 3, emitterTarget.texture);
|
|
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
|
|
gl.texSubImage2D(
|
|
gl.TEXTURE_2D,
|
|
0,
|
|
0,
|
|
0,
|
|
gl.RGBA,
|
|
gl.UNSIGNED_BYTE,
|
|
emitterCanvas,
|
|
);
|
|
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
|
|
};
|
|
|
|
const injectEmitters = () => {
|
|
if (!emitterTarget) return;
|
|
|
|
const source = currentState();
|
|
const target = nextState();
|
|
setTextureUnit(gl, 0, source.texture);
|
|
setTextureUnit(gl, 3, emitterTarget.texture);
|
|
gl.useProgram(injectProgram.program);
|
|
gl.uniform1i(uniform(injectProgram, "u_state"), 0);
|
|
gl.uniform1i(uniform(injectProgram, "u_emitter"), 3);
|
|
gl.uniform2i(uniform(injectProgram, "u_gridSize"), gridWidth, gridHeight);
|
|
renderPass(target, gridWidth, gridHeight, injectProgram.program);
|
|
swapState();
|
|
};
|
|
|
|
const simulate = () => {
|
|
const source = currentState();
|
|
const target = nextState();
|
|
|
|
setTextureUnit(gl, 0, source.texture);
|
|
gl.useProgram(simulationProgram.program);
|
|
gl.uniform1i(uniform(simulationProgram, "u_state"), 0);
|
|
gl.uniform2i(
|
|
uniform(simulationProgram, "u_gridSize"),
|
|
gridWidth,
|
|
gridHeight,
|
|
);
|
|
renderPass(target, gridWidth, gridHeight, simulationProgram.program);
|
|
swapState();
|
|
};
|
|
|
|
const stamp = (now: number) => {
|
|
const source = currentState();
|
|
const target = nextState();
|
|
|
|
setTextureUnit(gl, 0, source.texture);
|
|
gl.useProgram(stampProgram.program);
|
|
gl.uniform1i(uniform(stampProgram, "u_state"), 0);
|
|
gl.uniform2i(uniform(stampProgram, "u_gridSize"), gridWidth, gridHeight);
|
|
gl.uniform2f(
|
|
uniform(stampProgram, "u_center"),
|
|
pointer.col + 0.5,
|
|
pointer.row + 0.5,
|
|
);
|
|
gl.uniform1f(uniform(stampProgram, "u_radius"), BRUSH_RADIUS);
|
|
gl.uniform1f(
|
|
uniform(stampProgram, "u_hue"),
|
|
(((now % HUE_PERIOD_MS) / HUE_PERIOD_MS) * 255 || 1) / 255,
|
|
);
|
|
gl.uniform1i(uniform(stampProgram, "u_active"), 1);
|
|
renderPass(target, gridWidth, gridHeight, stampProgram.program);
|
|
swapState();
|
|
};
|
|
|
|
const colorize = () => {
|
|
if (!colorTarget || !sharpTarget || !smoothTarget) return;
|
|
|
|
setTextureUnit(gl, 0, currentState().texture);
|
|
setTextureUnit(gl, 1, paletteTexture);
|
|
gl.useProgram(colorProgram.program);
|
|
gl.uniform1i(uniform(colorProgram, "u_state"), 0);
|
|
gl.uniform1i(uniform(colorProgram, "u_palette"), 1);
|
|
gl.uniform2i(uniform(colorProgram, "u_gridSize"), gridWidth, gridHeight);
|
|
gl.uniform2f(uniform(colorProgram, "u_resolution"), gridWidth, gridHeight);
|
|
renderPass(colorTarget, gridWidth, gridHeight, colorProgram.program);
|
|
|
|
gl.uniform2f(
|
|
uniform(colorProgram, "u_resolution"),
|
|
canvasWidth,
|
|
canvasHeight,
|
|
);
|
|
renderPass(sharpTarget, canvasWidth, canvasHeight, colorProgram.program);
|
|
|
|
setTextureUnit(gl, 0, colorTarget.texture);
|
|
gl.useProgram(copyProgram.program);
|
|
gl.uniform1i(uniform(copyProgram, "u_image"), 0);
|
|
renderPass(smoothTarget, canvasWidth, canvasHeight, copyProgram.program);
|
|
};
|
|
|
|
const blur = () => {
|
|
if (!smoothTarget || !insideBlurTargets || !outsideBlurTargets) return;
|
|
|
|
const runBlurStack = (
|
|
stack: StackTuning,
|
|
targets: [TextureTarget, TextureTarget],
|
|
) => {
|
|
const iterations = Math.max(0, Math.floor(stack.blurStrength));
|
|
if (iterations === 0) {
|
|
setTextureUnit(gl, 0, smoothTarget!.texture);
|
|
gl.useProgram(copyProgram.program);
|
|
gl.uniform1i(uniform(copyProgram, "u_image"), 0);
|
|
renderPass(targets[1], canvasWidth, canvasHeight, copyProgram.program);
|
|
return;
|
|
}
|
|
|
|
gl.useProgram(blurProgram.program);
|
|
gl.uniform1i(uniform(blurProgram, "u_image"), 0);
|
|
gl.uniform1f(uniform(blurProgram, "u_radius"), stack.blurRadius);
|
|
gl.uniform2f(
|
|
uniform(blurProgram, "u_resolution"),
|
|
canvasWidth,
|
|
canvasHeight,
|
|
);
|
|
|
|
let sourceTexture = smoothTarget!.texture;
|
|
for (let index = 0; index < iterations; index++) {
|
|
setTextureUnit(gl, 0, sourceTexture);
|
|
gl.uniform2f(uniform(blurProgram, "u_direction"), 1, 0);
|
|
renderPass(targets[0], canvasWidth, canvasHeight, blurProgram.program);
|
|
|
|
setTextureUnit(gl, 0, targets[0].texture);
|
|
gl.uniform2f(uniform(blurProgram, "u_direction"), 0, 1);
|
|
renderPass(targets[1], canvasWidth, canvasHeight, blurProgram.program);
|
|
sourceTexture = targets[1].texture;
|
|
}
|
|
};
|
|
|
|
runBlurStack(tuning.inside, insideBlurTargets);
|
|
runBlurStack(tuning.outside, outsideBlurTargets);
|
|
};
|
|
|
|
const composite = () => {
|
|
if (!insideBlurTargets || !outsideBlurTargets) return;
|
|
|
|
const panelCount = updatePanelRects();
|
|
setTextureUnit(gl, 0, sharpTarget!.texture);
|
|
setTextureUnit(gl, 1, insideBlurTargets[1].texture);
|
|
setTextureUnit(gl, 2, outsideBlurTargets[1].texture);
|
|
setTextureUnit(gl, 3, paletteTexture);
|
|
gl.useProgram(compositeProgram.program);
|
|
gl.uniform1i(uniform(compositeProgram, "u_sharp"), 0);
|
|
gl.uniform1i(uniform(compositeProgram, "u_inBlur"), 1);
|
|
gl.uniform1i(uniform(compositeProgram, "u_outBlur"), 2);
|
|
gl.uniform1i(uniform(compositeProgram, "u_outBlur2"), 2);
|
|
gl.uniform1i(uniform(compositeProgram, "u_palette"), 3);
|
|
gl.uniform2f(
|
|
uniform(compositeProgram, "u_resolution"),
|
|
canvasWidth,
|
|
canvasHeight,
|
|
);
|
|
const deviceScale = canvasWidth / cssWidth;
|
|
gl.uniform1f(
|
|
uniform(compositeProgram, "u_inMinBlockSize"),
|
|
Math.max(0.25, tuning.inside.smallestBlock) * deviceScale,
|
|
);
|
|
gl.uniform1f(
|
|
uniform(compositeProgram, "u_inMaxBlockSize"),
|
|
Math.max(tuning.inside.largestBlock, tuning.inside.smallestBlock) *
|
|
deviceScale,
|
|
);
|
|
gl.uniform1i(uniform(compositeProgram, "u_inLevels"), tuning.inside.levels);
|
|
gl.uniform1f(
|
|
uniform(compositeProgram, "u_inDetailThreshold"),
|
|
tuning.inside.detailThreshold,
|
|
);
|
|
gl.uniform1f(
|
|
uniform(compositeProgram, "u_inDitherStrength"),
|
|
tuning.inside.ditherStrength,
|
|
);
|
|
gl.uniform1f(
|
|
uniform(compositeProgram, "u_inEdgeBias"),
|
|
tuning.inside.edgeBias,
|
|
);
|
|
gl.uniform1f(
|
|
uniform(compositeProgram, "u_inHueDrift"),
|
|
tuning.inside.hueDrift,
|
|
);
|
|
gl.uniform1f(
|
|
uniform(compositeProgram, "u_inStreakStrength"),
|
|
tuning.inside.streakStrength,
|
|
);
|
|
gl.uniform1f(
|
|
uniform(compositeProgram, "u_outMinBlockSize"),
|
|
Math.max(0.25, tuning.outside.smallestBlock) * deviceScale,
|
|
);
|
|
gl.uniform1f(
|
|
uniform(compositeProgram, "u_outMaxBlockSize"),
|
|
Math.max(tuning.outside.largestBlock, tuning.outside.smallestBlock) *
|
|
deviceScale,
|
|
);
|
|
gl.uniform1i(
|
|
uniform(compositeProgram, "u_outLevels"),
|
|
tuning.outside.levels,
|
|
);
|
|
gl.uniform1f(
|
|
uniform(compositeProgram, "u_outDetailThreshold"),
|
|
tuning.outside.detailThreshold,
|
|
);
|
|
gl.uniform1f(
|
|
uniform(compositeProgram, "u_outDitherStrength"),
|
|
tuning.outside.ditherStrength,
|
|
);
|
|
gl.uniform1f(
|
|
uniform(compositeProgram, "u_outEdgeBias"),
|
|
tuning.outside.edgeBias,
|
|
);
|
|
gl.uniform1f(
|
|
uniform(compositeProgram, "u_outHueDrift"),
|
|
tuning.outside.hueDrift,
|
|
);
|
|
gl.uniform1f(
|
|
uniform(compositeProgram, "u_outStreakStrength"),
|
|
tuning.outside.streakStrength,
|
|
);
|
|
gl.uniform1f(
|
|
uniform(compositeProgram, "u_outGlowStrength"),
|
|
tuning.outside.blurStrength,
|
|
);
|
|
gl.uniform1f(
|
|
uniform(compositeProgram, "u_outGlowThreshold"),
|
|
tuning.outsideGlow.threshold,
|
|
);
|
|
gl.uniform1f(
|
|
uniform(compositeProgram, "u_outGlowSoftness"),
|
|
tuning.outsideGlow.softness,
|
|
);
|
|
gl.uniform1f(
|
|
uniform(compositeProgram, "u_outGlowBoost"),
|
|
tuning.outsideGlow.boost,
|
|
);
|
|
gl.uniform4fv(uniform(compositeProgram, "u_panelRects"), panelRects);
|
|
gl.uniform1i(uniform(compositeProgram, "u_panelCount"), panelCount);
|
|
renderPass(null, canvasWidth, canvasHeight, compositeProgram.program);
|
|
};
|
|
|
|
const frame = (now: number) => {
|
|
rafId = window.requestAnimationFrame(frame);
|
|
|
|
if (document.visibilityState === "hidden") {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
canvasWidth !==
|
|
Math.round(
|
|
cssWidth * Math.min(window.devicePixelRatio || 1, MAX_DPR),
|
|
) ||
|
|
canvasHeight !==
|
|
Math.round(
|
|
cssHeight * Math.min(window.devicePixelRatio || 1, MAX_DPR),
|
|
) ||
|
|
cssWidth !== Math.round(window.innerWidth) ||
|
|
cssHeight !== Math.round(window.innerHeight)
|
|
) {
|
|
resizeTargets();
|
|
}
|
|
|
|
simulate();
|
|
|
|
if (pointer.active) {
|
|
stamp(now);
|
|
}
|
|
|
|
renderEmitters();
|
|
injectEmitters();
|
|
|
|
colorize();
|
|
blur();
|
|
composite();
|
|
};
|
|
|
|
document.addEventListener("mousemove", (event) => {
|
|
updatePointer(event.clientX, event.clientY);
|
|
});
|
|
|
|
document.addEventListener("mouseleave", () => {
|
|
pointer.active = false;
|
|
});
|
|
|
|
document.addEventListener("touchstart", (event) => {
|
|
const touch = event.touches.item(0);
|
|
if (touch) {
|
|
touchDragActive = false;
|
|
touchStartX = touch.clientX;
|
|
touchStartY = touch.clientY;
|
|
updatePointer(touch.clientX, touch.clientY);
|
|
}
|
|
});
|
|
|
|
document.addEventListener(
|
|
"touchmove",
|
|
(event) => {
|
|
const touch = event.touches.item(0);
|
|
if (!touch) {
|
|
return;
|
|
}
|
|
|
|
if (!touchDragActive) {
|
|
const distance = Math.hypot(
|
|
touch.clientX - touchStartX,
|
|
touch.clientY - touchStartY,
|
|
);
|
|
|
|
if (distance < TOUCH_DRAG_THRESHOLD) {
|
|
return;
|
|
}
|
|
|
|
touchDragActive = true;
|
|
}
|
|
|
|
event.preventDefault();
|
|
updatePointer(touch.clientX, touch.clientY);
|
|
},
|
|
{ passive: false },
|
|
);
|
|
|
|
document.addEventListener("touchend", () => {
|
|
touchDragActive = false;
|
|
pointer.active = false;
|
|
});
|
|
|
|
document.addEventListener("touchcancel", () => {
|
|
touchDragActive = false;
|
|
pointer.active = false;
|
|
});
|
|
|
|
window.addEventListener("blur", () => {
|
|
pointer.active = false;
|
|
});
|
|
|
|
gl.disable(gl.DEPTH_TEST);
|
|
gl.disable(gl.BLEND);
|
|
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
|
|
|
resizeTargets();
|
|
rafId = window.requestAnimationFrame(frame);
|
|
|
|
return {
|
|
destroy() {
|
|
window.cancelAnimationFrame(rafId);
|
|
deleteTextureTarget(gl, stateTargets?.[0] ?? null);
|
|
deleteTextureTarget(gl, stateTargets?.[1] ?? null);
|
|
deleteTextureTarget(gl, colorTarget);
|
|
deleteTextureTarget(gl, sharpTarget);
|
|
deleteTextureTarget(gl, smoothTarget);
|
|
deleteTextureTarget(gl, insideBlurTargets?.[0] ?? null);
|
|
deleteTextureTarget(gl, insideBlurTargets?.[1] ?? null);
|
|
deleteTextureTarget(gl, outsideBlurTargets?.[0] ?? null);
|
|
deleteTextureTarget(gl, outsideBlurTargets?.[1] ?? null);
|
|
deleteTextureTarget(gl, emitterTarget);
|
|
gl.deleteTexture(paletteTexture);
|
|
gl.deleteVertexArray(vao);
|
|
gl.deleteProgram(simulationProgram.program);
|
|
gl.deleteProgram(stampProgram.program);
|
|
gl.deleteProgram(injectProgram.program);
|
|
gl.deleteProgram(colorProgram.program);
|
|
gl.deleteProgram(copyProgram.program);
|
|
gl.deleteProgram(blurProgram.program);
|
|
gl.deleteProgram(compositeProgram.program);
|
|
},
|
|
};
|
|
}
|