// Singleton WebGL context manager for glass effects class GlassEffectManager { private static instance: GlassEffectManager | null = null private canvas: HTMLCanvasElement | null = null private gl: WebGLRenderingContext | null = null private program: WebGLProgram | null = null private uniforms: Record = {} private effects: Map = new Map() private animationFrame: number | null = null private startTime: number = performance.now() private mousePositions: Map = new Map() static getInstance(): GlassEffectManager { if (!GlassEffectManager.instance) { GlassEffectManager.instance = new GlassEffectManager() } return GlassEffectManager.instance } private constructor() { this.initializeContext() } private initializeContext() { // Create offscreen canvas this.canvas = document.createElement("canvas") this.canvas.width = 1024 // Default size, will be adjusted this.canvas.height = 1024 this.gl = this.canvas.getContext("webgl", { alpha: true, premultipliedAlpha: false, preserveDrawingBuffer: true, }) if (!this.gl) { console.error("WebGL not supported") return } this.setupShaders() this.startRenderLoop() } private setupShaders() { if (!this.gl) return const vsSource = ` attribute vec2 position; void main() { gl_Position = vec4(position, 0.0, 1.0); } ` const fsSource = ` precision mediump float; uniform vec2 iResolution; uniform float iTime; uniform vec2 iMouse; uniform float iExpanded; float noise(vec2 p) { return sin(p.x * 10.0) * sin(p.y * 10.0); } void main() { vec2 uv = gl_FragCoord.xy / iResolution.xy; vec2 mouse = iMouse / iResolution.xy; float dist = length(uv - mouse); vec2 distortion = vec2( sin(iTime * 2.0 + uv.y * 10.0) * 0.0025, cos(iTime * 1.5 + uv.x * 10.0) * 0.0025 ); vec2 refractedUV = uv + distortion * (1.0 - dist); vec3 glassColor = mix( vec3(0.1, 0.1, 0.12), vec3(0.16, 0.16, 0.19), 1.0 - length(uv - vec2(0.5)) ); float glow = exp(-dist * 5.0) * 0.35; glassColor += vec3(glow); float edge = 1.0 - smoothstep(0.0, 0.02, min( min(uv.x, 1.0 - uv.x), min(uv.y, 1.0 - uv.y) )); glassColor += edge * 0.3; float edgeGlow = 1.0 - smoothstep(0.0, 0.05, min( min(uv.x, 1.0 - uv.x), min(uv.y, 1.0 - uv.y) )); glassColor += vec3(edgeGlow * 0.1, edgeGlow * 0.1, edgeGlow * 0.2); float n = noise(refractedUV * 50.0 + vec2(iTime)) * 0.025; glassColor += vec3(n); float alpha = 0.25 + edge * 0.2 + glow * 0.2; alpha *= mix(0.8, 1.0, iExpanded); gl_FragColor = vec4(glassColor, alpha); } ` const createShader = (type: number, source: string) => { const shader = this.gl!.createShader(type) if (!shader) return null this.gl!.shaderSource(shader, source) this.gl!.compileShader(shader) if (!this.gl!.getShaderParameter(shader, this.gl!.COMPILE_STATUS)) { console.error("Shader error:", this.gl!.getShaderInfoLog(shader)) this.gl!.deleteShader(shader) return null } return shader } const vs = createShader(this.gl.VERTEX_SHADER, vsSource) const fs = createShader(this.gl.FRAGMENT_SHADER, fsSource) if (!vs || !fs) return this.program = this.gl.createProgram() if (!this.program) return this.gl.attachShader(this.program, vs) this.gl.attachShader(this.program, fs) this.gl.linkProgram(this.program) // biome-ignore lint/correctness/useHookAtTopLevel: Well, not a hook this.gl.useProgram(this.program) // Buffer setup const buffer = this.gl.createBuffer() this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer) this.gl.bufferData( this.gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), this.gl.STATIC_DRAW, ) const position = this.gl.getAttribLocation(this.program, "position") this.gl.enableVertexAttribArray(position) this.gl.vertexAttribPointer(position, 2, this.gl.FLOAT, false, 0, 0) // Store uniform locations this.uniforms = { resolution: this.gl.getUniformLocation(this.program, "iResolution"), time: this.gl.getUniformLocation(this.program, "iTime"), mouse: this.gl.getUniformLocation(this.program, "iMouse"), expanded: this.gl.getUniformLocation(this.program, "iExpanded"), } // Enable blending this.gl.enable(this.gl.BLEND) this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA) } registerEffect( id: string, targetCanvas: HTMLCanvasElement, isExpanded: boolean, ): () => void { // Ensure minimum dimensions const width = Math.max(1, targetCanvas.width) const height = Math.max(1, targetCanvas.height) const effect: EffectInstance = { id, targetCanvas, isExpanded, width, height, } this.effects.set(id, effect) this.mousePositions.set(id, { x: 0, y: 0 }) // Return cleanup function return () => { this.effects.delete(id) this.mousePositions.delete(id) if (this.effects.size === 0 && this.animationFrame) { cancelAnimationFrame(this.animationFrame) this.animationFrame = null } } } updateMousePosition(id: string, x: number, y: number) { this.mousePositions.set(id, { x, y }) } updateExpanded(id: string, isExpanded: boolean) { const effect = this.effects.get(id) if (effect) { effect.isExpanded = isExpanded } } updateSize(id: string, width: number, height: number) { const effect = this.effects.get(id) if (effect) { // Ensure minimum dimensions effect.width = Math.max(1, width) effect.height = Math.max(1, height) } } private startRenderLoop() { const render = () => { if (!this.gl || !this.program || this.effects.size === 0) { this.animationFrame = requestAnimationFrame(render) return } const currentTime = (performance.now() - this.startTime) / 1000 // Render each effect for (const [id, effect] of Array.from(this.effects)) { const mousePos = this.mousePositions.get(id) || { x: 0, y: 0 } // Skip rendering if dimensions are invalid if (effect.width <= 0 || effect.height <= 0) { continue } // Set canvas size if needed if ( this.canvas!.width !== effect.width || this.canvas!.height !== effect.height ) { this.canvas!.width = effect.width this.canvas!.height = effect.height this.gl.viewport(0, 0, effect.width, effect.height) } // Clear and render this.gl.clearColor(0, 0, 0, 0) this.gl.clear(this.gl.COLOR_BUFFER_BIT) // Set uniforms if (this.uniforms.resolution) { this.gl.uniform2f( this.uniforms.resolution, effect.width, effect.height, ) } if (this.uniforms.time) { this.gl.uniform1f(this.uniforms.time, currentTime) } if (this.uniforms.mouse) { this.gl.uniform2f(this.uniforms.mouse, mousePos.x, mousePos.y) } if (this.uniforms.expanded) { this.gl.uniform1f( this.uniforms.expanded, effect.isExpanded ? 1.0 : 0.0, ) } // Draw this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4) // Copy to target canvas const targetCtx = effect.targetCanvas.getContext("2d") if (targetCtx) { targetCtx.clearRect(0, 0, effect.width, effect.height) targetCtx.drawImage(this.canvas!, 0, 0) } } this.animationFrame = requestAnimationFrame(render) } render() } // Clean up method (optional, for when the app unmounts) destroy() { if (this.animationFrame) { cancelAnimationFrame(this.animationFrame) } if (this.gl && this.program) { this.gl.deleteProgram(this.program) } this.effects.clear() this.mousePositions.clear() GlassEffectManager.instance = null } } interface EffectInstance { id: string targetCanvas: HTMLCanvasElement isExpanded: boolean width: number height: number } export default GlassEffectManager