mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-24 02:36:02 +10:00
Refactor code structure for improved readability and maintainability
This commit is contained in:
459
website/crt-canvas.js
Normal file
459
website/crt-canvas.js
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* CRT Canvas Renderer
|
||||
* Renders content to a canvas with WebGL barrel distortion shader
|
||||
* Handles input coordinate transformation automatically
|
||||
*/
|
||||
class CRTCanvasRenderer {
|
||||
/**
|
||||
* @param {HTMLElement} sourceElement - Element to render (will be hidden)
|
||||
* @param {Object} options - Configuration options
|
||||
*/
|
||||
constructor(sourceElement, options = {}) {
|
||||
this.source = sourceElement;
|
||||
this.options = {
|
||||
bulgeStrength: options.bulgeStrength ?? 0.08,
|
||||
scanlineIntensity: options.scanlineIntensity ?? 0.1,
|
||||
vignetteStrength: options.vignetteStrength ?? 0.2,
|
||||
fps: options.fps ?? 60,
|
||||
...options
|
||||
};
|
||||
|
||||
this.canvas = null;
|
||||
this.gl = null;
|
||||
this.program = null;
|
||||
this.animationId = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the canvas and WebGL context
|
||||
*/
|
||||
init() {
|
||||
// Create canvas
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.canvas.id = 'crt-canvas';
|
||||
this.canvas.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
// Insert canvas before source
|
||||
this.source.parentNode.insertBefore(this.canvas, this.source);
|
||||
|
||||
// Hide source but keep it functional
|
||||
this.source.style.position = 'absolute';
|
||||
this.source.style.opacity = '0';
|
||||
this.source.style.pointerEvents = 'none';
|
||||
|
||||
// Get WebGL context
|
||||
this.gl = this.canvas.getContext('webgl', {
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
preserveDrawingBuffer: true
|
||||
});
|
||||
|
||||
if (!this.gl) {
|
||||
console.error('WebGL not supported, falling back to source element');
|
||||
this.source.style.opacity = '1';
|
||||
this.source.style.pointerEvents = 'auto';
|
||||
this.canvas.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
this.setupShaders();
|
||||
this.setupGeometry();
|
||||
this.setupTexture();
|
||||
this.setupEventListeners();
|
||||
this.resize();
|
||||
|
||||
window.addEventListener('resize', () => this.resize());
|
||||
|
||||
this.startRenderLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and compile WebGL shaders
|
||||
*/
|
||||
setupShaders() {
|
||||
const gl = this.gl;
|
||||
|
||||
// Vertex shader
|
||||
const vertexSource = `
|
||||
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 with barrel distortion
|
||||
const fragmentSource = `
|
||||
precision mediump float;
|
||||
|
||||
uniform sampler2D u_texture;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_bulgeStrength;
|
||||
uniform float u_scanlineIntensity;
|
||||
uniform float u_vignetteStrength;
|
||||
uniform float u_time;
|
||||
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
vec2 barrelDistort(vec2 uv) {
|
||||
// Center UV around origin
|
||||
vec2 centered = uv * 2.0 - 1.0;
|
||||
|
||||
// Calculate distance from center
|
||||
float distSq = dot(centered, centered);
|
||||
|
||||
// Apply barrel distortion
|
||||
float distortion = 1.0 + u_bulgeStrength * distSq;
|
||||
centered *= distortion;
|
||||
|
||||
// Convert back to 0-1 range
|
||||
return centered * 0.5 + 0.5;
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Apply barrel distortion
|
||||
vec2 distortedUV = barrelDistort(v_texCoord);
|
||||
|
||||
// Check if UV is out of bounds
|
||||
if (distortedUV.x < 0.0 || distortedUV.x > 1.0 ||
|
||||
distortedUV.y < 0.0 || distortedUV.y > 1.0) {
|
||||
gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sample texture
|
||||
vec4 color = texture2D(u_texture, distortedUV);
|
||||
|
||||
// Scanlines
|
||||
float scanline = sin(distortedUV.y * u_resolution.y * 1.5) * 0.5 + 0.5;
|
||||
color.rgb *= 1.0 - (u_scanlineIntensity * (1.0 - scanline));
|
||||
|
||||
// Subtle RGB shift for chromatic aberration
|
||||
float shift = u_bulgeStrength * 0.002;
|
||||
float r = texture2D(u_texture, distortedUV + vec2(shift, 0.0)).r;
|
||||
float b = texture2D(u_texture, distortedUV - vec2(shift, 0.0)).b;
|
||||
color.r = mix(color.r, r, 0.5);
|
||||
color.b = mix(color.b, b, 0.5);
|
||||
|
||||
// Vignette
|
||||
vec2 vignetteUV = v_texCoord * 2.0 - 1.0;
|
||||
float vignette = 1.0 - dot(vignetteUV, vignetteUV) * u_vignetteStrength;
|
||||
color.rgb *= vignette;
|
||||
|
||||
gl_FragColor = color;
|
||||
}
|
||||
`;
|
||||
|
||||
// Compile shaders
|
||||
const vertexShader = this.compileShader(gl.VERTEX_SHADER, vertexSource);
|
||||
const fragmentShader = this.compileShader(gl.FRAGMENT_SHADER, fragmentSource);
|
||||
|
||||
// Create program
|
||||
this.program = gl.createProgram();
|
||||
gl.attachShader(this.program, vertexShader);
|
||||
gl.attachShader(this.program, fragmentShader);
|
||||
gl.linkProgram(this.program);
|
||||
|
||||
if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) {
|
||||
console.error('Shader program failed to link:', gl.getProgramInfoLog(this.program));
|
||||
}
|
||||
|
||||
gl.useProgram(this.program);
|
||||
|
||||
// Get uniform locations
|
||||
this.uniforms = {
|
||||
texture: gl.getUniformLocation(this.program, 'u_texture'),
|
||||
resolution: gl.getUniformLocation(this.program, 'u_resolution'),
|
||||
bulgeStrength: gl.getUniformLocation(this.program, 'u_bulgeStrength'),
|
||||
scanlineIntensity: gl.getUniformLocation(this.program, 'u_scanlineIntensity'),
|
||||
vignetteStrength: gl.getUniformLocation(this.program, 'u_vignetteStrength'),
|
||||
time: gl.getUniformLocation(this.program, 'u_time')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a shader
|
||||
*/
|
||||
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('Shader compile error:', gl.getShaderInfoLog(shader));
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
|
||||
return shader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up geometry (full-screen quad)
|
||||
*/
|
||||
setupGeometry() {
|
||||
const gl = this.gl;
|
||||
|
||||
// Position attribute
|
||||
const positionBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
||||
-1, -1, 1, -1, -1, 1,
|
||||
-1, 1, 1, -1, 1, 1
|
||||
]), gl.STATIC_DRAW);
|
||||
|
||||
const positionLoc = gl.getAttribLocation(this.program, 'a_position');
|
||||
gl.enableVertexAttribArray(positionLoc);
|
||||
gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
// Texture coordinate attribute
|
||||
const texCoordBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
||||
0, 1, 1, 1, 0, 0,
|
||||
0, 0, 1, 1, 1, 0
|
||||
]), gl.STATIC_DRAW);
|
||||
|
||||
const texCoordLoc = gl.getAttribLocation(this.program, 'a_texCoord');
|
||||
gl.enableVertexAttribArray(texCoordLoc);
|
||||
gl.vertexAttribPointer(texCoordLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up texture for rendering source element
|
||||
*/
|
||||
setupTexture() {
|
||||
const gl = this.gl;
|
||||
|
||||
this.texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.texture);
|
||||
|
||||
// Set texture parameters
|
||||
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.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle canvas resize
|
||||
*/
|
||||
resize() {
|
||||
const rect = this.source.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
this.canvas.width = rect.width * dpr;
|
||||
this.canvas.height = rect.height * dpr;
|
||||
this.canvas.style.width = rect.width + 'px';
|
||||
this.canvas.style.height = rect.height + 'px';
|
||||
|
||||
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply inverse barrel distortion to get source coordinates from canvas coordinates
|
||||
*/
|
||||
inverseDistort(canvasX, canvasY) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
|
||||
// Normalize to 0-1
|
||||
let u = canvasX / rect.width;
|
||||
let v = canvasY / rect.height;
|
||||
|
||||
// Center around 0
|
||||
let x = u * 2 - 1;
|
||||
let y = v * 2 - 1;
|
||||
|
||||
// Iteratively solve inverse (Newton-Raphson)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const distSq = x * x + y * y;
|
||||
const distortion = 1 + this.options.bulgeStrength * distSq;
|
||||
|
||||
// The forward transform is: distorted = original * distortion
|
||||
// So inverse is approximately: original = distorted / distortion
|
||||
x = (u * 2 - 1) / distortion;
|
||||
y = (v * 2 - 1) / distortion;
|
||||
}
|
||||
|
||||
// Convert back to pixel coordinates
|
||||
const sourceX = ((x + 1) / 2) * rect.width;
|
||||
const sourceY = ((y + 1) / 2) * rect.height;
|
||||
|
||||
return { x: sourceX, y: sourceY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners for mouse/touch input
|
||||
*/
|
||||
setupEventListeners() {
|
||||
const forwardEvent = (e, type) => {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const canvasX = e.clientX - rect.left;
|
||||
const canvasY = e.clientY - rect.top;
|
||||
|
||||
const sourceCoords = this.inverseDistort(canvasX, canvasY);
|
||||
const sourceX = rect.left + sourceCoords.x;
|
||||
const sourceY = rect.top + sourceCoords.y;
|
||||
|
||||
// Find element at transformed position in source
|
||||
this.source.style.pointerEvents = 'auto';
|
||||
this.source.style.opacity = '0.001'; // Nearly invisible but still there
|
||||
const element = document.elementFromPoint(sourceX, sourceY);
|
||||
this.source.style.pointerEvents = 'none';
|
||||
this.source.style.opacity = '0';
|
||||
|
||||
if (element && this.source.contains(element)) {
|
||||
const newEvent = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: sourceX,
|
||||
clientY: sourceY,
|
||||
button: e.button,
|
||||
buttons: e.buttons
|
||||
});
|
||||
element.dispatchEvent(newEvent);
|
||||
|
||||
// Update cursor
|
||||
this.canvas.style.cursor = window.getComputedStyle(element).cursor;
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse events
|
||||
this.canvas.addEventListener('click', (e) => forwardEvent(e, 'click'));
|
||||
this.canvas.addEventListener('mousedown', (e) => forwardEvent(e, 'mousedown'));
|
||||
this.canvas.addEventListener('mouseup', (e) => forwardEvent(e, 'mouseup'));
|
||||
this.canvas.addEventListener('mousemove', (e) => forwardEvent(e, 'mousemove'));
|
||||
this.canvas.addEventListener('dblclick', (e) => forwardEvent(e, 'dblclick'));
|
||||
this.canvas.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
forwardEvent(e, 'contextmenu');
|
||||
});
|
||||
|
||||
// Wheel events
|
||||
this.canvas.addEventListener('wheel', (e) => {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const canvasX = e.clientX - rect.left;
|
||||
const canvasY = e.clientY - rect.top;
|
||||
|
||||
const sourceCoords = this.inverseDistort(canvasX, canvasY);
|
||||
const sourceX = rect.left + sourceCoords.x;
|
||||
const sourceY = rect.top + sourceCoords.y;
|
||||
|
||||
this.source.style.pointerEvents = 'auto';
|
||||
const element = document.elementFromPoint(sourceX, sourceY);
|
||||
this.source.style.pointerEvents = 'none';
|
||||
|
||||
if (element && this.source.contains(element)) {
|
||||
element.dispatchEvent(new WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
clientX: sourceX,
|
||||
clientY: sourceY,
|
||||
deltaX: e.deltaX,
|
||||
deltaY: e.deltaY,
|
||||
deltaMode: e.deltaMode
|
||||
}));
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
// Keyboard events should go to focused elements naturally
|
||||
// Focus management
|
||||
this.canvas.addEventListener('mousedown', () => {
|
||||
// Focus the terminal when clicking canvas
|
||||
const terminal = this.source.querySelector('.xterm-helper-textarea');
|
||||
if (terminal) {
|
||||
terminal.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture source element to texture using html2canvas
|
||||
*/
|
||||
async captureSource() {
|
||||
const gl = this.gl;
|
||||
|
||||
// Use html2canvas to render the source element
|
||||
try {
|
||||
const canvas = await html2canvas(this.source, {
|
||||
backgroundColor: null,
|
||||
scale: window.devicePixelRatio || 1,
|
||||
logging: false,
|
||||
useCORS: true
|
||||
});
|
||||
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.texture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
|
||||
} catch (err) {
|
||||
console.error('Failed to capture source:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render frame
|
||||
*/
|
||||
render(time) {
|
||||
const gl = this.gl;
|
||||
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
// Set uniforms
|
||||
gl.uniform1i(this.uniforms.texture, 0);
|
||||
gl.uniform2f(this.uniforms.resolution, this.canvas.width, this.canvas.height);
|
||||
gl.uniform1f(this.uniforms.bulgeStrength, this.options.bulgeStrength);
|
||||
gl.uniform1f(this.uniforms.scanlineIntensity, this.options.scanlineIntensity);
|
||||
gl.uniform1f(this.uniforms.vignetteStrength, this.options.vignetteStrength);
|
||||
gl.uniform1f(this.uniforms.time, time * 0.001);
|
||||
|
||||
// Draw
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the render loop
|
||||
*/
|
||||
startRenderLoop() {
|
||||
const frameInterval = 1000 / this.options.fps;
|
||||
let lastCapture = 0;
|
||||
|
||||
const loop = async (time) => {
|
||||
// Capture at reduced rate to save performance
|
||||
if (time - lastCapture > frameInterval) {
|
||||
await this.captureSource();
|
||||
lastCapture = time;
|
||||
}
|
||||
|
||||
this.render(time);
|
||||
this.animationId = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
this.animationId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop rendering
|
||||
*/
|
||||
destroy() {
|
||||
if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
}
|
||||
this.canvas.remove();
|
||||
this.source.style.opacity = '1';
|
||||
this.source.style.pointerEvents = 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use
|
||||
window.CRTCanvasRenderer = CRTCanvasRenderer;
|
||||
Reference in New Issue
Block a user