686 lines
26 KiB
TypeScript
686 lines
26 KiB
TypeScript
|
|
|
|
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<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();
|
|
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();
|
|
}
|
|
}
|