// src/renderer/components/ds/CrtDisplay.tsx // 시안 A: CRT 디스플레이 — WebGL 셰이더 (스캔라인, 비네팅, 노이즈, 글리치, 파형) import { useRef, useEffect, useCallback } from 'react' import { Box } from '@mui/material' import { d3roPalette, d3roFontMono } from '../../theme' // ── WebGL 유틸 ───────────────────────────────────────── function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null { const shader = gl.createShader(type) if (!shader) return null gl.shaderSource(shader, source) gl.compileShader(shader) if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { gl.deleteShader(shader) return null } return shader } function createProgram(gl: WebGLRenderingContext, vsSource: string, fsSource: string): WebGLProgram | null { const vs = createShader(gl, gl.VERTEX_SHADER, vsSource) const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource) if (!vs || !fs) return null const prog = gl.createProgram() if (!prog) return null gl.attachShader(prog, vs) gl.attachShader(prog, fs) gl.linkProgram(prog) return prog } const VERTEX_SHADER = ` attribute vec2 position; varying vec2 vUv; void main() { gl_Position = vec4(position, 0.0, 1.0); vUv = position * 0.5 + 0.5; } ` const FRAGMENT_SHADER = ` precision highp float; varying vec2 vUv; uniform float u_time; uniform float u_glitch; uniform float u_amp; uniform float u_freq; float random(vec2 st) { return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123); } void main() { vec2 uv = vUv; // Glitch displacement if (u_glitch > 0.0) { float gOffset = (random(vec2(uv.y * 5.0, u_time)) - 0.5) * u_glitch * 0.1; uv.x += gOffset; } // Base phosphor background vec3 color = vec3(0.02, 0.015, 0.01); // Background Grid float gridX = step(0.98, fract(uv.x * 15.0)); float gridY = step(0.98, fract(uv.y * 10.0)); color += vec3(0.1, 0.04, 0.02) * max(gridX, gridY) * (1.0 - u_glitch); // Oscilloscope Waveform float t = u_time * 0.5 + u_glitch * random(uv) * 0.1; float waveY = sin((uv.x * u_freq) + t) * u_amp; float waveDist = abs((uv.y - 0.5) - waveY); float lineThick = 0.005 + (u_glitch * 0.02); float waveGlow = smoothstep(lineThick * 4.0, 0.0, waveDist); float waveCore = smoothstep(lineThick, 0.0, waveDist); // Amber wave (#f25b29) vec3 waveColor = vec3(0.95, 0.35, 0.16); color += waveColor * waveCore; color += waveColor * 0.4 * waveGlow; // Scanlines float scanline = sin(uv.y * 800.0 - u_time * 10.0) * 0.04; color -= scanline; // Noise float noise = random(uv + u_time) * 0.08; color += noise; // Vignette float dist = distance(vUv, vec2(0.5)); float vig = smoothstep(0.8, 0.4, dist); color *= vig; // Edge darkening color *= smoothstep(0.0, 0.02, vUv.x) * smoothstep(1.0, 0.98, vUv.x); color *= smoothstep(0.0, 0.05, vUv.y) * smoothstep(1.0, 0.95, vUv.y); gl_FragColor = vec4(color, 1.0); } ` const QUAD = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]) // ── Props ────────────────────────────────────────────── interface CrtDisplayProps { /** 파형 진폭 (0.0 ~ 1.0) */ amplitude?: number /** 파형 주파수 */ frequency?: number /** 글리치 트리거 (변경 시 글리치 발생) */ glitchTrigger?: number /** 오버레이 콘텐츠 (인광 텍스트 등) */ children?: React.ReactNode /** 높이 (기본 280px) */ height?: number | string } export function CrtDisplay({ amplitude = 0.1, frequency = 8.0, glitchTrigger = 0, children, height = 280, }: CrtDisplayProps): React.ReactElement { const canvasRef = useRef(null) const glRef = useRef<{ gl: WebGLRenderingContext uTime: WebGLUniformLocation | null uGlitch: WebGLUniformLocation | null uAmp: WebGLUniformLocation | null uFreq: WebGLUniformLocation | null } | null>(null) const animRef = useRef(0) const startTimeRef = useRef(Date.now()) const glitchRef = useRef(0) const ampRef = useRef(amplitude) const freqRef = useRef(frequency) const currentAmpRef = useRef(amplitude) const currentFreqRef = useRef(frequency) // amplitude/frequency 변경 추적 useEffect(() => { ampRef.current = amplitude freqRef.current = frequency }, [amplitude, frequency]) // 글리치 트리거 useEffect(() => { if (glitchTrigger > 0) { glitchRef.current = 1.0 } }, [glitchTrigger]) const render = useCallback(() => { const ctx = glRef.current if (!ctx) return const { gl, uTime, uGlitch, uAmp, uFreq } = ctx // Smoothing currentAmpRef.current += (ampRef.current - currentAmpRef.current) * 0.1 currentFreqRef.current += (freqRef.current - currentFreqRef.current) * 0.1 glitchRef.current *= 0.85 gl.uniform1f(uTime, (Date.now() - startTimeRef.current) / 1000) gl.uniform1f(uGlitch, glitchRef.current) gl.uniform1f(uAmp, currentAmpRef.current) gl.uniform1f(uFreq, currentFreqRef.current) gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) animRef.current = requestAnimationFrame(render) }, []) useEffect(() => { const canvas = canvasRef.current if (!canvas) return const gl = canvas.getContext('webgl', { alpha: true }) if (!gl) return canvas.width = canvas.clientWidth * 2 canvas.height = canvas.clientHeight * 2 gl.viewport(0, 0, canvas.width, canvas.height) const prog = createProgram(gl, VERTEX_SHADER, FRAGMENT_SHADER) if (!prog) return gl.useProgram(prog) const buffer = gl.createBuffer() gl.bindBuffer(gl.ARRAY_BUFFER, buffer) gl.bufferData(gl.ARRAY_BUFFER, QUAD, gl.STATIC_DRAW) const posLoc = gl.getAttribLocation(prog, 'position') gl.enableVertexAttribArray(posLoc) gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0) glRef.current = { gl, uTime: gl.getUniformLocation(prog, 'u_time'), uGlitch: gl.getUniformLocation(prog, 'u_glitch'), uAmp: gl.getUniformLocation(prog, 'u_amp'), uFreq: gl.getUniformLocation(prog, 'u_freq'), } startTimeRef.current = Date.now() animRef.current = requestAnimationFrame(render) return () => { cancelAnimationFrame(animRef.current) } }, [render]) return ( {/* Glass surface */} {/* Glass reflection */} {/* Content overlay (phosphor text) */} {children} ) }