feat: Implement WebGL-based ASCII renderer with zoom and magnifier, and optimize image queue fetching.

This commit is contained in:
syntaxbullet
2026-02-09 14:15:15 +01:00
parent bfefaa0055
commit bf2a11a84d
2 changed files with 561 additions and 33 deletions

View File

@@ -12,6 +12,7 @@ import TuiButton from "../components/TuiButton.astro";
<div class="ascii-layer"> <div class="ascii-layer">
<div id="loading">GENERATING...</div> <div id="loading">GENERATING...</div>
<pre id="ascii-result">Preparing art...</pre> <pre id="ascii-result">Preparing art...</pre>
<canvas id="ascii-canvas"></canvas>
</div> </div>
<!-- Foreground Layer: Content --> <!-- Foreground Layer: Content -->
@@ -203,9 +204,26 @@ import TuiButton from "../components/TuiButton.astro";
autoTuneImage, autoTuneImage,
CHAR_SETS, CHAR_SETS,
} from "../scripts/ascii.js"; } from "../scripts/ascii.js";
import { fetchRandomAnimeImage } from "../scripts/anime-api.js"; import {
fetchRandomAnimeImage,
fetchMultipleAnimeImages,
} from "../scripts/anime-api.js";
import { WebGLAsciiRenderer } from "../scripts/webgl-ascii.js";
const generator = new AsciiGenerator(); const generator = new AsciiGenerator();
const canvas = document.getElementById(
"ascii-canvas",
) as HTMLCanvasElement;
let webglRenderer: WebGLAsciiRenderer | null = null;
try {
webglRenderer = new WebGLAsciiRenderer(canvas);
} catch (e) {
console.warn(
"WebGL renderer failed to initialize, falling back to CPU",
e,
);
}
// State // State
let currentImgUrl: string | null = null; let currentImgUrl: string | null = null;
@@ -236,7 +254,7 @@ import TuiButton from "../components/TuiButton.astro";
"loading", "loading",
) as HTMLDivElement; ) as HTMLDivElement;
if (!asciiResult || !loadingIndicator) { if (!asciiResult || !loadingIndicator || !canvas) {
throw new Error("Critical UI elements missing"); throw new Error("Critical UI elements missing");
} }
@@ -365,35 +383,175 @@ import TuiButton from "../components/TuiButton.astro";
heightRows = widthCols / (imgRatio / fontAspectRatio); heightRows = widthCols / (imgRatio / fontAspectRatio);
try { // Decide whether to use WebGL or CPU
const result = await generator.generate(imgEl, { // We use WebGL primarily for color mode since it's the biggest performance hit
width: widthCols, // But we can also use it for everything if currentSettings.color is true
height: Math.floor(heightRows), const useWebGL = webglRenderer !== null;
...currentSettings,
});
// Handle color output (returns object) vs plain text (returns string) if (useWebGL) {
if (typeof result === "object" && result.isHtml) { asciiResult.style.display = "none";
asciiResult.innerHTML = result.output; canvas.style.display = "block";
// Calculate intended aspect ratio of the ASCII grid
const gridAspect = (widthCols * fontAspectRatio) / heightRows;
const screenW = window.innerWidth;
const screenH = window.innerHeight;
// Available space with margins (matching CPU version's 0.9 scale)
const maxW = screenW * 0.95;
const maxH = screenH * 0.95;
let finalW, finalH;
if (gridAspect > maxW / maxH) {
finalW = maxW;
finalH = maxW / gridAspect;
} else { } else {
asciiResult.textContent = result as string; finalH = maxH;
finalW = maxH * gridAspect;
} }
// Auto-fit font size // Set canvas CSS size to fit centered and internal resolution for sharpness
const sizeW = (screenW * 0.9) / (widthCols * fontAspectRatio); canvas.style.width = `${finalW}px`;
const sizeH = (screenH * 0.9) / heightRows; canvas.style.height = `${finalH}px`;
const bestSize = Math.min(sizeW, sizeH);
asciiResult.style.fontSize = `${Math.max(4, bestSize).toFixed(2)}px`; const dpr = window.devicePixelRatio || 1;
asciiResult.style.opacity = "1"; canvas.width = finalW * dpr;
} catch (e) { canvas.height = finalH * dpr;
console.error("Render error", e);
const charSetContent =
CHAR_SETS[currentSettings.charSet] || CHAR_SETS.standard;
webglRenderer!.render(imgEl, {
width: widthCols,
height: Math.floor(heightRows),
charSetContent: charSetContent,
...currentSettings,
zoom: zoom,
zoomCenter: zoomCenter,
mousePos: mousePos,
showMagnifier: showMagnifier,
magnifierRadius: 0.03,
magnifierZoom: 2.5,
});
canvas.style.opacity = "1";
} else {
canvas.style.display = "none";
asciiResult.style.display = "block";
try {
const result = await generator.generate(imgEl, {
width: widthCols,
height: Math.floor(heightRows),
...currentSettings,
});
// Handle color output (returns object) vs plain text (returns string)
if (typeof result === "object" && result.isHtml) {
asciiResult.innerHTML = result.output;
} else {
asciiResult.textContent = result as string;
}
// Auto-fit font size
const sizeW =
(screenW * 0.9) / (widthCols * fontAspectRatio);
const sizeH = (screenH * 0.9) / heightRows;
const bestSize = Math.min(sizeW, sizeH);
asciiResult.style.fontSize = `${Math.max(4, bestSize).toFixed(2)}px`;
asciiResult.style.opacity = "1";
} catch (e) {
console.error("Render error", e);
}
} }
} }
// Zoom & Magnifier State
let zoom = 1.0;
let zoomCenter = { x: 0.5, y: 0.5 };
let mousePos = { x: -1, y: -1 };
let showMagnifier = false;
const heroWrapper = document.querySelector(".hero-wrapper");
if (heroWrapper) {
heroWrapper.addEventListener(
"wheel",
(e: any) => {
// If over controls, don't zoom
if (e.target.closest("#tui-controls")) return;
// Only zoom if using WebGL (as CPU version doesn't support it yet)
if (webglRenderer) {
e.preventDefault();
const delta = -e.deltaY;
const factor = delta > 0 ? 1.1 : 0.9;
const oldZoom = zoom;
zoom *= factor;
// Cap zoom
zoom = Math.min(Math.max(zoom, 1.0), 10.0);
if (zoom === 1.0) {
zoomCenter = { x: 0.5, y: 0.5 };
} else if (oldZoom !== zoom) {
// Calculate where the mouse is relative to the canvas
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height;
// To zoom into the mouse, we want the image coordinate under the mouse to stay fixed.
// Shader formula: uv = (v_texCoord - C) / Z + C
// We want: (mx - C1) / Z1 + C1 == (mx - C2) / Z2 + C2
const imgX =
(mx - zoomCenter.x) / oldZoom + zoomCenter.x;
const imgY =
(my - zoomCenter.y) / oldZoom + zoomCenter.y;
// Solve for C2: K = (mx - C2) / Z2 + C2 => C2 = (K - mx/Z2) / (1 - 1/Z2)
zoomCenter.x = (imgX - mx / zoom) / (1 - 1 / zoom);
zoomCenter.y = (imgY - my / zoom) / (1 - 1 / zoom);
}
generate();
}
},
{ passive: false },
);
heroWrapper.addEventListener("mousemove", (e: any) => {
if (webglRenderer) {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height;
mousePos = { x: mx, y: my };
// Show magnifier if mouse is over canvas
const wasShowing = showMagnifier;
showMagnifier = mx >= 0 && mx <= 1 && my >= 0 && my <= 1;
if (showMagnifier || wasShowing) {
generate(); // Re-render to update magnifier position
}
}
});
heroWrapper.addEventListener("mouseleave", () => {
if (showMagnifier) {
showMagnifier = false;
generate();
}
});
}
// Queue System // Queue System
const imageQueue: { data: any; imgElement: HTMLImageElement }[] = []; const imageQueue: { data: any; imgElement: HTMLImageElement }[] = [];
const TARGET_QUEUE_SIZE = 3; const TARGET_QUEUE_SIZE = 5;
let isFetchingQueue = false; let isFetchingQueue = false;
async function fillQueue() { async function fillQueue() {
@@ -405,18 +563,31 @@ import TuiButton from "../components/TuiButton.astro";
isFetchingQueue = true; isFetchingQueue = true;
try { try {
// Fetch one by one until full const needed = TARGET_QUEUE_SIZE - imageQueue.length;
while (imageQueue.length < TARGET_QUEUE_SIZE) { if (needed <= 0) return;
const data = await fetchRandomAnimeImage();
// Preload // Fetch multiple metadata entries at once (API allows this)
const img = await resolveImage(data.url); const results = await fetchMultipleAnimeImages({
// Store amount: needed,
imageQueue.push({ });
data: data,
imgElement: img, // Load images SEQUENTIALLY with a small delay to avoid 429s from the CDN
}); for (const data of results) {
// Brief pause to be nice to API if (imageQueue.length >= TARGET_QUEUE_SIZE) break;
await new Promise((r) => setTimeout(r, 500)); try {
const img = await resolveImage(data.url);
imageQueue.push({
data: data,
imgElement: img,
});
updateQueueStatus();
// Small staggered delay
await new Promise((r) => setTimeout(r, 300));
} catch (e) {
console.error("Failed to preload image", data.url, e);
// If we hit an error (likely rate limit or CORS), wait longer
await new Promise((r) => setTimeout(r, 2000));
}
} }
} catch (e) { } catch (e) {
console.error("Queue fill error", e); console.error("Queue fill error", e);
@@ -455,6 +626,10 @@ import TuiButton from "../components/TuiButton.astro";
fillQueue(); fillQueue();
} }
// Reset zoom on new image
zoom = 1.0;
zoomCenter = { x: 0.5, y: 0.5 };
// Reset auto mode and apply auto-detected settings // Reset auto mode and apply auto-detected settings
invertMode = "auto"; invertMode = "auto";
detectedInvert = suggestions.invert; detectedInvert = suggestions.invert;
@@ -698,6 +873,16 @@ import TuiButton from "../components/TuiButton.astro";
transform-origin: center; transform-origin: center;
} }
#ascii-canvas {
width: 100%;
height: 100%;
object-fit: contain;
display: none;
image-rendering: pixelated;
opacity: 0;
transition: opacity 0.5s ease;
}
#loading { #loading {
position: absolute; position: absolute;
top: 50%; top: 50%;

343
src/scripts/webgl-ascii.js Normal file
View File

@@ -0,0 +1,343 @@
export class WebGLAsciiRenderer {
constructor(canvas) {
this.canvas = canvas;
this.gl = canvas.getContext('webgl', { antialias: false });
if (!this.gl) {
throw new Error('WebGL not supported');
}
this.program = null;
this.textures = {};
this.buffers = {};
this.charAtlas = null;
this.charSet = '';
this.fontSize = 12;
this.fontFamily = "'JetBrains Mono', monospace";
this.init();
}
init() {
const gl = this.gl;
// Vertex Shader
const vsSource = `
attribute vec2 a_position;
attribute vec2 a_texCoord;
varying vec2 v_texCoord;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_texCoord = a_texCoord;
}
`;
// Fragment Shader
const fsSource = `
precision mediump float;
varying vec2 v_texCoord;
uniform sampler2D u_image;
uniform sampler2D u_atlas;
uniform float u_charCount;
uniform vec2 u_gridSize; // cols, rows
uniform vec2 u_texSize; // atlas size
// Adjustments
uniform float u_exposure;
uniform float u_contrast;
uniform float u_saturation;
uniform float u_gamma;
uniform bool u_invert;
uniform bool u_color;
uniform float u_overlayStrength;
uniform bool u_enhanceEdges;
// Zoom & Magnifier
uniform float u_zoom;
uniform vec2 u_zoomCenter;
uniform vec2 u_mousePos;
uniform float u_magnifierRadius;
uniform float u_magnifierZoom;
uniform bool u_showMagnifier;
uniform float u_aspect;
vec3 adjust(vec3 color) {
// Exposure
color *= u_exposure;
// Contrast
color = (color - 0.5) * u_contrast + 0.5;
// Saturation
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
color = mix(vec3(luma), color, u_saturation);
// Gamma
color = pow(max(color, 0.0), vec3(u_gamma));
return clamp(color, 0.0, 1.0);
}
void main() {
vec2 uv = v_texCoord;
// Apply global zoom
uv = (uv - u_zoomCenter) / u_zoom + u_zoomCenter;
// Magnifier logic
vec2 diff = (v_texCoord - u_mousePos);
diff.x *= u_aspect;
float dist = length(diff);
bool inMagnifier = u_showMagnifier && dist < u_magnifierRadius;
if (inMagnifier) {
// Zoom towards mouse position inside the magnifier
uv = (v_texCoord - u_mousePos) / u_magnifierZoom + u_mousePos;
// Also account for the global zoom background
uv = (uv - u_zoomCenter) / u_zoom + u_zoomCenter;
}
// Calculate which cell we are in
vec2 cellCoords = floor(uv * u_gridSize);
vec2 uvInCell = fract(uv * u_gridSize);
// Sample image at the center of the cell
vec2 sampleUV = (cellCoords + 0.5) / u_gridSize;
// 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;
// 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;
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);
}
// Apply adjustments
color = adjust(color);
// Overlay blend-like effect (boost mid-contrast)
if (u_overlayStrength > 0.0) {
vec3 overlay = color;
vec3 result;
if (dot(color, vec3(0.333)) < 0.5) {
result = 2.0 * color * overlay;
} else {
result = 1.0 - 2.0 * (1.0 - color) * (1.0 - overlay);
}
color = mix(color, result, u_overlayStrength);
}
// Calculate luminance
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
if (u_invert) {
luma = 1.0 - luma;
}
// Map luma to character index
float charIndex = floor(luma * (u_charCount - 1.0) + 0.5);
// Sample character atlas
vec2 atlasUV = vec2(
(charIndex + uvInCell.x) / u_charCount,
uvInCell.y
);
float charAlpha = texture2D(u_atlas, atlasUV).r;
// Loup border effect
if (u_showMagnifier) {
float edgeWidth = 0.005;
if (dist > u_magnifierRadius - edgeWidth && dist < u_magnifierRadius) {
charAlpha = 1.0;
color = vec3(1.0, 0.4039, 0.0); // Safety Orange border for the loupe
}
}
vec3 finalColor = u_color ? color : vec3(1.0, 0.4039, 0.0);
gl_FragColor = vec4(finalColor * charAlpha, charAlpha);
}
`;
this.program = this.createProgram(vsSource, fsSource);
// Grid buffers
const positions = new Float32Array([
-1, -1, 1, -1, -1, 1,
-1, 1, 1, -1, 1, 1
]);
const texCoords = new Float32Array([
0, 1, 1, 1, 0, 0,
0, 0, 1, 1, 1, 0
]);
this.buffers.position = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.position);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
this.buffers.texCoord = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.texCoord);
gl.bufferData(gl.ARRAY_BUFFER, texCoords, gl.STATIC_DRAW);
}
createProgram(vsSource, fsSource) {
const gl = this.gl;
const vs = this.compileShader(gl.VERTEX_SHADER, vsSource);
const fs = this.compileShader(gl.FRAGMENT_SHADER, fsSource);
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program));
return null;
}
return program;
}
compileShader(type, source) {
const gl = this.gl;
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
updateAtlas(charSet, fontName = 'monospace') {
if (this.charSet === charSet && this.fontFamily === fontName && this.charAtlas) return;
this.charSet = charSet;
this.fontFamily = fontName;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const fontSize = 32; // Higher resolution for atlas
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;
canvas.width = charWidth * charSet.length;
canvas.height = charHeight;
ctx.font = `${fontSize}px ${fontName}`;
ctx.fillStyle = 'white';
ctx.textBaseline = 'top';
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < charSet.length; i++) {
ctx.fillText(charSet[i], i * charWidth, 0);
}
const gl = this.gl;
if (this.textures.atlas) gl.deleteTexture(this.textures.atlas);
this.textures.atlas = gl.createTexture();
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);
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);
this.charAtlas = {
width: canvas.width,
height: canvas.height,
charWidth,
charHeight,
count: charSet.length
};
}
render(image, options) {
const gl = this.gl;
const program = this.program;
this.updateAtlas(options.charSetContent, options.fontFamily || 'monospace');
// Update image texture only if it's a new image
if (this.lastImage !== image) {
if (this.textures.image) gl.deleteTexture(this.textures.image);
this.textures.image = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.textures.image);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.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);
this.lastImage = image;
}
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
// Attributes
const posLoc = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(posLoc);
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.position);
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
const texLoc = gl.getAttribLocation(program, 'a_texCoord');
gl.enableVertexAttribArray(texLoc);
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.texCoord);
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
// Uniforms
gl.uniform1i(gl.getUniformLocation(program, 'u_image'), 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.textures.image);
gl.uniform1i(gl.getUniformLocation(program, 'u_atlas'), 1);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas);
gl.uniform1f(gl.getUniformLocation(program, 'u_charCount'), this.charAtlas.count);
gl.uniform2f(gl.getUniformLocation(program, 'u_gridSize'), options.width, options.height);
gl.uniform2f(gl.getUniformLocation(program, 'u_texSize'), this.charAtlas.width, this.charAtlas.height);
gl.uniform1f(gl.getUniformLocation(program, 'u_exposure'), options.exposure);
gl.uniform1f(gl.getUniformLocation(program, 'u_contrast'), options.contrast);
gl.uniform1f(gl.getUniformLocation(program, 'u_saturation'), options.saturation);
gl.uniform1f(gl.getUniformLocation(program, 'u_gamma'), options.gamma);
gl.uniform1i(gl.getUniformLocation(program, 'u_invert'), options.invert ? 1 : 0);
gl.uniform1i(gl.getUniformLocation(program, 'u_color'), options.color ? 1 : 0);
gl.uniform1f(gl.getUniformLocation(program, 'u_overlayStrength'), options.overlayStrength || 0.0);
gl.uniform1i(gl.getUniformLocation(program, 'u_enhanceEdges'), options.enhanceEdges ? 1 : 0);
// Zoom & Magnifier
gl.uniform1f(gl.getUniformLocation(program, 'u_zoom'), options.zoom || 1.0);
gl.uniform2f(gl.getUniformLocation(program, 'u_zoomCenter'), options.zoomCenter?.x ?? 0.5, options.zoomCenter?.y ?? 0.5);
gl.uniform2f(gl.getUniformLocation(program, 'u_mousePos'), options.mousePos?.x ?? -1.0, options.mousePos?.y ?? -1.0);
gl.uniform1f(gl.getUniformLocation(program, 'u_magnifierRadius'), options.magnifierRadius || 0.15);
gl.uniform1f(gl.getUniformLocation(program, 'u_magnifierZoom'), options.magnifierZoom || 2.0);
gl.uniform1i(gl.getUniformLocation(program, 'u_showMagnifier'), options.showMagnifier ? 1 : 0);
gl.uniform1f(gl.getUniformLocation(program, 'u_aspect'), gl.canvas.width / gl.canvas.height);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
}