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 id="loading">GENERATING...</div>
<pre id="ascii-result">Preparing art...</pre>
<canvas id="ascii-canvas"></canvas>
</div>
<!-- Foreground Layer: Content -->
@@ -203,9 +204,26 @@ import TuiButton from "../components/TuiButton.astro";
autoTuneImage,
CHAR_SETS,
} 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 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
let currentImgUrl: string | null = null;
@@ -236,7 +254,7 @@ import TuiButton from "../components/TuiButton.astro";
"loading",
) as HTMLDivElement;
if (!asciiResult || !loadingIndicator) {
if (!asciiResult || !loadingIndicator || !canvas) {
throw new Error("Critical UI elements missing");
}
@@ -365,35 +383,175 @@ import TuiButton from "../components/TuiButton.astro";
heightRows = widthCols / (imgRatio / fontAspectRatio);
try {
const result = await generator.generate(imgEl, {
width: widthCols,
height: Math.floor(heightRows),
...currentSettings,
});
// Decide whether to use WebGL or CPU
// We use WebGL primarily for color mode since it's the biggest performance hit
// But we can also use it for everything if currentSettings.color is true
const useWebGL = webglRenderer !== null;
// Handle color output (returns object) vs plain text (returns string)
if (typeof result === "object" && result.isHtml) {
asciiResult.innerHTML = result.output;
if (useWebGL) {
asciiResult.style.display = "none";
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 {
asciiResult.textContent = result as string;
finalH = maxH;
finalW = maxH * gridAspect;
}
// Auto-fit font size
const sizeW = (screenW * 0.9) / (widthCols * fontAspectRatio);
const sizeH = (screenH * 0.9) / heightRows;
const bestSize = Math.min(sizeW, sizeH);
// Set canvas CSS size to fit centered and internal resolution for sharpness
canvas.style.width = `${finalW}px`;
canvas.style.height = `${finalH}px`;
asciiResult.style.fontSize = `${Math.max(4, bestSize).toFixed(2)}px`;
asciiResult.style.opacity = "1";
} catch (e) {
console.error("Render error", e);
const dpr = window.devicePixelRatio || 1;
canvas.width = finalW * dpr;
canvas.height = finalH * dpr;
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
const imageQueue: { data: any; imgElement: HTMLImageElement }[] = [];
const TARGET_QUEUE_SIZE = 3;
const TARGET_QUEUE_SIZE = 5;
let isFetchingQueue = false;
async function fillQueue() {
@@ -405,18 +563,31 @@ import TuiButton from "../components/TuiButton.astro";
isFetchingQueue = true;
try {
// Fetch one by one until full
while (imageQueue.length < TARGET_QUEUE_SIZE) {
const data = await fetchRandomAnimeImage();
// Preload
const img = await resolveImage(data.url);
// Store
imageQueue.push({
data: data,
imgElement: img,
});
// Brief pause to be nice to API
await new Promise((r) => setTimeout(r, 500));
const needed = TARGET_QUEUE_SIZE - imageQueue.length;
if (needed <= 0) return;
// Fetch multiple metadata entries at once (API allows this)
const results = await fetchMultipleAnimeImages({
amount: needed,
});
// Load images SEQUENTIALLY with a small delay to avoid 429s from the CDN
for (const data of results) {
if (imageQueue.length >= TARGET_QUEUE_SIZE) break;
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) {
console.error("Queue fill error", e);
@@ -455,6 +626,10 @@ import TuiButton from "../components/TuiButton.astro";
fillQueue();
}
// Reset zoom on new image
zoom = 1.0;
zoomCenter = { x: 0.5, y: 0.5 };
// Reset auto mode and apply auto-detected settings
invertMode = "auto";
detectedInvert = suggestions.invert;
@@ -698,6 +873,16 @@ import TuiButton from "../components/TuiButton.astro";
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 {
position: absolute;
top: 50%;