Skip to content

bandlayash/xmb

Repository files navigation

XMB Wave Background

A GPU-accelerated, animated wave background inspired by Sony's XrossMediaBar (XMB) interface — the iconic home screen of the PlayStation 3 and PSP. Built with raw WebGL and GLSL shaders. Zero runtime dependencies.

Preview


Installation

From GitHub:

npm install github:bandlayash/xmb

Script tag (CDN):

<script src="https://cdn.jsdelivr.net/gh/bandlayash/xmb/dist/index.global.js"></script>

Or just copy dist/index.global.js into your project — no package manager needed.


Quick Start

import { XMBWaves } from 'xmb-waves';

new XMBWaves({ target: '#my-background' });

That's it. Creates a fullscreen canvas with 9 animated teal waves at 45% opacity.

With options:

const waves = new XMBWaves({
  target: '#hero',
  color: '#9933ff',
  waveCount: 5,
  opacity: 0.3,
  speed: 1.5,
  blur: 2,
});

// Update at runtime
waves.color = '#ff3399';
waves.speed = 0.5;

// Cleanup (e.g. on SPA navigation)
waves.destroy();

Script tag usage:

<div id="background" style="position: fixed; inset: 0; z-index: 0;"></div>

<script src="dist/index.global.js"></script>
<script>
  const { XMBWaves: XMB } = XMBWaves;
  new XMB({ target: '#background' });
</script>

API Reference

new XMBWaves(options)

Option Type Default Description
target HTMLCanvasElement | HTMLElement | string required A canvas (used directly), any element (canvas created inside), or CSS selector
color string | [number, number, number] "#00d1d1" Wave color as hex or normalized RGB tuple
waves WaveDefinition[] Built-in 12-wave preset Custom wave definitions
waveCount number 9 Number of waves to render (from the waves array)
opacity number 0.45 Canvas opacity (0-1)
speed number 1.0 Speed multiplier
blur number 1 CSS blur in pixels
autoResize boolean true Auto-resize with container
pixelRatio number 1 Pixel density multiplier

Properties (read/write)

All options except target and autoResize are available as runtime properties:

waves.color = '#ff0000';      // cheap: updates uniform
waves.speed = 2.0;            // cheap: updates uniform
waves.opacity = 0.8;          // cheap: updates canvas CSS
waves.blur = 3;               // cheap: updates canvas CSS
waves.waveCount = 12;         // expensive: recompiles shader
waves.waves = customWaves;    // expensive: recompiles shader

Methods

Method Description
pause() Stop the render loop (GL context stays alive)
resume() Restart after pause
destroy() Full cleanup: stops rendering, removes observers, deletes GL resources, removes auto-created canvas
resize() Force a resize (called automatically if autoResize is true)

Read-only Properties

Property Type Description
canvas HTMLCanvasElement The canvas element
gl WebGLRenderingContext The WebGL context
isDestroyed boolean Whether destroy() has been called
isPaused boolean Whether pause() is active

WaveDefinition

interface WaveDefinition {
  speed: number;    // Animation speed
  freq: number;     // Wave frequency
  amp: number;      // Wave amplitude
  phase: number;    // Phase shift
  vOff: number;     // Vertical offset (0 = bottom, 1 = top)
  lw: number;       // Line width
  sharp: number;    // Sharpness (higher = thinner, brighter)
  invert: boolean;  // Flip the falloff direction
}

DEFAULT_WAVES

The built-in 12-wave preset is exported for extending:

import { XMBWaves, DEFAULT_WAVES } from 'xmb-waves';

const customWaves = [
  ...DEFAULT_WAVES,
  { speed: 0.3, freq: 0.45, amp: 0.10, phase: 0, vOff: 0.55, lw: 0.08, sharp: 18, invert: false },
];

new XMBWaves({ target: 'body', waves: customWaves, waveCount: 13 });

Demo

Open demo/index.html to see the interactive demo with a live control panel for color, wave count, opacity, speed, and blur.


Design Philosophy

Why XMB?

The PlayStation XMB background is one of the most recognizable ambient UI elements in consumer electronics. Its slowly undulating ribbons of light create a sense of calm, depth, and futurism without demanding attention. The design principles at work:

  • Ambient, not decorative — The waves exist in the background. They don't compete with content; they provide atmosphere.
  • Luminous ribbons — Each wave isn't a hard line but a soft, glowing ribbon. The brightness fades smoothly from the wave's center, creating a volumetric "lit from within" appearance.
  • Layered depth — Multiple waves at different speeds, sizes, and positions create parallax-like depth without any 3D engine.
  • Additive composition — Where waves overlap, their light adds together, creating brighter intersections that shimmer organically.

Why WebGL over Canvas 2D?

The wave effect requires evaluating a mathematical function at every pixel on every frame. For a 1920x1080 display at 60fps, that's ~124 million pixel evaluations per second. Canvas 2D would choke — it runs on the CPU and would need to iterate through each pixel in JavaScript.

WebGL compiles the wave math into a GLSL fragment shader that runs on the GPU, evaluating all pixels in parallel. The result: buttery 60fps on any modern device, with near-zero CPU usage.


Architecture Overview

The entire effect consists of three parts:

┌──────────────────────────────────────────────────────────────┐
│  Vertex Shader                                               │
│  Draws a fullscreen quad (two triangles covering the screen) │
│  Passes coordinates to fragment shader                       │
└───────────────────────┬──────────────────────────────────────┘
                        │
                        ▼
┌──────────────────────────────────────────────────────────────┐
│  Fragment Shader                                             │
│  For each pixel:                                             │
│    1. Compute 9 sine waves via calcSine()                    │
│    2. Accumulate their color contributions (additive)        │
│    3. Push bright areas toward white (luminosity glow)       │
│    4. Output final RGBA                                      │
└───────────────────────┬──────────────────────────────────────┘
                        │
                        ▼
┌──────────────────────────────────────────────────────────────┐
│  Render Loop (JavaScript)                                    │
│  requestAnimationFrame → update time uniform → draw          │
│  Also handles: resize, color/opacity changes                 │
└──────────────────────────────────────────────────────────────┘

The Fullscreen Quad

The vertex shader receives 4 vertices forming a rectangle that covers clip space from (-1, -1) to (1, 1):

attribute vec2 aVertexPosition;
void main() {
  gl_Position = vec4(aVertexPosition, 0.0, 1.0);
}

This is a standard WebGL pattern for full-screen post-processing effects. The real work happens in the fragment shader.


The Math Behind the Waves

Core Function: calcSine()

Every wave is generated by the same function. It takes a pixel position and wave parameters, and returns how much light that wave contributes at that pixel.

vec3 calcSine(
  vec2 uv,             // normalized pixel coordinate (0–1)
  float speed,         // how fast the wave moves
  float frequency,     // how many oscillations across the screen
  float amplitude,     // height of the wave peaks
  float phaseShift,    // horizontal offset
  float verticalOffset,// vertical center position
  vec3 baseColor,      // wave color (RGB)
  float lineWidth,     // thickness of the visible ribbon
  float sharpness,     // how quickly brightness falls off
  bool invertFalloff   // glow direction: above or below the wave
)

Step 1: Compute the Wave Position

$$\theta = -t \cdot s \cdot f + 2(\varphi + x)$$

$$y_{\text{wave}} = A \sin(\theta) + y_0$$

Where $t$ is time, $s$ is speed, $f$ is frequency, $\varphi$ is phase shift, $x$ is the pixel's horizontal coordinate, $A$ is amplitude, and $y_0$ is the vertical offset.

float angle = uTime * speed * frequency * -1.0 + (phaseShift + uv.x) * 2.0;
float waveY = sin(angle) * amplitude + verticalOffset;

This is a traveling sine wave. Breaking down the angle calculation:

Term Purpose
uTime * speed * frequency * -1.0 Drives the wave leftward over time. Higher speed or frequency = faster movement. The -1.0 reverses direction.
(phaseShift + uv.x) * 2.0 Maps the horizontal screen position to the wave's phase. The * 2.0 means ~1 full cycle is visible across the screen width (before frequency scaling).

The result $y_{\text{wave}}$ is where the wave's center sits vertically at this x-coordinate, offset by $y_0$.

In plain English: As time advances, the sine function's input changes, causing the wave to slide horizontally. Each pixel's x-coordinate maps to a different point along the sine curve, creating the classic undulating shape.

Step 2: Measure Distance from the Wave

$$\Delta y = y_{\text{wave}} - y_{\text{pixel}}$$

$$d = |y_{\text{wave}} - y_{\text{pixel}}|$$

float deltaY = waveY - uv.y;
float distanceVal = distance(waveY, uv.y);

For each pixel, we compute the signed delta $\Delta y$ (used for falloff direction) and the absolute distance $d$ from the wave line.

Step 3: Directional Falloff

The distance is asymmetrically scaled based on which side of the wave the pixel falls:

$$d' = \begin{cases} 4d & \text{if pixel is on the shadowed side} \ d & \text{otherwise} \end{cases}$$

if (invertFalloff) {
  if (deltaY > 0.0) distanceVal *= 4.0;
} else {
  if (deltaY < 0.0) distanceVal *= 4.0;
}

This is what gives the waves their one-sided glow. Instead of glowing equally above and below, each wave is bright on one side and fades rapidly on the other (distance is multiplied by 4, making it fall off 4x faster).

  • invertFalloff = false → Wave glows upward (bright above the line, dim below)
  • invertFalloff = true → Wave glows downward (bright below the line, dim above)

This mimics light refracting through a curved surface — one side catches the light, the other is in shadow.

Step 4: Smooth Brightness Curve

First, a Hermite interpolation maps distance to a $[0, 1]$ brightness value:

$$S = \text{smoothstep}(w \cdot 1.5,; 0,; d') = \begin{cases} 1 & d' = 0 \ 0 & d' \geq w \cdot 1.5 \ 3t^2 - 2t^3 & \text{otherwise, where } t = 1 - \frac{d'}{w \cdot 1.5} \end{cases}$$

Then sharpness concentrates the brightness near the wave center:

$$B = S^{,n}$$

Where $w$ is the line width, $d'$ is the (possibly scaled) distance, and $n$ is the sharpness exponent.

float smoothVal = smoothstep(lineWidth * waveWidthFactor, 0.0, distanceVal);
float scaleVal = pow(smoothVal, sharpness);

Two functions shape the brightness:

smoothstep(edge, 0.0, distance) — A built-in GLSL function that returns:

  • 1.0 when distance = 0 (right on the wave line)
  • 0.0 when distance >= edge (far from the wave)
  • A smooth Hermite interpolation in between (no harsh cutoff)

The edge is lineWidth * 1.5 (the waveWidthFactor constant), controlling how far the glow extends.

pow(smoothVal, sharpness) — Raises the smooth value to a high power ($n = 15$–$23$ in the defaults). This dramatically concentrates brightness near the wave center:

sharpness = 1  → linear falloff (broad, diffuse glow)
sharpness = 15 → sharp peak (tight, concentrated ribbon)
sharpness = 23 → very sharp peak (thin, laser-like line)

The relationship is exponential. Here's how $S^n$ reshapes the curve:

S:     0.0   0.25   0.5   0.75   1.0
n=1:   0.0   0.25   0.5   0.75   1.0    ← linear
n=15:  0.0   0.00   0.00  0.01   1.0    ← concentrated
n=23:  0.0   0.00   0.00  0.00   1.0    ← razor sharp

Step 5: Final Color Output

$$C_{\text{out}} = \min(C_{\text{base}} \cdot B,; C_{\text{base}})$$

return min(baseColor * scaleVal, baseColor);

Multiplies the base color by the brightness scalar $B$. The $\min$ clamp prevents any channel from exceeding the input color (avoiding over-saturation from floating point accumulation).

Compositing All Waves

All wave contributions are summed additively:

$$C_{\text{acc}} = \sum_{i=1}^{N} C_{\text{wave}_i}$$

vec3 acc = vec3(0.0);
acc += calcSine(uv, ...);  // wave 1
acc += calcSine(uv, ...);  // wave 2
// ... up to N waves

Additive blending means where waves overlap, their brightness stacks — creating those characteristic bright nodes at intersections.

The Luminosity Push

The brightest channel is extracted and used to desaturate the color:

$$M = \max(R,, G,, B)$$

$$C_{\text{final}} = \text{lerp}(C_{\text{acc}},; \vec{M},; 0.65) = C_{\text{acc}} + 0.65,(\vec{M} - C_{\text{acc}})$$

float maxCh = max(acc.r, max(acc.g, acc.b));
vec3 outColor = mix(acc, vec3(maxCh), 0.65);

This is the secret sauce for the luminous glow effect. It takes the accumulated color and shifts it 65% toward pure white (well, toward the brightest channel as a grey value). The result:

  • Dim areas stay colored (teal, purple, whatever you set)
  • Bright areas wash out toward white
  • This mimics how real light sources appear — the center is white-hot, with color visible only at the fringes

Without this step, the waves would look flat and evenly colored. With it, they appear to emit light.

The Discard Optimization

Pixels where no wave contributes any light ($M \leq 0$) are discarded entirely:

if (maxCh <= 0.0) discard;

The GPU skips writing these pixels to the framebuffer. Since most of the screen is empty space between waves, this significantly reduces memory bandwidth usage.


Wave Parameter Reference

The default configuration uses 9 waves organized in three groups:

Upper Ribbon Group (verticalOffset = 0.50)

# Speed Frequency Amplitude Phase Line Width Sharpness Falloff
1 0.20 0.20 0.20 0.0 0.10 15.0 Up
2 0.40 0.40 0.15 0.0 0.10 17.0 Up
3 0.30 0.60 0.15 0.0 0.05 23.0 Up

These form the main central ribbon — a slow, wide wave layered with faster, thinner harmonics.

Lower Ribbon Group (verticalOffset = 0.30)

# Speed Frequency Amplitude Phase Line Width Sharpness Falloff
4 0.10 0.26 0.07 0.0 0.10 17.0 Down
5 0.30 0.36 0.07 0.0 0.10 17.0 Down
6 0.50 0.46 0.07 0.0 0.05 23.0 Down
7 0.20 0.58 0.05 0.0 0.20 15.0 Down

A subtler, lower set of waves with inverted falloff, creating the illusion of depth beneath the main ribbon.

Fill Waves (top & bottom edges)

# Speed Frequency Amplitude Phase V.Offset Line Width Sharpness Falloff
8 0.15 0.30 0.12 0.5 0.15 0.10 15.0 Up
9 0.25 0.22 0.15 0.3 0.75 0.10 17.0 Down

These extend the visual into the top and bottom of the viewport so the background doesn't feel empty at the edges.


Customization Guide

Changing the Color

The wave color is defined as an RGB triplet, normalized to 0.0–1.0:

let waveColor = [0.0, 0.82, 0.82];  // Teal (default)

Common color presets:

Color R G B Hex
Teal (default) 0.00 0.82 0.82 #00d1d1
Neon Green 0.00 1.00 0.40 #00ff66
Electric Purple 0.60 0.20 1.00 #9933ff
Warm Orange 1.00 0.45 0.00 #ff7300
Ice Blue 0.30 0.60 1.00 #4d99ff
Hot Pink 1.00 0.20 0.60 #ff3399
Gold 1.00 0.84 0.00 #ffd700

In the demo, use the color picker in the control panel. In code, modify the waveColor array or the WAVE_COLOR constant.

Adjusting the Number of Waves

Waves are defined in the ALL_WAVES array. The waveCount variable controls how many are active:

let waveCount = 9;  // Default: all 9 waves

Effect of wave count:

Count Visual Effect
1–2 Minimal, single flowing ribbon
3–4 Clear layered ribbons with some depth
5–7 Rich, complex pattern with visible layering
8–9 Full XMB effect with edge-to-edge coverage
10–12 Dense, saturated (extra waves available in the demo)

To add entirely new waves, push to the ALL_WAVES array:

ALL_WAVES.push({
  speed: 0.3,      // Movement speed
  freq: 0.45,      // Oscillation frequency
  amp: 0.10,       // Wave height
  phase: 0.0,      // Horizontal offset
  vOff: 0.55,      // Vertical center (0 = bottom, 1 = top)
  lw: 0.08,        // Line width (glow radius)
  sharp: 18.0,     // Brightness concentration
  invert: false    // Glow direction: false = up, true = down
});

Then increase waveCount (or the slider max) accordingly.

Speed, Frequency, and Amplitude

These three parameters control the character of each wave:

Speed (0.10.5 typical) Controls how fast the wave moves horizontally. Slow waves feel calm and ambient; fast waves feel energetic. Mixing speeds creates parallax depth.

Frequency (0.20.6 typical) Controls how many oscillations fit across the screen. Low frequency = one broad curve. High frequency = several tight oscillations. The visual frequency is $\frac{2f}{\pi}$ cycles per screen width.

Amplitude (0.050.20 typical) Controls the vertical range of the wave's oscillation. Large amplitude = dramatic swooping motion. Small amplitude = subtle ripples. Be careful with values above 0.25 — the wave can extend off-screen.

Line Width and Sharpness

These control the thickness and softness of each ribbon:

Line Width (0.050.20 typical) The radius of the glow around the wave center. Multiplied by waveWidthFactor (1.5) to get the actual extent. Wider = more diffuse glow.

Sharpness (15.023.0 typical) The exponent $n$ in $B = S^n$. Higher values concentrate light near the wave center. Think of it as a "tightness" control:

  • $n = 10$ → Soft, atmospheric glow
  • $n = 15$ → Balanced ribbon (default for main waves)
  • $n \geq 20$ → Thin, crisp line

Opacity and Blur (CSS-Level)

These are applied to the canvas element, not the shader:

canvas.style.opacity = 0.45;       // 0.0 (invisible) – 1.0 (full)
canvas.style.filter = 'blur(1px)'; // 0px (sharp) – 8px (dreamy)
  • Opacity controls how strongly the waves show against your background. For subtle backgrounds, use 0.2–0.4. For hero sections, try 0.5–0.7.
  • Blur adds a soft-focus effect. The default 1px smooths out aliasing. Higher values create a dreamy, out-of-focus look.

The invertFalloff Flag

This boolean controls which side of the wave glows:

invertFalloff: false      invertFalloff: true
     ░░░░                       ─────
     ░░░░░                     ░░░░░
     ░░░░░░░                  ░░░░░░░
     ─────────               ░░░░░░░░░░
  • false — Glow extends above the wave line (like light shining up from below)
  • true — Glow extends below the wave line (like light shining down from above)

The default configuration uses false for upper waves and true for lower waves, creating the impression of light concentrated in the middle band.

The Luminosity Mix Factor

vec3 outColor = mix(acc, vec3(maxCh), 0.65);

The $0.65$ factor ($\alpha$ in $C_{\text{final}} = C_{\text{acc}} + \alpha(\vec{M} - C_{\text{acc}})$) controls how much bright areas wash toward white:

  • $\alpha = 0$ → Pure color, no white glow (flat, neon look)
  • $\alpha = 0.65$ → Default (natural luminous glow)
  • $\alpha = 1$ → Full white-out on bright areas (overexposed look)

To change this, edit the 0.65 value in the fragment shader string inside buildFragmentShader().


Framework Integration

React

import { useRef, useEffect } from 'react';
import { XMBWaves } from 'xmb-waves';

export function WaveBackground({ color = '#00d1d1', opacity = 0.45 }) {
  const containerRef = useRef(null);

  useEffect(() => {
    const waves = new XMBWaves({
      target: containerRef.current,
      color,
      opacity,
    });
    return () => waves.destroy();
  }, []);

  return (
    <div
      ref={containerRef}
      style={{ position: 'fixed', inset: 0, zIndex: 0, pointerEvents: 'none' }}
    />
  );
}

Vue

<template>
  <div ref="container" class="wave-bg" />
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { XMBWaves } from 'xmb-waves';

const container = ref(null);
let waves;

onMounted(() => {
  waves = new XMBWaves({ target: container.value, color: '#00d1d1' });
});
onBeforeUnmount(() => waves?.destroy());
</script>

<style scoped>
.wave-bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
</style>

As a Section Background

To use waves behind specific content rather than fullscreen:

<div id="hero" style="position: relative; overflow: hidden; height: 100vh;">
  <div style="position: relative; z-index: 1;">
    <h1>Your content here</h1>
  </div>
</div>

<script type="module">
  import { XMBWaves } from 'xmb-waves';
  new XMBWaves({ target: '#hero', opacity: 0.3 });
</script>

Performance Notes

  • GPU load: Minimal. The shader is mathematically simple — 9 sine evaluations per pixel with no texture sampling or branching (aside from the discard).
  • CPU load: Near zero. JavaScript only updates 4 uniform values per frame.
  • Memory: One fullscreen framebuffer + a 32-byte vertex buffer. No textures.
  • Mobile: Works on all modern mobile GPUs. Consider reducing wave count to 4–5 on low-end devices or using requestAnimationFrame throttling.
  • Battery: For background tabs, the browser automatically pauses requestAnimationFrame. No extra handling needed.

Browser Support

Works in all browsers that support WebGL 1.0:

  • Chrome 9+
  • Firefox 4+
  • Safari 8+
  • Edge 12+
  • iOS Safari 8+
  • Android Chrome 30+

License

MIT - See LICENSE


Built by Yash Bandla and Claude Code

About

PS3 XMB animation library for your frontend

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors