feat: Implement WebGL-based ASCII renderer with zoom and magnifier, and optimize image queue fetching.
This commit is contained in:
@@ -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
343
src/scripts/webgl-ascii.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user