Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/hetari/creative-coding/llms.txt

Use this file to discover all available pages before exploring further.

A full-screen WebGL shader that renders an animated heart shape entirely on the GPU. The heart boundary is computed using a signed distance function (SDF) written in GLSL. Colors cycle continuously through a cosine-palette, producing smooth gradient shifts driven by uTime. Five real-time controls — speed, glow intensity, and three palette color stops — are wired to lil-gui sliders and color pickers, so every parameter change is reflected in the next draw frame without recompiling the shader.

Registry Entry

This sketch is registered in src/config/sketches.ts as:
{
  id: 'shader-learn',
  title: 'Learn',
  category: 'shader',
  path: '/shader/learn',
  order: 0,
  previewMode: 'iframe',
}

Tech Stack

LayerTool
Canvas / draw loopuseP5 composable, p5.js WEBGL mode
ShadersGLSL vertex + fragment, loaded with Vite ?raw
GUI controlsLilGui component
Performance overlayStatsPanel component
External linksResourcesList component

GLSL Uniforms

The Vue component sets the following uniforms every frame inside p.draw():
UniformTypeDescription
uTimefloatAnimation time — p5.millis() / 2000, advancing roughly 0.5 units per second
uResolutionvec2Canvas width and height in physical pixels (matches p.width / p.height)
PIfloatMath.PI — passed as a uniform so the fragment shader doesn’t need to redeclare it
uSpeedfloatAnimation speed multiplier. GUI range: 0.1 – 5.0, default 1.0
uGlowfloatGlow intensity divisor applied to the detail layer. GUI range: 0.01 – 1.0, default 0.25
uPaletteAvec3First cosine-palette coefficient. Color picker default: #803333 (≈ 0.5, 0.2, 0.2)
uPaletteBvec3Second cosine-palette coefficient. Color picker default: #804D4D (≈ 0.5, 0.3, 0.3)
uPaletteDvec3Fourth (phase-shift) cosine-palette coefficient. Color picker default: #001A33 (≈ 0.0, 0.1, 0.2)

Fragment Shader Walkthrough

The fragment shader is the core of this sketch. Here is the full source exactly as it lives in shader.frag:
#version 300 es

precision mediump float;

out vec4 fragColor;
uniform float uTime;
uniform vec2 uResolution;
uniform float PI;
uniform float uSpeed;
uniform float uGlow;
uniform vec3 uPaletteA;
uniform vec3 uPaletteB;
uniform vec3 uPaletteD;

float heart(vec2 p) {
  p.y -= 0.25;
  float a = atan(p.x, p.y) / 3.14159;
  float r = length(p);
  float h = abs(a);
  float d = (13.0 * h - 22.0 * h * h + 10.0 * h * h * h) / (6.0 - 5.0 * h);
  return r - d * 0.5;
}

vec3 palette(float t) {
  vec3 c = vec3(1.0, 1.0, 1.0);
  return uPaletteA + uPaletteB * cos(6.28318 * (c * t + uPaletteD));
}

void main() {
  vec2 uv = (gl_FragCoord.xy * 2.0 - uResolution.xy) / uResolution.y;

  vec3 color = palette(heart(uv) + uTime * uSpeed);

  uv = fract(uv * 2.0);
  uv -= 0.5;

  float d = heart(uv);
  d = sin(d * PI * 2.0);
  d = abs(d);
  d = uGlow / d;

  fragColor = vec4(color * d, 1.0);
}

heart(vec2 p) — Heart SDF

The function computes the signed distance from the point p to a heart-shaped curve:
  1. Vertical offsetp.y -= 0.25 shifts the heart upward so it is centred on screen.
  2. Polar conversionatan(p.x, p.y) and length(p) convert the Cartesian point to polar coordinates (a, r).
  3. Polynomial radial profile — The heart’s radius at angle a is approximated by a cubic rational: (13h − 22h² + 10h³) / (6 − 5h) where h = |a / π|. This polynomial closely traces the classic heart curve.
  4. SDF valuer - d * 0.5 returns a positive value outside the heart, negative inside, and zero exactly on the boundary.

palette(float t) — Cosine Color Palette

The palette function maps a scalar t to a colour using Inigo Quilez’s cosine-palette formula:
color = uPaletteA + uPaletteB × cos(2π × (c × t + uPaletteD))
where c = vec3(1, 1, 1) so all three channels oscillate at the same frequency. Changing uPaletteA, uPaletteB, or uPaletteD through the GUI shifts the midpoint, amplitude, or phase of the colour cycle in real time.

main() — Two-Layer Composition

The main function runs in two passes on the same UV space:
  1. Coordinate normalisation(gl_FragCoord.xy * 2.0 - uResolution.xy) / uResolution.y maps pixel coordinates to the range −0.5 … 0.5, preserving square pixels on any aspect ratio.
  2. Background colour — The palette is sampled at heart(uv) + uTime * uSpeed. Because the heart SDF varies continuously across the screen, each pixel gets a different phase input, producing a colour gradient that ripples outward from the heart boundary over time.
  3. Detail layerfract(uv * 2.0) − 0.5 tiles the coordinate space 2×, so a second heart appears at half-size. The glow effect is computed by applying a sine wave to the SDF value, taking its absolute value, then dividing uGlow by that value — creating bright spikes at every zero crossing.
  4. Final outputcolor * d multiplies the background colour by the glow brightness, then writes the result to fragColor with full opacity.

Vue Component Structure

The Vue <script setup> wires reactive state to shader uniforms every frame:
// Hex → normalized RGB (0–1)
function hexToVec3(hex: string): [number, number, number] {
  const h = hex.replace('#', '')
  return [
    Number.parseInt(h.slice(0, 2), 16) / 255,
    Number.parseInt(h.slice(2, 4), 16) / 255,
    Number.parseInt(h.slice(4, 6), 16) / 255,
  ]
}

// Reactive state exposed to LilGui
const speed = ref(1.0)
const glow = ref(0.25)

// Palette colors as hex strings — LilGui renders color pickers
const paletteA = ref('#803333')
const paletteB = ref('#804D4D')
const paletteD = ref('#001A33')

// Compute normalized vec3 arrays from the hex refs
const paletteAVec = computed(() => hexToVec3(paletteA.value))
const paletteBVec = computed(() => hexToVec3(paletteB.value))
const paletteDVec = computed(() => hexToVec3(paletteD.value))
Inside p.draw(), every uniform is forwarded to the shader before the fullscreen rectangle is drawn:
myShader.setUniform('uTime', p.millis() / 2000.0)
myShader.setUniform('uResolution', [p.width, p.height])
myShader.setUniform('PI', Math.PI)
myShader.setUniform('uSpeed', speed.value)
myShader.setUniform('uGlow', glow.value)
myShader.setUniform('uPaletteA', paletteAVec.value)
myShader.setUniform('uPaletteB', paletteBVec.value)
myShader.setUniform('uPaletteD', paletteDVec.value)

p.rect(-p.width / 2, -p.height / 2, p.width, p.height)
The rectangle covers the entire canvas, ensuring every pixel is touched by the fragment shader on each frame.

GUI Controls

The controls array passed to <LilGui>:
const guiControls = [
  { label: 'Speed', ref: speed, min: 0.1, max: 5.0, step: 0.1 },
  { label: 'Glow', ref: glow, min: 0.01, max: 1.0, step: 0.01 },
  { label: 'Palette A', ref: paletteA, type: 'color' as const },
  { label: 'Palette B', ref: paletteB, type: 'color' as const },
  { label: 'Palette D', ref: paletteD, type: 'color' as const },
]

Speed

Multiplies uTime before it reaches the palette function. Higher values cause the colour gradient to sweep across the heart faster.

Glow

Sets the numerator of the uGlow / d expression. Larger values produce a wider, brighter halo around every heart contour in the detail layer.

Palette A / B / D

Three vec3 colour stops fed directly into the cosine-palette formula. Drag the colour pickers to shift the hue, saturation, and phase of the animation cycle.

Vertex Shader

The vertex shader simply maps p5.js aPosition attribute coordinates into clip space with position.xy = position.xy * 2.0 - 1.0, filling the entire viewport.
The hex color refs (paletteA, paletteB, paletteD) are stored as CSS hex strings so LilGui can render native color pickers. Before being uploaded to the shader they are converted to normalised [r, g, b] float triples by hexToVec3(), and the conversion is memoised by three computed refs — so no per-frame string parsing occurs inside the draw loop.

Build docs developers (and LLMs) love