export interface RenderOptions { charSetContent: string; fontFamily?: string; width: number; height: number; exposure: number; contrast: number; saturation: number; gamma: number; invert: boolean; color: boolean; overlayStrength?: number; edgeMode?: number; // 0=none, 1=simple, 2=sobel, 3=canny dither?: number; denoise?: boolean; zoom?: number; zoomCenter?: { x: number; y: number }; mousePos?: { x: number; y: number }; magnifierRadius?: number; magnifierZoom?: number; showMagnifier?: boolean; } export interface MagnifierOptions { mousePos?: { x: number; y: number }; zoom?: number; zoomCenter?: { x: number; y: number }; magnifierRadius?: number; magnifierZoom?: number; showMagnifier?: boolean; } export class WebGLAsciiRenderer { private gl: WebGLRenderingContext; private program: WebGLProgram | null; 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; private uniformLocations: Record = {}; private fontFamily: string; private lastImage: HTMLImageElement | null; constructor(_canvas: HTMLCanvasElement) { const gl = _canvas.getContext('webgl', { antialias: false }); if (!gl) { throw new Error('WebGL not supported'); } this.gl = gl; this.program = null; this.textures = {}; this.buffers = {}; this.charAtlas = null; this.charSet = ''; this.lastImage = null; this.fontFamily = "'JetBrains Mono', monospace"; this.init(); this.loadBlueNoiseTexture(); } 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 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 // 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 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; uniform vec2 u_zoomCenter; uniform vec2 u_mousePos; uniform float u_magnifierRadius; uniform float u_magnifierZoom; 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; // 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); } // 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; // 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 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; // 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 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)); // 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; } // Map luma to character index 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_charSizeUV.x, uvInCell.y * u_charSizeUV.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); this.cacheUniformLocations(); // 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); } private cacheUniformLocations(): void { if (!this.program) return; const gl = this.gl; const uniforms = [ '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_edgeMode', 'u_dither', 'u_denoise', 'u_zoom', 'u_zoomCenter', 'u_mousePos', 'u_magnifierRadius', 'u_magnifierZoom', 'u_showMagnifier', 'u_aspect' ]; for (const name of uniforms) { this.uniformLocations[name] = gl.getUniformLocation(this.program, name); } } createProgram(vsSource: string, fsSource: string): WebGLProgram | null { const gl = this.gl; const vs = this.compileShader(gl.VERTEX_SHADER, vsSource); const fs = this.compileShader(gl.FRAGMENT_SHADER, fsSource); if (!vs || !fs) return null; const program = gl.createProgram(); if (!program) return null; 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: number, source: string): WebGLShader | null { const gl = this.gl; const shader = gl.createShader(type); if (!shader) return null; 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: string, 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'); 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 charContentWidth = Math.ceil(metrics.width); const charContentHeight = Math.ceil(fontSize * 1.2); // 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'; ctx.textBaseline = 'top'; ctx.clearRect(0, 0, canvas.width, canvas.height); for (let i = 0; i < charSet.length; i++) { // 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; if (this.textures.atlas) gl.deleteTexture(this.textures.atlas); const atlasTexture = gl.createTexture(); if (!atlasTexture) return; this.textures.atlas = atlasTexture; gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas); // 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: texWidth, height: texHeight, charWidth, charHeight, count: charSet.length }; } updateGrid(width: number, height: number) { const gl = this.gl; const u = this.uniformLocations; gl.useProgram(this.program); gl.uniform2f(u['u_gridSize'], width, height); } updateUniforms(options: RenderOptions) { const gl = this.gl; const u = this.uniformLocations; gl.useProgram(this.program); // Update Atlas if needed (expensive check inside) 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); } gl.uniform1f(u['u_exposure'], options.exposure); gl.uniform1f(u['u_contrast'], options.contrast); gl.uniform1f(u['u_saturation'], options.saturation); gl.uniform1f(u['u_gamma'], options.gamma); 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_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); gl.uniform2f(u['u_zoomCenter'], options.zoomCenter?.x ?? 0.5, options.zoomCenter?.y ?? 0.5); gl.uniform2f(u['u_mousePos'], options.mousePos?.x ?? -1.0, options.mousePos?.y ?? -1.0); gl.uniform1f(u['u_magnifierRadius'], options.magnifierRadius || 0.15); gl.uniform1f(u['u_magnifierZoom'], options.magnifierZoom || 2.0); gl.uniform1i(u['u_showMagnifier'], options.showMagnifier ? 1 : 0); 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; const gl = this.gl; if (this.textures.image) gl.deleteTexture(this.textures.image); const texture = gl.createTexture(); if (!texture) throw new Error('Failed to create texture'); this.textures.image = texture; 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; } draw() { const gl = this.gl; const program = this.program; if (!program || !this.textures.image || !this.textures.atlas || !this.buffers.position || !this.buffers.texCoord) return; gl.useProgram(program); gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); // 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); // Bind Textures const u = this.uniformLocations; gl.uniform1i(u['u_image'], 0); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.textures.image); gl.uniform1i(u['u_atlas'], 1); 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); } render(image: HTMLImageElement, options: RenderOptions) { this.updateTexture(image); this.updateGrid(options.width, options.height); this.updateUniforms(options); 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; const program = this.program; if (!program) return; gl.useProgram(program); // Only update magnifier-related uniforms (using cached locations) const u = this.uniformLocations; const mousePos = options.mousePos ?? { x: -1, y: -1 }; gl.uniform2f(u['u_mousePos'], mousePos.x, mousePos.y); gl.uniform1f(u['u_magnifierRadius'], options.magnifierRadius || 0.03); gl.uniform1f(u['u_magnifierZoom'], options.magnifierZoom || 2.0); gl.uniform1i(u['u_showMagnifier'], options.showMagnifier ? 1 : 0); if (options.zoom !== undefined) { gl.uniform1f(u['u_zoom'], options.zoom || 1.0); gl.uniform2f(u['u_zoomCenter'], options.zoomCenter?.x ?? 0.5, options.zoomCenter?.y ?? 0.5); } // We can just call draw here as it's lightweight this.draw(); } }