diff --git a/src/pages/index.astro b/src/pages/index.astro
index 3c77cec..f7f473f 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -12,6 +12,7 @@ import TuiButton from "../components/TuiButton.astro";
GENERATING...
Preparing art...
+
@@ -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%;
diff --git a/src/scripts/webgl-ascii.js b/src/scripts/webgl-ascii.js
new file mode 100644
index 0000000..8db5c46
--- /dev/null
+++ b/src/scripts/webgl-ascii.js
@@ -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);
+ }
+}