import { createSignal, createEffect, onMount, onCleanup, Accessor } from "solid-js" import "./spotlight.css" export interface ParticlesConfig { enabled: boolean amount: number size: [number, number] speed: number opacity: number drift: number } export interface SpotlightConfig { placement: [number, number] color: string speed: number spread: number length: number width: number pulsating: false | [number, number] distance: number saturation: number noiseAmount: number distortion: number opacity: number particles: ParticlesConfig } export const defaultConfig: SpotlightConfig = { placement: [0.5, -0.15], color: "#ffffff", speed: 0.8, spread: 0.5, length: 4.0, width: 0.15, pulsating: [0.95, 1.1], distance: 3.5, saturation: 0.35, noiseAmount: 0.15, distortion: 0.05, opacity: 0.325, particles: { enabled: true, amount: 70, size: [1.25, 1.5], speed: 0.75, opacity: 0.9, drift: 1.5, }, } export interface SpotlightAnimationState { time: number intensity: number pulseValue: number } interface SpotlightProps { config: Accessor class?: string onAnimationFrame?: (state: SpotlightAnimationState) => 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 = ( placement: [number, number], w: number, h: number, ): { anchor: [number, number]; dir: [number, number] } => { const [px, py] = placement const outside = 0.2 let anchorX = px * w let anchorY = py * h let dirX = 0 let dirY = 0 const centerX = 0.5 const centerY = 0.5 if (py <= 0.25) { anchorY = -outside * h + py * h dirY = 1 dirX = (centerX - px) * 0.5 } else if (py >= 0.75) { anchorY = (1 + outside) * h - (1 - py) * h dirY = -1 dirX = (centerX - px) * 0.5 } else if (px <= 0.25) { anchorX = -outside * w + px * w dirX = 1 dirY = (centerY - py) * 0.5 } else if (px >= 0.75) { anchorX = (1 + outside) * w - (1 - px) * w dirX = -1 dirY = (centerY - py) * 0.5 } else { dirY = 1 } const len = Math.sqrt(dirX * dirX + dirY * dirY) if (len > 0) { dirX /= len dirY /= len } return { anchor: [anchorX, anchorY], dir: [dirX, dirY] } } interface UniformData { iTime: number iResolution: [number, number] lightPos: [number, number] lightDir: [number, number] color: [number, number, number] speed: number lightSpread: number lightLength: number sourceWidth: number pulsating: number pulsatingMin: number pulsatingMax: number fadeDistance: number saturation: number noiseAmount: number distortion: number particlesEnabled: number particleAmount: number particleSizeMin: number particleSizeMax: number particleSpeed: number particleOpacity: number particleDrift: number } const WGSL_SHADER = ` struct Uniforms { iTime: f32, _pad0: f32, iResolution: vec2, lightPos: vec2, lightDir: vec2, color: vec3, speed: f32, lightSpread: f32, lightLength: f32, sourceWidth: f32, pulsating: f32, pulsatingMin: f32, pulsatingMax: f32, fadeDistance: f32, saturation: f32, noiseAmount: f32, distortion: f32, particlesEnabled: f32, particleAmount: f32, particleSizeMin: f32, particleSizeMax: f32, particleSpeed: f32, particleOpacity: f32, particleDrift: f32, _pad1: f32, _pad2: 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 hash(p: vec2) -> f32 { let p3 = fract(p.xyx * 0.1031); return fract((p3.x + p3.y) * p3.z + dot(p3, p3.yzx + 33.33)); } fn hash2(p: vec2) -> vec2 { let n = sin(dot(p, vec2(41.0, 289.0))); return fract(vec2(n * 262144.0, n * 32768.0)); } fn fastNoise(st: vec2) -> f32 { return fract(sin(dot(st, vec2(12.9898, 78.233))) * 43758.5453); } fn lightStrengthCombined(lightSource: vec2, lightRefDirection: vec2, coord: vec2) -> f32 { let sourceToCoord = coord - lightSource; let distSq = dot(sourceToCoord, sourceToCoord); let distance = sqrt(distSq); let baseSize = min(uniforms.iResolution.x, uniforms.iResolution.y); let maxDistance = max(baseSize * uniforms.lightLength, 0.001); if (distance > maxDistance) { return 0.0; } let invDist = 1.0 / max(distance, 0.001); let dirNorm = sourceToCoord * invDist; let cosAngle = dot(dirNorm, lightRefDirection); if (cosAngle < 0.0) { return 0.0; } let side = dot(dirNorm, vec2(-lightRefDirection.y, lightRefDirection.x)); let time = uniforms.iTime; let speed = uniforms.speed; let asymNoise = fastNoise(vec2(side * 6.0 + time * 0.12, distance * 0.004 + cosAngle * 2.0)); let asymShift = (asymNoise - 0.5) * uniforms.distortion * 0.6; let distortPhase = time * 1.4 + distance * 0.006 + cosAngle * 4.5 + side * 1.7; let distortedAngle = cosAngle + uniforms.distortion * sin(distortPhase) * 0.22 + asymShift; let flickerSeed = cosAngle * 9.0 + side * 4.0 + time * speed * 0.35; let flicker = 0.86 + fastNoise(vec2(flickerSeed, distance * 0.01)) * 0.28; let asymSpread = max(uniforms.lightSpread * (0.9 + (asymNoise - 0.5) * 0.25), 0.001); let spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / asymSpread); let lengthFalloff = clamp(1.0 - distance / maxDistance, 0.0, 1.0); let fadeMaxDist = max(baseSize * uniforms.fadeDistance, 0.001); let fadeFalloff = clamp((fadeMaxDist - distance) / fadeMaxDist, 0.0, 1.0); var pulse: f32 = 1.0; if (uniforms.pulsating > 0.5) { let pulseCenter = (uniforms.pulsatingMin + uniforms.pulsatingMax) * 0.5; let pulseAmplitude = (uniforms.pulsatingMax - uniforms.pulsatingMin) * 0.5; pulse = pulseCenter + pulseAmplitude * sin(time * speed * 3.0); } let timeSpeed = time * speed; let wave = 0.5 + 0.25 * sin(cosAngle * 28.0 + side * 8.0 + timeSpeed * 1.2) + 0.18 * cos(cosAngle * 22.0 - timeSpeed * 0.95 + side * 6.0) + 0.12 * sin(cosAngle * 35.0 + timeSpeed * 1.6 + asymNoise * 3.0); let minStrength = 0.14 + asymNoise * 0.06; let baseStrength = max(clamp(wave * (0.85 + asymNoise * 0.3), 0.0, 1.0), minStrength); let lightStrength = baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse * flicker; let ambientLight = (0.06 + asymNoise * 0.04) * lengthFalloff * fadeFalloff * spreadFactor; return max(lightStrength, ambientLight); } fn particle(coord: vec2, particlePos: vec2, size: f32) -> f32 { let delta = coord - particlePos; let distSq = dot(delta, delta); let sizeSq = size * size; if (distSq > sizeSq * 9.0) { return 0.0; } let d = sqrt(distSq); let core = smoothstep(size, size * 0.35, d); let glow = smoothstep(size * 3.0, 0.0, d) * 0.55; return core + glow; } fn renderParticles(coord: vec2, lightSource: vec2, lightDir: vec2) -> f32 { if (uniforms.particlesEnabled < 0.5 || uniforms.particleAmount < 1.0) { return 0.0; } var particleSum: f32 = 0.0; let particleCount = i32(uniforms.particleAmount); let time = uniforms.iTime * uniforms.particleSpeed; let perpDir = vec2(-lightDir.y, lightDir.x); let baseSize = min(uniforms.iResolution.x, uniforms.iResolution.y); let maxDist = max(baseSize * uniforms.lightLength, 1.0); let spreadScale = uniforms.lightSpread * baseSize * 0.65; let coneHalfWidth = uniforms.lightSpread * baseSize * 0.55; for (var i: i32 = 0; i < particleCount; i = i + 1) { let fi = f32(i); let seed = vec2(fi * 127.1, fi * 311.7); let rnd = hash2(seed); let lifeDuration = 2.0 + hash(seed + vec2(19.0, 73.0)) * 3.0; let lifeOffset = hash(seed + vec2(91.0, 37.0)) * lifeDuration; let lifeProgress = fract((time + lifeOffset) / lifeDuration); let fadeIn = smoothstep(0.0, 0.2, lifeProgress); let fadeOut = 1.0 - smoothstep(0.8, 1.0, lifeProgress); let lifeFade = fadeIn * fadeOut; if (lifeFade < 0.01) { continue; } let alongLight = rnd.x * maxDist * 0.8; let perpOffset = (rnd.y - 0.5) * spreadScale; let floatPhase = rnd.y * 6.28318 + fi * 0.37; let floatSpeed = 0.35 + rnd.x * 0.9; let drift = vec2( sin(time * floatSpeed + floatPhase), cos(time * floatSpeed * 0.85 + floatPhase * 1.3) ) * uniforms.particleDrift * baseSize * 0.08; let wobble = vec2( sin(time * 1.4 + floatPhase * 2.1), cos(time * 1.1 + floatPhase * 1.6) ) * uniforms.particleDrift * baseSize * 0.03; let flowOffset = (rnd.x - 0.5) * baseSize * 0.12 + fract(time * 0.06 + rnd.y) * baseSize * 0.1; let basePos = lightSource + lightDir * (alongLight + flowOffset) + perpDir * perpOffset + drift + wobble; let toParticle = basePos - lightSource; let projLen = dot(toParticle, lightDir); if (projLen < 0.0 || projLen > maxDist) { continue; } let sideDist = abs(dot(toParticle, perpDir)); if (sideDist > coneHalfWidth) { continue; } let size = mix(uniforms.particleSizeMin, uniforms.particleSizeMax, rnd.x); let twinkle = 0.7 + 0.3 * sin(time * (1.5 + rnd.y * 2.0) + floatPhase); let distFade = 1.0 - smoothstep(maxDist * 0.2, maxDist * 0.95, projLen); if (distFade < 0.01) { continue; } let p = particle(coord, basePos, size); if (p > 0.0) { particleSum = particleSum + p * lifeFade * twinkle * distFade * uniforms.particleOpacity; if (particleSum >= 1.0) { break; } } } return min(particleSum, 1.0); } @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.lightDir.y, uniforms.lightDir.x); let adjustedLightPos = uniforms.lightPos + perpDir * widthOffset; let lightValue = lightStrengthCombined(adjustedLightPos, uniforms.lightDir, coord); if (lightValue < 0.001) { let particles = renderParticles(coord, adjustedLightPos, uniforms.lightDir); if (particles < 0.001) { return vec4(0.0, 0.0, 0.0, 0.0); } let particleBrightness = particles * 1.8; return vec4(uniforms.color * particleBrightness, particles * 0.9); } var fragColor = vec4(lightValue, lightValue, lightValue, lightValue); if (uniforms.noiseAmount > 0.01) { let n = fastNoise(coord * 0.5 + uniforms.iTime * 0.5); let grain = mix(1.0, n, uniforms.noiseAmount * 0.5); fragColor = vec4(fragColor.rgb * grain, fragColor.a); } let brightness = 1.0 - (coord.y / uniforms.iResolution.y); fragColor = vec4( fragColor.x * (0.15 + brightness * 0.85), fragColor.y * (0.35 + brightness * 0.65), fragColor.z * (0.55 + brightness * 0.45), fragColor.a ); if (abs(uniforms.saturation - 1.0) > 0.01) { 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.color, fragColor.a); let particles = renderParticles(coord, adjustedLightPos, uniforms.lightDir); if (particles > 0.001) { let particleBrightness = particles * 1.8; fragColor = vec4(fragColor.rgb + uniforms.color * particleBrightness, max(fragColor.a, particles * 0.9)); } return fragColor; } ` const UNIFORM_BUFFER_SIZE = 144 function updateUniformBuffer(buffer: Float32Array, data: UniformData): void { buffer[0] = data.iTime buffer[2] = data.iResolution[0] buffer[3] = data.iResolution[1] buffer[4] = data.lightPos[0] buffer[5] = data.lightPos[1] buffer[6] = data.lightDir[0] buffer[7] = data.lightDir[1] buffer[8] = data.color[0] buffer[9] = data.color[1] buffer[10] = data.color[2] buffer[11] = data.speed buffer[12] = data.lightSpread buffer[13] = data.lightLength 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.noiseAmount buffer[21] = data.distortion buffer[22] = data.particlesEnabled buffer[23] = data.particleAmount buffer[24] = data.particleSizeMin buffer[25] = data.particleSizeMax buffer[26] = data.particleSpeed buffer[27] = data.particleOpacity buffer[28] = data.particleDrift } export default function Spotlight(props: SpotlightProps) { 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 let uniformArrayRef: Float32Array | null = null let configRef: SpotlightConfig = props.config() let frameCount = 0 const [isVisible, setIsVisible] = createSignal(false) createEffect(() => { configRef = props.config() }) 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({ powerPreference: "high-performance", }) 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, 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.placement, w, h) uniformDataRef = { iTime: 0, iResolution: [w, h], lightPos: anchor, lightDir: dir, color: hexToRgb(config.color), speed: config.speed, lightSpread: config.spread, lightLength: config.length, sourceWidth: config.width, pulsating: config.pulsating !== false ? 1.0 : 0.0, pulsatingMin: config.pulsating !== false ? config.pulsating[0] : 1.0, pulsatingMax: config.pulsating !== false ? config.pulsating[1] : 1.0, fadeDistance: config.distance, saturation: config.saturation, noiseAmount: config.noiseAmount, distortion: config.distortion, particlesEnabled: config.particles.enabled ? 1.0 : 0.0, particleAmount: config.particles.amount, particleSizeMin: config.particles.size[0], particleSizeMax: config.particles.size[1], particleSpeed: config.particles.speed, particleOpacity: config.particles.opacity, particleDrift: config.particles.drift, } 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 { anchor, dir } = getAnchorAndDir(configRef.placement, w, h) uniformDataRef.lightPos = anchor uniformDataRef.lightDir = dir } const loop = (t: number) => { if (!deviceRef || !contextRef || !pipelineRef || !uniformBufferRef || !bindGroupRef || !uniformDataRef) { return } const timeSeconds = t * 0.001 uniformDataRef.iTime = timeSeconds frameCount++ if (props.onAnimationFrame && frameCount % 2 === 0) { const pulsatingMin = configRef.pulsating !== false ? configRef.pulsating[0] : 1.0 const pulsatingMax = configRef.pulsating !== false ? configRef.pulsating[1] : 1.0 const pulseCenter = (pulsatingMin + pulsatingMax) * 0.5 const pulseAmplitude = (pulsatingMax - pulsatingMin) * 0.5 const pulseValue = configRef.pulsating !== false ? pulseCenter + pulseAmplitude * Math.sin(timeSeconds * configRef.speed * 3.0) : 1.0 const baseIntensity1 = 0.45 + 0.15 * Math.sin(timeSeconds * configRef.speed * 1.5) const baseIntensity2 = 0.3 + 0.2 * Math.cos(timeSeconds * configRef.speed * 1.1) const intensity = Math.max((baseIntensity1 + baseIntensity2) * pulseValue, 0.55) props.onAnimationFrame({ time: timeSeconds, intensity, pulseValue: Math.max(pulseValue, 0.9), }) } try { if (!uniformArrayRef) { uniformArrayRef = new Float32Array(36) } updateUniformBuffer(uniformArrayRef, uniformDataRef) deviceRef.queue.writeBuffer(uniformBufferRef, 0, uniformArrayRef.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.color = hexToRgb(config.color) uniformDataRef.speed = config.speed uniformDataRef.lightSpread = config.spread uniformDataRef.lightLength = config.length uniformDataRef.sourceWidth = config.width uniformDataRef.pulsating = config.pulsating !== false ? 1.0 : 0.0 uniformDataRef.pulsatingMin = config.pulsating !== false ? config.pulsating[0] : 1.0 uniformDataRef.pulsatingMax = config.pulsating !== false ? config.pulsating[1] : 1.0 uniformDataRef.fadeDistance = config.distance uniformDataRef.saturation = config.saturation uniformDataRef.noiseAmount = config.noiseAmount uniformDataRef.distortion = config.distortion uniformDataRef.particlesEnabled = config.particles.enabled ? 1.0 : 0.0 uniformDataRef.particleAmount = config.particles.amount uniformDataRef.particleSizeMin = config.particles.size[0] uniformDataRef.particleSizeMax = config.particles.size[1] uniformDataRef.particleSpeed = config.particles.speed uniformDataRef.particleOpacity = config.particles.opacity uniformDataRef.particleDrift = config.particles.drift const dpr = Math.min(window.devicePixelRatio, 2) const { clientWidth: wCSS, clientHeight: hCSS } = containerRef const { anchor, dir } = getAnchorAndDir(config.placement, wCSS * dpr, hCSS * dpr) uniformDataRef.lightPos = anchor uniformDataRef.lightDir = dir }) return (
) }