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
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/heatmapFiles 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);
}