import { createSignal, createEffect, onMount, onCleanup, Show, For, Accessor, Setter } from "solid-js" 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: 1.0, lightSpread: 1.2, rayLength: 4.5, sourceWidth: 0.1, pulsating: true, pulsatingMin: 0.9, pulsatingMax: 1.05, fadeDistance: 1.25, saturation: 0.35, followMouse: false, mouseInfluence: 0.05, noiseAmount: 0.5, distortion: 0.0, opacity: 0.35, } export interface LightRaysAnimationState { time: number intensity: number pulseValue: number } interface LightRaysProps { config: Accessor class?: string onAnimationFrame?: (state: LightRaysAnimationState) => void } 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] } } } interface UniformData { iTime: number iResolution: [number, number] rayPos: [number, number] rayDir: [number, number] raysColor: [number, number, number] raysSpeed: number lightSpread: number rayLength: number sourceWidth: number pulsating: number pulsatingMin: number pulsatingMax: number fadeDistance: number saturation: number mousePos: [number, number] mouseInfluence: number noiseAmount: number distortion: number } const WGSL_SHADER = ` struct Uniforms { iTime: f32, _pad0: f32, iResolution: vec2, rayPos: vec2, rayDir: vec2, raysColor: vec3, raysSpeed: f32, lightSpread: f32, rayLength: f32, sourceWidth: f32, pulsating: f32, pulsatingMin: f32, pulsatingMax: f32, fadeDistance: f32, saturation: f32, mousePos: vec2, mouseInfluence: f32, noiseAmount: f32, distortion: f32, _pad1: f32, _pad2: f32, _pad3: f32, }; @group(0) @binding(0) var uniforms: Uniforms; struct VertexOutput { @builtin(position) position: vec4, @location(0) vUv: vec2, }; @vertex fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { var positions = array, 3>( vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0) ); var output: VertexOutput; let pos = positions[vertexIndex]; output.position = vec4(pos, 0.0, 1.0); output.vUv = pos * 0.5 + 0.5; return output; } fn noise(st: vec2) -> f32 { return fract(sin(dot(st, vec2(12.9898, 78.233))) * 43758.5453123); } fn rayStrength(raySource: vec2, rayRefDirection: vec2, coord: vec2, seedA: f32, seedB: f32, speed: f32) -> f32 { let sourceToCoord = coord - raySource; let dirNorm = normalize(sourceToCoord); let cosAngle = dot(dirNorm, rayRefDirection); let distortedAngle = cosAngle + uniforms.distortion * sin(uniforms.iTime * 2.0 + length(sourceToCoord) * 0.01) * 0.2; let spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / max(uniforms.lightSpread, 0.001)); let distance = length(sourceToCoord); let maxDistance = uniforms.iResolution.x * uniforms.rayLength; let lengthFalloff = clamp((maxDistance - distance) / maxDistance, 0.0, 1.0); let fadeFalloff = clamp((uniforms.iResolution.x * uniforms.fadeDistance - distance) / (uniforms.iResolution.x * uniforms.fadeDistance), 0.5, 1.0); let pulseCenter = (uniforms.pulsatingMin + uniforms.pulsatingMax) * 0.5; let pulseAmplitude = (uniforms.pulsatingMax - uniforms.pulsatingMin) * 0.5; var pulse: f32; if (uniforms.pulsating > 0.5) { pulse = pulseCenter + pulseAmplitude * sin(uniforms.iTime * speed * 3.0); } else { pulse = 1.0; } let baseStrength = clamp( (0.45 + 0.15 * sin(distortedAngle * seedA + uniforms.iTime * speed)) + (0.3 + 0.2 * cos(-distortedAngle * seedB + uniforms.iTime * speed)), 0.0, 1.0 ); return baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse; } @fragment fn fragmentMain(@builtin(position) fragCoord: vec4, @location(0) vUv: vec2) -> @location(0) vec4 { let coord = vec2(fragCoord.x, fragCoord.y); let normalizedX = (coord.x / uniforms.iResolution.x) - 0.5; let widthOffset = -normalizedX * uniforms.sourceWidth * uniforms.iResolution.x; let perpDir = vec2(-uniforms.rayDir.y, uniforms.rayDir.x); let adjustedRayPos = uniforms.rayPos + perpDir * widthOffset; var finalRayDir = uniforms.rayDir; if (uniforms.mouseInfluence > 0.0) { let mouseScreenPos = uniforms.mousePos * uniforms.iResolution; let mouseDirection = normalize(mouseScreenPos - adjustedRayPos); finalRayDir = normalize(mix(uniforms.rayDir, mouseDirection, uniforms.mouseInfluence)); } let rays1 = vec4(1.0) * rayStrength(adjustedRayPos, finalRayDir, coord, 36.2214, 21.11349, 1.5 * uniforms.raysSpeed); let rays2 = vec4(1.0) * rayStrength(adjustedRayPos, finalRayDir, coord, 22.3991, 18.0234, 1.1 * uniforms.raysSpeed); var fragColor = rays1 * 0.5 + rays2 * 0.4; if (uniforms.noiseAmount > 0.0) { let n = noise(coord * 0.01 + uniforms.iTime * 0.1); fragColor = vec4(fragColor.rgb * (1.0 - uniforms.noiseAmount + uniforms.noiseAmount * n), fragColor.a); } let brightness = 1.0 - (coord.y / uniforms.iResolution.y); fragColor.x = fragColor.x * (0.1 + brightness * 0.8); fragColor.y = fragColor.y * (0.3 + brightness * 0.6); fragColor.z = fragColor.z * (0.5 + brightness * 0.5); if (uniforms.saturation != 1.0) { let gray = dot(fragColor.rgb, vec3(0.299, 0.587, 0.114)); fragColor = vec4(mix(vec3(gray), fragColor.rgb, uniforms.saturation), fragColor.a); } fragColor = vec4(fragColor.rgb * uniforms.raysColor, fragColor.a); return fragColor; } ` const UNIFORM_BUFFER_SIZE = 96 function createUniformBuffer(data: UniformData): Float32Array { const buffer = new Float32Array(24) buffer[0] = data.iTime buffer[1] = 0 buffer[2] = data.iResolution[0] buffer[3] = data.iResolution[1] buffer[4] = data.rayPos[0] buffer[5] = data.rayPos[1] buffer[6] = data.rayDir[0] buffer[7] = data.rayDir[1] buffer[8] = data.raysColor[0] buffer[9] = data.raysColor[1] buffer[10] = data.raysColor[2] buffer[11] = data.raysSpeed buffer[12] = data.lightSpread buffer[13] = data.rayLength buffer[14] = data.sourceWidth buffer[15] = data.pulsating buffer[16] = data.pulsatingMin buffer[17] = data.pulsatingMax buffer[18] = data.fadeDistance buffer[19] = data.saturation buffer[20] = data.mousePos[0] buffer[21] = data.mousePos[1] buffer[22] = data.mouseInfluence buffer[23] = data.noiseAmount return buffer } const UNIFORM_BUFFER_SIZE_CORRECTED = 112 function createUniformBufferCorrected(data: UniformData): Float32Array { const buffer = new Float32Array(28) buffer[0] = data.iTime buffer[1] = 0 buffer[2] = data.iResolution[0] buffer[3] = data.iResolution[1] buffer[4] = data.rayPos[0] buffer[5] = data.rayPos[1] buffer[6] = data.rayDir[0] buffer[7] = data.rayDir[1] buffer[8] = data.raysColor[0] buffer[9] = data.raysColor[1] buffer[10] = data.raysColor[2] buffer[11] = data.raysSpeed buffer[12] = data.lightSpread buffer[13] = data.rayLength buffer[14] = data.sourceWidth buffer[15] = data.pulsating buffer[16] = data.pulsatingMin buffer[17] = data.pulsatingMax buffer[18] = data.fadeDistance buffer[19] = data.saturation buffer[20] = data.mousePos[0] buffer[21] = data.mousePos[1] buffer[22] = data.mouseInfluence buffer[23] = data.noiseAmount buffer[24] = data.distortion buffer[25] = 0 buffer[26] = 0 buffer[27] = 0 return buffer } export default function LightRays(props: LightRaysProps) { let containerRef: HTMLDivElement | undefined let canvasRef: HTMLCanvasElement | null = null let deviceRef: GPUDevice | null = null let contextRef: GPUCanvasContext | null = null let pipelineRef: GPURenderPipeline | null = null let uniformBufferRef: GPUBuffer | null = null let bindGroupRef: GPUBindGroup | null = null let animationIdRef: number | null = null let cleanupFunctionRef: (() => void) | null = null let uniformDataRef: UniformData | 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 initializeWebGPU = async () => { if (!containerRef) { return } await new Promise((resolve) => setTimeout(resolve, 10)) if (!containerRef) { return } if (!navigator.gpu) { console.warn("WebGPU is not supported in this browser") return } const adapter = await navigator.gpu.requestAdapter() if (!adapter) { console.warn("Failed to get WebGPU adapter") return } const device = await adapter.requestDevice() deviceRef = device const canvas = document.createElement("canvas") canvas.style.width = "100%" canvas.style.height = "100%" canvasRef = canvas while (containerRef.firstChild) { containerRef.removeChild(containerRef.firstChild) } containerRef.appendChild(canvas) const context = canvas.getContext("webgpu") if (!context) { console.warn("Failed to get WebGPU context") return } contextRef = context const presentationFormat = navigator.gpu.getPreferredCanvasFormat() context.configure({ device, format: presentationFormat, alphaMode: "premultiplied", }) const shaderModule = device.createShaderModule({ code: WGSL_SHADER, }) const uniformBuffer = device.createBuffer({ size: UNIFORM_BUFFER_SIZE_CORRECTED, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }) uniformBufferRef = uniformBuffer const bindGroupLayout = device.createBindGroupLayout({ entries: [ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" }, }, ], }) const bindGroup = device.createBindGroup({ layout: bindGroupLayout, entries: [ { binding: 0, resource: { buffer: uniformBuffer }, }, ], }) bindGroupRef = bindGroup const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout], }) const pipeline = device.createRenderPipeline({ layout: pipelineLayout, vertex: { module: shaderModule, entryPoint: "vertexMain", }, fragment: { module: shaderModule, entryPoint: "fragmentMain", targets: [ { format: presentationFormat, blend: { color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add", }, alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add", }, }, }, ], }, primitive: { topology: "triangle-list", }, }) pipelineRef = pipeline const { clientWidth: wCSS, clientHeight: hCSS } = containerRef const dpr = Math.min(window.devicePixelRatio, 2) const w = wCSS * dpr const h = hCSS * dpr const { anchor, dir } = getAnchorAndDir(config.raysOrigin, w, h) uniformDataRef = { iTime: 0, iResolution: [w, h], rayPos: anchor, rayDir: dir, raysColor: hexToRgb(config.raysColor), raysSpeed: config.raysSpeed, lightSpread: config.lightSpread, rayLength: config.rayLength, sourceWidth: config.sourceWidth, pulsating: config.pulsating ? 1.0 : 0.0, pulsatingMin: config.pulsatingMin, pulsatingMax: config.pulsatingMax, fadeDistance: config.fadeDistance, saturation: config.saturation, mousePos: [0.5, 0.5], mouseInfluence: config.mouseInfluence, noiseAmount: config.noiseAmount, distortion: config.distortion, } const updatePlacement = () => { if (!containerRef || !canvasRef || !uniformDataRef) { return } const dpr = Math.min(window.devicePixelRatio, 2) const { clientWidth: wCSS, clientHeight: hCSS } = containerRef const w = Math.floor(wCSS * dpr) const h = Math.floor(hCSS * dpr) canvasRef.width = w canvasRef.height = h uniformDataRef.iResolution = [w, h] const currentConfig = props.config() const { anchor, dir } = getAnchorAndDir(currentConfig.raysOrigin, w, h) uniformDataRef.rayPos = anchor uniformDataRef.rayDir = dir } const loop = (t: number) => { if (!deviceRef || !contextRef || !pipelineRef || !uniformBufferRef || !bindGroupRef || !uniformDataRef) { return } const currentConfig = props.config() const timeSeconds = t * 0.001 uniformDataRef.iTime = timeSeconds 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) uniformDataRef.mousePos = [smoothMouseRef.x, smoothMouseRef.y] } if (props.onAnimationFrame) { const pulseCenter = (currentConfig.pulsatingMin + currentConfig.pulsatingMax) * 0.5 const pulseAmplitude = (currentConfig.pulsatingMax - currentConfig.pulsatingMin) * 0.5 const pulseValue = currentConfig.pulsating ? pulseCenter + pulseAmplitude * Math.sin(timeSeconds * currentConfig.raysSpeed * 3.0) : 1.0 const baseIntensity1 = 0.45 + 0.15 * Math.sin(timeSeconds * currentConfig.raysSpeed * 1.5) const baseIntensity2 = 0.3 + 0.2 * Math.cos(timeSeconds * currentConfig.raysSpeed * 1.1) const intensity = (baseIntensity1 + baseIntensity2) * pulseValue props.onAnimationFrame({ time: timeSeconds, intensity, pulseValue, }) } try { const uniformData = createUniformBufferCorrected(uniformDataRef) deviceRef.queue.writeBuffer(uniformBufferRef, 0, uniformData.buffer) const commandEncoder = deviceRef.createCommandEncoder() const textureView = contextRef.getCurrentTexture().createView() const renderPass = commandEncoder.beginRenderPass({ colorAttachments: [ { view: textureView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store", }, ], }) renderPass.setPipeline(pipelineRef) renderPass.setBindGroup(0, bindGroupRef) renderPass.draw(3) renderPass.end() deviceRef.queue.submit([commandEncoder.finish()]) animationIdRef = requestAnimationFrame(loop) } catch (error) { console.warn("WebGPU rendering error:", error) return } } window.addEventListener("resize", updatePlacement) updatePlacement() animationIdRef = requestAnimationFrame(loop) cleanupFunctionRef = () => { if (animationIdRef) { cancelAnimationFrame(animationIdRef) animationIdRef = null } window.removeEventListener("resize", updatePlacement) if (uniformBufferRef) { uniformBufferRef.destroy() uniformBufferRef = null } if (deviceRef) { deviceRef.destroy() deviceRef = null } if (canvasRef && canvasRef.parentNode) { canvasRef.parentNode.removeChild(canvasRef) } canvasRef = null contextRef = null pipelineRef = null bindGroupRef = null uniformDataRef = null } } initializeWebGPU() onCleanup(() => { if (cleanupFunctionRef) { cleanupFunctionRef() cleanupFunctionRef = null } }) }) createEffect(() => { if (!uniformDataRef || !containerRef) { return } const config = props.config() uniformDataRef.raysColor = hexToRgb(config.raysColor) uniformDataRef.raysSpeed = config.raysSpeed uniformDataRef.lightSpread = config.lightSpread uniformDataRef.rayLength = config.rayLength uniformDataRef.sourceWidth = config.sourceWidth uniformDataRef.pulsating = config.pulsating ? 1.0 : 0.0 uniformDataRef.pulsatingMin = config.pulsatingMin uniformDataRef.pulsatingMax = config.pulsatingMax uniformDataRef.fadeDistance = config.fadeDistance uniformDataRef.saturation = config.saturation uniformDataRef.mouseInfluence = config.mouseInfluence uniformDataRef.noiseAmount = config.noiseAmount uniformDataRef.distortion = config.distortion const dpr = Math.min(window.devicePixelRatio, 2) const { clientWidth: wCSS, clientHeight: hCSS } = containerRef const { anchor, dir } = getAnchorAndDir(config.raysOrigin, wCSS * dpr, hCSS * dpr) uniformDataRef.rayPos = anchor uniformDataRef.rayDir = dir }) createEffect(() => { const config = props.config() if (!config.followMouse) { return } const handleMouseMove = (e: MouseEvent) => { if (!containerRef) { 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))} />
) }