Kaynağa Gözat

feat(console): /black shader improvements, performance, details (#8871)

Aaron Iker 1 ay önce
ebeveyn
işleme
d5a5e6e062

+ 0 - 186
packages/console/app/src/component/light-rays.css

@@ -1,186 +0,0 @@
-.light-rays-container {
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  pointer-events: none;
-  overflow: hidden;
-}
-
-.light-rays-container canvas {
-  display: block;
-  width: 100%;
-  height: 100%;
-}
-
-.light-rays-controls {
-  position: fixed;
-  top: 16px;
-  right: 16px;
-  z-index: 9999;
-  font-family: var(--font-mono, monospace);
-  font-size: 12px;
-  color: #fff;
-}
-
-.light-rays-controls-toggle {
-  background: rgba(0, 0, 0, 0.8);
-  border: 1px solid rgba(255, 255, 255, 0.2);
-  border-radius: 4px;
-  padding: 8px 12px;
-  color: #fff;
-  cursor: pointer;
-  font-family: inherit;
-  font-size: inherit;
-  width: 100%;
-  text-align: left;
-}
-
-.light-rays-controls-toggle:hover {
-  background: rgba(0, 0, 0, 0.9);
-  border-color: rgba(255, 255, 255, 0.3);
-}
-
-.light-rays-controls-panel {
-  background: rgba(0, 0, 0, 0.85);
-  border: 1px solid rgba(255, 255, 255, 0.2);
-  border-radius: 4px;
-  padding: 12px;
-  margin-top: 4px;
-  display: flex;
-  flex-direction: column;
-  gap: 10px;
-  min-width: 240px;
-  max-height: calc(100vh - 100px);
-  overflow-y: auto;
-  backdrop-filter: blur(8px);
-}
-
-.control-group {
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
-}
-
-.control-group label {
-  color: rgba(255, 255, 255, 0.7);
-  font-size: 11px;
-  text-transform: uppercase;
-  letter-spacing: 0.5px;
-}
-
-.control-group.checkbox {
-  flex-direction: row;
-  align-items: center;
-}
-
-.control-group.checkbox label {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  cursor: pointer;
-  text-transform: none;
-}
-
-.control-group input[type="range"] {
-  -webkit-appearance: none;
-  appearance: none;
-  width: 100%;
-  height: 4px;
-  background: rgba(255, 255, 255, 0.2);
-  border-radius: 2px;
-  outline: none;
-}
-
-.control-group input[type="range"]::-webkit-slider-thumb {
-  -webkit-appearance: none;
-  appearance: none;
-  width: 14px;
-  height: 14px;
-  background: #fff;
-  border-radius: 50%;
-  cursor: pointer;
-  transition: transform 0.1s;
-}
-
-.control-group input[type="range"]::-webkit-slider-thumb:hover {
-  transform: scale(1.1);
-}
-
-.control-group input[type="range"]::-moz-range-thumb {
-  width: 14px;
-  height: 14px;
-  background: #fff;
-  border-radius: 50%;
-  cursor: pointer;
-  border: none;
-}
-
-.control-group input[type="color"] {
-  -webkit-appearance: none;
-  appearance: none;
-  width: 100%;
-  height: 32px;
-  border: 1px solid rgba(255, 255, 255, 0.2);
-  border-radius: 4px;
-  background: transparent;
-  cursor: pointer;
-  padding: 2px;
-}
-
-.control-group input[type="color"]::-webkit-color-swatch-wrapper {
-  padding: 0;
-}
-
-.control-group input[type="color"]::-webkit-color-swatch {
-  border: none;
-  border-radius: 2px;
-}
-
-.control-group select {
-  background: rgba(255, 255, 255, 0.1);
-  border: 1px solid rgba(255, 255, 255, 0.2);
-  border-radius: 4px;
-  padding: 6px 8px;
-  color: #fff;
-  font-family: inherit;
-  font-size: inherit;
-  cursor: pointer;
-  outline: none;
-}
-
-.control-group select:hover {
-  border-color: rgba(255, 255, 255, 0.3);
-}
-
-.control-group select option {
-  background: #1a1a1a;
-  color: #fff;
-}
-
-.control-group input[type="checkbox"] {
-  width: 16px;
-  height: 16px;
-  accent-color: #fff;
-  cursor: pointer;
-}
-
-.reset-button {
-  background: rgba(255, 255, 255, 0.1);
-  border: 1px solid rgba(255, 255, 255, 0.2);
-  border-radius: 4px;
-  padding: 8px 12px;
-  color: rgba(255, 255, 255, 0.7);
-  cursor: pointer;
-  font-family: inherit;
-  font-size: inherit;
-  margin-top: 4px;
-  transition: all 0.15s;
-}
-
-.reset-button:hover {
-  background: rgba(255, 255, 255, 0.15);
-  border-color: rgba(255, 255, 255, 0.3);
-  color: #fff;
-}

+ 0 - 924
packages/console/app/src/component/light-rays.tsx

@@ -1,924 +0,0 @@
-import { createSignal, createEffect, onMount, onCleanup, Show, For, Accessor, Setter } from "solid-js"
-import "./light-rays.css"
-
-export type RaysOrigin =
-  | "top-center"
-  | "top-left"
-  | "top-right"
-  | "right"
-  | "left"
-  | "bottom-center"
-  | "bottom-right"
-  | "bottom-left"
-
-export interface LightRaysConfig {
-  raysOrigin: RaysOrigin
-  raysColor: string
-  raysSpeed: number
-  lightSpread: number
-  rayLength: number
-  sourceWidth: number
-  pulsating: boolean
-  pulsatingMin: number
-  pulsatingMax: number
-  fadeDistance: number
-  saturation: number
-  followMouse: boolean
-  mouseInfluence: number
-  noiseAmount: number
-  distortion: number
-  opacity: number
-}
-
-export const defaultConfig: LightRaysConfig = {
-  raysOrigin: "top-center",
-  raysColor: "#ffffff",
-  raysSpeed: 1.0,
-  lightSpread: 1.2,
-  rayLength: 4.5,
-  sourceWidth: 0.1,
-  pulsating: true,
-  pulsatingMin: 0.9,
-  pulsatingMax: 1.05,
-  fadeDistance: 1.25,
-  saturation: 0.35,
-  followMouse: false,
-  mouseInfluence: 0.05,
-  noiseAmount: 0.5,
-  distortion: 0.0,
-  opacity: 0.35,
-}
-
-export interface LightRaysAnimationState {
-  time: number
-  intensity: number
-  pulseValue: number
-}
-
-interface LightRaysProps {
-  config: Accessor<LightRaysConfig>
-  class?: string
-  onAnimationFrame?: (state: LightRaysAnimationState) => void
-}
-
-const hexToRgb = (hex: string): [number, number, number] => {
-  const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
-  return m ? [parseInt(m[1], 16) / 255, parseInt(m[2], 16) / 255, parseInt(m[3], 16) / 255] : [1, 1, 1]
-}
-
-const getAnchorAndDir = (
-  origin: RaysOrigin,
-  w: number,
-  h: number,
-): { anchor: [number, number]; dir: [number, number] } => {
-  const outside = 0.2
-  switch (origin) {
-    case "top-left":
-      return { anchor: [0, -outside * h], dir: [0, 1] }
-    case "top-right":
-      return { anchor: [w, -outside * h], dir: [0, 1] }
-    case "left":
-      return { anchor: [-outside * w, 0.5 * h], dir: [1, 0] }
-    case "right":
-      return { anchor: [(1 + outside) * w, 0.5 * h], dir: [-1, 0] }
-    case "bottom-left":
-      return { anchor: [0, (1 + outside) * h], dir: [0, -1] }
-    case "bottom-center":
-      return { anchor: [0.5 * w, (1 + outside) * h], dir: [0, -1] }
-    case "bottom-right":
-      return { anchor: [w, (1 + outside) * h], dir: [0, -1] }
-    default: // "top-center"
-      return { anchor: [0.5 * w, -outside * h], dir: [0, 1] }
-  }
-}
-
-interface UniformData {
-  iTime: number
-  iResolution: [number, number]
-  rayPos: [number, number]
-  rayDir: [number, number]
-  raysColor: [number, number, number]
-  raysSpeed: number
-  lightSpread: number
-  rayLength: number
-  sourceWidth: number
-  pulsating: number
-  pulsatingMin: number
-  pulsatingMax: number
-  fadeDistance: number
-  saturation: number
-  mousePos: [number, number]
-  mouseInfluence: number
-  noiseAmount: number
-  distortion: number
-}
-
-const WGSL_SHADER = `
-  struct Uniforms {
-    iTime: f32,
-    _pad0: f32,
-    iResolution: vec2<f32>,
-    rayPos: vec2<f32>,
-    rayDir: vec2<f32>,
-    raysColor: vec3<f32>,
-    raysSpeed: f32,
-    lightSpread: f32,
-    rayLength: f32,
-    sourceWidth: f32,
-    pulsating: f32,
-    pulsatingMin: f32,
-    pulsatingMax: f32,
-    fadeDistance: f32,
-    saturation: f32,
-    mousePos: vec2<f32>,
-    mouseInfluence: f32,
-    noiseAmount: f32,
-    distortion: f32,
-    _pad1: f32,
-    _pad2: f32,
-    _pad3: 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 noise(st: vec2<f32>) -> f32 {
-    return fract(sin(dot(st, vec2<f32>(12.9898, 78.233))) * 43758.5453123);
-  }
-
-  fn rayStrength(raySource: vec2<f32>, rayRefDirection: vec2<f32>, coord: vec2<f32>,
-                seedA: f32, seedB: f32, speed: f32) -> f32 {
-    let sourceToCoord = coord - raySource;
-    let dirNorm = normalize(sourceToCoord);
-    let cosAngle = dot(dirNorm, rayRefDirection);
-
-    let distortedAngle = cosAngle + uniforms.distortion * sin(uniforms.iTime * 2.0 + length(sourceToCoord) * 0.01) * 0.2;
-    
-    let spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / max(uniforms.lightSpread, 0.001));
-
-    let distance = length(sourceToCoord);
-    let maxDistance = uniforms.iResolution.x * uniforms.rayLength;
-    let lengthFalloff = clamp((maxDistance - distance) / maxDistance, 0.0, 1.0);
-    
-    let fadeFalloff = clamp((uniforms.iResolution.x * uniforms.fadeDistance - distance) / (uniforms.iResolution.x * uniforms.fadeDistance), 0.5, 1.0);
-    let pulseCenter = (uniforms.pulsatingMin + uniforms.pulsatingMax) * 0.5;
-    let pulseAmplitude = (uniforms.pulsatingMax - uniforms.pulsatingMin) * 0.5;
-    var pulse: f32;
-    if (uniforms.pulsating > 0.5) {
-      pulse = pulseCenter + pulseAmplitude * sin(uniforms.iTime * speed * 3.0);
-    } else {
-      pulse = 1.0;
-    }
-
-    let baseStrength = clamp(
-      (0.45 + 0.15 * sin(distortedAngle * seedA + uniforms.iTime * speed)) +
-      (0.3 + 0.2 * cos(-distortedAngle * seedB + uniforms.iTime * speed)),
-      0.0, 1.0
-    );
-
-    return baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse;
-  }
-
-  @fragment
-  fn fragmentMain(@builtin(position) fragCoord: vec4<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.rayDir.y, uniforms.rayDir.x);
-    let adjustedRayPos = uniforms.rayPos + perpDir * widthOffset;
-    
-    var finalRayDir = uniforms.rayDir;
-    if (uniforms.mouseInfluence > 0.0) {
-      let mouseScreenPos = uniforms.mousePos * uniforms.iResolution;
-      let mouseDirection = normalize(mouseScreenPos - adjustedRayPos);
-      finalRayDir = normalize(mix(uniforms.rayDir, mouseDirection, uniforms.mouseInfluence));
-    }
-
-    let rays1 = vec4<f32>(1.0) *
-                rayStrength(adjustedRayPos, finalRayDir, coord, 36.2214, 21.11349,
-                            1.5 * uniforms.raysSpeed);
-    let rays2 = vec4<f32>(1.0) *
-                rayStrength(adjustedRayPos, finalRayDir, coord, 22.3991, 18.0234,
-                            1.1 * uniforms.raysSpeed);
-
-    var fragColor = rays1 * 0.5 + rays2 * 0.4;
-
-    if (uniforms.noiseAmount > 0.0) {
-      let n = noise(coord * 0.01 + uniforms.iTime * 0.1);
-      fragColor = vec4<f32>(fragColor.rgb * (1.0 - uniforms.noiseAmount + uniforms.noiseAmount * n), fragColor.a);
-    }
-
-    let brightness = 1.0 - (coord.y / uniforms.iResolution.y);
-    fragColor.x = fragColor.x * (0.1 + brightness * 0.8);
-    fragColor.y = fragColor.y * (0.3 + brightness * 0.6);
-    fragColor.z = fragColor.z * (0.5 + brightness * 0.5);
-
-    if (uniforms.saturation != 1.0) {
-      let gray = dot(fragColor.rgb, vec3<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.raysColor, fragColor.a);
-    
-    return fragColor;
-  }
-`
-
-const UNIFORM_BUFFER_SIZE = 96
-
-function createUniformBuffer(data: UniformData): Float32Array {
-  const buffer = new Float32Array(24)
-  buffer[0] = data.iTime
-  buffer[1] = 0
-  buffer[2] = data.iResolution[0]
-  buffer[3] = data.iResolution[1]
-  buffer[4] = data.rayPos[0]
-  buffer[5] = data.rayPos[1]
-  buffer[6] = data.rayDir[0]
-  buffer[7] = data.rayDir[1]
-  buffer[8] = data.raysColor[0]
-  buffer[9] = data.raysColor[1]
-  buffer[10] = data.raysColor[2]
-  buffer[11] = data.raysSpeed
-  buffer[12] = data.lightSpread
-  buffer[13] = data.rayLength
-  buffer[14] = data.sourceWidth
-  buffer[15] = data.pulsating
-  buffer[16] = data.pulsatingMin
-  buffer[17] = data.pulsatingMax
-  buffer[18] = data.fadeDistance
-  buffer[19] = data.saturation
-  buffer[20] = data.mousePos[0]
-  buffer[21] = data.mousePos[1]
-  buffer[22] = data.mouseInfluence
-  buffer[23] = data.noiseAmount
-  return buffer
-}
-
-const UNIFORM_BUFFER_SIZE_CORRECTED = 112
-
-function createUniformBufferCorrected(data: UniformData): Float32Array {
-  const buffer = new Float32Array(28)
-  buffer[0] = data.iTime
-  buffer[1] = 0
-  buffer[2] = data.iResolution[0]
-  buffer[3] = data.iResolution[1]
-  buffer[4] = data.rayPos[0]
-  buffer[5] = data.rayPos[1]
-  buffer[6] = data.rayDir[0]
-  buffer[7] = data.rayDir[1]
-  buffer[8] = data.raysColor[0]
-  buffer[9] = data.raysColor[1]
-  buffer[10] = data.raysColor[2]
-  buffer[11] = data.raysSpeed
-  buffer[12] = data.lightSpread
-  buffer[13] = data.rayLength
-  buffer[14] = data.sourceWidth
-  buffer[15] = data.pulsating
-  buffer[16] = data.pulsatingMin
-  buffer[17] = data.pulsatingMax
-  buffer[18] = data.fadeDistance
-  buffer[19] = data.saturation
-  buffer[20] = data.mousePos[0]
-  buffer[21] = data.mousePos[1]
-  buffer[22] = data.mouseInfluence
-  buffer[23] = data.noiseAmount
-  buffer[24] = data.distortion
-  buffer[25] = 0
-  buffer[26] = 0
-  buffer[27] = 0
-  return buffer
-}
-
-export default function LightRays(props: LightRaysProps) {
-  let containerRef: HTMLDivElement | undefined
-  let canvasRef: HTMLCanvasElement | null = null
-  let deviceRef: GPUDevice | null = null
-  let contextRef: GPUCanvasContext | null = null
-  let pipelineRef: GPURenderPipeline | null = null
-  let uniformBufferRef: GPUBuffer | null = null
-  let bindGroupRef: GPUBindGroup | null = null
-  let animationIdRef: number | null = null
-  let cleanupFunctionRef: (() => void) | null = null
-  let uniformDataRef: UniformData | null = null
-
-  const mouseRef = { x: 0.5, y: 0.5 }
-  const smoothMouseRef = { x: 0.5, y: 0.5 }
-
-  const [isVisible, setIsVisible] = createSignal(false)
-
-  onMount(() => {
-    if (!containerRef) return
-
-    const observer = new IntersectionObserver(
-      (entries) => {
-        const entry = entries[0]
-        setIsVisible(entry.isIntersecting)
-      },
-      { threshold: 0.1 },
-    )
-
-    observer.observe(containerRef)
-
-    onCleanup(() => {
-      observer.disconnect()
-    })
-  })
-
-  createEffect(() => {
-    const visible = isVisible()
-    const config = props.config()
-    if (!visible || !containerRef) {
-      return
-    }
-
-    if (cleanupFunctionRef) {
-      cleanupFunctionRef()
-      cleanupFunctionRef = null
-    }
-
-    const initializeWebGPU = async () => {
-      if (!containerRef) {
-        return
-      }
-
-      await new Promise((resolve) => setTimeout(resolve, 10))
-
-      if (!containerRef) {
-        return
-      }
-
-      if (!navigator.gpu) {
-        console.warn("WebGPU is not supported in this browser")
-        return
-      }
-
-      const adapter = await navigator.gpu.requestAdapter()
-      if (!adapter) {
-        console.warn("Failed to get WebGPU adapter")
-        return
-      }
-
-      const device = await adapter.requestDevice()
-      deviceRef = device
-
-      const canvas = document.createElement("canvas")
-      canvas.style.width = "100%"
-      canvas.style.height = "100%"
-      canvasRef = canvas
-
-      while (containerRef.firstChild) {
-        containerRef.removeChild(containerRef.firstChild)
-      }
-      containerRef.appendChild(canvas)
-
-      const context = canvas.getContext("webgpu")
-      if (!context) {
-        console.warn("Failed to get WebGPU context")
-        return
-      }
-      contextRef = context
-
-      const presentationFormat = navigator.gpu.getPreferredCanvasFormat()
-      context.configure({
-        device,
-        format: presentationFormat,
-        alphaMode: "premultiplied",
-      })
-
-      const shaderModule = device.createShaderModule({
-        code: WGSL_SHADER,
-      })
-
-      const uniformBuffer = device.createBuffer({
-        size: UNIFORM_BUFFER_SIZE_CORRECTED,
-        usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
-      })
-      uniformBufferRef = uniformBuffer
-
-      const bindGroupLayout = device.createBindGroupLayout({
-        entries: [
-          {
-            binding: 0,
-            visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
-            buffer: { type: "uniform" },
-          },
-        ],
-      })
-
-      const bindGroup = device.createBindGroup({
-        layout: bindGroupLayout,
-        entries: [
-          {
-            binding: 0,
-            resource: { buffer: uniformBuffer },
-          },
-        ],
-      })
-      bindGroupRef = bindGroup
-
-      const pipelineLayout = device.createPipelineLayout({
-        bindGroupLayouts: [bindGroupLayout],
-      })
-
-      const pipeline = device.createRenderPipeline({
-        layout: pipelineLayout,
-        vertex: {
-          module: shaderModule,
-          entryPoint: "vertexMain",
-        },
-        fragment: {
-          module: shaderModule,
-          entryPoint: "fragmentMain",
-          targets: [
-            {
-              format: presentationFormat,
-              blend: {
-                color: {
-                  srcFactor: "src-alpha",
-                  dstFactor: "one-minus-src-alpha",
-                  operation: "add",
-                },
-                alpha: {
-                  srcFactor: "one",
-                  dstFactor: "one-minus-src-alpha",
-                  operation: "add",
-                },
-              },
-            },
-          ],
-        },
-        primitive: {
-          topology: "triangle-list",
-        },
-      })
-      pipelineRef = pipeline
-
-      const { clientWidth: wCSS, clientHeight: hCSS } = containerRef
-      const dpr = Math.min(window.devicePixelRatio, 2)
-      const w = wCSS * dpr
-      const h = hCSS * dpr
-      const { anchor, dir } = getAnchorAndDir(config.raysOrigin, w, h)
-
-      uniformDataRef = {
-        iTime: 0,
-        iResolution: [w, h],
-        rayPos: anchor,
-        rayDir: dir,
-        raysColor: hexToRgb(config.raysColor),
-        raysSpeed: config.raysSpeed,
-        lightSpread: config.lightSpread,
-        rayLength: config.rayLength,
-        sourceWidth: config.sourceWidth,
-        pulsating: config.pulsating ? 1.0 : 0.0,
-        pulsatingMin: config.pulsatingMin,
-        pulsatingMax: config.pulsatingMax,
-        fadeDistance: config.fadeDistance,
-        saturation: config.saturation,
-        mousePos: [0.5, 0.5],
-        mouseInfluence: config.mouseInfluence,
-        noiseAmount: config.noiseAmount,
-        distortion: config.distortion,
-      }
-
-      const updatePlacement = () => {
-        if (!containerRef || !canvasRef || !uniformDataRef) {
-          return
-        }
-
-        const dpr = Math.min(window.devicePixelRatio, 2)
-        const { clientWidth: wCSS, clientHeight: hCSS } = containerRef
-        const w = Math.floor(wCSS * dpr)
-        const h = Math.floor(hCSS * dpr)
-
-        canvasRef.width = w
-        canvasRef.height = h
-
-        uniformDataRef.iResolution = [w, h]
-
-        const currentConfig = props.config()
-        const { anchor, dir } = getAnchorAndDir(currentConfig.raysOrigin, w, h)
-        uniformDataRef.rayPos = anchor
-        uniformDataRef.rayDir = dir
-      }
-
-      const loop = (t: number) => {
-        if (!deviceRef || !contextRef || !pipelineRef || !uniformBufferRef || !bindGroupRef || !uniformDataRef) {
-          return
-        }
-
-        const currentConfig = props.config()
-        const timeSeconds = t * 0.001
-        uniformDataRef.iTime = timeSeconds
-
-        if (currentConfig.followMouse && currentConfig.mouseInfluence > 0.0) {
-          const smoothing = 0.92
-
-          smoothMouseRef.x = smoothMouseRef.x * smoothing + mouseRef.x * (1 - smoothing)
-          smoothMouseRef.y = smoothMouseRef.y * smoothing + mouseRef.y * (1 - smoothing)
-
-          uniformDataRef.mousePos = [smoothMouseRef.x, smoothMouseRef.y]
-        }
-
-        if (props.onAnimationFrame) {
-          const pulseCenter = (currentConfig.pulsatingMin + currentConfig.pulsatingMax) * 0.5
-          const pulseAmplitude = (currentConfig.pulsatingMax - currentConfig.pulsatingMin) * 0.5
-          const pulseValue = currentConfig.pulsating
-            ? pulseCenter + pulseAmplitude * Math.sin(timeSeconds * currentConfig.raysSpeed * 3.0)
-            : 1.0
-
-          const baseIntensity1 = 0.45 + 0.15 * Math.sin(timeSeconds * currentConfig.raysSpeed * 1.5)
-          const baseIntensity2 = 0.3 + 0.2 * Math.cos(timeSeconds * currentConfig.raysSpeed * 1.1)
-          const intensity = (baseIntensity1 + baseIntensity2) * pulseValue
-
-          props.onAnimationFrame({
-            time: timeSeconds,
-            intensity,
-            pulseValue,
-          })
-        }
-
-        try {
-          const uniformData = createUniformBufferCorrected(uniformDataRef)
-          deviceRef.queue.writeBuffer(uniformBufferRef, 0, uniformData.buffer)
-
-          const commandEncoder = deviceRef.createCommandEncoder()
-
-          const textureView = contextRef.getCurrentTexture().createView()
-
-          const renderPass = commandEncoder.beginRenderPass({
-            colorAttachments: [
-              {
-                view: textureView,
-                clearValue: { r: 0, g: 0, b: 0, a: 0 },
-                loadOp: "clear",
-                storeOp: "store",
-              },
-            ],
-          })
-
-          renderPass.setPipeline(pipelineRef)
-          renderPass.setBindGroup(0, bindGroupRef)
-          renderPass.draw(3)
-          renderPass.end()
-
-          deviceRef.queue.submit([commandEncoder.finish()])
-
-          animationIdRef = requestAnimationFrame(loop)
-        } catch (error) {
-          console.warn("WebGPU rendering error:", error)
-          return
-        }
-      }
-
-      window.addEventListener("resize", updatePlacement)
-      updatePlacement()
-      animationIdRef = requestAnimationFrame(loop)
-
-      cleanupFunctionRef = () => {
-        if (animationIdRef) {
-          cancelAnimationFrame(animationIdRef)
-          animationIdRef = null
-        }
-
-        window.removeEventListener("resize", updatePlacement)
-
-        if (uniformBufferRef) {
-          uniformBufferRef.destroy()
-          uniformBufferRef = null
-        }
-
-        if (deviceRef) {
-          deviceRef.destroy()
-          deviceRef = null
-        }
-
-        if (canvasRef && canvasRef.parentNode) {
-          canvasRef.parentNode.removeChild(canvasRef)
-        }
-
-        canvasRef = null
-        contextRef = null
-        pipelineRef = null
-        bindGroupRef = null
-        uniformDataRef = null
-      }
-    }
-
-    initializeWebGPU()
-
-    onCleanup(() => {
-      if (cleanupFunctionRef) {
-        cleanupFunctionRef()
-        cleanupFunctionRef = null
-      }
-    })
-  })
-
-  createEffect(() => {
-    if (!uniformDataRef || !containerRef) {
-      return
-    }
-
-    const config = props.config()
-
-    uniformDataRef.raysColor = hexToRgb(config.raysColor)
-    uniformDataRef.raysSpeed = config.raysSpeed
-    uniformDataRef.lightSpread = config.lightSpread
-    uniformDataRef.rayLength = config.rayLength
-    uniformDataRef.sourceWidth = config.sourceWidth
-    uniformDataRef.pulsating = config.pulsating ? 1.0 : 0.0
-    uniformDataRef.pulsatingMin = config.pulsatingMin
-    uniformDataRef.pulsatingMax = config.pulsatingMax
-    uniformDataRef.fadeDistance = config.fadeDistance
-    uniformDataRef.saturation = config.saturation
-    uniformDataRef.mouseInfluence = config.mouseInfluence
-    uniformDataRef.noiseAmount = config.noiseAmount
-    uniformDataRef.distortion = config.distortion
-
-    const dpr = Math.min(window.devicePixelRatio, 2)
-    const { clientWidth: wCSS, clientHeight: hCSS } = containerRef
-    const { anchor, dir } = getAnchorAndDir(config.raysOrigin, wCSS * dpr, hCSS * dpr)
-    uniformDataRef.rayPos = anchor
-    uniformDataRef.rayDir = dir
-  })
-
-  createEffect(() => {
-    const config = props.config()
-    if (!config.followMouse) {
-      return
-    }
-
-    const handleMouseMove = (e: MouseEvent) => {
-      if (!containerRef) {
-        return
-      }
-      const rect = containerRef.getBoundingClientRect()
-      const x = (e.clientX - rect.left) / rect.width
-      const y = (e.clientY - rect.top) / rect.height
-      mouseRef.x = x
-      mouseRef.y = y
-    }
-
-    window.addEventListener("mousemove", handleMouseMove)
-
-    onCleanup(() => {
-      window.removeEventListener("mousemove", handleMouseMove)
-    })
-  })
-
-  return (
-    <div
-      ref={containerRef}
-      class={`light-rays-container ${props.class ?? ""}`.trim()}
-      style={{ opacity: props.config().opacity }}
-    />
-  )
-}
-
-interface LightRaysControlsProps {
-  config: Accessor<LightRaysConfig>
-  setConfig: Setter<LightRaysConfig>
-}
-
-export function LightRaysControls(props: LightRaysControlsProps) {
-  const [isOpen, setIsOpen] = createSignal(true)
-
-  const updateConfig = <K extends keyof LightRaysConfig>(key: K, value: LightRaysConfig[K]) => {
-    props.setConfig((prev) => ({ ...prev, [key]: value }))
-  }
-
-  const origins: RaysOrigin[] = [
-    "top-center",
-    "top-left",
-    "top-right",
-    "left",
-    "right",
-    "bottom-center",
-    "bottom-left",
-    "bottom-right",
-  ]
-
-  return (
-    <div class="light-rays-controls">
-      <button class="light-rays-controls-toggle" onClick={() => setIsOpen(!isOpen())}>
-        {isOpen() ? "▼" : "▶"} Light Rays
-      </button>
-      <Show when={isOpen()}>
-        <div class="light-rays-controls-panel">
-          <div class="control-group">
-            <label>Origin</label>
-            <select
-              value={props.config().raysOrigin}
-              onChange={(e) => updateConfig("raysOrigin", e.currentTarget.value as RaysOrigin)}
-            >
-              <For each={origins}>{(origin) => <option value={origin}>{origin}</option>}</For>
-            </select>
-          </div>
-
-          <div class="control-group">
-            <label>Color</label>
-            <input
-              type="color"
-              value={props.config().raysColor}
-              onInput={(e) => updateConfig("raysColor", e.currentTarget.value)}
-            />
-          </div>
-
-          <div class="control-group">
-            <label>Speed: {props.config().raysSpeed.toFixed(2)}</label>
-            <input
-              type="range"
-              min="0"
-              max="3"
-              step="0.01"
-              value={props.config().raysSpeed}
-              onInput={(e) => updateConfig("raysSpeed", parseFloat(e.currentTarget.value))}
-            />
-          </div>
-
-          <div class="control-group">
-            <label>Light Spread: {props.config().lightSpread.toFixed(2)}</label>
-            <input
-              type="range"
-              min="0.1"
-              max="5"
-              step="0.01"
-              value={props.config().lightSpread}
-              onInput={(e) => updateConfig("lightSpread", parseFloat(e.currentTarget.value))}
-            />
-          </div>
-
-          <div class="control-group">
-            <label>Ray Length: {props.config().rayLength.toFixed(2)}</label>
-            <input
-              type="range"
-              min="0.1"
-              max="5"
-              step="0.01"
-              value={props.config().rayLength}
-              onInput={(e) => updateConfig("rayLength", parseFloat(e.currentTarget.value))}
-            />
-          </div>
-
-          <div class="control-group">
-            <label>Source Width: {props.config().sourceWidth.toFixed(2)}</label>
-            <input
-              type="range"
-              min="0"
-              max="2"
-              step="0.01"
-              value={props.config().sourceWidth}
-              onInput={(e) => updateConfig("sourceWidth", parseFloat(e.currentTarget.value))}
-            />
-          </div>
-
-          <div class="control-group">
-            <label>Fade Distance: {props.config().fadeDistance.toFixed(2)}</label>
-            <input
-              type="range"
-              min="0.1"
-              max="3"
-              step="0.01"
-              value={props.config().fadeDistance}
-              onInput={(e) => updateConfig("fadeDistance", parseFloat(e.currentTarget.value))}
-            />
-          </div>
-
-          <div class="control-group">
-            <label>Saturation: {props.config().saturation.toFixed(2)}</label>
-            <input
-              type="range"
-              min="0"
-              max="2"
-              step="0.01"
-              value={props.config().saturation}
-              onInput={(e) => updateConfig("saturation", parseFloat(e.currentTarget.value))}
-            />
-          </div>
-
-          <div class="control-group">
-            <label>Mouse Influence: {props.config().mouseInfluence.toFixed(2)}</label>
-            <input
-              type="range"
-              min="0"
-              max="1"
-              step="0.01"
-              value={props.config().mouseInfluence}
-              onInput={(e) => updateConfig("mouseInfluence", parseFloat(e.currentTarget.value))}
-            />
-          </div>
-
-          <div class="control-group">
-            <label>Noise: {props.config().noiseAmount.toFixed(2)}</label>
-            <input
-              type="range"
-              min="0"
-              max="1"
-              step="0.01"
-              value={props.config().noiseAmount}
-              onInput={(e) => updateConfig("noiseAmount", parseFloat(e.currentTarget.value))}
-            />
-          </div>
-
-          <div class="control-group">
-            <label>Distortion: {props.config().distortion.toFixed(2)}</label>
-            <input
-              type="range"
-              min="0"
-              max="2"
-              step="0.01"
-              value={props.config().distortion}
-              onInput={(e) => updateConfig("distortion", parseFloat(e.currentTarget.value))}
-            />
-          </div>
-
-          <div class="control-group">
-            <label>Opacity: {props.config().opacity.toFixed(2)}</label>
-            <input
-              type="range"
-              min="0"
-              max="1"
-              step="0.01"
-              value={props.config().opacity}
-              onInput={(e) => updateConfig("opacity", parseFloat(e.currentTarget.value))}
-            />
-          </div>
-
-          <div class="control-group checkbox">
-            <label>
-              <input
-                type="checkbox"
-                checked={props.config().pulsating}
-                onChange={(e) => updateConfig("pulsating", e.currentTarget.checked)}
-              />
-              Pulsating
-            </label>
-          </div>
-
-          <Show when={props.config().pulsating}>
-            <div class="control-group">
-              <label>Pulse Min: {props.config().pulsatingMin.toFixed(2)}</label>
-              <input
-                type="range"
-                min="0"
-                max="1"
-                step="0.01"
-                value={props.config().pulsatingMin}
-                onInput={(e) => updateConfig("pulsatingMin", parseFloat(e.currentTarget.value))}
-              />
-            </div>
-
-            <div class="control-group">
-              <label>Pulse Max: {props.config().pulsatingMax.toFixed(2)}</label>
-              <input
-                type="range"
-                min="0"
-                max="2"
-                step="0.01"
-                value={props.config().pulsatingMax}
-                onInput={(e) => updateConfig("pulsatingMax", parseFloat(e.currentTarget.value))}
-              />
-            </div>
-          </Show>
-
-          <div class="control-group checkbox">
-            <label>
-              <input
-                type="checkbox"
-                checked={props.config().followMouse}
-                onChange={(e) => updateConfig("followMouse", e.currentTarget.checked)}
-              />
-              Follow Mouse
-            </label>
-          </div>
-
-          <button class="reset-button" onClick={() => props.setConfig(defaultConfig)}>
-            Reset to Defaults
-          </button>
-        </div>
-      </Show>
-    </div>
-  )
-}

+ 15 - 0
packages/console/app/src/component/spotlight.css

@@ -0,0 +1,15 @@
+.spotlight-container {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 50dvh;
+  pointer-events: none;
+  overflow: hidden;
+}
+
+.spotlight-container canvas {
+  display: block;
+  width: 100%;
+  height: 100%;
+}

+ 820 - 0
packages/console/app/src/component/spotlight.tsx

@@ -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 }}
+    />
+  )
+}

+ 12 - 11
packages/console/app/src/routes/black.tsx

@@ -3,7 +3,7 @@ import { Title, Meta, Link } from "@solidjs/meta"
 import { createMemo, createSignal } from "solid-js"
 import { createMemo, createSignal } from "solid-js"
 import { github } from "~/lib/github"
 import { github } from "~/lib/github"
 import { config } from "~/config"
 import { config } from "~/config"
-import LightRays, { defaultConfig, type LightRaysConfig, type LightRaysAnimationState } from "~/component/light-rays"
+import Spotlight, { defaultConfig, type SpotlightAnimationState } from "~/component/spotlight"
 import "./black.css"
 import "./black.css"
 
 
 export default function BlackLayout(props: RouteSectionProps) {
 export default function BlackLayout(props: RouteSectionProps) {
@@ -17,15 +17,14 @@ export default function BlackLayout(props: RouteSectionProps) {
       : config.github.starsFormatted.compact,
       : config.github.starsFormatted.compact,
   )
   )
 
 
-  const [lightRaysConfig, setLightRaysConfig] = createSignal<LightRaysConfig>(defaultConfig)
-  const [rayAnimationState, setRayAnimationState] = createSignal<LightRaysAnimationState>({
+  const [spotlightAnimationState, setSpotlightAnimationState] = createSignal<SpotlightAnimationState>({
     time: 0,
     time: 0,
     intensity: 0.5,
     intensity: 0.5,
     pulseValue: 1,
     pulseValue: 1,
   })
   })
 
 
   const svgLightingValues = createMemo(() => {
   const svgLightingValues = createMemo(() => {
-    const state = rayAnimationState()
+    const state = spotlightAnimationState()
     const t = state.time
     const t = state.time
 
 
     const wave1 = Math.sin(t * 1.5) * 0.5 + 0.5
     const wave1 = Math.sin(t * 1.5) * 0.5 + 0.5
@@ -33,11 +32,11 @@ export default function BlackLayout(props: RouteSectionProps) {
     const wave3 = Math.sin(t * 0.8 + 2.5) * 0.5 + 0.5
     const wave3 = Math.sin(t * 0.8 + 2.5) * 0.5 + 0.5
 
 
     const shimmerPos = Math.sin(t * 0.7) * 0.5 + 0.5
     const shimmerPos = Math.sin(t * 0.7) * 0.5 + 0.5
-    const glowIntensity = state.intensity * state.pulseValue * 0.35
-    const fillOpacity = 0.1 + wave1 * 0.08 * state.pulseValue
-    const strokeBrightness = 55 + wave2 * 25 * state.pulseValue
+    const glowIntensity = Math.max(state.intensity * state.pulseValue * 0.35, 0.15)
+    const fillOpacity = Math.max(0.1 + wave1 * 0.08 * state.pulseValue, 0.12)
+    const strokeBrightness = Math.max(55 + wave2 * 25 * state.pulseValue, 60)
 
 
-    const shimmerIntensity = wave3 * 0.15 * state.pulseValue
+    const shimmerIntensity = Math.max(wave3 * 0.15 * state.pulseValue, 0.08)
 
 
     return {
     return {
       glowIntensity,
       glowIntensity,
@@ -56,10 +55,12 @@ export default function BlackLayout(props: RouteSectionProps) {
     } as Record<string, string>
     } as Record<string, string>
   })
   })
 
 
-  const handleAnimationFrame = (state: LightRaysAnimationState) => {
-    setRayAnimationState(state)
+  const handleAnimationFrame = (state: SpotlightAnimationState) => {
+    setSpotlightAnimationState(state)
   }
   }
 
 
+  const spotlightConfig = () => defaultConfig
+
   return (
   return (
     <div data-page="black">
     <div data-page="black">
       <Title>OpenCode Black | Access all the world's best coding models</Title>
       <Title>OpenCode Black | Access all the world's best coding models</Title>
@@ -84,7 +85,7 @@ export default function BlackLayout(props: RouteSectionProps) {
       />
       />
       <Meta name="twitter:image" content="/social-share-black.png" />
       <Meta name="twitter:image" content="/social-share-black.png" />
 
 
-      <LightRays config={lightRaysConfig} class="header-light-rays" onAnimationFrame={handleAnimationFrame} />
+      <Spotlight config={spotlightConfig} class="header-spotlight" onAnimationFrame={handleAnimationFrame} />
 
 
       <header data-component="header">
       <header data-component="header">
         <A href="/" data-component="header-logo">
         <A href="/" data-component="header-logo">