feat: add procedural cloud background with WebGL shaders

Add alternative animated background using multi-pass WebGL rendering with
simplex noise, FBM, and ASCII-style quantization. Randomly alternates with
existing dots background (50/50 chance). Supports light/dark themes with
different contrast and opacity settings.
This commit is contained in:
2026-01-14 00:29:03 -06:00
parent c7dbd77b72
commit c78fd44ccd
4 changed files with 923 additions and 4 deletions
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO tag_cooccurrence (tag_a, tag_b, count)\n SELECT\n LEAST(t1.tag_id, t2.tag_id) as tag_a,\n GREATEST(t1.tag_id, t2.tag_id) as tag_b,\n COUNT(*)::int as count\n FROM project_tags t1\n JOIN project_tags t2 ON t1.project_id = t2.project_id\n WHERE t1.tag_id < t2.tag_id\n GROUP BY tag_a, tag_b\n HAVING COUNT(*) > 0\n ",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "dc2e163d0cbfa64bdc9ea63f8b7502adeb64ed912ce2573f046e81f0091417f0"
}
+2 -2
View File
@@ -153,7 +153,7 @@ pub async fn run(pool: &PgPool) -> Result<(), Box<dyn std::error::Error>> {
(
"rustdoc-mcp",
"rustdoc-mcp",
"MCP server providing AI assistants access to Rust documentation",
"intelligent MCP server providing access to Rust documentation",
"A Model Context Protocol (MCP) server that provides AI assistants with direct access to Rust crate documentation. Enables LLMs to query rustdoc-generated documentation, search for types, traits, and functions, and retrieve detailed API information for any published Rust crate. Integrates with Claude, GPT, and other MCP-compatible AI tools to provide accurate, up-to-date Rust API references without hallucination.",
"active",
Some("Xevion/rustdoc-mcp"),
@@ -289,7 +289,7 @@ pub async fn run(pool: &PgPool) -> Result<(), Box<dyn std::error::Error>> {
sqlx::query!(
r#"
INSERT INTO tag_cooccurrence (tag_a, tag_b, count)
SELECT
SELECT
LEAST(t1.tag_id, t2.tag_id) as tag_a,
GREATEST(t1.tag_id, t2.tag_id) as tag_b,
COUNT(*)::int as count
+896
View File
@@ -0,0 +1,896 @@
<script lang="ts" module>
// UniformManager at module scope - shared across all component instances
class UniformManager {
private gl: WebGL2RenderingContext;
private locations = new Map<string, WebGLUniformLocation>();
constructor(
gl: WebGL2RenderingContext,
program: WebGLProgram,
uniforms: string[],
) {
this.gl = gl;
uniforms.forEach((name) => {
const loc = gl.getUniformLocation(program, name);
if (loc) this.locations.set(name, loc);
});
}
set(
name: string,
value: number | [number, number] | [number, number, number],
) {
const loc = this.locations.get(name);
if (!loc) return;
if (Array.isArray(value)) {
if (value.length === 2) {
this.gl.uniform2f(loc, value[0], value[1]);
} else if (value.length === 3) {
this.gl.uniform3f(loc, value[0], value[1], value[2]);
}
} else {
this.gl.uniform1f(loc, value);
}
}
set1i(name: string, value: number) {
const loc = this.locations.get(name);
if (loc) this.gl.uniform1i(loc, value);
}
set1fv(name: string, values: number[]) {
const loc = this.locations.get(name);
if (loc) this.gl.uniform1fv(loc, values);
}
setVec2(name: string, vec: [number, number]) {
const loc = this.locations.get(name);
if (loc) this.gl.uniform2f(loc, vec[0], vec[1]);
}
}
// Shader compilation utilities
function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string,
): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error("Shader compile error:", gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function createProgram(
gl: WebGL2RenderingContext,
vertexShader: WebGLShader,
fragmentShader: WebGLShader,
): WebGLProgram | null {
const program = gl.createProgram();
if (!program) return null;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error("Program link error:", gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
return program;
}
function generateSeed(): number {
return Math.floor(Math.random() * 10000);
}
</script>
<script lang="ts">
import { cn } from "$lib/utils";
import type { ClassValue } from "clsx";
import { onMount, onDestroy } from "svelte";
import { themeStore } from "$lib/stores/theme.svelte";
// Type for shader settings that can be theme-overridden
type ShaderSettings = {
cellSize: number;
waveAmplitude: number;
waveSpeed: number;
noiseIntensity: number;
timeSpeed: number;
radialVignetteIntensity: number;
radialVignetteRadius: number;
horizontalVignetteIntensity: number;
horizontalVignetteRadius: number;
brightnessAdjust: number;
contrastAdjust: number;
thresholds: [number, number, number, number, number];
opacity: number;
glyphColor: [number, number, number];
};
// Base defaults (shared between themes)
const baseDefaults: ShaderSettings = {
cellSize: 10,
waveAmplitude: 0.25,
waveSpeed: 0.1,
noiseIntensity: 0.025,
timeSpeed: 0.4,
radialVignetteIntensity: 0.2,
radialVignetteRadius: 0.1,
horizontalVignetteIntensity: 0.5,
horizontalVignetteRadius: 0.7,
brightnessAdjust: 0.09,
contrastAdjust: 1.0,
thresholds: [0.35, 0.4, 0.45, 0.6, 0.75],
opacity: 0.9,
glyphColor: [0.502, 0.502, 0.502], // 128/255 pre-computed
};
// Dark mode overrides
const darkModeOverrides: Partial<ShaderSettings> = {
glyphColor: [0.784, 0.784, 0.784], // 200/255 pre-computed
};
// Light mode overrides - more contrast and variation
const lightModeOverrides: Partial<ShaderSettings> = {
glyphColor: [55 / 255, 55 / 255, 55 / 255], // 40/255 pre-computed
brightnessAdjust: 0.15,
contrastAdjust: 1.2,
thresholds: [0.4, 0.45, 0.53, 0.65, 0.72],
opacity: 0.55,
};
let {
class: className = "",
style = "",
}: {
class?: ClassValue;
style?: string;
} = $props();
// Compute effective settings based on theme
const settings = $derived({
...baseDefaults,
...(themeStore.isDark ? darkModeOverrides : lightModeOverrides),
});
let canvas: HTMLCanvasElement;
let cleanupFns: (() => void)[] = [];
let ready = $state(false);
let webglFailed = $state(false);
function addCleanup(fn: () => void) {
cleanupFns.push(fn);
}
// Vertex shader - simple fullscreen quad
const vertexShaderSource = `#version 300 es
in vec2 a_position;
out vec2 v_uv;
void main() {
v_uv = a_position * 0.5 + 0.5;
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
// Pass 1: Noise generation with FBM and domain warping
const noiseFragmentShaderSource = `#version 300 es
precision highp float;
in vec2 v_uv;
out vec4 fragColor;
uniform float u_time;
uniform vec2 u_resolution;
uniform float u_waveAmplitude;
uniform float u_waveSpeed;
uniform float u_noiseIntensity;
uniform float u_radialVignetteIntensity;
uniform float u_radialVignetteRadius;
uniform float u_horizontalVignetteIntensity;
uniform float u_horizontalVignetteRadius;
uniform float u_brightnessAdjust;
uniform float u_contrastAdjust;
uniform float u_noiseSeed;
// Simplex 3D noise implementation
// Based on Ashima Arts webgl-noise: https://github.com/ashima/webgl-noise
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
// Permutation polynomial: (34x + 1) * x mod 289
vec4 permute(vec4 x) { return mod289(((x*34.0)+1.0)*x); }
// Fast inverse square root approximation
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec3 v) {
// Skewing factors for 3D simplex grid
const vec2 C = vec2(1.0/6.0, 1.0/3.0);
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
// First corner (skewed simplex cell origin)
vec3 i = floor(v + dot(v, C.yyy));
vec3 x0 = v - i + dot(i, C.xxx);
// Determine which simplex we're in by comparing coordinates
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min(g.xyz, l.zxy);
vec3 i2 = max(g.xyz, l.zxy);
// Offsets for remaining corners
vec3 x1 = x0 - i1 + C.xxx;
vec3 x2 = x0 - i2 + C.yyy;
vec3 x3 = x0 - D.yyy;
// Permutation for pseudo-random gradient selection
i = mod289(i);
vec4 p = permute(permute(permute(
i.z + vec4(0.0, i1.z, i2.z, 1.0))
+ i.y + vec4(0.0, i1.y, i2.y, 1.0))
+ i.x + vec4(0.0, i1.x, i2.x, 1.0));
// Gradient calculation using 7x7 grid mapped to sphere surface
const float ONE_SEVENTH = 1.0 / 7.0;
vec3 ns = ONE_SEVENTH * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_);
vec4 x = x_ *ns.x + ns.yyyy;
vec4 y = y_ *ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4(x.xy, y.xy);
vec4 b1 = vec4(x.zw, y.zw);
vec4 s0 = floor(b0)*2.0 + 1.0;
vec4 s1 = floor(b1)*2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy;
vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww;
vec3 p0 = vec3(a0.xy, h.x);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 p3 = vec3(a1.zw, h.w);
// Normalize gradients
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
p0 *= norm.x;
p1 *= norm.y;
p2 *= norm.z;
p3 *= norm.w;
// Radial falloff from each corner, then sum gradient contributions
// 42.0 is the normalization factor to map output to approximately [-1, 1]
vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
m = m * m;
return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
}
// Fractional Brownian Motion - smooth version
float fbm(vec3 p) {
float value = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < 4; i++) {
value += amplitude * snoise(p * frequency);
amplitude *= 0.5;
frequency *= 2.1; // Slightly irregular lacunarity for more organic feel
}
return value;
}
// Ridged FBM - creates sharp mountain/cloud ridges
float fbmRidged(vec3 p) {
float value = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
float prev = 1.0;
for (int i = 0; i < 4; i++) {
float n = 1.0 - abs(snoise(p * frequency));
n = n * n; // Square for sharper ridges
value += amplitude * n * prev;
prev = n;
amplitude *= 0.5;
frequency *= 2.1;
}
return value;
}
// Billowy FBM - puffy cloud-like shapes
float fbmBillowy(vec3 p) {
float value = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < 4; i++) {
value += amplitude * abs(snoise(p * frequency));
amplitude *= 0.5;
frequency *= 2.1;
}
return value;
}
void main() {
// Calculate vignette from original UV (before aspect correction) so it stays circular
vec2 center = vec2(0.5, 0.5);
float dist = length(v_uv - center);
// Fix aspect ratio - reveal more pattern instead of stretching
float aspect = u_resolution.x / u_resolution.y;
vec2 uv = v_uv;
uv.x *= aspect;
// Unified drift - all layers move together for coherent motion
vec2 drift = u_time * (0.02 + 0.02 * u_waveSpeed) * vec2(0.3, 0.2);
float warpTime = u_time * max(0.025, 0.04 * u_waveSpeed);
// IQ-style domain warping with unified drift
// First layer: q = (fbm(p + drift), fbm(p + drift + offset))
vec2 q = vec2(
fbm(vec3(uv + drift, warpTime + u_noiseSeed)),
fbm(vec3(uv + drift + vec2(5.2, 1.3), warpTime + u_noiseSeed))
);
// Second layer: r uses same drift for coherent motion
vec2 r = vec2(
fbm(vec3(uv + 4.0 * q + vec2(1.7, 9.2) + drift, warpTime + u_noiseSeed)),
fbm(vec3(uv + 4.0 * q + vec2(8.3, 2.8) + drift, warpTime + u_noiseSeed))
);
// Apply domain warping
float warpStrength = u_waveAmplitude * 1.5;
vec2 warpedUV = uv + warpStrength * r + drift;
// Multi-layer density combining different noise types
vec3 samplePos = vec3(warpedUV * 3.0, warpTime * 0.5 + u_noiseSeed); // Lower freq = larger shapes
// Base: smooth FBM for overall cloud mass
float smoothLayer = fbm(samplePos) * 0.5 + 0.5;
// Ridged: subtle sharp accents at lower frequency
float ridgedLayer = fbmRidged(samplePos * 0.5);
// Billowy: soft puffy texture
float billowyLayer = fbmBillowy(samplePos * 0.6);
// Blend: heavily favor smooth base, subtle accents from others
float density = smoothLayer * 0.75 + ridgedLayer * 0.12 + billowyLayer * 0.13;
// Subtle edge detail - less aggressive
float edgeMask = smoothstep(0.25, 0.45, density) * smoothstep(0.75, 0.55, density);
float detailNoise = snoise(vec3(warpedUV * 10.0, warpTime * 0.4 + u_noiseSeed)) * 0.5 + 0.5;
density = mix(density, density + (detailNoise - 0.5) * 0.15, edgeMask);
// Add subtle grain noise
density += (snoise(vec3(uv * 50.0 + drift * 10.0, u_noiseSeed)) * 0.5 + 0.5) * u_noiseIntensity;
// Gentle S-curve instead of hard threshold - preserves gradient across full range
// This keeps the smooth transitions rather than clamping to plateaus
float visible = smoothstep(0.0, 1.0, density);
// Radial vignette (uses original UV distance so it stays circular)
float edgeFade = 1.0 - smoothstep(u_radialVignetteRadius * 0.5, u_radialVignetteRadius, dist) * u_radialVignetteIntensity;
visible *= edgeFade;
// Horizontal vignette - fades center, stronger at left/right edges
float hDist = abs(v_uv.x - 0.5); // 0 at center, 0.5 at edges
float hFade = mix(1.0 - u_horizontalVignetteIntensity, 1.0, smoothstep(u_horizontalVignetteRadius * 0.1, u_horizontalVignetteRadius * 0.5, hDist));
visible *= hFade;
// Brightness and contrast adjustments
visible = (visible + u_brightnessAdjust) * u_contrastAdjust;
visible = clamp(visible, 0.0, 1.0);
fragColor = vec4(vec3(visible), 1.0);
}
`;
// Pass 2: Glyph rendering (grayscale, theme-aware)
const glyphFragmentShaderSource = `#version 300 es
precision highp float;
in vec2 v_uv;
out vec4 fragColor;
uniform sampler2D u_noiseTexture;
uniform vec2 u_resolution;
uniform float u_cellSize;
uniform float u_opacity;
uniform float u_thresholds[5];
uniform vec3 u_glyphColor;
// Glyph drawing functions (SDF-based)
float drawDot(vec2 uv) {
vec2 center = vec2(0.5, 0.5);
float dist = length(uv - center);
return smoothstep(0.2, 0.15, dist);
}
float drawDash(vec2 uv) {
float h = smoothstep(0.35, 0.4, uv.y) * smoothstep(0.65, 0.6, uv.y);
float w = smoothstep(0.15, 0.2, uv.x) * smoothstep(0.85, 0.8, uv.x);
return h * w;
}
float drawPlus(vec2 uv) {
float horiz = smoothstep(0.35, 0.4, uv.y) * smoothstep(0.65, 0.6, uv.y) *
smoothstep(0.1, 0.15, uv.x) * smoothstep(0.9, 0.85, uv.x);
float vert = smoothstep(0.35, 0.4, uv.x) * smoothstep(0.65, 0.6, uv.x) *
smoothstep(0.1, 0.15, uv.y) * smoothstep(0.9, 0.85, uv.y);
return max(horiz, vert);
}
float drawO(vec2 uv) {
vec2 center = vec2(0.5, 0.5);
float dist = length(uv - center);
float outer = smoothstep(0.4, 0.35, dist);
float inner = smoothstep(0.2, 0.25, dist);
return outer * inner;
}
float drawX(vec2 uv) {
vec2 c = uv - 0.5;
float d1 = abs(c.x - c.y);
float d2 = abs(c.x + c.y);
float line1 = smoothstep(0.15, 0.1, d1);
float line2 = smoothstep(0.15, 0.1, d2);
float bounds = smoothstep(0.45, 0.4, abs(c.x)) * smoothstep(0.45, 0.4, abs(c.y));
return max(line1, line2) * bounds;
}
float getGlyph(float brightness, vec2 localUV) {
if (brightness < u_thresholds[0]) {
return 0.0; // Empty
} else if (brightness < u_thresholds[1]) {
return drawDot(localUV);
} else if (brightness < u_thresholds[2]) {
return drawDash(localUV);
} else if (brightness < u_thresholds[3]) {
return drawPlus(localUV);
} else if (brightness < u_thresholds[4]) {
return drawO(localUV);
} else {
return drawX(localUV);
}
}
void main() {
// Calculate cell coordinates
vec2 cellCount = u_resolution / u_cellSize;
vec2 cellCoord = floor(v_uv * cellCount);
vec2 cellUV = (cellCoord + 0.5) / cellCount;
// Sample brightness at cell center
float brightness = texture(u_noiseTexture, cellUV).r;
// Get local position within cell (0-1)
vec2 localUV = fract(v_uv * cellCount);
// Get glyph value
float glyphValue = getGlyph(brightness, localUV);
// Output with alpha for transparency (background handled by CSS)
float alpha = glyphValue * brightness * u_opacity;
fragColor = vec4(u_glyphColor, alpha);
}
`;
function initWebGL2(canvas: HTMLCanvasElement): {
gl: WebGL2RenderingContext;
noiseProgram: WebGLProgram;
glyphProgram: WebGLProgram;
noiseVAO: WebGLVertexArrayObject;
glyphVAO: WebGLVertexArrayObject;
noiseUniforms: UniformManager;
glyphUniforms: UniformManager;
} | null {
const gl = canvas.getContext("webgl2", {
alpha: true,
premultipliedAlpha: false,
});
if (!gl) {
console.warn("WebGL2 not supported, Clouds will not render");
return null;
}
// Compile vertex shader once and reuse for both programs
const vertexShader = compileShader(
gl,
gl.VERTEX_SHADER,
vertexShaderSource,
);
if (!vertexShader) {
console.error("Vertex shader compilation failed");
return null;
}
const fShaderNoise = compileShader(
gl,
gl.FRAGMENT_SHADER,
noiseFragmentShaderSource,
);
const fShaderGlyph = compileShader(
gl,
gl.FRAGMENT_SHADER,
glyphFragmentShaderSource,
);
if (!fShaderNoise || !fShaderGlyph) {
console.error("Fragment shader compilation failed");
return null;
}
const noiseProgram = createProgram(gl, vertexShader, fShaderNoise);
const glyphProgram = createProgram(gl, vertexShader, fShaderGlyph);
if (!noiseProgram || !glyphProgram) {
console.error("Program linking failed");
return null;
}
// Create fullscreen quad buffer
const quadBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW,
);
// Create VAO for noise program
const noiseVAO = gl.createVertexArray();
if (!noiseVAO) {
console.error("Failed to create noise VAO");
return null;
}
gl.bindVertexArray(noiseVAO);
const noisePosLoc = gl.getAttribLocation(noiseProgram, "a_position");
gl.enableVertexAttribArray(noisePosLoc);
gl.vertexAttribPointer(noisePosLoc, 2, gl.FLOAT, false, 0, 0);
// Create VAO for glyph program
const glyphVAO = gl.createVertexArray();
if (!glyphVAO) {
console.error("Failed to create glyph VAO");
return null;
}
gl.bindVertexArray(glyphVAO);
const glyphPosLoc = gl.getAttribLocation(glyphProgram, "a_position");
gl.enableVertexAttribArray(glyphPosLoc);
gl.vertexAttribPointer(glyphPosLoc, 2, gl.FLOAT, false, 0, 0);
// Uniform managers
const noiseUniforms = new UniformManager(gl, noiseProgram, [
"u_time",
"u_resolution",
"u_waveAmplitude",
"u_waveSpeed",
"u_noiseIntensity",
"u_radialVignetteIntensity",
"u_radialVignetteRadius",
"u_horizontalVignetteIntensity",
"u_horizontalVignetteRadius",
"u_brightnessAdjust",
"u_contrastAdjust",
"u_noiseSeed",
]);
const glyphUniforms = new UniformManager(gl, glyphProgram, [
"u_noiseTexture",
"u_resolution",
"u_cellSize",
"u_opacity",
"u_thresholds",
"u_glyphColor",
]);
return {
gl,
noiseProgram,
glyphProgram,
noiseVAO,
glyphVAO,
noiseUniforms,
glyphUniforms,
};
}
onMount(() => {
const context = initWebGL2(canvas);
if (!context) {
webglFailed = true;
return;
}
const {
gl,
noiseProgram,
glyphProgram,
noiseVAO,
glyphVAO,
noiseUniforms,
glyphUniforms,
} = context;
// Framebuffer and texture for noise pass (half resolution for performance)
let framebuffer: WebGLFramebuffer | null = null;
let noiseTexture: WebGLTexture | null = null;
let noiseWidth = 0;
let noiseHeight = 0;
function createFramebuffer(width: number, height: number) {
if (framebuffer) gl.deleteFramebuffer(framebuffer);
if (noiseTexture) gl.deleteTexture(noiseTexture);
// Half resolution for noise pass - glyph shader samples at cell centers anyway
noiseWidth = Math.floor(width / 2);
noiseHeight = Math.floor(height / 2);
noiseTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, noiseTexture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
noiseWidth,
noiseHeight,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
null,
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
noiseTexture,
0,
);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
}
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
const seed = generateSeed();
// Pre-allocated arrays for uniforms (avoid allocation in render loop)
const noiseResolutionVec: [number, number] = [0, 0];
const glyphResolutionVec: [number, number] = [0, 0];
let canvasWidth = 0;
let canvasHeight = 0;
// Debounced resize handler
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
const RESIZE_DEBOUNCE_MS = 150;
const resizeCanvas = () => {
const width = Math.floor(window.innerWidth * dpr);
const height = Math.floor(window.innerHeight * dpr);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
canvas.style.width = `${window.innerWidth}px`;
canvas.style.height = `${window.innerHeight}px`;
canvasWidth = width;
canvasHeight = height;
createFramebuffer(width, height);
// Update pre-allocated resolution vectors
noiseResolutionVec[0] = noiseWidth;
noiseResolutionVec[1] = noiseHeight;
glyphResolutionVec[0] = width;
glyphResolutionVec[1] = height;
}
};
const debouncedResize = () => {
if (resizeTimeout) clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(resizeCanvas, RESIZE_DEBOUNCE_MS);
};
// Initial resize (immediate, not debounced)
resizeCanvas();
window.addEventListener("resize", debouncedResize);
addCleanup(() => {
window.removeEventListener("resize", debouncedResize);
if (resizeTimeout) clearTimeout(resizeTimeout);
});
// Enable blending for transparent output
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
const startTime = performance.now();
let animationId: number | null = null;
let firstFrameRendered = false;
let readyRafIds: number[] = [];
// Track settings version to detect theme changes
let lastSettingsRef = settings;
// Set static uniforms initially
function setStaticNoiseUniforms() {
gl.useProgram(noiseProgram);
noiseUniforms.set("u_waveAmplitude", settings.waveAmplitude);
noiseUniforms.set("u_waveSpeed", settings.waveSpeed);
noiseUniforms.set("u_noiseIntensity", settings.noiseIntensity);
noiseUniforms.set(
"u_radialVignetteIntensity",
settings.radialVignetteIntensity,
);
noiseUniforms.set(
"u_radialVignetteRadius",
settings.radialVignetteRadius,
);
noiseUniforms.set(
"u_horizontalVignetteIntensity",
settings.horizontalVignetteIntensity,
);
noiseUniforms.set(
"u_horizontalVignetteRadius",
settings.horizontalVignetteRadius,
);
noiseUniforms.set("u_brightnessAdjust", settings.brightnessAdjust);
noiseUniforms.set("u_contrastAdjust", settings.contrastAdjust);
noiseUniforms.set("u_noiseSeed", seed);
}
function setStaticGlyphUniforms() {
gl.useProgram(glyphProgram);
glyphUniforms.set1i("u_noiseTexture", 0);
glyphUniforms.set("u_cellSize", settings.cellSize * dpr);
glyphUniforms.set("u_opacity", settings.opacity);
glyphUniforms.set1fv("u_thresholds", settings.thresholds);
glyphUniforms.set("u_glyphColor", settings.glyphColor);
}
// Set static uniforms on init
setStaticNoiseUniforms();
setStaticGlyphUniforms();
function render() {
// Check if settings changed (theme switch) and update static uniforms
if (lastSettingsRef !== settings) {
lastSettingsRef = settings;
setStaticNoiseUniforms();
setStaticGlyphUniforms();
}
const time =
((performance.now() - startTime) / 1000) * settings.timeSpeed;
// Pass 1: Render noise to framebuffer (half resolution)
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.viewport(0, 0, noiseWidth, noiseHeight);
gl.useProgram(noiseProgram);
gl.bindVertexArray(noiseVAO);
// Only dynamic uniforms in render loop
noiseUniforms.set("u_time", time);
noiseUniforms.setVec2("u_resolution", noiseResolutionVec);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
// Pass 2: Render glyphs to screen (full resolution)
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, canvasWidth, canvasHeight);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(glyphProgram);
gl.bindVertexArray(glyphVAO);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, noiseTexture);
// Only dynamic uniforms in render loop
glyphUniforms.setVec2("u_resolution", glyphResolutionVec);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
// Wait for browser to complete paint before showing canvas
// Two frames ensures the GPU has flushed the rendered content
if (!firstFrameRendered) {
firstFrameRendered = true;
const raf1 = requestAnimationFrame(() => {
const raf2 = requestAnimationFrame(() => {
ready = true;
});
readyRafIds.push(raf2);
});
readyRafIds.push(raf1);
}
animationId = requestAnimationFrame(render);
}
// Visibility change handling - stop RAF loop when hidden, restart when visible
let isVisible = !document.hidden;
const handleVisibilityChange = () => {
if (document.hidden) {
isVisible = false;
if (animationId !== null) {
cancelAnimationFrame(animationId);
animationId = null;
}
} else {
if (!isVisible) {
isVisible = true;
animationId = requestAnimationFrame(render);
}
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
addCleanup(() =>
document.removeEventListener("visibilitychange", handleVisibilityChange),
);
// Start rendering
render();
addCleanup(() => {
if (animationId !== null) cancelAnimationFrame(animationId);
readyRafIds.forEach((id) => cancelAnimationFrame(id));
});
addCleanup(() => {
if (framebuffer) gl.deleteFramebuffer(framebuffer);
if (noiseTexture) gl.deleteTexture(noiseTexture);
gl.getExtension("WEBGL_lose_context")?.loseContext();
});
});
onDestroy(() => {
cleanupFns.forEach((fn) => fn());
});
</script>
<!-- Wrapper for background + ASCII clouds canvas -->
<div class="pointer-events-none fixed inset-0 -z-20" {style}>
<!-- Background overlay (also serves as fallback when WebGL fails) -->
<div
class="absolute inset-0 bg-white dark:bg-black transition-colors duration-300"
></div>
<!-- ASCII Clouds canvas (hidden if WebGL failed) -->
{#if !webglFailed}
<canvas
bind:this={canvas}
class={cn(
"absolute inset-0 z-10 transition-opacity duration-1300 ease-out",
ready ? "opacity-100" : "opacity-0",
className,
)}
></canvas>
{/if}
</div>
+13 -2
View File
@@ -9,11 +9,15 @@
import { themeStore } from "$lib/stores/theme.svelte";
import { page } from "$app/stores";
import { onNavigate } from "$app/navigation";
import Clouds from "$lib/components/Clouds.svelte";
import Dots from "$lib/components/Dots.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
let { children, data } = $props();
// Randomly choose background component on mount (stable, doesn't change after initial load)
let backgroundComponent = $state<"clouds" | "dots" | null>(null);
const defaultMetadata = {
title: "Xevion.dev",
description:
@@ -57,6 +61,9 @@
});
onMount(() => {
// Randomly choose background component (50/50 chance)
backgroundComponent = Math.random() < 0.5 ? "clouds" : "dots";
// Initialize theme store
themeStore.init();
@@ -103,8 +110,12 @@
<!-- Persistent background layer - only for public routes -->
<!-- These elements have view-transition-name to exclude them from page transitions -->
{#if showGlobalBackground}
<!-- Dots component includes both background overlay and animated dots -->
<Dots style="view-transition-name: background" />
<!-- Randomly chosen background component (Clouds or Dots) -->
{#if backgroundComponent === "clouds"}
<Clouds style="view-transition-name: background" />
{:else if backgroundComponent === "dots"}
<Dots style="view-transition-name: background" />
{/if}
<!-- Theme toggle - persistent across page transitions -->
<div