From cf4fe5dc82ee94ce3bcb26cd3f8de00e90a2dba8 Mon Sep 17 00:00:00 2001 From: Aaron Iker Date: Thu, 15 Jan 2026 17:52:48 +0100 Subject: [PATCH] add light rays --- .../console/app/src/component/light-rays.css | 186 +++++ .../console/app/src/component/light-rays.tsx | 710 ++++++++++++++++++ 2 files changed, 896 insertions(+) create mode 100644 packages/console/app/src/component/light-rays.css create mode 100644 packages/console/app/src/component/light-rays.tsx diff --git a/packages/console/app/src/component/light-rays.css b/packages/console/app/src/component/light-rays.css new file mode 100644 index 0000000000..b688e6d9e3 --- /dev/null +++ b/packages/console/app/src/component/light-rays.css @@ -0,0 +1,186 @@ +.light-rays-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: hidden; +} + +.light-rays-container canvas { + display: block; + width: 100%; + height: 100%; +} + +.light-rays-controls { + position: fixed; + top: 16px; + right: 16px; + z-index: 9999; + font-family: var(--font-mono, monospace); + font-size: 12px; + color: #fff; +} + +.light-rays-controls-toggle { + background: rgba(0, 0, 0, 0.8); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + padding: 8px 12px; + color: #fff; + cursor: pointer; + font-family: inherit; + font-size: inherit; + width: 100%; + text-align: left; +} + +.light-rays-controls-toggle:hover { + background: rgba(0, 0, 0, 0.9); + border-color: rgba(255, 255, 255, 0.3); +} + +.light-rays-controls-panel { + background: rgba(0, 0, 0, 0.85); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + padding: 12px; + margin-top: 4px; + display: flex; + flex-direction: column; + gap: 10px; + min-width: 240px; + max-height: calc(100vh - 100px); + overflow-y: auto; + backdrop-filter: blur(8px); +} + +.control-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.control-group label { + color: rgba(255, 255, 255, 0.7); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.control-group.checkbox { + flex-direction: row; + align-items: center; +} + +.control-group.checkbox label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + text-transform: none; +} + +.control-group input[type="range"] { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 4px; + background: rgba(255, 255, 255, 0.2); + border-radius: 2px; + outline: none; +} + +.control-group input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + background: #fff; + border-radius: 50%; + cursor: pointer; + transition: transform 0.1s; +} + +.control-group input[type="range"]::-webkit-slider-thumb:hover { + transform: scale(1.1); +} + +.control-group input[type="range"]::-moz-range-thumb { + width: 14px; + height: 14px; + background: #fff; + border-radius: 50%; + cursor: pointer; + border: none; +} + +.control-group input[type="color"] { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 32px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: transparent; + cursor: pointer; + padding: 2px; +} + +.control-group input[type="color"]::-webkit-color-swatch-wrapper { + padding: 0; +} + +.control-group input[type="color"]::-webkit-color-swatch { + border: none; + border-radius: 2px; +} + +.control-group select { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + padding: 6px 8px; + color: #fff; + font-family: inherit; + font-size: inherit; + cursor: pointer; + outline: none; +} + +.control-group select:hover { + border-color: rgba(255, 255, 255, 0.3); +} + +.control-group select option { + background: #1a1a1a; + color: #fff; +} + +.control-group input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: #fff; + cursor: pointer; +} + +.reset-button { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + padding: 8px 12px; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + font-family: inherit; + font-size: inherit; + margin-top: 4px; + transition: all 0.15s; +} + +.reset-button:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); + color: #fff; +} diff --git a/packages/console/app/src/component/light-rays.tsx b/packages/console/app/src/component/light-rays.tsx new file mode 100644 index 0000000000..d8081cee9b --- /dev/null +++ b/packages/console/app/src/component/light-rays.tsx @@ -0,0 +1,710 @@ +import { createSignal, createEffect, onMount, onCleanup, Show, For, Accessor, Setter } from "solid-js" +import { Renderer, Program, Triangle, Mesh } from "ogl" +import "./light-rays.css" + +export type RaysOrigin = + | "top-center" + | "top-left" + | "top-right" + | "right" + | "left" + | "bottom-center" + | "bottom-right" + | "bottom-left" + +export interface LightRaysConfig { + raysOrigin: RaysOrigin + raysColor: string + raysSpeed: number + lightSpread: number + rayLength: number + sourceWidth: number + pulsating: boolean + pulsatingMin: number + pulsatingMax: number + fadeDistance: number + saturation: number + followMouse: boolean + mouseInfluence: number + noiseAmount: number + distortion: number + opacity: number +} + +export const defaultConfig: LightRaysConfig = { + raysOrigin: "top-center", + raysColor: "#ffffff", + raysSpeed: 0.2, + lightSpread: 1.1, + rayLength: 2.25, + sourceWidth: 0.1, + pulsating: true, + pulsatingMin: 0.7, + pulsatingMax: 0.9, + fadeDistance: 1.5, + saturation: 0.25, + followMouse: false, + mouseInfluence: 0.05, + noiseAmount: 0.0, + distortion: 0.0, + opacity: 0.5, +} + +interface LightRaysProps { + config: Accessor + class?: string +} + +const hexToRgb = (hex: string): [number, number, number] => { + const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) + return m ? [parseInt(m[1], 16) / 255, parseInt(m[2], 16) / 255, parseInt(m[3], 16) / 255] : [1, 1, 1] +} + +const getAnchorAndDir = ( + origin: RaysOrigin, + w: number, + h: number, +): { anchor: [number, number]; dir: [number, number] } => { + const outside = 0.2 + switch (origin) { + case "top-left": + return { anchor: [0, -outside * h], dir: [0, 1] } + case "top-right": + return { anchor: [w, -outside * h], dir: [0, 1] } + case "left": + return { anchor: [-outside * w, 0.5 * h], dir: [1, 0] } + case "right": + return { anchor: [(1 + outside) * w, 0.5 * h], dir: [-1, 0] } + case "bottom-left": + return { anchor: [0, (1 + outside) * h], dir: [0, -1] } + case "bottom-center": + return { anchor: [0.5 * w, (1 + outside) * h], dir: [0, -1] } + case "bottom-right": + return { anchor: [w, (1 + outside) * h], dir: [0, -1] } + default: // "top-center" + return { anchor: [0.5 * w, -outside * h], dir: [0, 1] } + } +} + +type Vec2 = [number, number] +type Vec3 = [number, number, number] + +interface Uniforms { + iTime: { value: number } + iResolution: { value: Vec2 } + rayPos: { value: Vec2 } + rayDir: { value: Vec2 } + raysColor: { value: Vec3 } + raysSpeed: { value: number } + lightSpread: { value: number } + rayLength: { value: number } + sourceWidth: { value: number } + pulsating: { value: number } + pulsatingMin: { value: number } + pulsatingMax: { value: number } + fadeDistance: { value: number } + saturation: { value: number } + mousePos: { value: Vec2 } + mouseInfluence: { value: number } + noiseAmount: { value: number } + distortion: { value: number } +} + +export default function LightRays(props: LightRaysProps) { + let containerRef: HTMLDivElement | undefined + let uniformsRef: Uniforms | null = null + let rendererRef: Renderer | null = null + let meshRef: Mesh | null = null + let animationIdRef: number | null = null + let cleanupFunctionRef: (() => void) | null = null + + const mouseRef = { x: 0.5, y: 0.5 } + const smoothMouseRef = { x: 0.5, y: 0.5 } + + const [isVisible, setIsVisible] = createSignal(false) + + onMount(() => { + if (!containerRef) return + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0] + setIsVisible(entry.isIntersecting) + }, + { threshold: 0.1 }, + ) + + observer.observe(containerRef) + + onCleanup(() => { + observer.disconnect() + }) + }) + + createEffect(() => { + const visible = isVisible() + const config = props.config() + if (!visible || !containerRef) { + return + } + + if (cleanupFunctionRef) { + cleanupFunctionRef() + cleanupFunctionRef = null + } + + const initializeWebGL = async () => { + if (!containerRef) { + return + } + + await new Promise((resolve) => setTimeout(resolve, 10)) + + if (!containerRef) { + return + } + + const renderer = new Renderer({ + dpr: Math.min(window.devicePixelRatio, 2), + alpha: true, + }) + rendererRef = renderer + + const gl = renderer.gl + gl.canvas.style.width = "100%" + gl.canvas.style.height = "100%" + + while (containerRef.firstChild) { + containerRef.removeChild(containerRef.firstChild) + } + containerRef.appendChild(gl.canvas) + + const vert = ` +attribute vec2 position; +varying vec2 vUv; +void main() { + vUv = position * 0.5 + 0.5; + gl_Position = vec4(position, 0.0, 1.0); +}` + + const frag = `precision highp float; + +uniform float iTime; +uniform vec2 iResolution; + +uniform vec2 rayPos; +uniform vec2 rayDir; +uniform vec3 raysColor; +uniform float raysSpeed; +uniform float lightSpread; +uniform float rayLength; +uniform float sourceWidth; +uniform float pulsating; +uniform float pulsatingMin; +uniform float pulsatingMax; +uniform float fadeDistance; +uniform float saturation; +uniform vec2 mousePos; +uniform float mouseInfluence; +uniform float noiseAmount; +uniform float distortion; + +varying vec2 vUv; + +float noise(vec2 st) { + return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123); +} + +float rayStrength(vec2 raySource, vec2 rayRefDirection, vec2 coord, + float seedA, float seedB, float speed) { + vec2 sourceToCoord = coord - raySource; + vec2 dirNorm = normalize(sourceToCoord); + float cosAngle = dot(dirNorm, rayRefDirection); + + float distortedAngle = cosAngle + distortion * sin(iTime * 2.0 + length(sourceToCoord) * 0.01) * 0.2; + + float spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / max(lightSpread, 0.001)); + + float distance = length(sourceToCoord); + float maxDistance = iResolution.x * rayLength; + float lengthFalloff = clamp((maxDistance - distance) / maxDistance, 0.0, 1.0); + + float fadeFalloff = clamp((iResolution.x * fadeDistance - distance) / (iResolution.x * fadeDistance), 0.5, 1.0); + float pulseCenter = (pulsatingMin + pulsatingMax) * 0.5; + float pulseAmplitude = (pulsatingMax - pulsatingMin) * 0.5; + float pulse = pulsating > 0.5 ? (pulseCenter + pulseAmplitude * sin(iTime * speed * 3.0)) : 1.0; + + float baseStrength = clamp( + (0.45 + 0.15 * sin(distortedAngle * seedA + iTime * speed)) + + (0.3 + 0.2 * cos(-distortedAngle * seedB + iTime * speed)), + 0.0, 1.0 + ); + + return baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse; +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 coord = vec2(fragCoord.x, iResolution.y - fragCoord.y); + + // Calculate source position offset based on sourceWidth + // Negative offset makes rays spread wider (source moves opposite to pixel position) + float normalizedX = (coord.x / iResolution.x) - 0.5; // -0.5 to 0.5 + float widthOffset = -normalizedX * sourceWidth * iResolution.x; + + // Perpendicular to ray direction for width offset + vec2 perpDir = vec2(-rayDir.y, rayDir.x); + vec2 adjustedRayPos = rayPos + perpDir * widthOffset; + + vec2 finalRayDir = rayDir; + if (mouseInfluence > 0.0) { + vec2 mouseScreenPos = mousePos * iResolution.xy; + vec2 mouseDirection = normalize(mouseScreenPos - adjustedRayPos); + finalRayDir = normalize(mix(rayDir, mouseDirection, mouseInfluence)); + } + + vec4 rays1 = vec4(1.0) * + rayStrength(adjustedRayPos, finalRayDir, coord, 36.2214, 21.11349, + 1.5 * raysSpeed); + vec4 rays2 = vec4(1.0) * + rayStrength(adjustedRayPos, finalRayDir, coord, 22.3991, 18.0234, + 1.1 * raysSpeed); + + fragColor = rays1 * 0.5 + rays2 * 0.4; + + if (noiseAmount > 0.0) { + float n = noise(coord * 0.01 + iTime * 0.1); + fragColor.rgb *= (1.0 - noiseAmount + noiseAmount * n); + } + + float brightness = 1.0 - (coord.y / iResolution.y); + fragColor.x *= 0.1 + brightness * 0.8; + fragColor.y *= 0.3 + brightness * 0.6; + fragColor.z *= 0.5 + brightness * 0.5; + + if (saturation != 1.0) { + float gray = dot(fragColor.rgb, vec3(0.299, 0.587, 0.114)); + fragColor.rgb = mix(vec3(gray), fragColor.rgb, saturation); + } + + fragColor.rgb *= raysColor; +} + +void main() { + vec4 color; + mainImage(color, gl_FragCoord.xy); + gl_FragColor = color; +}` + + const uniforms: Uniforms = { + iTime: { value: 0 }, + iResolution: { value: [1, 1] }, + + rayPos: { value: [0, 0] }, + rayDir: { value: [0, 1] }, + + raysColor: { value: hexToRgb(config.raysColor) }, + raysSpeed: { value: config.raysSpeed }, + lightSpread: { value: config.lightSpread }, + rayLength: { value: config.rayLength }, + sourceWidth: { value: config.sourceWidth }, + pulsating: { value: config.pulsating ? 1.0 : 0.0 }, + pulsatingMin: { value: config.pulsatingMin }, + pulsatingMax: { value: config.pulsatingMax }, + fadeDistance: { value: config.fadeDistance }, + saturation: { value: config.saturation }, + mousePos: { value: [0.5, 0.5] }, + mouseInfluence: { value: config.mouseInfluence }, + noiseAmount: { value: config.noiseAmount }, + distortion: { value: config.distortion }, + } + uniformsRef = uniforms + + const geometry = new Triangle(gl) + const program = new Program(gl, { + vertex: vert, + fragment: frag, + uniforms, + }) + const mesh = new Mesh(gl, { geometry, program }) + meshRef = mesh + + const updatePlacement = () => { + if (!containerRef || !renderer) { + return + } + + renderer.dpr = Math.min(window.devicePixelRatio, 2) + + const { clientWidth: wCSS, clientHeight: hCSS } = containerRef + renderer.setSize(wCSS, hCSS) + + const dpr = renderer.dpr + const w = wCSS * dpr + const h = hCSS * dpr + + uniforms.iResolution.value = [w, h] + + const currentConfig = props.config() + const { anchor, dir } = getAnchorAndDir(currentConfig.raysOrigin, w, h) + uniforms.rayPos.value = anchor + uniforms.rayDir.value = dir + } + + const loop = (t: number) => { + if (!rendererRef || !uniformsRef || !meshRef) { + return + } + + const currentConfig = props.config() + uniforms.iTime.value = t * 0.001 + + if (currentConfig.followMouse && currentConfig.mouseInfluence > 0.0) { + const smoothing = 0.92 + + smoothMouseRef.x = smoothMouseRef.x * smoothing + mouseRef.x * (1 - smoothing) + smoothMouseRef.y = smoothMouseRef.y * smoothing + mouseRef.y * (1 - smoothing) + + uniforms.mousePos.value = [smoothMouseRef.x, smoothMouseRef.y] + } + + try { + renderer.render({ scene: mesh }) + animationIdRef = requestAnimationFrame(loop) + } catch (error) { + console.warn("WebGL rendering error:", error) + return + } + } + + window.addEventListener("resize", updatePlacement) + updatePlacement() + animationIdRef = requestAnimationFrame(loop) + + cleanupFunctionRef = () => { + if (animationIdRef) { + cancelAnimationFrame(animationIdRef) + animationIdRef = null + } + + window.removeEventListener("resize", updatePlacement) + + if (renderer) { + try { + const canvas = renderer.gl.canvas + const loseContextExt = renderer.gl.getExtension("WEBGL_lose_context") + if (loseContextExt) { + loseContextExt.loseContext() + } + + if (canvas && canvas.parentNode) { + canvas.parentNode.removeChild(canvas) + } + } catch (error) { + console.warn("Error during WebGL cleanup:", error) + } + } + + rendererRef = null + uniformsRef = null + meshRef = null + } + } + + initializeWebGL() + + onCleanup(() => { + if (cleanupFunctionRef) { + cleanupFunctionRef() + cleanupFunctionRef = null + } + }) + }) + + createEffect(() => { + if (!uniformsRef || !containerRef || !rendererRef) { + return + } + + const config = props.config() + const u = uniformsRef + const renderer = rendererRef + + u.raysColor.value = hexToRgb(config.raysColor) + u.raysSpeed.value = config.raysSpeed + u.lightSpread.value = config.lightSpread + u.rayLength.value = config.rayLength + u.sourceWidth.value = config.sourceWidth + u.pulsating.value = config.pulsating ? 1.0 : 0.0 + u.pulsatingMin.value = config.pulsatingMin + u.pulsatingMax.value = config.pulsatingMax + u.fadeDistance.value = config.fadeDistance + u.saturation.value = config.saturation + u.mouseInfluence.value = config.mouseInfluence + u.noiseAmount.value = config.noiseAmount + u.distortion.value = config.distortion + + const { clientWidth: wCSS, clientHeight: hCSS } = containerRef + const dpr = renderer.dpr + const { anchor, dir } = getAnchorAndDir(config.raysOrigin, wCSS * dpr, hCSS * dpr) + u.rayPos.value = anchor + u.rayDir.value = dir + }) + + createEffect(() => { + const config = props.config() + if (!config.followMouse) { + return + } + + const handleMouseMove = (e: MouseEvent) => { + if (!containerRef || !rendererRef) { + return + } + const rect = containerRef.getBoundingClientRect() + const x = (e.clientX - rect.left) / rect.width + const y = (e.clientY - rect.top) / rect.height + mouseRef.x = x + mouseRef.y = y + } + + window.addEventListener("mousemove", handleMouseMove) + + onCleanup(() => { + window.removeEventListener("mousemove", handleMouseMove) + }) + }) + + return ( +
+ ) +} + +interface LightRaysControlsProps { + config: Accessor + setConfig: Setter +} + +export function LightRaysControls(props: LightRaysControlsProps) { + const [isOpen, setIsOpen] = createSignal(true) + + const updateConfig = (key: K, value: LightRaysConfig[K]) => { + props.setConfig((prev) => ({ ...prev, [key]: value })) + } + + const origins: RaysOrigin[] = [ + "top-center", + "top-left", + "top-right", + "left", + "right", + "bottom-center", + "bottom-left", + "bottom-right", + ] + + return ( +
+ + +
+
+ + +
+ +
+ + updateConfig("raysColor", e.currentTarget.value)} + /> +
+ +
+ + updateConfig("raysSpeed", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("lightSpread", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("rayLength", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("sourceWidth", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("fadeDistance", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("saturation", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("mouseInfluence", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("noiseAmount", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("distortion", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("opacity", parseFloat(e.currentTarget.value))} + /> +
+ +
+ +
+ + +
+ + updateConfig("pulsatingMin", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("pulsatingMax", parseFloat(e.currentTarget.value))} + /> +
+
+ +
+ +
+ + +
+
+
+ ) +}