feat: convert js modules to ts and optimize performance

This commit is contained in:
syntaxbullet
2026-02-09 20:11:34 +01:00
parent bf2a11a84d
commit 658f4ab841
11 changed files with 1269 additions and 952 deletions

472
src/scripts/webgl-ascii.ts Normal file
View File

@@ -0,0 +1,472 @@
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;
enhanceEdges?: boolean;
dither?: boolean;
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 };
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<string, WebGLUniformLocation | null> = {};
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();
}
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);
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_charCount', 'u_gridSize', 'u_texSize',
'u_exposure', 'u_contrast', 'u_saturation', 'u_gamma',
'u_invert', 'u_color', 'u_overlayStrength', 'u_enhanceEdges',
'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
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);
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);
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
};
}
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);
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_enhanceEdges'], options.enhanceEdges ? 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);
}
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);
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();
}
// 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();
}
}