821 lines
24 KiB
TypeScript
821 lines
24 KiB
TypeScript
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 }}
|
|
/>
|
|
)
|
|
}
|