spotlight.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820
  1. import { createSignal, createEffect, onMount, onCleanup, Accessor } from "solid-js"
  2. import "./spotlight.css"
  3. export interface ParticlesConfig {
  4. enabled: boolean
  5. amount: number
  6. size: [number, number]
  7. speed: number
  8. opacity: number
  9. drift: number
  10. }
  11. export interface SpotlightConfig {
  12. placement: [number, number]
  13. color: string
  14. speed: number
  15. spread: number
  16. length: number
  17. width: number
  18. pulsating: false | [number, number]
  19. distance: number
  20. saturation: number
  21. noiseAmount: number
  22. distortion: number
  23. opacity: number
  24. particles: ParticlesConfig
  25. }
  26. export const defaultConfig: SpotlightConfig = {
  27. placement: [0.5, -0.15],
  28. color: "#ffffff",
  29. speed: 0.8,
  30. spread: 0.5,
  31. length: 4.0,
  32. width: 0.15,
  33. pulsating: [0.95, 1.1],
  34. distance: 3.5,
  35. saturation: 0.35,
  36. noiseAmount: 0.15,
  37. distortion: 0.05,
  38. opacity: 0.325,
  39. particles: {
  40. enabled: true,
  41. amount: 70,
  42. size: [1.25, 1.5],
  43. speed: 0.75,
  44. opacity: 0.9,
  45. drift: 1.5,
  46. },
  47. }
  48. export interface SpotlightAnimationState {
  49. time: number
  50. intensity: number
  51. pulseValue: number
  52. }
  53. interface SpotlightProps {
  54. config: Accessor<SpotlightConfig>
  55. class?: string
  56. onAnimationFrame?: (state: SpotlightAnimationState) => void
  57. }
  58. const hexToRgb = (hex: string): [number, number, number] => {
  59. const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
  60. return m ? [parseInt(m[1], 16) / 255, parseInt(m[2], 16) / 255, parseInt(m[3], 16) / 255] : [1, 1, 1]
  61. }
  62. const getAnchorAndDir = (
  63. placement: [number, number],
  64. w: number,
  65. h: number,
  66. ): { anchor: [number, number]; dir: [number, number] } => {
  67. const [px, py] = placement
  68. const outside = 0.2
  69. let anchorX = px * w
  70. let anchorY = py * h
  71. let dirX = 0
  72. let dirY = 0
  73. const centerX = 0.5
  74. const centerY = 0.5
  75. if (py <= 0.25) {
  76. anchorY = -outside * h + py * h
  77. dirY = 1
  78. dirX = (centerX - px) * 0.5
  79. } else if (py >= 0.75) {
  80. anchorY = (1 + outside) * h - (1 - py) * h
  81. dirY = -1
  82. dirX = (centerX - px) * 0.5
  83. } else if (px <= 0.25) {
  84. anchorX = -outside * w + px * w
  85. dirX = 1
  86. dirY = (centerY - py) * 0.5
  87. } else if (px >= 0.75) {
  88. anchorX = (1 + outside) * w - (1 - px) * w
  89. dirX = -1
  90. dirY = (centerY - py) * 0.5
  91. } else {
  92. dirY = 1
  93. }
  94. const len = Math.sqrt(dirX * dirX + dirY * dirY)
  95. if (len > 0) {
  96. dirX /= len
  97. dirY /= len
  98. }
  99. return { anchor: [anchorX, anchorY], dir: [dirX, dirY] }
  100. }
  101. interface UniformData {
  102. iTime: number
  103. iResolution: [number, number]
  104. lightPos: [number, number]
  105. lightDir: [number, number]
  106. color: [number, number, number]
  107. speed: number
  108. lightSpread: number
  109. lightLength: number
  110. sourceWidth: number
  111. pulsating: number
  112. pulsatingMin: number
  113. pulsatingMax: number
  114. fadeDistance: number
  115. saturation: number
  116. noiseAmount: number
  117. distortion: number
  118. particlesEnabled: number
  119. particleAmount: number
  120. particleSizeMin: number
  121. particleSizeMax: number
  122. particleSpeed: number
  123. particleOpacity: number
  124. particleDrift: number
  125. }
  126. const WGSL_SHADER = `
  127. struct Uniforms {
  128. iTime: f32,
  129. _pad0: f32,
  130. iResolution: vec2<f32>,
  131. lightPos: vec2<f32>,
  132. lightDir: vec2<f32>,
  133. color: vec3<f32>,
  134. speed: f32,
  135. lightSpread: f32,
  136. lightLength: f32,
  137. sourceWidth: f32,
  138. pulsating: f32,
  139. pulsatingMin: f32,
  140. pulsatingMax: f32,
  141. fadeDistance: f32,
  142. saturation: f32,
  143. noiseAmount: f32,
  144. distortion: f32,
  145. particlesEnabled: f32,
  146. particleAmount: f32,
  147. particleSizeMin: f32,
  148. particleSizeMax: f32,
  149. particleSpeed: f32,
  150. particleOpacity: f32,
  151. particleDrift: f32,
  152. _pad1: f32,
  153. _pad2: f32,
  154. };
  155. @group(0) @binding(0) var<uniform> uniforms: Uniforms;
  156. struct VertexOutput {
  157. @builtin(position) position: vec4<f32>,
  158. @location(0) vUv: vec2<f32>,
  159. };
  160. @vertex
  161. fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
  162. var positions = array<vec2<f32>, 3>(
  163. vec2<f32>(-1.0, -1.0),
  164. vec2<f32>(3.0, -1.0),
  165. vec2<f32>(-1.0, 3.0)
  166. );
  167. var output: VertexOutput;
  168. let pos = positions[vertexIndex];
  169. output.position = vec4<f32>(pos, 0.0, 1.0);
  170. output.vUv = pos * 0.5 + 0.5;
  171. return output;
  172. }
  173. fn hash(p: vec2<f32>) -> f32 {
  174. let p3 = fract(p.xyx * 0.1031);
  175. return fract((p3.x + p3.y) * p3.z + dot(p3, p3.yzx + 33.33));
  176. }
  177. fn hash2(p: vec2<f32>) -> vec2<f32> {
  178. let n = sin(dot(p, vec2<f32>(41.0, 289.0)));
  179. return fract(vec2<f32>(n * 262144.0, n * 32768.0));
  180. }
  181. fn fastNoise(st: vec2<f32>) -> f32 {
  182. return fract(sin(dot(st, vec2<f32>(12.9898, 78.233))) * 43758.5453);
  183. }
  184. fn lightStrengthCombined(lightSource: vec2<f32>, lightRefDirection: vec2<f32>, coord: vec2<f32>) -> f32 {
  185. let sourceToCoord = coord - lightSource;
  186. let distSq = dot(sourceToCoord, sourceToCoord);
  187. let distance = sqrt(distSq);
  188. let baseSize = min(uniforms.iResolution.x, uniforms.iResolution.y);
  189. let maxDistance = max(baseSize * uniforms.lightLength, 0.001);
  190. if (distance > maxDistance) {
  191. return 0.0;
  192. }
  193. let invDist = 1.0 / max(distance, 0.001);
  194. let dirNorm = sourceToCoord * invDist;
  195. let cosAngle = dot(dirNorm, lightRefDirection);
  196. if (cosAngle < 0.0) {
  197. return 0.0;
  198. }
  199. let side = dot(dirNorm, vec2<f32>(-lightRefDirection.y, lightRefDirection.x));
  200. let time = uniforms.iTime;
  201. let speed = uniforms.speed;
  202. let asymNoise = fastNoise(vec2<f32>(side * 6.0 + time * 0.12, distance * 0.004 + cosAngle * 2.0));
  203. let asymShift = (asymNoise - 0.5) * uniforms.distortion * 0.6;
  204. let distortPhase = time * 1.4 + distance * 0.006 + cosAngle * 4.5 + side * 1.7;
  205. let distortedAngle = cosAngle + uniforms.distortion * sin(distortPhase) * 0.22 + asymShift;
  206. let flickerSeed = cosAngle * 9.0 + side * 4.0 + time * speed * 0.35;
  207. let flicker = 0.86 + fastNoise(vec2<f32>(flickerSeed, distance * 0.01)) * 0.28;
  208. let asymSpread = max(uniforms.lightSpread * (0.9 + (asymNoise - 0.5) * 0.25), 0.001);
  209. let spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / asymSpread);
  210. let lengthFalloff = clamp(1.0 - distance / maxDistance, 0.0, 1.0);
  211. let fadeMaxDist = max(baseSize * uniforms.fadeDistance, 0.001);
  212. let fadeFalloff = clamp((fadeMaxDist - distance) / fadeMaxDist, 0.0, 1.0);
  213. var pulse: f32 = 1.0;
  214. if (uniforms.pulsating > 0.5) {
  215. let pulseCenter = (uniforms.pulsatingMin + uniforms.pulsatingMax) * 0.5;
  216. let pulseAmplitude = (uniforms.pulsatingMax - uniforms.pulsatingMin) * 0.5;
  217. pulse = pulseCenter + pulseAmplitude * sin(time * speed * 3.0);
  218. }
  219. let timeSpeed = time * speed;
  220. let wave = 0.5
  221. + 0.25 * sin(cosAngle * 28.0 + side * 8.0 + timeSpeed * 1.2)
  222. + 0.18 * cos(cosAngle * 22.0 - timeSpeed * 0.95 + side * 6.0)
  223. + 0.12 * sin(cosAngle * 35.0 + timeSpeed * 1.6 + asymNoise * 3.0);
  224. let minStrength = 0.14 + asymNoise * 0.06;
  225. let baseStrength = max(clamp(wave * (0.85 + asymNoise * 0.3), 0.0, 1.0), minStrength);
  226. let lightStrength = baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse * flicker;
  227. let ambientLight = (0.06 + asymNoise * 0.04) * lengthFalloff * fadeFalloff * spreadFactor;
  228. return max(lightStrength, ambientLight);
  229. }
  230. fn particle(coord: vec2<f32>, particlePos: vec2<f32>, size: f32) -> f32 {
  231. let delta = coord - particlePos;
  232. let distSq = dot(delta, delta);
  233. let sizeSq = size * size;
  234. if (distSq > sizeSq * 9.0) {
  235. return 0.0;
  236. }
  237. let d = sqrt(distSq);
  238. let core = smoothstep(size, size * 0.35, d);
  239. let glow = smoothstep(size * 3.0, 0.0, d) * 0.55;
  240. return core + glow;
  241. }
  242. fn renderParticles(coord: vec2<f32>, lightSource: vec2<f32>, lightDir: vec2<f32>) -> f32 {
  243. if (uniforms.particlesEnabled < 0.5 || uniforms.particleAmount < 1.0) {
  244. return 0.0;
  245. }
  246. var particleSum: f32 = 0.0;
  247. let particleCount = i32(uniforms.particleAmount);
  248. let time = uniforms.iTime * uniforms.particleSpeed;
  249. let perpDir = vec2<f32>(-lightDir.y, lightDir.x);
  250. let baseSize = min(uniforms.iResolution.x, uniforms.iResolution.y);
  251. let maxDist = max(baseSize * uniforms.lightLength, 1.0);
  252. let spreadScale = uniforms.lightSpread * baseSize * 0.65;
  253. let coneHalfWidth = uniforms.lightSpread * baseSize * 0.55;
  254. for (var i: i32 = 0; i < particleCount; i = i + 1) {
  255. let fi = f32(i);
  256. let seed = vec2<f32>(fi * 127.1, fi * 311.7);
  257. let rnd = hash2(seed);
  258. let lifeDuration = 2.0 + hash(seed + vec2<f32>(19.0, 73.0)) * 3.0;
  259. let lifeOffset = hash(seed + vec2<f32>(91.0, 37.0)) * lifeDuration;
  260. let lifeProgress = fract((time + lifeOffset) / lifeDuration);
  261. let fadeIn = smoothstep(0.0, 0.2, lifeProgress);
  262. let fadeOut = 1.0 - smoothstep(0.8, 1.0, lifeProgress);
  263. let lifeFade = fadeIn * fadeOut;
  264. if (lifeFade < 0.01) {
  265. continue;
  266. }
  267. let alongLight = rnd.x * maxDist * 0.8;
  268. let perpOffset = (rnd.y - 0.5) * spreadScale;
  269. let floatPhase = rnd.y * 6.28318 + fi * 0.37;
  270. let floatSpeed = 0.35 + rnd.x * 0.9;
  271. let drift = vec2<f32>(
  272. sin(time * floatSpeed + floatPhase),
  273. cos(time * floatSpeed * 0.85 + floatPhase * 1.3)
  274. ) * uniforms.particleDrift * baseSize * 0.08;
  275. let wobble = vec2<f32>(
  276. sin(time * 1.4 + floatPhase * 2.1),
  277. cos(time * 1.1 + floatPhase * 1.6)
  278. ) * uniforms.particleDrift * baseSize * 0.03;
  279. let flowOffset = (rnd.x - 0.5) * baseSize * 0.12 + fract(time * 0.06 + rnd.y) * baseSize * 0.1;
  280. let basePos = lightSource + lightDir * (alongLight + flowOffset) + perpDir * perpOffset + drift + wobble;
  281. let toParticle = basePos - lightSource;
  282. let projLen = dot(toParticle, lightDir);
  283. if (projLen < 0.0 || projLen > maxDist) {
  284. continue;
  285. }
  286. let sideDist = abs(dot(toParticle, perpDir));
  287. if (sideDist > coneHalfWidth) {
  288. continue;
  289. }
  290. let size = mix(uniforms.particleSizeMin, uniforms.particleSizeMax, rnd.x);
  291. let twinkle = 0.7 + 0.3 * sin(time * (1.5 + rnd.y * 2.0) + floatPhase);
  292. let distFade = 1.0 - smoothstep(maxDist * 0.2, maxDist * 0.95, projLen);
  293. if (distFade < 0.01) {
  294. continue;
  295. }
  296. let p = particle(coord, basePos, size);
  297. if (p > 0.0) {
  298. particleSum = particleSum + p * lifeFade * twinkle * distFade * uniforms.particleOpacity;
  299. if (particleSum >= 1.0) {
  300. break;
  301. }
  302. }
  303. }
  304. return min(particleSum, 1.0);
  305. }
  306. @fragment
  307. fn fragmentMain(@builtin(position) fragCoord: vec4<f32>, @location(0) vUv: vec2<f32>) -> @location(0) vec4<f32> {
  308. let coord = vec2<f32>(fragCoord.x, fragCoord.y);
  309. let normalizedX = (coord.x / uniforms.iResolution.x) - 0.5;
  310. let widthOffset = -normalizedX * uniforms.sourceWidth * uniforms.iResolution.x;
  311. let perpDir = vec2<f32>(-uniforms.lightDir.y, uniforms.lightDir.x);
  312. let adjustedLightPos = uniforms.lightPos + perpDir * widthOffset;
  313. let lightValue = lightStrengthCombined(adjustedLightPos, uniforms.lightDir, coord);
  314. if (lightValue < 0.001) {
  315. let particles = renderParticles(coord, adjustedLightPos, uniforms.lightDir);
  316. if (particles < 0.001) {
  317. return vec4<f32>(0.0, 0.0, 0.0, 0.0);
  318. }
  319. let particleBrightness = particles * 1.8;
  320. return vec4<f32>(uniforms.color * particleBrightness, particles * 0.9);
  321. }
  322. var fragColor = vec4<f32>(lightValue, lightValue, lightValue, lightValue);
  323. if (uniforms.noiseAmount > 0.01) {
  324. let n = fastNoise(coord * 0.5 + uniforms.iTime * 0.5);
  325. let grain = mix(1.0, n, uniforms.noiseAmount * 0.5);
  326. fragColor = vec4<f32>(fragColor.rgb * grain, fragColor.a);
  327. }
  328. let brightness = 1.0 - (coord.y / uniforms.iResolution.y);
  329. fragColor = vec4<f32>(
  330. fragColor.x * (0.15 + brightness * 0.85),
  331. fragColor.y * (0.35 + brightness * 0.65),
  332. fragColor.z * (0.55 + brightness * 0.45),
  333. fragColor.a
  334. );
  335. if (abs(uniforms.saturation - 1.0) > 0.01) {
  336. let gray = dot(fragColor.rgb, vec3<f32>(0.299, 0.587, 0.114));
  337. fragColor = vec4<f32>(mix(vec3<f32>(gray), fragColor.rgb, uniforms.saturation), fragColor.a);
  338. }
  339. fragColor = vec4<f32>(fragColor.rgb * uniforms.color, fragColor.a);
  340. let particles = renderParticles(coord, adjustedLightPos, uniforms.lightDir);
  341. if (particles > 0.001) {
  342. let particleBrightness = particles * 1.8;
  343. fragColor = vec4<f32>(fragColor.rgb + uniforms.color * particleBrightness, max(fragColor.a, particles * 0.9));
  344. }
  345. return fragColor;
  346. }
  347. `
  348. const UNIFORM_BUFFER_SIZE = 144
  349. function updateUniformBuffer(buffer: Float32Array, data: UniformData): void {
  350. buffer[0] = data.iTime
  351. buffer[2] = data.iResolution[0]
  352. buffer[3] = data.iResolution[1]
  353. buffer[4] = data.lightPos[0]
  354. buffer[5] = data.lightPos[1]
  355. buffer[6] = data.lightDir[0]
  356. buffer[7] = data.lightDir[1]
  357. buffer[8] = data.color[0]
  358. buffer[9] = data.color[1]
  359. buffer[10] = data.color[2]
  360. buffer[11] = data.speed
  361. buffer[12] = data.lightSpread
  362. buffer[13] = data.lightLength
  363. buffer[14] = data.sourceWidth
  364. buffer[15] = data.pulsating
  365. buffer[16] = data.pulsatingMin
  366. buffer[17] = data.pulsatingMax
  367. buffer[18] = data.fadeDistance
  368. buffer[19] = data.saturation
  369. buffer[20] = data.noiseAmount
  370. buffer[21] = data.distortion
  371. buffer[22] = data.particlesEnabled
  372. buffer[23] = data.particleAmount
  373. buffer[24] = data.particleSizeMin
  374. buffer[25] = data.particleSizeMax
  375. buffer[26] = data.particleSpeed
  376. buffer[27] = data.particleOpacity
  377. buffer[28] = data.particleDrift
  378. }
  379. export default function Spotlight(props: SpotlightProps) {
  380. let containerRef: HTMLDivElement | undefined
  381. let canvasRef: HTMLCanvasElement | null = null
  382. let deviceRef: GPUDevice | null = null
  383. let contextRef: GPUCanvasContext | null = null
  384. let pipelineRef: GPURenderPipeline | null = null
  385. let uniformBufferRef: GPUBuffer | null = null
  386. let bindGroupRef: GPUBindGroup | null = null
  387. let animationIdRef: number | null = null
  388. let cleanupFunctionRef: (() => void) | null = null
  389. let uniformDataRef: UniformData | null = null
  390. let uniformArrayRef: Float32Array | null = null
  391. let configRef: SpotlightConfig = props.config()
  392. let frameCount = 0
  393. const [isVisible, setIsVisible] = createSignal(false)
  394. createEffect(() => {
  395. configRef = props.config()
  396. })
  397. onMount(() => {
  398. if (!containerRef) return
  399. const observer = new IntersectionObserver(
  400. (entries) => {
  401. const entry = entries[0]
  402. setIsVisible(entry.isIntersecting)
  403. },
  404. { threshold: 0.1 },
  405. )
  406. observer.observe(containerRef)
  407. onCleanup(() => {
  408. observer.disconnect()
  409. })
  410. })
  411. createEffect(() => {
  412. const visible = isVisible()
  413. const config = props.config()
  414. if (!visible || !containerRef) {
  415. return
  416. }
  417. if (cleanupFunctionRef) {
  418. cleanupFunctionRef()
  419. cleanupFunctionRef = null
  420. }
  421. const initializeWebGPU = async () => {
  422. if (!containerRef) {
  423. return
  424. }
  425. await new Promise((resolve) => setTimeout(resolve, 10))
  426. if (!containerRef) {
  427. return
  428. }
  429. if (!navigator.gpu) {
  430. console.warn("WebGPU is not supported in this browser")
  431. return
  432. }
  433. const adapter = await navigator.gpu.requestAdapter({
  434. powerPreference: "high-performance",
  435. })
  436. if (!adapter) {
  437. console.warn("Failed to get WebGPU adapter")
  438. return
  439. }
  440. const device = await adapter.requestDevice()
  441. deviceRef = device
  442. const canvas = document.createElement("canvas")
  443. canvas.style.width = "100%"
  444. canvas.style.height = "100%"
  445. canvasRef = canvas
  446. while (containerRef.firstChild) {
  447. containerRef.removeChild(containerRef.firstChild)
  448. }
  449. containerRef.appendChild(canvas)
  450. const context = canvas.getContext("webgpu")
  451. if (!context) {
  452. console.warn("Failed to get WebGPU context")
  453. return
  454. }
  455. contextRef = context
  456. const presentationFormat = navigator.gpu.getPreferredCanvasFormat()
  457. context.configure({
  458. device,
  459. format: presentationFormat,
  460. alphaMode: "premultiplied",
  461. })
  462. const shaderModule = device.createShaderModule({
  463. code: WGSL_SHADER,
  464. })
  465. const uniformBuffer = device.createBuffer({
  466. size: UNIFORM_BUFFER_SIZE,
  467. usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
  468. })
  469. uniformBufferRef = uniformBuffer
  470. const bindGroupLayout = device.createBindGroupLayout({
  471. entries: [
  472. {
  473. binding: 0,
  474. visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
  475. buffer: { type: "uniform" },
  476. },
  477. ],
  478. })
  479. const bindGroup = device.createBindGroup({
  480. layout: bindGroupLayout,
  481. entries: [
  482. {
  483. binding: 0,
  484. resource: { buffer: uniformBuffer },
  485. },
  486. ],
  487. })
  488. bindGroupRef = bindGroup
  489. const pipelineLayout = device.createPipelineLayout({
  490. bindGroupLayouts: [bindGroupLayout],
  491. })
  492. const pipeline = device.createRenderPipeline({
  493. layout: pipelineLayout,
  494. vertex: {
  495. module: shaderModule,
  496. entryPoint: "vertexMain",
  497. },
  498. fragment: {
  499. module: shaderModule,
  500. entryPoint: "fragmentMain",
  501. targets: [
  502. {
  503. format: presentationFormat,
  504. blend: {
  505. color: {
  506. srcFactor: "src-alpha",
  507. dstFactor: "one-minus-src-alpha",
  508. operation: "add",
  509. },
  510. alpha: {
  511. srcFactor: "one",
  512. dstFactor: "one-minus-src-alpha",
  513. operation: "add",
  514. },
  515. },
  516. },
  517. ],
  518. },
  519. primitive: {
  520. topology: "triangle-list",
  521. },
  522. })
  523. pipelineRef = pipeline
  524. const { clientWidth: wCSS, clientHeight: hCSS } = containerRef
  525. const dpr = Math.min(window.devicePixelRatio, 2)
  526. const w = wCSS * dpr
  527. const h = hCSS * dpr
  528. const { anchor, dir } = getAnchorAndDir(config.placement, w, h)
  529. uniformDataRef = {
  530. iTime: 0,
  531. iResolution: [w, h],
  532. lightPos: anchor,
  533. lightDir: dir,
  534. color: hexToRgb(config.color),
  535. speed: config.speed,
  536. lightSpread: config.spread,
  537. lightLength: config.length,
  538. sourceWidth: config.width,
  539. pulsating: config.pulsating !== false ? 1.0 : 0.0,
  540. pulsatingMin: config.pulsating !== false ? config.pulsating[0] : 1.0,
  541. pulsatingMax: config.pulsating !== false ? config.pulsating[1] : 1.0,
  542. fadeDistance: config.distance,
  543. saturation: config.saturation,
  544. noiseAmount: config.noiseAmount,
  545. distortion: config.distortion,
  546. particlesEnabled: config.particles.enabled ? 1.0 : 0.0,
  547. particleAmount: config.particles.amount,
  548. particleSizeMin: config.particles.size[0],
  549. particleSizeMax: config.particles.size[1],
  550. particleSpeed: config.particles.speed,
  551. particleOpacity: config.particles.opacity,
  552. particleDrift: config.particles.drift,
  553. }
  554. const updatePlacement = () => {
  555. if (!containerRef || !canvasRef || !uniformDataRef) {
  556. return
  557. }
  558. const dpr = Math.min(window.devicePixelRatio, 2)
  559. const { clientWidth: wCSS, clientHeight: hCSS } = containerRef
  560. const w = Math.floor(wCSS * dpr)
  561. const h = Math.floor(hCSS * dpr)
  562. canvasRef.width = w
  563. canvasRef.height = h
  564. uniformDataRef.iResolution = [w, h]
  565. const { anchor, dir } = getAnchorAndDir(configRef.placement, w, h)
  566. uniformDataRef.lightPos = anchor
  567. uniformDataRef.lightDir = dir
  568. }
  569. const loop = (t: number) => {
  570. if (!deviceRef || !contextRef || !pipelineRef || !uniformBufferRef || !bindGroupRef || !uniformDataRef) {
  571. return
  572. }
  573. const timeSeconds = t * 0.001
  574. uniformDataRef.iTime = timeSeconds
  575. frameCount++
  576. if (props.onAnimationFrame && frameCount % 2 === 0) {
  577. const pulsatingMin = configRef.pulsating !== false ? configRef.pulsating[0] : 1.0
  578. const pulsatingMax = configRef.pulsating !== false ? configRef.pulsating[1] : 1.0
  579. const pulseCenter = (pulsatingMin + pulsatingMax) * 0.5
  580. const pulseAmplitude = (pulsatingMax - pulsatingMin) * 0.5
  581. const pulseValue =
  582. configRef.pulsating !== false
  583. ? pulseCenter + pulseAmplitude * Math.sin(timeSeconds * configRef.speed * 3.0)
  584. : 1.0
  585. const baseIntensity1 = 0.45 + 0.15 * Math.sin(timeSeconds * configRef.speed * 1.5)
  586. const baseIntensity2 = 0.3 + 0.2 * Math.cos(timeSeconds * configRef.speed * 1.1)
  587. const intensity = Math.max((baseIntensity1 + baseIntensity2) * pulseValue, 0.55)
  588. props.onAnimationFrame({
  589. time: timeSeconds,
  590. intensity,
  591. pulseValue: Math.max(pulseValue, 0.9),
  592. })
  593. }
  594. try {
  595. if (!uniformArrayRef) {
  596. uniformArrayRef = new Float32Array(36)
  597. }
  598. updateUniformBuffer(uniformArrayRef, uniformDataRef)
  599. deviceRef.queue.writeBuffer(uniformBufferRef, 0, uniformArrayRef.buffer)
  600. const commandEncoder = deviceRef.createCommandEncoder()
  601. const textureView = contextRef.getCurrentTexture().createView()
  602. const renderPass = commandEncoder.beginRenderPass({
  603. colorAttachments: [
  604. {
  605. view: textureView,
  606. clearValue: { r: 0, g: 0, b: 0, a: 0 },
  607. loadOp: "clear",
  608. storeOp: "store",
  609. },
  610. ],
  611. })
  612. renderPass.setPipeline(pipelineRef)
  613. renderPass.setBindGroup(0, bindGroupRef)
  614. renderPass.draw(3)
  615. renderPass.end()
  616. deviceRef.queue.submit([commandEncoder.finish()])
  617. animationIdRef = requestAnimationFrame(loop)
  618. } catch (error) {
  619. console.warn("WebGPU rendering error:", error)
  620. return
  621. }
  622. }
  623. window.addEventListener("resize", updatePlacement)
  624. updatePlacement()
  625. animationIdRef = requestAnimationFrame(loop)
  626. cleanupFunctionRef = () => {
  627. if (animationIdRef) {
  628. cancelAnimationFrame(animationIdRef)
  629. animationIdRef = null
  630. }
  631. window.removeEventListener("resize", updatePlacement)
  632. if (uniformBufferRef) {
  633. uniformBufferRef.destroy()
  634. uniformBufferRef = null
  635. }
  636. if (deviceRef) {
  637. deviceRef.destroy()
  638. deviceRef = null
  639. }
  640. if (canvasRef && canvasRef.parentNode) {
  641. canvasRef.parentNode.removeChild(canvasRef)
  642. }
  643. canvasRef = null
  644. contextRef = null
  645. pipelineRef = null
  646. bindGroupRef = null
  647. uniformDataRef = null
  648. }
  649. }
  650. initializeWebGPU()
  651. onCleanup(() => {
  652. if (cleanupFunctionRef) {
  653. cleanupFunctionRef()
  654. cleanupFunctionRef = null
  655. }
  656. })
  657. })
  658. createEffect(() => {
  659. if (!uniformDataRef || !containerRef) {
  660. return
  661. }
  662. const config = props.config()
  663. uniformDataRef.color = hexToRgb(config.color)
  664. uniformDataRef.speed = config.speed
  665. uniformDataRef.lightSpread = config.spread
  666. uniformDataRef.lightLength = config.length
  667. uniformDataRef.sourceWidth = config.width
  668. uniformDataRef.pulsating = config.pulsating !== false ? 1.0 : 0.0
  669. uniformDataRef.pulsatingMin = config.pulsating !== false ? config.pulsating[0] : 1.0
  670. uniformDataRef.pulsatingMax = config.pulsating !== false ? config.pulsating[1] : 1.0
  671. uniformDataRef.fadeDistance = config.distance
  672. uniformDataRef.saturation = config.saturation
  673. uniformDataRef.noiseAmount = config.noiseAmount
  674. uniformDataRef.distortion = config.distortion
  675. uniformDataRef.particlesEnabled = config.particles.enabled ? 1.0 : 0.0
  676. uniformDataRef.particleAmount = config.particles.amount
  677. uniformDataRef.particleSizeMin = config.particles.size[0]
  678. uniformDataRef.particleSizeMax = config.particles.size[1]
  679. uniformDataRef.particleSpeed = config.particles.speed
  680. uniformDataRef.particleOpacity = config.particles.opacity
  681. uniformDataRef.particleDrift = config.particles.drift
  682. const dpr = Math.min(window.devicePixelRatio, 2)
  683. const { clientWidth: wCSS, clientHeight: hCSS } = containerRef
  684. const { anchor, dir } = getAnchorAndDir(config.placement, wCSS * dpr, hCSS * dpr)
  685. uniformDataRef.lightPos = anchor
  686. uniformDataRef.lightDir = dir
  687. })
  688. return (
  689. <div
  690. ref={containerRef}
  691. class={`spotlight-container ${props.class ?? ""}`.trim()}
  692. style={{ opacity: props.config().opacity }}
  693. />
  694. )
  695. }