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
The item URL works with no components.json setup. Files land in your tree and npm dependencies install alongside; the shader needs vgpu's WGSL loader wired once, in Setup.
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.
Between them the passes compose these modules, deduplicated across all of them; every one 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);
}