Glow Bloom
Post-processing bloom effect — bright shapes bloom and bleed light into surrounding space with adjustable spread and intensity
Post-processing bloom effect — bright shapes bloom and bleed light into surrounding space with adjustable spread and intensity
import { Scene, Entity, CanvasRenderer } from '@vectojs/core';
class BloomPipeline extends Entity {
brightCanvas = document.createElement('canvas');
bloomCanvas = document.createElement('canvas');
time = 0;
blurRadius = 12;
intensity = 0.4;
glowColor = '#d97757';
coreColor = '#ffffff';
mouseX = -1;
mouseY = -1;
private _w = 0;
private _h = 0;
private _blurW = 0;
private _blurH = 0;
isPointInside() {
return false;
}
hasPendingAnimations() {
return true;
}
update(dt: number) {
this.time += dt;
}
render(r: CanvasRenderer) {
const W = this.scene.width;
const H = this.scene.height;
if (W === 0 || H === 0) return;
if (this._w !== W || this._h !== H) {
this._w = W;
this._h = H;
this.brightCanvas.width = W;
this.brightCanvas.height = H;
}
const blurW = Math.max(1, Math.round(W * 0.4));
const blurH = Math.max(1, Math.round(H * 0.4));
if (this._blurW !== blurW || this._blurH !== blurH) {
this._blurW = blurW;
this._blurH = blurH;
this.bloomCanvas.width = blurW;
this.bloomCanvas.height = blurH;
}
const bc = this.brightCanvas.getContext('2d')!;
bc.clearRect(0, 0, W, H);
this.drawBrightContent(bc, W, H, this.time * 0.001);
const blc = this.bloomCanvas.getContext('2d')!;
blc.clearRect(0, 0, blurW, blurH);
blc.filter = `blur(${this.blurRadius}px)`;
blc.drawImage(this.brightCanvas, 0, 0, blurW, blurH);
blc.filter = 'none';
const ctx = r.getContext();
r.save();
ctx.fillStyle = '#1a1a1e';
ctx.fillRect(0, 0, W, H);
// IRenderer exposes setGlobalAlpha(a) — there is NO `globalAlpha` property.
// Assigning one only adds an expando to the renderer, leaving the bloom
// layer fully opaque; that is why the intensity slider had no visible
// effect (measured: ctx.globalAlpha stayed 1 at every drawImage).
r.setGlobalAlpha(this.intensity);
r.drawImage(this.bloomCanvas, 0, 0, W, H);
r.setGlobalAlpha(1);
// IRenderer.drawImage requires the full 5-arg (source, dx, dy, dw, dh)
// signature — unlike native CanvasRenderingContext2D.drawImage, it has
// no 3-arg overload, so omitting dw/dh passes them through as
// `undefined` and the native call silently draws nothing (this was
// why the sharp foreground never appeared, only the blurred bloom).
r.drawImage(this.brightCanvas, 0, 0, W, H);
r.restore();
}
drawBrightContent(ctx: CanvasRenderingContext2D, W: number, H: number, t: number) {
ctx.save();
const cx = W / 2;
const cy = H / 2;
const fontSize = Math.min(96, W * 0.13);
ctx.font = `900 ${fontSize}px "Inter", system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = this.coreColor;
ctx.fillText('BLOOM', cx, cy);
const ringR = Math.min(W, H) * 0.35 + Math.sin(t * 1.5) * 8;
ctx.beginPath();
ctx.arc(cx, cy, ringR, 0, Math.PI * 2);
ctx.strokeStyle = this.glowColor;
ctx.lineWidth = 2 + Math.sin(t * 2) * 1;
ctx.stroke();
for (let i = 0; i < 6; i++) {
const angle = (i / 6) * Math.PI * 2 + t * 0.8;
const radius = Math.min(W, H) * 0.28;
const ox = cx + Math.cos(angle) * radius;
const oy = cy + Math.sin(angle) * radius;
const size = 16 + Math.sin(t * 3 + i) * 5;
ctx.beginPath();
ctx.arc(ox, oy, size, 0, Math.PI * 2);
ctx.fillStyle = i % 2 === 0 ? this.glowColor : '#fff';
ctx.fill();
}
for (let i = 0; i < 24; i++) {
const angle = (i / 24) * Math.PI * 2 + t * 0.5;
const radius = Math.min(W, H) * 0.42;
const dx = cx + Math.cos(angle) * radius;
const dy = cy + Math.sin(angle) * radius;
ctx.beginPath();
ctx.arc(dx, dy, 3 + Math.sin(t * 2 + i) * 2, 0, Math.PI * 2);
ctx.fillStyle = this.glowColor;
ctx.fill();
}
if (this.mouseX >= 0 && this.mouseY >= 0) {
const g = ctx.createRadialGradient(
this.mouseX,
this.mouseY,
0,
this.mouseX,
this.mouseY,
100,
);
g.addColorStop(0, 'rgba(255,255,255,0.9)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.fillRect(this.mouseX - 100, this.mouseY - 100, 200, 200);
}
ctx.restore();
}
}
const app = document.getElementById('app')!;
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const blurInput = document.getElementById('input-blur') as HTMLInputElement;
const intenseInput = document.getElementById('input-intense') as HTMLInputElement;
const glowColorInput = document.getElementById('input-glow-color') as HTMLInputElement;
const coreColorInput = document.getElementById('input-core-color') as HTMLInputElement;
const scene = new Scene(canvas, {
// 'always' is Scene's default renderMode; the bloom animates continuously.
maxFPS: 60,
disableWindowResize: true,
maxDPR: 2,
});
const bloom = new BloomPipeline();
scene.add(bloom);
scene.start();
blurInput.addEventListener('input', () => {
bloom.blurRadius = Number(blurInput.value);
document.getElementById('value-blur')!.textContent = `${bloom.blurRadius}px`;
});
intenseInput.addEventListener('input', () => {
bloom.intensity = Number(intenseInput.value) / 20;
document.getElementById('value-intense')!.textContent = bloom.intensity.toFixed(1);
});
glowColorInput.addEventListener('input', () => {
bloom.glowColor = glowColorInput.value;
});
coreColorInput.addEventListener('input', () => {
bloom.coreColor = coreColorInput.value;
});
canvas.addEventListener('mousemove', (e) => {
const r = canvas.getBoundingClientRect();
bloom.mouseX = e.clientX - r.left;
bloom.mouseY = e.clientY - r.top;
});
canvas.addEventListener('mouseleave', () => {
bloom.mouseX = -1;
bloom.mouseY = -1;
});
const observer = new ResizeObserver(() => {
const w = app.clientWidth;
const h = app.clientHeight;
if (w > 0 && h > 0) scene.resize(w, h);
});
observer.observe(app);
document.getElementById('hud')!.textContent =
`bloom ${bloom.blurRadius}px · intensity ${bloom.intensity.toFixed(1)}\n` +
`move mouse to paint glow`;
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="/no-ff-webgpu.js"></script>
<title>Glow Bloom — Motif</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;900&family=JetBrains+Mono:wght@500&display=swap"
rel="stylesheet"
/>
<style>
* {
margin: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
background: #1a1a1e;
}
#app {
position: relative;
width: 100%;
height: 100%;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
#panel {
position: absolute;
top: 14px;
left: 14px;
right: 14px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font:
500 12px "Inter",
sans-serif;
z-index: 10;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
padding: 10px 12px;
color: #e8e4de;
}
#panel .field {
display: flex;
align-items: center;
gap: 6px;
}
#panel label {
color: #a09888;
font-size: 11px;
white-space: nowrap;
}
#panel input[type="range"] {
width: 72px;
accent-color: #d97757;
}
#panel input[type="color"] {
width: 26px;
height: 26px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
cursor: pointer;
background: none;
}
#panel .value {
font:
500 11px "JetBrains Mono",
monospace;
color: #a09888;
min-width: 28px;
}
#panel .divider {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.14);
}
#hud {
position: absolute;
top: 68px;
left: 14px;
font:
500 12px "JetBrains Mono",
monospace;
color: #e8e4de;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 7px;
padding: 8px 12px;
line-height: 1.6;
z-index: 10;
white-space: pre;
}
</style>
<script type="importmap">
{
"imports": {
"@vectojs/core": "https://esm.sh/@vectojs/core@1.26.0"
}
}
</script>
</head>
<body>
<div id="app">
<canvas id="canvas"></canvas>
<div id="panel">
<div class="field">
<label for="input-blur">Bloom</label>
<input
id="input-blur"
type="range"
min="1"
max="30"
step="1"
value="12"
/>
<span class="value" id="value-blur">12px</span>
</div>
<div class="field">
<label for="input-intense">Intensity</label>
<input
id="input-intense"
type="range"
min="1"
max="20"
step="1"
value="8"
/>
<span class="value" id="value-intense">0.4</span>
</div>
<div class="divider"></div>
<div class="field">
<label for="input-glow-color">Glow</label>
<input id="input-glow-color" type="color" value="#d97757" />
<input id="input-core-color" type="color" value="#ffffff" />
</div>
</div>
<div id="hud">BLOOM</div>
</div>
<script type="module" src="./demo.js"></script>
</body>
</html>