|
|
@@ -0,0 +1,820 @@
|
|
|
+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<SpotlightConfig>
|
|
|
+ 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<f32>,
|
|
|
+ lightPos: vec2<f32>,
|
|
|
+ lightDir: vec2<f32>,
|
|
|
+ color: vec3<f32>,
|
|
|
+ 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<uniform> uniforms: Uniforms;
|
|
|
+
|
|
|
+ struct VertexOutput {
|
|
|
+ @builtin(position) position: vec4<f32>,
|
|
|
+ @location(0) vUv: vec2<f32>,
|
|
|
+ };
|
|
|
+
|
|
|
+ @vertex
|
|
|
+ fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
|
|
|
+ var positions = array<vec2<f32>, 3>(
|
|
|
+ vec2<f32>(-1.0, -1.0),
|
|
|
+ vec2<f32>(3.0, -1.0),
|
|
|
+ vec2<f32>(-1.0, 3.0)
|
|
|
+ );
|
|
|
+
|
|
|
+ var output: VertexOutput;
|
|
|
+ let pos = positions[vertexIndex];
|
|
|
+ output.position = vec4<f32>(pos, 0.0, 1.0);
|
|
|
+ output.vUv = pos * 0.5 + 0.5;
|
|
|
+ return output;
|
|
|
+ }
|
|
|
+
|
|
|
+ fn hash(p: vec2<f32>) -> 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<f32>) -> vec2<f32> {
|
|
|
+ let n = sin(dot(p, vec2<f32>(41.0, 289.0)));
|
|
|
+ return fract(vec2<f32>(n * 262144.0, n * 32768.0));
|
|
|
+ }
|
|
|
+
|
|
|
+ fn fastNoise(st: vec2<f32>) -> f32 {
|
|
|
+ return fract(sin(dot(st, vec2<f32>(12.9898, 78.233))) * 43758.5453);
|
|
|
+ }
|
|
|
+
|
|
|
+ fn lightStrengthCombined(lightSource: vec2<f32>, lightRefDirection: vec2<f32>, coord: vec2<f32>) -> 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<f32>(-lightRefDirection.y, lightRefDirection.x));
|
|
|
+ let time = uniforms.iTime;
|
|
|
+ let speed = uniforms.speed;
|
|
|
+
|
|
|
+ let asymNoise = fastNoise(vec2<f32>(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<f32>(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<f32>, particlePos: vec2<f32>, 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<f32>, lightSource: vec2<f32>, lightDir: vec2<f32>) -> 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<f32>(-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<f32>(fi * 127.1, fi * 311.7);
|
|
|
+ let rnd = hash2(seed);
|
|
|
+
|
|
|
+ let lifeDuration = 2.0 + hash(seed + vec2<f32>(19.0, 73.0)) * 3.0;
|
|
|
+ let lifeOffset = hash(seed + vec2<f32>(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<f32>(
|
|
|
+ sin(time * floatSpeed + floatPhase),
|
|
|
+ cos(time * floatSpeed * 0.85 + floatPhase * 1.3)
|
|
|
+ ) * uniforms.particleDrift * baseSize * 0.08;
|
|
|
+
|
|
|
+ let wobble = vec2<f32>(
|
|
|
+ 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<f32>, @location(0) vUv: vec2<f32>) -> @location(0) vec4<f32> {
|
|
|
+ let coord = vec2<f32>(fragCoord.x, fragCoord.y);
|
|
|
+
|
|
|
+ let normalizedX = (coord.x / uniforms.iResolution.x) - 0.5;
|
|
|
+ let widthOffset = -normalizedX * uniforms.sourceWidth * uniforms.iResolution.x;
|
|
|
+
|
|
|
+ let perpDir = vec2<f32>(-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<f32>(0.0, 0.0, 0.0, 0.0);
|
|
|
+ }
|
|
|
+ let particleBrightness = particles * 1.8;
|
|
|
+ return vec4<f32>(uniforms.color * particleBrightness, particles * 0.9);
|
|
|
+ }
|
|
|
+
|
|
|
+ var fragColor = vec4<f32>(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<f32>(fragColor.rgb * grain, fragColor.a);
|
|
|
+ }
|
|
|
+
|
|
|
+ let brightness = 1.0 - (coord.y / uniforms.iResolution.y);
|
|
|
+ fragColor = vec4<f32>(
|
|
|
+ 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<f32>(0.299, 0.587, 0.114));
|
|
|
+ fragColor = vec4<f32>(mix(vec3<f32>(gray), fragColor.rgb, uniforms.saturation), fragColor.a);
|
|
|
+ }
|
|
|
+
|
|
|
+ fragColor = vec4<f32>(fragColor.rgb * uniforms.color, fragColor.a);
|
|
|
+
|
|
|
+ let particles = renderParticles(coord, adjustedLightPos, uniforms.lightDir);
|
|
|
+ if (particles > 0.001) {
|
|
|
+ let particleBrightness = particles * 1.8;
|
|
|
+ fragColor = vec4<f32>(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 (
|
|
|
+ <div
|
|
|
+ ref={containerRef}
|
|
|
+ class={`spotlight-container ${props.class ?? ""}`.trim()}
|
|
|
+ style={{ opacity: props.config().opacity }}
|
|
|
+ />
|
|
|
+ )
|
|
|
+}
|