heatmap@0.1.0

Heatmap

A glowing gradient of heat flowing through any shape, ported from Paper Shaders' heatmap: staggered soft waves travel through the interior, an animated band sweeps the outer glow, and the summed heat walks a four-color ramp. Feed it a logo through the shipped preparer or, as here, a rotating model whose packed channels are built live on the GPU.

Controls

Background
#05020D
Heat 1
#260D59
Heat 2
#D92659
Heat 3
#FF8C26
Heat 4
#FFF2BF
Colors4
Contour0.50
Inner glow0.50
Outer glow0.35
Angle0
Noise0.25
Speed1.00

Install

One command copies the composition into your project (with the @vshaders namespace registered in components.json; the plain URL https://vshaders.com/r/heatmap.json works with no setup). npm dependencies install alongside.

npx shadcn@latest add @vshaders/heatmap

Files that land in your tree: shaders/heatmap.wgsl, shaders/heatmap-unpack.wgsl, lib/heatmap-prepare.ts, wgsl-env.d.ts.

The heatmap is a post-process pass over one packed texture: R holds the lightly blurred shape (dark on white), G a wide outer blur, B a tight inner blur. lib/heatmap-prepare.ts builds that packing from any logo (PNG/JPG/SVG) as ImageData on the CPU. To get it onto the GPU in vgpu (targets take no direct pixel upload), use the shipped shaders/heatmap-unpack.wgsl: write image.data into a storage(gpu, byteLength, "read") buffer, bind it as `pixels` on an unpack effect (uniforms: resolution = target size, imageSize = image size), draw that one pass into an offscreen target(), then bind the target as this shader's srcTexture with a linear srcSampler and draw to your canvas. Colors are display-referred sRGB (hex / 255, no linearization). Uniforms: colorBack, color1-4 + colorsCount, contour, innerGlow, outerGlow, angle, noise, speed. Ported from Paper Shaders (github.com/paper-design/shaders, Apache-2.0). The full pattern with code: https://vshaders.com/docs/effects. Live demo: https://vshaders.com/effects/heatmap.

Passes

Rendered as 5 shader passes into offscreen textures each frame, in this order; only the display pass draws to the canvas.

scene.wgsl · The demo stage: the model as a flat black silhouette on white.

blur.wgsl · One direction of a separable Gaussian; run in pairs to build the packed channels live.

pack.wgsl · Folds contour, wide, and tight blurs into the filter's packed R/G/B contract.

unpack.wgsl · Writes a CPU-prepared logo (lib/heatmap-prepare.ts) from a storage buffer into the pack target.

display.wgsl · The filter itself - the registry item, ported from Paper Shaders: heat waves through the packed shape.

Imports

What the passes compose, deduplicated across all of them; every module resolves from npm.

import { clamp01 } from "@vgpu/wgsl-std/math";

import { hash2 } from "@vgpu/wgsl-std/hash";

Source

The display pass, heatmap/display.wgsl; the other passes follow below. Copy them and they are yours.

// heatmap · display — the filter, and the shader the registry ships.
// A glowing gradient of heat flowing through an input shape: three staggered
// soft waves travel upward through the shape's interior, an animated band
// sweeps its outer glow, and the summed heat indexes a ramp of up to four
// colors over the background. The input is one *packed* texture — R holds
// the lightly blurred shape (dark shape on white ground), G a wide outer
// blur, B a tight inner blur — exactly the channel contract of Paper's
// preprocessed heatmap image; lib/heatmap-prepare.ts builds it from any logo,
// and the gallery builds it live from a rotating GLB.
//
// Ported to WGSL from Paper Shaders' heatmap fragment shader
// (github.com/paper-design/shaders, packages/shaders/src/shaders/heatmap.ts,
// Apache-2.0, Copyright paper-design). Changes: rewritten in WGSL on
// vshaders' scene-texture architecture (a live texture as input, so the demo
// can feed a rotating model); their image fit/rotate/offset box dropped
// (the source is a fullscreen texture); their Apple-logo easter-egg circles
// and decorative ball shapes removed from the traveling wave; the color ramp
// carries 4 colors instead of 10 (our control system is scalar/vec3), alpha
// compositing simplified to opaque; the in-shader 3x3 refinement blur
// dropped (our G channel is a real GPU or CPU blur).
// Works display-referred like the print filters: colors are authored in sRGB.

import { clamp01 } from "@vgpu/wgsl-std/math";
import { hash2 } from "@vgpu/wgsl-std/hash";

struct Uniforms {
  // Resolution of the output, physical pixels.
  resolution: vec2f,
  time: f32,
  // Multiplies time in every animation term.
  speed: f32,
  // The ground the heat glows over.
  colorBack: vec3f,
  // How many ramp colors are active, 1 to 4.
  colorsCount: f32,
  // The heat ramp, cold to hot.
  color1: vec3f,
  // Heat pinned to the shape's edges, 0 to 1.
  contour: f32,
  color2: vec3f,
  // Size of the heated area inside the shape, 0 to 1.
  innerGlow: f32,
  color3: vec3f,
  // Size of the heated area outside the shape, 0 to 1.
  outerGlow: f32,
  color4: vec3f,
  // Direction of the heat waves, degrees.
  angle: f32,
  // Grain over the whole graphic, 0 to 1.
  noise: f32,
}

@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var srcTexture: texture_2d<f32>;
@group(0) @binding(2) var srcSampler: sampler;

fn lst(edge0: f32, edge1: f32, x: f32) -> f32 {
  return clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0);
}

fn circleShape(uv: vec2f, c: vec2f, r: vec2f) -> f32 {
  return 1.0 - smoothstep(r.x, r.y, length(uv - c));
}

// The traveling wave: a squashed soft disc rising through the shape, its top
// flattened into a gradient front. Paper's shadowShape, sans easter eggs.
fn shadowShape(uv: vec2f, t: f32) -> f32 {
  var scaledUV = uv;
  let posY = mix(-1.0, 2.0, t);

  scaledUV.y -= 0.5;
  let mainCircleScale = smoothstep(0.0, 0.8, posY) * lst(1.4, 0.9, posY);
  scaledUV *= vec2f(1.0, 1.0 + 1.5 * mainCircleScale);
  scaledUV.y += 0.5;

  let innerR = 0.4;
  let outerR = 1.0 - 0.3 * (smoothstep(0.1, 0.2, t) * (1.0 - smoothstep(0.2, 0.5, t)));
  var s = circleShape(scaledUV, vec2f(0.5, posY - 0.2), vec2f(innerR, outerR));
  s = pow(s, 1.4) * 1.2;

  // The flat gradient front that takes over as the wave passes.
  let pos = posY - uv.y;
  var topFlattener = lst(-0.4, 0.0, pos) * (1.0 - smoothstep(0.0, 1.2, pos));
  topFlattener = pow(topFlattener, 3.0);
  let topFlattenerMixer = 1.0 - smoothstep(0.0, 0.3, pos);
  s = mix(topFlattener, s, topFlattenerMixer);

  return clamp01(s);
}

@fragment
fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
  let img = textureSampleLevel(srcTexture, srcSampler, uv, 0.0).rgb;

  var t = 0.1 * uniforms.time * uniforms.speed - 0.3;
  var tCopy = fract(t + 1.0 / 3.0);
  var tCopy2 = fract(t + 2.0 / 3.0);
  t = fract(t);

  // Waves travel along `angle`.
  let angle = -uniforms.angle * 0.0174533;
  let cosA = cos(angle);
  let sinA = sin(angle);
  var animationUV = uv - vec2f(0.5);
  animationUV = vec2f(
    animationUV.x * cosA - animationUV.y * sinA,
    animationUV.x * sinA + animationUV.y * cosA,
  ) + vec2f(0.5);

  // The packed channels: dark shape on white ground.
  let shape = img.r;
  let outerBlur = 1.0 - mix(1.0, img.g, shape);
  let innerBlur = mix(img.g, 0.0, shape);
  let contourBand = mix(img.b, 0.0, shape);

  let shadow = shadowShape(animationUV, t);
  let shadowCopy = shadowShape(animationUV, tCopy);
  let shadowCopy2 = shadowShape(animationUV, tCopy2);

  var inner = 0.8 + 0.8 * innerBlur;
  inner = mix(inner, 0.0, shadow);
  inner = mix(inner, 0.0, shadowCopy);
  inner = mix(inner, 0.0, shadowCopy2);
  inner *= mix(0.0, 2.0, uniforms.innerGlow);
  inner += (uniforms.contour * 2.0) * contourBand;
  inner = min(inner, 1.0);
  inner *= 1.0 - shape;

  var outer = 0.9 * pow(clamp01(outerBlur), 0.8);
  let tOuter = fract(t * 3.0 - 0.1);
  let y = fract(animationUV.y - tOuter);
  var animatedMask = smoothstep(0.3, 0.65, y) * (1.0 - smoothstep(0.65, 1.0, y));
  animatedMask = 0.5 + animatedMask;
  outer *= animatedMask;
  outer *= mix(0.0, 5.0, pow(uniforms.outerGlow, 2.0));

  inner = pow(inner, 1.2);
  var heat = clamp01(inner + outer);
  heat += (0.005 + 0.35 * uniforms.noise) * (hash2(position.xy * 0.771).x - 0.5);

  // The ramp: up to four colors, walked one unit of heat at a time.
  let count = clamp(round(uniforms.colorsCount), 1.0, 4.0);
  let mixer = heat * count;
  var gradient = uniforms.color1;
  let outerShape = clamp01(mixer);
  gradient = mix(gradient, uniforms.color2, clamp01(mixer - 1.0) * step(2.0, count));
  gradient = mix(gradient, uniforms.color3, clamp01(mixer - 2.0) * step(3.0, count));
  gradient = mix(gradient, uniforms.color4, clamp01(mixer - 3.0) * step(4.0, count));

  var color = mix(uniforms.colorBack, gradient, outerShape);
  color += 0.02 * (hash2(position.xy * 0.913 + 1.0).x - 0.5);

  return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), 1.0);
}
heatmap/scene.wgsl
// heatmap · scene — the demo's stage, not the product. The model rendered as
// a flat silhouette: black shape on a white ground, the luma convention the
// packed-channel pipeline expects (dark = inside). The blur and pack passes
// turn this into the filter's input; the registry ships the filter and the
// CPU preparer only.

struct Uniforms {
  // Camera clip transform and the model's orbit pose, column-major.
  viewProjection: mat4x4f,
  model: mat4x4f,
}

@group(0) @binding(0) var<uniform> uniforms: Uniforms;

struct VertexOut {
  @builtin(position) position: vec4f,
}

@vertex
fn vs_main(@location(0) position: vec3f, @location(1) normal: vec3f) -> VertexOut {
  var out: VertexOut;
  out.position = uniforms.viewProjection * uniforms.model * vec4f(position, 1.0);
  return out;
}

@fragment
fn fs_main() -> @location(0) vec4f {
  return vec4f(0.0, 0.0, 0.0, 1.0);
}
heatmap/blur.wgsl
// heatmap · blur — one direction of a separable Gaussian over the red
// channel, run in H/V pairs (twice for the wide outer glow) to build the
// packed channels live from the silhouette. Demo plumbing, not the product.

import { clamp01 } from "@vgpu/wgsl-std/math";

struct Uniforms {
  // Resolution of the TARGET being written, physical pixels.
  resolution: vec2f,
  // Blur axis: (1,0) horizontal, (0,1) vertical.
  direction: vec2f,
  // Tap spacing in source texels; the effective radius is ~6x this.
  radius: f32,
}

@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var srcTexture: texture_2d<f32>;
@group(0) @binding(2) var srcSampler: sampler;

@fragment
fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
  let texel = uniforms.direction / max(uniforms.resolution, vec2f(1.0));
  let step = texel * max(uniforms.radius, 0.0);
  // 13-tap Gaussian, sigma ~2 in tap units.
  var sum = textureSampleLevel(srcTexture, srcSampler, uv, 0.0).r * 0.1974;
  var weights = array<f32, 6>(0.1747, 0.1210, 0.0656, 0.0278, 0.0092, 0.0024);
  for (var i = 1; i <= 6; i++) {
    let offset = step * f32(i);
    let w = weights[i - 1];
    sum += textureSampleLevel(srcTexture, srcSampler, uv + offset, 0.0).r * w;
    sum += textureSampleLevel(srcTexture, srcSampler, uv - offset, 0.0).r * w;
  }
  return vec4f(vec3f(clamp01(sum)), 1.0);
}
heatmap/pack.wgsl
// heatmap · pack — folds the silhouette pipeline into the filter's packed
// contract: R = the lightly blurred shape (contour source), G = the wide
// outer blur, B = the tight inner blur. Demo plumbing, not the product;
// lib/heatmap-prepare.ts builds the same packing on the CPU for logos.

struct Uniforms {
  // Resolution of the pack target, physical pixels.
  resolution: vec2f,
}

@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var contourTexture: texture_2d<f32>;
@group(0) @binding(2) var bigTexture: texture_2d<f32>;
@group(0) @binding(3) var innerTexture: texture_2d<f32>;
@group(0) @binding(4) var srcSampler: sampler;

@fragment
fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
  let contour = textureSampleLevel(contourTexture, srcSampler, uv, 0.0).r;
  let big = textureSampleLevel(bigTexture, srcSampler, uv, 0.0).r;
  let inner = textureSampleLevel(innerTexture, srcSampler, uv, 0.0).r;
  return vec4f(contour, big, inner, 1.0);
}
heatmap/unpack.wgsl
// heatmap · unpack — writes a CPU-prepared packed image (see
// lib/heatmap-prepare.ts) from a storage buffer into the pack target, one
// u32 RGBA texel per element. The bridge for dropped logos: vgpu targets
// have no copy_dst, so the pixels arrive by buffer instead. Demo plumbing.

struct Uniforms {
  // Resolution of the pack target, physical pixels.
  resolution: vec2f,
  // Dimensions of the buffered image, texels.
  imageSize: vec2f,
}

@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var<storage, read> pixels: array<u32>;

@fragment
fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
  let size = max(uniforms.imageSize, vec2f(1.0));
  // Fit the image centered in the target, contain-style.
  let targetAspect = uniforms.resolution.x / max(uniforms.resolution.y, 1.0);
  let imageAspect = size.x / size.y;
  var fitted = uv - vec2f(0.5);
  if (targetAspect > imageAspect) {
    fitted.x *= targetAspect / imageAspect;
  } else {
    fitted.y *= imageAspect / targetAspect;
  }
  fitted += vec2f(0.5);
  if (fitted.x < 0.0 || fitted.x > 1.0 || fitted.y < 0.0 || fitted.y > 1.0) {
    // Outside the image: bare ground (white), no blur reach.
    return vec4f(1.0, 1.0, 1.0, 1.0);
  }
  let texelCoord = vec2u(clamp(fitted, vec2f(0.0), vec2f(0.9999)) * size);
  let raw = pixels[texelCoord.y * u32(size.x) + texelCoord.x];
  let r = f32(raw & 0xffu) / 255.0;
  let g = f32((raw >> 8u) & 0xffu) / 255.0;
  let b = f32((raw >> 16u) & 0xffu) / 255.0;
  return vec4f(r, g, b, 1.0);
}