summaryrefslogtreecommitdiffhomepage
path: root/packages/console
diff options
context:
space:
mode:
authorAaron Iker <[email protected]>2026-01-16 13:35:58 +0100
committerGitHub <[email protected]>2026-01-16 13:35:58 +0100
commitd5a5e6e0623049f999276a278bbdfbc2c96093d2 (patch)
tree7f27a68cac26f4aecf82799bb4ed83d850c61b74 /packages/console
parente8dad8523370cc8bd9088abad2a4f13f6853715f (diff)
downloadopencode-d5a5e6e0623049f999276a278bbdfbc2c96093d2.tar.gz
opencode-d5a5e6e0623049f999276a278bbdfbc2c96093d2.zip
feat(console): /black shader improvements, performance, details (#8871)
Diffstat (limited to 'packages/console')
-rw-r--r--packages/console/app/src/component/light-rays.css186
-rw-r--r--packages/console/app/src/component/light-rays.tsx924
-rw-r--r--packages/console/app/src/component/spotlight.css15
-rw-r--r--packages/console/app/src/component/spotlight.tsx820
-rw-r--r--packages/console/app/src/routes/black.tsx23
5 files changed, 847 insertions, 1121 deletions
diff --git a/packages/console/app/src/component/light-rays.css b/packages/console/app/src/component/light-rays.css
deleted file mode 100644
index b688e6d9e..000000000
--- a/packages/console/app/src/component/light-rays.css
+++ /dev/null
@@ -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;
-}
diff --git a/packages/console/app/src/component/light-rays.tsx b/packages/console/app/src/component/light-rays.tsx
deleted file mode 100644
index 53daeb2f3..000000000
--- a/packages/console/app/src/component/light-rays.tsx
+++ /dev/null
@@ -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>
- )
-}
diff --git a/packages/console/app/src/component/spotlight.css b/packages/console/app/src/component/spotlight.css
new file mode 100644
index 000000000..4b311c3d0
--- /dev/null
+++ b/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%;
+}
diff --git a/packages/console/app/src/component/spotlight.tsx b/packages/console/app/src/component/spotlight.tsx
new file mode 100644
index 000000000..704306990
--- /dev/null
+++ b/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 }}
+ />
+ )
+}
diff --git a/packages/console/app/src/routes/black.tsx b/packages/console/app/src/routes/black.tsx
index 36c9d1eaf..b991a60a7 100644
--- a/packages/console/app/src/routes/black.tsx
+++ b/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 { github } from "~/lib/github"
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"
export default function BlackLayout(props: RouteSectionProps) {
@@ -17,15 +17,14 @@ export default function BlackLayout(props: RouteSectionProps) {
: config.github.starsFormatted.compact,
)
- const [lightRaysConfig, setLightRaysConfig] = createSignal<LightRaysConfig>(defaultConfig)
- const [rayAnimationState, setRayAnimationState] = createSignal<LightRaysAnimationState>({
+ const [spotlightAnimationState, setSpotlightAnimationState] = createSignal<SpotlightAnimationState>({
time: 0,
intensity: 0.5,
pulseValue: 1,
})
const svgLightingValues = createMemo(() => {
- const state = rayAnimationState()
+ const state = spotlightAnimationState()
const t = state.time
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 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 {
glowIntensity,
@@ -56,10 +55,12 @@ export default function BlackLayout(props: RouteSectionProps) {
} as Record<string, string>
})
- const handleAnimationFrame = (state: LightRaysAnimationState) => {
- setRayAnimationState(state)
+ const handleAnimationFrame = (state: SpotlightAnimationState) => {
+ setSpotlightAnimationState(state)
}
+ const spotlightConfig = () => defaultConfig
+
return (
<div data-page="black">
<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" />
- <LightRays config={lightRaysConfig} class="header-light-rays" onAnimationFrame={handleAnimationFrame} />
+ <Spotlight config={spotlightConfig} class="header-spotlight" onAnimationFrame={handleAnimationFrame} />
<header data-component="header">
<A href="/" data-component="header-logo">