Research date: 2026-03-29 Scope: Porting MonoGame C# post-processing to web (SvelteKit + Canvas/WebGL)
The source desktop app (MonoGame C#) uses a set of post-processing effects that give scenes their "atmosphere": a darkened vignette overlay, soft glow circles for light sources, scene crossfades, and GPU-interpolated sky gradients. This document surveys how each effect translates to the web, with implementation approaches, performance costs, and honest verdicts on complexity vs. value.
The target stack is SvelteKit (Svelte 5 runes) rendering to an HTML <canvas> element. The rendering layer will be either Canvas 2D API or WebGL depending on effect requirements.
Before diving into individual effects, the architecture matters. There are three broad approaches:
Pure CSS/HTML elements layered over the canvas via absolute positioning. Zero rendering cost, but limited to blending modes available in CSS and no access to pixel data.
Effects drawn directly onto the game canvas using globalCompositeOperation and createRadialGradient. Simple, widely supported, but cannot access individual pixel data for shader-style effects.
Render the scene to a framebuffer texture, then apply one or more post-processing fragment shaders before displaying the final result on screen. This is the "correct" approach for multi-effect pipelines and matches how the MonoGame version works. It has a higher setup cost but runs entirely on the GPU.
Recommended architecture for this project: Hybrid. Use CSS overlays for static or near-static effects (vignette), Canvas 2D compositing for simple glow circles, and reserve WebGL framebuffer pipeline for effects that genuinely need per-pixel access (bloom, dithering, palette LUTs). This avoids paying the full WebGL setup cost for effects that don't need it.
function createRenderTarget(gl, width, height) {
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0,
gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const fb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D, texture, 0);
return { texture, fb };
}
// Helper: always set viewport to match render target
function bindTarget(gl, target, width, height) {
gl.bindFramebuffer(gl.FRAMEBUFFER, target?.fb ?? null);
gl.viewport(0, 0, width, height);
}
The ping-pong pattern (alternating between two render targets) is used for multi-pass effects like bloom.
256×256 pre-rendered radial gradient texture, strength 0.3, dark purple default, drawn over the scene at full canvas size each frame.
The simplest and cheapest option. A <div> absolutely positioned over the canvas:
.vignette-overlay {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(
ellipse at center,
transparent 40%,
rgba(20, 10, 40, 0.75) 140%
);
}
Strengths:
--vignette-color, --vignette-strengthWeaknesses:
mix-blend-mode)Verdict: Use this. Replaces the 256×256 texture with zero memory cost and no per-frame draw call.
Draw a radial gradient directly on the canvas after the scene is rendered:
function drawVignette(ctx, width, height, color = 'rgba(20,10,40,0.65)') {
const cx = width / 2, cy = height / 2;
const radius = Math.sqrt(cx * cx + cy * cy);
const grad = ctx.createRadialGradient(cx, cy, radius * 0.4,
cx, cy, radius * 1.2);
grad.addColorStop(0, 'transparent');
grad.addColorStop(1, color);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, width, height);
}
Strengths: Interacts with canvas pixel data; works inside the render loop. Weaknesses: Redraws every frame; moderate CPU cost at 60fps; blocks the main thread.
// fragment shader
uniform vec2 u_resolution;
uniform float u_strength;
uniform vec3 u_color;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
vec2 d = (uv - 0.5) * 2.0;
float vignette = 1.0 - dot(d, d) * u_strength;
vec4 scene = texture2D(u_scene, uv);
gl_FragColor = vec4(mix(u_color, scene.rgb, vignette), scene.a);
}
Strengths: Exact port of the MonoGame approach; GPU-only; composable with other post-processing shaders. Weaknesses: Requires full WebGL pipeline. Overkill if vignette is the only effect.
Performance comparison:
| Method | Per-frame cost | Setup | Flexibility |
|---|---|---|---|
| CSS overlay | ~0ms (compositor) | 2 lines | High via CSS vars |
| Canvas 2D gradient | ~0.3ms @ 1080p | 8 lines | Full |
| WebGL shader | ~0.05ms (GPU) | ~50 lines + pipeline | Full |
128×128 radial white-to-transparent gradient, quadratic falloff, used for soft light sources (candles, stars, etc.). Currently drawn as pre-rendered sprites.
filter: blur() + screen blend modeThe "poor man's bloom": render light sources into an absolutely positioned overlay div, apply CSS blur, and use mix-blend-mode: screen to additively blend.
.glow-layer {
position: absolute;
inset: 0;
pointer-events: none;
mix-blend-mode: screen;
filter: blur(12px);
}
// Draw glow circles into an offscreen canvas, then set as bg-image
// OR use canvas element directly with the CSS applied
Important caveat: CSS filter: blur() is notoriously expensive in practice, particularly in Firefox (historical bug 925025) and on canvas elements. A single large blur on a canvas can force software rendering fallback. Use only on small, bounded elements, never on the full-scene canvas.
Verdict: Suitable for a small number (< 10) of discrete light sources if each glow is a small element. Not suitable for scene-wide bloom.
globalCompositeOperation: 'screen'Draw pre-scaled radial gradients onto the canvas using the screen composite operation, which matches additive light blending:
function drawGlowCircle(ctx, x, y, radius, color = 'white', alpha = 0.5) {
ctx.save();
ctx.globalCompositeOperation = 'screen';
ctx.globalAlpha = alpha;
const grad = ctx.createRadialGradient(x, y, 0, x, y, radius);
grad.addColorStop(0, color);
grad.addColorStop(1, 'transparent');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
This is the direct equivalent of the MonoGame glow circle sprites. screen composite operation inverts-multiplies-inverts, producing a lighter result — exactly what additive light blending looks like.
Performance: Each radial gradient fill call costs ~0.1–0.3ms depending on radius. Fine for < 20 lights per frame at 60fps.
True bloom requires a three-stage pipeline:
// Pass 1: brightness extraction
uniform float u_threshold;
void main() {
vec4 color = texture2D(u_scene, v_uv);
float luminance = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
gl_FragColor = luminance > u_threshold ? color : vec4(0.0);
}
// Pass 2: separable Gaussian blur (horizontal, then vertical)
uniform bool u_horizontal;
float weights[5] = float[](0.2270, 0.1945, 0.1216, 0.0540, 0.0162);
void main() {
vec2 offset = u_horizontal
? vec2(1.0 / u_resolution.x, 0.0)
: vec2(0.0, 1.0 / u_resolution.y);
vec4 result = texture2D(u_tex, v_uv) * weights[0];
for (int i = 1; i < 5; i++) {
result += texture2D(u_tex, v_uv + offset * float(i)) * weights[i];
result += texture2D(u_tex, v_uv - offset * float(i)) * weights[i];
}
gl_FragColor = result;
}
// Pass 3: composite
void main() {
vec4 scene = texture2D(u_scene, v_uv);
vec4 bloom = texture2D(u_bloom, v_uv);
gl_FragColor = scene + bloom * u_bloom_intensity;
}
Performance: 2–4 blur passes (4–8 shader invocations per frame) at typical scene resolution. Negligible on desktop (< 1ms), noticeable on mobile if resolution is high.
Verdict for this project: The Canvas 2D screen compositing approach is the direct port of the existing glow sprites and should be the first implementation. Full WebGL bloom is a planned feature and can be added when the WebGL pipeline is in place.
Ordered dithering and palette limiting for a stylized retro look.
The Bayer threshold matrix is the standard approach for pixel art: a precomputed 4×4 (or 8×8) grid of thresholds that, when applied per-pixel, distributes quantization error in a structured, visually pleasing pattern.
Canvas 2D implementation (CPU, slow but works):
const BAYER_4x4 = [
0, 8, 2, 10,
12, 4, 14, 6,
3, 11, 1, 9,
15, 7, 13, 5,
];
const BAYER_SCALE = 16;
function applyDither(imageData, paletteSize = 4) {
const { data, width } = imageData;
for (let i = 0; i < data.length; i += 4) {
const px = (i / 4) % width;
const py = Math.floor((i / 4) / width);
const threshold = BAYER_4x4[(py % 4) * 4 + (px % 4)] / BAYER_SCALE;
for (let c = 0; c < 3; c++) {
const norm = data[i + c] / 255;
const quantized = Math.floor(norm * (paletteSize - 1) + threshold) / (paletteSize - 1);
data[i + c] = Math.min(255, Math.max(0, Math.round(quantized * 255)));
}
}
}
WebGL shader implementation (GPU, runs at 60fps at any resolution):
// Bayer 4x4 as uniform array
uniform float u_bayer[16];
uniform float u_palette_size;
void main() {
vec2 pixelPos = floor(gl_FragCoord.xy);
int bx = int(mod(pixelPos.x, 4.0));
int by = int(mod(pixelPos.y, 4.0));
float threshold = u_bayer[by * 4 + bx] / 16.0;
vec4 color = texture2D(u_scene, v_uv);
float n = u_palette_size - 1.0;
vec3 quantized = floor(color.rgb * n + threshold) / n;
gl_FragColor = vec4(quantized, color.a);
}
Performance: GPU-based Bayer dithering renders a full 4K frame in under 0.2ms. The Canvas 2D CPU version touching every pixel at 1080p takes ~15–30ms, making it unusable for real-time use. Use the WebGL shader version.
Palette sizes and visual results:
paletteSize |
Colors per channel | Total addressable colors |
|---|---|---|
| 2 | 1-bit | 8 (monochrome-ish) |
| 4 | 2-bit | 64 |
| 8 | 3-bit | 512 |
| 16 | 4-bit | 4096 |
Floyd-Steinberg propagates quantization error to neighboring pixels for softer transitions. It produces better results than ordered dithering for photographs but cannot be parallelized — each pixel depends on its left neighbor. This makes it GPU-hostile. It's appropriate for offline image processing (thumbnails, placeholders) but not real-time rendering. Skip for the render loop.
More visually pleasing than Bayer for organic scenes: uses a pre-computed blue noise texture (downloadable as a PNG) sampled at screen coordinates:
uniform sampler2D u_blue_noise;
uniform vec2 u_resolution;
void main() {
vec2 noiseUV = gl_FragCoord.xy / 64.0; // tile 64px noise texture
float threshold = texture2D(u_blue_noise, noiseUV).r;
vec4 color = texture2D(u_scene, v_uv);
float n = u_palette_size - 1.0;
vec3 quantized = floor(color.rgb * n + threshold) / n;
gl_FragColor = vec4(quantized, color.a);
}
A 3D LUT (Look-Up Table) maps any input RGB triplet to a new output color. It is the standard tool for cinematic color grading and palette limiting in games.
A 3D LUT is stored as a 2D texture of cube slices. A 16×16×16 LUT is stored as a 256×16 texture (16 slices of 16×16). To sample it:
vec4 applyLUT(sampler2D lut, vec3 color, float lutSize) {
float sliceSize = 1.0 / lutSize;
float sliceInner = (lutSize - 1.0) / lutSize;
float zSlice0 = floor(color.b * (lutSize - 1.0));
float zSlice1 = min(zSlice0 + 1.0, lutSize - 1.0);
float xOffset = sliceSize * 0.5 + color.r * sliceInner * sliceSize;
float yRange = (color.g * (lutSize - 1.0) + 0.5) / lutSize;
float zOffset = (color.b * (lutSize - 1.0) - zSlice0);
vec4 s0 = texture2D(lut, vec2(zSlice0 * sliceSize + xOffset, yRange));
vec4 s1 = texture2D(lut, vec2(zSlice1 * sliceSize + xOffset, yRange));
return mix(s0, s1, zOffset);
}
This is the cleanest way to implement the "palette limiting" feature planned in the desktop app. The LUT itself encodes the palette restriction, and changing the mood of a scene is as simple as swapping the LUT texture.
If you don't want a pre-authored LUT, posterization quantizes each channel directly:
uniform float u_levels; // e.g. 4.0 = 4 shades per channel
void main() {
vec4 color = texture2D(u_scene, v_uv);
vec3 posterized = floor(color.rgb * u_levels) / u_levels;
gl_FragColor = vec4(posterized, color.a);
}
Performance: Both LUT and posterization run in a single pass at negligible GPU cost. A 16³ LUT texture is 16KB.
CSS filter composites effects on any HTML element, including <canvas>. Available functions:
| Filter | Use case | Performance |
|---|---|---|
blur(Xpx) |
Soft glow approximation | Expensive (GPU, but can fallback to CPU) |
brightness(X) |
Fade to black/white | Cheap |
contrast(X) |
Stylistic punch | Cheap |
saturate(X) |
Desaturate for night/rain mood | Cheap |
hue-rotate(Xdeg) |
Color shift for mood | Cheap |
sepia(X) |
Aged/warm look | Cheap |
Filters except blur are cheap — they apply as a single-pass GPU color matrix. blur is expensive because Gaussian blur is O(radius²) and large radii on large canvases can force software rendering, particularly in Firefox.
Stacking filters:
canvas.night-mode {
filter: brightness(0.6) saturate(0.4) hue-rotate(200deg);
}
Multiple filters on one element are composed as a single GPU pass — no additional cost for stacking cheap filters.
For scene mood transitions (entering night, storm, etc.), animating CSS filter values with a CSS transition is extremely efficient and requires zero JS per-frame work:
canvas {
transition: filter 1.5s ease;
}
canvas.night {
filter: brightness(0.5) saturate(0.3) hue-rotate(180deg);
}
Performance verdict: Use CSS filters liberally for everything except blur on large elements. Apply blur only to small, bounded elements.
globalCompositeOperation ReferenceFor Canvas 2D post-processing, the composite operation controls how new draws interact with existing pixels:
| Mode | Effect | Use case |
|---|---|---|
source-over |
Normal alpha blend (default) | Everything |
screen |
Additive light | Glow, bloom, lens flares |
lighten |
Keep lighter pixel | Fog, soft light |
multiply |
Darken overlap | Shadows, tinting |
overlay |
Contrast enhancement | Color grading |
color-burn |
Deep shadow darkening | Night atmosphere |
luminosity |
Transfer brightness only | Preserve color, change brightness |
Setting globalCompositeOperation before drawing an element applies that blend to every pixel of that draw call. Restore with ctx.save() / ctx.restore().
// Additive glow: same as 'screen' blend mode
ctx.globalCompositeOperation = 'screen';
// Draw glow sprite here
ctx.globalCompositeOperation = 'source-over'; // reset
Performance: All composite operations run on the GPU via the Canvas compositing pipeline. The mode itself has no meaningful performance difference; the cost is the fill area of the draw calls.
1.5s crossfade: interpolates opacity, particle positions, and light intensities between two scenes.
Maintain two canvas elements, animate opacity between them:
<!-- Svelte 5 runes -->
<script>
let alpha = $state(1);
async function transitionTo(newScene) {
// Render new scene to back canvas while front is still visible
renderScene(backCanvas, newScene);
// Animate opacity
const start = performance.now();
const duration = 1500;
function frame(now) {
const t = Math.min((now - start) / duration, 1);
const eased = t < 0.5 ? 2*t*t : -1+(4-2*t)*t; // ease in-out quad
alpha = 1 - eased;
if (t < 1) requestAnimationFrame(frame);
else swapCanvases();
}
requestAnimationFrame(frame);
}
</script>
<div class="scene-container">
<canvas bind:this={frontCanvas} style:opacity={alpha} />
<canvas bind:this={backCanvas} style:opacity={1 - alpha} />
</div>
For interpolated lights and particles during the transition, lerp their positions and intensities with the same eased t value.
SvelteKit 1.24+ supports the View Transitions API for page-level crossfades:
document.startViewTransition(() => {
renderNewScene();
});
This works well for route changes (moving between study modes), but not for in-scene atmospheric transitions where particles and lights must interpolate.
Verdict: Use dual-canvas opacity lerp for in-scene transitions. Use View Transitions API for route-level page changes.
GPU-interpolated vertex-colored sky gradients with zero banding (because the MonoGame renderer uses HDR color interpolation internally).
CSS gradients and Canvas 2D linear gradients operate in 8-bit sRGB. On a monitor with subtle banding, a dark sky gradient (navy → midnight → black) over 1080px will show ~5–8 visible bands.
<svg width="0" height="0" aria-hidden="true">
<filter id="grain" color-interpolation-filters="sRGB"
x="0" y="0" width="1" height="1">
<feTurbulence type="fractalNoise" baseFrequency="0.75"
numOctaves="4" stitchTiles="stitch"/>
<feDisplacementMap in="SourceGraphic" scale="8"
xChannelSelector="R" yChannelSelector="G"/>
</filter>
</svg>
<style>
.sky-gradient {
background: linear-gradient(180deg, #0a0a1a 0%, #1a1030 60%, #2a1a50 100%);
filter: url(#grain);
clip-path: inset(0); /* prevent overflow */
}
</style>
The displacement map uses two noise channels to slightly offset pixel positions, breaking up the banding into imperceptible grain without altering the overall colors.
function drawAntiAliasedGradient(ctx, width, height, stops) {
const grad = ctx.createLinearGradient(0, 0, 0, height);
stops.forEach(([pos, color]) => grad.addColorStop(pos, color));
ctx.fillStyle = grad;
ctx.fillRect(0, 0, width, height);
// Add subtle noise to break banding
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const noise = (Math.random() - 0.5) * 6; // ±3 units of noise
data[i] = Math.min(255, Math.max(0, data[i] + noise));
data[i + 1] = Math.min(255, Math.max(0, data[i + 1] + noise));
data[i + 2] = Math.min(255, Math.max(0, data[i + 2] + noise));
}
ctx.putImageData(imageData, 0, 0);
}
Performance note: This reads and writes every pixel in CPU, at 1920×400 sky strip that's ~300K pixel operations. At 60fps this is unusable. Pre-render the sky once and cache it.
If sky is rendered as a quad in WebGL, add a small dither in the fragment shader:
// In sky fragment shader
vec3 color = mix(u_sky_bottom, u_sky_top, v_position.y);
// Triangular noise: two rand calls averaged (removes DC bias)
float r1 = fract(sin(dot(gl_FragCoord.xy, vec2(127.1, 311.7))) * 43758.5);
float r2 = fract(sin(dot(gl_FragCoord.xy, vec2(269.5, 183.3))) * 31415.9);
float noise = (r1 + r2 - 1.0) / 255.0; // ±0.5/255 range
gl_FragColor = vec4(color + noise, 1.0);
This is the exact equivalent of what MonoGame's GPU vertex color interpolation achieves: sub-pixel noise eliminates quantization bands at zero visual cost.
Not in the current desktop app but frequently paired with the above effects for a retro/filmic look.
uniform float u_aberration_strength;
void main() {
vec2 uv = v_uv;
vec2 offset = (uv - 0.5) * u_aberration_strength;
float r = texture2D(u_scene, uv + offset).r;
float g = texture2D(u_scene, uv).g;
float b = texture2D(u_scene, uv - offset).b;
gl_FragColor = vec4(r, g, b, 1.0);
}
Three texture samples per pixel. Negligible cost. Works well combined with vignette in a single composite shader pass.
| Effect | Approach | When to implement |
|---|---|---|
| Vignette | CSS radial-gradient overlay | Phase 1 (now) |
| Glow circles | Canvas 2D screen compositing |
Phase 1 (now) |
| Scene transitions | Dual-canvas opacity lerp | Phase 1 (now) |
| Sky anti-banding | WebGL vertex dither or SVG noise | Phase 1 (now) |
| CSS mood filters | CSS filter on <canvas> |
Phase 1 (now) |
| Bloom (full) | WebGL 3-pass pipeline | Phase 2 (when WebGL pipeline exists) |
| Dithering | WebGL Bayer matrix shader | Phase 2 |
| Palette LUT | WebGL 3D LUT shader | Phase 2 |
| Chromatic aberration | WebGL shader (single pass) | Phase 3 (optional) |
Key architectural decision: The Phase 1 effects (CSS + Canvas 2D) can all be implemented without a WebGL pipeline. When Phase 2 effects are needed, set up the WebGL render-to-texture infrastructure once, and all shader-based effects can share it.
SvelteKit component pattern:
<script>
import { onMount, onDestroy } from 'svelte';
let canvas;
let gl;
let frameId;
onMount(() => {
gl = canvas.getContext('webgl2');
// init shaders, framebuffers, etc.
frameId = requestAnimationFrame(renderLoop);
});
onDestroy(() => {
cancelAnimationFrame(frameId);
// cleanup WebGL resources
gl.getExtension('WEBGL_lose_context')?.loseContext();
});
</script>
<div class="scene-wrapper">
<canvas bind:this={canvas} />
<div class="vignette-overlay" /> <!-- CSS vignette -->
</div>
| Feature | Chrome | Firefox | Safari | Notes |
|---|---|---|---|---|
| CSS radial-gradient | 100% | 100% | 100% | Universal |
| Canvas 2D compositing | 100% | 100% | 100% | Universal |
| WebGL 2.0 | 100% | 100% | 100% (15+) | Safari support since 2021 |
CSS filter |
100% | 100% | 100% | All non-blur filters cheap |
mix-blend-mode: screen |
100% | 100% | 100% | Universal |
| OffscreenCanvas | Chrome/Edge/FF | FF 105+ | Limited | Safari support incomplete |
| View Transitions API | Chrome 111+ | FF 119+ | 18+ | Progressively enhanced |
SVG feTurbulence |
100% | 100% | 100% | Universal |
image-rendering: pixelated |
Yes | Yes | Yes | ~95% global coverage |