refactor(core): modularize ASCII logic and add dither/edge detection

This commit is contained in:
syntaxbullet
2026-02-09 22:33:58 +01:00
parent 961383b402
commit 9b9976c70a
8 changed files with 1566 additions and 27 deletions

View File

@@ -12,8 +12,8 @@ export interface RenderOptions {
invert: boolean;
color: boolean;
overlayStrength?: number;
enhanceEdges?: boolean;
dither?: boolean;
edgeMode?: number; // 0=none, 1=simple, 2=sobel, 3=canny
dither?: number;
denoise?: boolean;
zoom?: number;
zoomCenter?: { x: number; y: number };
@@ -36,7 +36,7 @@ export class WebGLAsciiRenderer {
private gl: WebGLRenderingContext;
private program: WebGLProgram | null;
private textures: { image?: WebGLTexture; atlas?: WebGLTexture };
private textures: { image?: WebGLTexture; atlas?: WebGLTexture; blueNoise?: WebGLTexture };
private buffers: { position?: WebGLBuffer; texCoord?: WebGLBuffer };
private charAtlas: { width: number; height: number; charWidth: number; charHeight: number; count: number } | null;
private charSet: string;
@@ -61,6 +61,7 @@ export class WebGLAsciiRenderer {
this.fontFamily = "'JetBrains Mono', monospace";
this.init();
this.loadBlueNoiseTexture();
}
init() {
@@ -84,7 +85,9 @@ export class WebGLAsciiRenderer {
uniform sampler2D u_image;
uniform sampler2D u_atlas;
uniform sampler2D u_blueNoise;
uniform float u_charCount;
uniform vec2 u_charSizeUV; // Size of one char in UV space (width/texWidth, height/texHeight)
uniform vec2 u_gridSize; // cols, rows
uniform vec2 u_texSize; // atlas size
@@ -96,7 +99,9 @@ export class WebGLAsciiRenderer {
uniform bool u_invert;
uniform bool u_color;
uniform float u_overlayStrength;
uniform bool u_enhanceEdges;
uniform int u_edgeMode; // 0=none, 1=simple, 2=sobel, 3=canny
uniform float u_dither; // Dither strength 0.0 - 1.0
uniform bool u_denoise;
// Zoom & Magnifier
uniform float u_zoom;
@@ -107,6 +112,15 @@ export class WebGLAsciiRenderer {
uniform bool u_showMagnifier;
uniform float u_aspect;
// Blue Noise Dithering
float blueNoise(vec2 pos) {
// Map screen coordinates to texture coordinates (64x64 texture)
vec2 noiseUV = pos / 64.0;
float noiseVal = texture2D(u_blueNoise, noiseUV).r;
// Shift range to -0.5 to 0.5 for dither offset
return noiseVal - 0.5;
}
vec3 adjust(vec3 color) {
// Exposure
color *= u_exposure;
@@ -124,6 +138,59 @@ export class WebGLAsciiRenderer {
return clamp(color, 0.0, 1.0);
}
// Function to get average color from a cell using 5 samples (center + corners)
vec3 getAverageColor(vec2 cellCenterUV, vec2 cellSize) {
vec3 sum = vec3(0.0);
vec2 halfSize = cellSize * 0.25; // Sample halfway to the edge
// Center
sum += texture2D(u_image, cellCenterUV).rgb;
// Corners
sum += texture2D(u_image, cellCenterUV + vec2(-halfSize.x, -halfSize.y)).rgb;
sum += texture2D(u_image, cellCenterUV + vec2(halfSize.x, -halfSize.y)).rgb;
sum += texture2D(u_image, cellCenterUV + vec2(-halfSize.x, halfSize.y)).rgb;
sum += texture2D(u_image, cellCenterUV + vec2(halfSize.x, halfSize.y)).rgb;
return sum / 5.0;
}
// Sobel Filter - returns gradient magnitude and direction (approx)
vec2 sobelFilter(vec2 uv, vec2 cellSize) {
vec3 t = texture2D(u_image, uv + vec2(0.0, -cellSize.y)).rgb;
vec3 b = texture2D(u_image, uv + vec2(0.0, cellSize.y)).rgb;
vec3 l = texture2D(u_image, uv + vec2(-cellSize.x, 0.0)).rgb;
vec3 r = texture2D(u_image, uv + vec2(cellSize.x, 0.0)).rgb;
vec3 tl = texture2D(u_image, uv + vec2(-cellSize.x, -cellSize.y)).rgb;
vec3 tr = texture2D(u_image, uv + vec2(cellSize.x, -cellSize.y)).rgb;
vec3 bl = texture2D(u_image, uv + vec2(-cellSize.x, cellSize.y)).rgb;
vec3 br = texture2D(u_image, uv + vec2(cellSize.x, cellSize.y)).rgb;
// Convert to luma
float lt = dot(t, vec3(0.299, 0.587, 0.114));
float lb = dot(b, vec3(0.299, 0.587, 0.114));
float ll = dot(l, vec3(0.299, 0.587, 0.114));
float lr = dot(r, vec3(0.299, 0.587, 0.114));
float ltl = dot(tl, vec3(0.299, 0.587, 0.114));
float ltr = dot(tr, vec3(0.299, 0.587, 0.114));
float lbl = dot(bl, vec3(0.299, 0.587, 0.114));
float lbr = dot(br, vec3(0.299, 0.587, 0.114));
// Sobel kernels
// Gx: -1 0 1
// -2 0 2
// -1 0 1
float gx = (ltr + 2.0*lr + lbr) - (ltl + 2.0*ll + lbl);
// Gy: -1 -2 -1
// 0 0 0
// 1 2 1
float gy = (lbl + 2.0*lb + lbr) - (ltl + 2.0*lt + ltr);
float mag = sqrt(gx*gx + gy*gy);
return vec2(mag, atan(gy, gx));
}
void main() {
vec2 uv = v_texCoord;
@@ -148,27 +215,65 @@ export class WebGLAsciiRenderer {
vec2 uvInCell = fract(uv * u_gridSize);
// Sample image at the center of the cell
vec2 sampleUV = (cellCoords + 0.5) / u_gridSize;
vec2 cellSize = 1.0 / u_gridSize;
vec2 sampleUV = (cellCoords + 0.5) * cellSize;
// Out of bounds check for zoomed UV
if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0) {
discard;
}
vec3 color = texture2D(u_image, sampleUV).rgb;
vec3 color;
// Edge Enhancement (Simple Laplacian-like check)
if (u_enhanceEdges) {
vec2 texel = 1.0 / u_gridSize;
vec3 center = texture2D(u_image, sampleUV).rgb;
vec3 top = texture2D(u_image, sampleUV + vec2(0.0, -texel.y)).rgb;
vec3 bottom = texture2D(u_image, sampleUV + vec2(0.0, texel.y)).rgb;
vec3 left = texture2D(u_image, sampleUV + vec2(-texel.x, 0.0)).rgb;
vec3 right = texture2D(u_image, sampleUV + vec2(texel.x, 0.0)).rgb;
// Denoise: 3x3 box blur (applied to the base sampling if enabled)
if (u_denoise) {
color = getAverageColor(sampleUV, cellSize * 2.0);
} else {
color = getAverageColor(sampleUV, cellSize);
}
// Edge Detection Logic
if (u_edgeMode == 1) {
// Simple Laplacian-like
vec2 texel = cellSize;
vec3 center = color;
vec3 top = getAverageColor(sampleUV + vec2(0.0, -texel.y), cellSize);
vec3 bottom = getAverageColor(sampleUV + vec2(0.0, texel.y), cellSize);
vec3 left = getAverageColor(sampleUV + vec2(-texel.x, 0.0), cellSize);
vec3 right = getAverageColor(sampleUV + vec2(texel.x, 0.0), cellSize);
vec3 edges = abs(center - top) + abs(center - bottom) + abs(center - left) + abs(center - right);
float edgeLum = dot(edges, vec3(0.2126, 0.7152, 0.0722));
color = mix(color, color * (1.0 - edgeLum * 2.0), 0.5);
} else if (u_edgeMode == 2) {
// Sobel Gradient
vec2 sobel = sobelFilter(sampleUV, cellSize);
float edgeStr = clamp(sobel.x * 2.0, 0.0, 1.0);
// Darken edges
color = mix(color, vec3(0.0), edgeStr * 0.8);
} else if (u_edgeMode == 3) {
// "Canny-like" (Sobel + gradient suppression)
vec2 sobel = sobelFilter(sampleUV, cellSize);
float mag = sobel.x;
float angle = sobel.y;
// Non-maximum suppression (simplified)
// Check neighbors in gradient direction
vec2 dir = vec2(cos(angle), sin(angle)) * cellSize;
vec2 s1 = sobelFilter(sampleUV + dir, cellSize);
vec2 s2 = sobelFilter(sampleUV - dir, cellSize);
if (mag < s1.x || mag < s2.x || mag < 0.15) {
mag = 0.0;
} else {
mag = 1.0; // Strong edge
}
// Apply strong crisp edges
color = mix(color, vec3(0.0), mag);
}
// Apply adjustments
@@ -189,6 +294,16 @@ export class WebGLAsciiRenderer {
// Calculate luminance
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
// Apply Blue Noise dithering before character mapping
if (u_dither > 0.0) {
// Use cell coordinates for stable dithering patterns
float noise = blueNoise(cellCoords);
// Scale noise by dither strength and 1/charCount
luma = luma + noise * (1.0 / u_charCount) * u_dither;
luma = clamp(luma, 0.0, 1.0);
}
if (u_invert) {
luma = 1.0 - luma;
}
@@ -197,9 +312,11 @@ export class WebGLAsciiRenderer {
float charIndex = floor(luma * (u_charCount - 1.0) + 0.5);
// Sample character atlas
// Use u_charSizeUV to scale, instead of just 1.0/u_charCount
// x = charIndex * charWidthUV + uvInCell.x * charWidthUV
vec2 atlasUV = vec2(
(charIndex + uvInCell.x) / u_charCount,
uvInCell.y
(charIndex + uvInCell.x) * u_charSizeUV.x,
uvInCell.y * u_charSizeUV.y
);
float charAlpha = texture2D(u_atlas, atlasUV).r;
@@ -245,9 +362,10 @@ export class WebGLAsciiRenderer {
if (!this.program) return;
const gl = this.gl;
const uniforms = [
'u_image', 'u_atlas', 'u_charCount', 'u_gridSize', 'u_texSize',
'u_image', 'u_atlas', 'u_blueNoise', 'u_charCount', 'u_charSizeUV', 'u_gridSize', 'u_texSize',
'u_exposure', 'u_contrast', 'u_saturation', 'u_gamma',
'u_invert', 'u_color', 'u_overlayStrength', 'u_enhanceEdges',
'u_invert', 'u_color', 'u_overlayStrength', 'u_edgeMode',
'u_dither', 'u_denoise',
'u_zoom', 'u_zoomCenter', 'u_mousePos',
'u_magnifierRadius', 'u_magnifierZoom', 'u_showMagnifier', 'u_aspect'
];
@@ -300,16 +418,32 @@ export class WebGLAsciiRenderer {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
const fontSize = 32; // Higher resolution for atlas
// Add padding to prevent bleeding
const padding = 4;
ctx.font = `${fontSize}px ${fontName}`;
// Measure first char to get dimensions
const metrics = ctx.measureText('W');
const charWidth = Math.ceil(metrics.width);
const charHeight = fontSize * 1.2;
const charContentWidth = Math.ceil(metrics.width);
const charContentHeight = Math.ceil(fontSize * 1.2);
canvas.width = charWidth * charSet.length;
canvas.height = charHeight;
// Full cell size including padding
const charWidth = charContentWidth + padding * 2;
const charHeight = charContentHeight + padding * 2;
const neededWidth = charWidth * charSet.length;
const neededHeight = charHeight;
// Calculate Next Power of Two
const nextPowerOfTwo = (v: number) => Math.pow(2, Math.ceil(Math.log(v) / Math.log(2)));
const texWidth = nextPowerOfTwo(neededWidth);
const texHeight = nextPowerOfTwo(neededHeight);
canvas.width = texWidth;
canvas.height = texHeight;
ctx.font = `${fontSize}px ${fontName}`;
ctx.fillStyle = 'white';
@@ -317,7 +451,10 @@ export class WebGLAsciiRenderer {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < charSet.length; i++) {
ctx.fillText(charSet[i], i * charWidth, 0);
// Draw character centered in its padded cell
// x position: start of cell (i * charWidth) + padding
// y position: padding
ctx.fillText(charSet[i], i * charWidth + padding, padding);
}
const gl = this.gl;
@@ -329,14 +466,17 @@ export class WebGLAsciiRenderer {
gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
// Use Mipmaps for smoother downscaling (fixes shimmering/aliasing)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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.generateMipmap(gl.TEXTURE_2D);
this.charAtlas = {
width: canvas.width,
height: canvas.height,
width: texWidth,
height: texHeight,
charWidth,
charHeight,
count: charSet.length
@@ -360,6 +500,11 @@ export class WebGLAsciiRenderer {
this.updateAtlas(options.charSetContent, options.fontFamily || 'monospace');
if (this.charAtlas) {
gl.uniform1f(u['u_charCount'], this.charAtlas.count);
// Pass the normalized size of one character cell for UV mapping
gl.uniform2f(u['u_charSizeUV'],
this.charAtlas.charWidth / this.charAtlas.width,
this.charAtlas.charHeight / this.charAtlas.height
);
gl.uniform2f(u['u_texSize'], this.charAtlas.width, this.charAtlas.height);
}
@@ -370,7 +515,9 @@ export class WebGLAsciiRenderer {
gl.uniform1i(u['u_invert'], options.invert ? 1 : 0);
gl.uniform1i(u['u_color'], options.color ? 1 : 0);
gl.uniform1f(u['u_overlayStrength'], options.overlayStrength || 0.0);
gl.uniform1i(u['u_enhanceEdges'], options.enhanceEdges ? 1 : 0);
gl.uniform1i(u['u_edgeMode'], options.edgeMode || 0);
gl.uniform1f(u['u_dither'], options.dither || 0.0);
gl.uniform1i(u['u_denoise'], options.denoise ? 1 : 0);
// Zoom & Magnifier
gl.uniform1f(u['u_zoom'], options.zoom || 1.0);
@@ -382,6 +529,33 @@ export class WebGLAsciiRenderer {
gl.uniform1f(u['u_aspect'], gl.canvas.width / gl.canvas.height);
}
private loadBlueNoiseTexture() {
const gl = this.gl;
const texture = gl.createTexture();
if (!texture) return;
this.textures.blueNoise = texture;
const image = new Image();
image.src = '/assets/blue-noise.png';
image.onload = () => {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
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.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
this.requestRender();
};
}
// Helper to trigger a redraw if we have a controller reference, otherwise just rely on next loop
private requestRender() {
// Since we don't have a direct reference to the controller here,
// and we are in a render loop managed by the controller,
// the texture will just appear on the next frame.
}
updateTexture(image: HTMLImageElement) {
if (this.lastImage === image && this.textures.image) return;
@@ -434,6 +608,12 @@ export class WebGLAsciiRenderer {
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas);
if (this.textures.blueNoise) {
gl.uniform1i(u['u_blueNoise'], 2);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.textures.blueNoise);
}
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
@@ -444,6 +624,39 @@ export class WebGLAsciiRenderer {
this.draw();
}
/**
* Dispose of all WebGL resources.
* Call this when the renderer is no longer needed.
*/
dispose(): void {
const gl = this.gl;
if (this.textures.image) {
gl.deleteTexture(this.textures.image);
}
if (this.textures.atlas) {
gl.deleteTexture(this.textures.atlas);
}
if (this.textures.blueNoise) {
gl.deleteTexture(this.textures.blueNoise);
}
if (this.buffers.position) {
gl.deleteBuffer(this.buffers.position);
}
if (this.buffers.texCoord) {
gl.deleteBuffer(this.buffers.texCoord);
}
if (this.program) {
gl.deleteProgram(this.program);
}
this.textures = {};
this.buffers = {};
this.program = null;
this.charAtlas = null;
this.lastImage = null;
}
// Kept for backward compatibility or specialized updates
updateMagnifier(options: MagnifierOptions) {
const gl = this.gl;