Research date: March 2026 Context: BibleWeb SvelteKit app — porting pixel art atmospheric scenes from a MonoGame C# desktop app. Scenes include procedural Perlin-noise landscapes, animated sky gradients, particle systems (20–500 particles), dynamic lighting (moonlight, campfire, sun rays), post-processing (vignette, bloom, dithering), and 1.5s crossfade scene transitions.
The HTML5 Canvas 2D API is the most direct web analogue to MonoGame's SpriteBatch-style 2D rendering. It offers pixel-level access via ImageData and is universally supported. For pixel art, it is often the simplest starting point.
The core mechanism for procedural generation (Perlin noise landscapes, sky gradients) is ImageData:
const ctx = canvas.getContext('2d', { willReadFrequently: false });
const imageData = ctx.createImageData(width, height);
const data = imageData.data; // Uint8ClampedArray, RGBA per pixel
for (let i = 0; i < data.length; i += 4) {
data[i] = r; // red
data[i + 1] = g; // green
data[i + 2] = b; // blue
data[i + 3] = 255; // alpha
}
ctx.putImageData(imageData, 0, 0);
Writing each frame's terrain via putImageData is the most faithful port of MonoGame's per-pixel noise sampling. The downside is that this is a CPU-bound path — the pixel buffer must be written on the CPU then uploaded to the GPU each frame. For a static or slow-changing background (landscape, sky gradient) this is perfectly acceptable. For a 60fps fully dynamic scene it can become a bottleneck.
To render a low-resolution pixel art buffer (e.g. 320×180) scaled up to fill a larger canvas without blur, disable smoothing:
ctx.imageSmoothingEnabled = false;
For CSS scaling of the canvas element itself:
canvas {
image-rendering: pixelated; /* Chrome 41+, Edge 76+, Safari 10+ */
image-rendering: crisp-edges; /* Firefox */
}
This ensures 1:1 pixel mapping when the canvas is scaled, which is essential for a pixel art aesthetic.
Chrome's Canvas 2D API update added several useful capabilities:
ctx.reset() — clears canvas and resets all state in one callnew CanvasFilter() — programmatic filter application (Gaussian blur, color matrix, convolution)willReadFrequently: true context option — hints to the browser to keep the canvas on CPU memory, drastically speeding up getImageData() calls for read-heavy workflowscreateConicGradient() — useful for angular sky gradient effectscontextlost, contextrestored) for graceful GPU memory handling| Aspect | Assessment |
|---|---|
| Terrain via putImageData | CPU-bound; fine for 30fps backgrounds |
| Particle sprites (drawImage) | ~100–500 particles feasible at 60fps |
| Post-processing passes | Not native; requires manual pixel loops |
| GPU compositing | Canvas is GPU-composited once drawn |
Canvas 2D cannot batch draw calls. Each drawImage or fillRect is an individual API call. Beyond ~500 moving particles the CPU cost becomes visible. For 20–100 ambient dust particles Canvas 2D is more than sufficient.
<script>
import { onMount } from 'svelte';
let canvas;
onMount(() => {
const ctx = canvas.getContext('2d');
let raf;
function render(t) {
// draw frame
raf = requestAnimationFrame(render);
}
raf = requestAnimationFrame(render);
return () => cancelAnimationFrame(raf); // cleanup on unmount
});
</script>
<canvas bind:this={canvas} width={320} height={180} />
onMount does not run on the server, so there are no SSR concerns. The returned cleanup function cancels the animation loop when the component is destroyed.
Zero — Canvas 2D is a built-in browser API. No dependencies.
Best fit for: Procedural landscape generation (Perlin noise → pixel buffer), sky gradient rendering, scene crossfades. Adequate for particle counts under ~300. Simple to maintain.
WebGL2 exposes OpenGL ES 3.0-level GPU programming via GLSL shaders. It unlocks GPU-accelerated rendering for all visual effects in the scene list: particle systems, dynamic lighting, post-processing passes, and procedural generation via fragment shaders.
WebGL2 achieved full cross-browser support in February 2022. As of 2026 it is universally available across Chrome, Firefox, Safari (including iOS), and Edge. It is the most battle-tested GPU API on the web.
WebGL shines when you need:
A minimal approach: render a full-screen quad, then run a fragment shader that samples Perlin noise to paint the terrain:
// fragment shader (GLSL ES 3.0)
precision mediump float;
in vec2 vUV;
uniform float uTime;
out vec4 fragColor;
// ... noise functions ...
void main() {
float h = fbm(vUV + vec2(uTime * 0.01, 0.0)); // fractal Brownian motion
vec3 color = mix(vec3(0.1, 0.2, 0.4), vec3(0.6, 0.5, 0.3), h);
fragColor = vec4(color, 1.0);
}
This runs entirely on the GPU. No CPU pixel loop, no putImageData upload.
// vertex shader — positions passed as instanced attributes
in vec2 aParticlePos;
in float aParticleSize;
WebGL2 instanced drawing (drawArraysInstanced) renders all particles in a single draw call. This is the correct approach for 500-particle campfire sparks at 60fps.
WebGL multi-pass rendering:
Each pass is a full-screen quad with a different fragment shader. This is a direct port of MonoGame's post-processing stack.
| Aspect | Assessment |
|---|---|
| Procedural generation | GPU-native, scales to full HD at 60fps |
| 500-particle systems | Trivial via instancing |
| Post-processing passes | 2–4 passes at 1080p = <2ms GPU time |
| Setup overhead | ~200–300 lines of boilerplate |
Same onMount / bind:this pattern as Canvas 2D, but WebGL context:
const gl = canvas.getContext('webgl2');
WebGL context loss requires handling the webglcontextlost / webglcontextrestored events. This is particularly important in mobile scenarios where the OS can reclaim GPU memory.
Raw WebGL: zero additional bytes. Using a helper like twgl.js (reduces boilerplate) adds ~30KB minified. PixiJS (full 2D WebGL renderer) adds ~476KB minified (~120KB gzipped).
Best fit for: Full GPU rendering pipeline — shader-based terrain, instanced particle systems, multi-pass post-processing. The complexity cost is real (GLSL shader code, framebuffer management) but the payoff is a complete, performant port of the MonoGame rendering stack. Recommended if you intend to replicate all visual effects faithfully.
WebGPU is the next-generation GPU API for the web, built on Vulkan/Metal/Direct3D 12 concepts. It offers compute shaders, better CPU overhead, and substantially higher GPU throughput than WebGL.
| Browser | Status |
|---|---|
| Chrome / Edge | Shipped since v113 (2023); Windows, macOS, ChromeOS, Android |
| Firefox 147 | Shipped January 2026 (Windows + ARM64 macOS); Linux planned |
| Safari | Shipped by default in iOS/iPadOS/macOS 26 (2025/2026) |
| Global coverage | ~70% of browsers |
The remaining ~30% is primarily older browsers, Firefox on Linux, and some legacy mobile devices. WebGPU is not yet safe as the sole renderer — a WebGL2 fallback is required for production use.
For 2D particle rendering specifically: WebGPU achieves up to 37 million particles at 60fps on advanced GPUs vs 2.7 million for WebGL. For typical scenes (20–500 particles), this difference is irrelevant — WebGL2 is more than fast enough. WebGPU's advantage appears at scale.
WebGPU's compute shaders are the real differentiator: particle physics simulation (gravity, collision) can run entirely on the GPU without reading data back to the CPU.
WebGPU is not recommended as a primary renderer right now for a production Bible study app. The 30% missing browser coverage is too large a gap. However, designing the rendering abstraction layer to allow a future WebGPU path (via progressive enhancement) is worthwhile.
Pattern: implement in WebGL2, add WebGPU as an opt-in enhancement:
const gl = canvas.getContext('webgpu') ?? canvas.getContext('webgl2');
CSS can replicate a meaningful subset of the atmospheric effects:
| Effect | CSS Approach | Quality |
|---|---|---|
| Sky gradient (static) | background: linear-gradient(...) |
Excellent |
| Sky gradient (animated) | @property + CSS transition, or background-position on large gradient |
Good |
| Vignette overlay | radial-gradient(transparent, black) overlay div, or box-shadow: inset |
Excellent |
| Soft glow / bloom | filter: blur() + mix-blend-mode: screen |
Acceptable |
| Parallax layers | transform: translateX() on layered divs |
Good |
| Stars / dust particles | box-shadow multi-value trick (static), or @keyframes on pseudo-elements |
Limited (~50 particles) |
| Campfire flicker | @keyframes brightness/color cycle |
Low fidelity |
CSS gradients support multiple color stops, making them suitable for the day/night cycle gradient described. Using @property for animatable gradients:
@property --sky-top {
syntax: '<color>';
inherits: false;
initial-value: #0a0a2e;
}
.sky {
background: linear-gradient(var(--sky-top), var(--sky-bottom));
transition: --sky-top 1.5s ease, --sky-bottom 1.5s ease;
}
This enables smooth transitions between sky states — directly analogous to the crossfade described.
CSS backdrop-filter: blur() is the most expensive CSS property — it reads pixels behind the element and re-composites them. Keep blur radius under 20px; above that GPU workload grows exponentially. For a vignette effect, avoid backdrop-filter entirely and use a simple radial-gradient overlay instead, which has near-zero rendering cost.
Animating background-position over a large gradient is GPU-efficient and the recommended approach for flowing sky effects.
div elements) become unusable beyond ~50 elementsUse CSS for: Sky gradient layer (CSS custom property animation), vignette overlay, and simple UI-layer atmospheric touches. Do not use CSS for: Terrain generation, particle systems, dynamic lighting, or post-processing.
SVG is a retained-mode vector format with animation support via CSS, SMIL, or JavaScript. It excels at resolution-independent vector art and interactivity on individual elements.
SVG renders well up to a few thousand elements, then degrades quickly. The DOM overhead of each SVG element (event listeners, computed styles, accessibility tree) makes it expensive at scale. Benchmarks show SVG outperforming Canvas when the number of objects is small (under ~200) and the surface area is large — the opposite of a dense particle system.
| Effect | SVG Approach | Suitability |
|---|---|---|
| Static mountain silhouette | <polygon> / <path> |
Good |
| Animated sky (CSS on gradient stop) | <linearGradient> + CSS animation |
Acceptable |
| Particle system (200 particles) | 200 <circle> elements + CSS animation |
Poor — DOM overhead |
| Procedural terrain | Not possible without scripted manipulation | Impractical |
| Dynamic lighting | <radialGradient> with JS-updated attributes |
Limited |
| Post-processing | SVG filters (feGaussianBlur, feComposite) |
Functional but heavy |
SVG <filter> effects (feGaussianBlur, feBlend, feColorMatrix) can produce some atmospheric effects, but they are evaluated on the CPU in many browsers and are known to be slow on large surfaces.
SVG is not recommended as the primary rendering layer for this project. It has no path to procedural pixel-art generation, and its particle performance is inadequate. SVG could be used for UI overlay elements (icons, HUD elements) layered above a Canvas/WebGL background.
The most practical production architecture is a layered hybrid:
┌─────────────────────────────────────────────┐
│ HTML/Svelte UI Layer (text, controls, HUD) │ ← DOM / CSS
├─────────────────────────────────────────────┤
│ CSS Sky Gradient Layer │ ← CSS custom properties
├─────────────────────────────────────────────┤
│ Vignette Overlay │ ← CSS radial-gradient
├─────────────────────────────────────────────┤
│ WebGL Canvas (particles, lighting, fx) │ ← WebGL2
├─────────────────────────────────────────────┤
│ Canvas 2D (terrain pixel buffer) │ ← Canvas 2D + putImageData
└─────────────────────────────────────────────┘
A 1.5s crossfade between scenes can be implemented at the Canvas level (globalAlpha tween via requestAnimationFrame) or at the CSS level (opacity transition on the canvas wrapper). The CSS approach is simplest:
<div class="scene-wrapper" style:opacity={opacity}>
<canvas bind:this={canvas} />
</div>
Tween opacity from 0→1 over 1.5s when scene changes. CSS transition: opacity 1.5s ease delegates the fade to the compositor thread.
OffscreenCanvas decouples canvas rendering from the main thread by transferring canvas ownership to a Web Worker. The worker runs the render loop, particle physics, and procedural generation without blocking UI interactions.
| Browser | Version |
|---|---|
| Chrome | 69+ |
| Edge | 79+ |
| Firefox | 105+ |
| Safari | 16.4+ |
Full cross-browser support as of 2023. Safe to use in production with no fallback needed for modern browsers.
// main thread
const offscreen = canvasEl.transferControlToOffscreen();
const worker = new Worker(new URL('./render.worker.js', import.meta.url), { type: 'module' });
worker.postMessage({ canvas: offscreen, width: 320, height: 180 }, [offscreen]);
// render.worker.js
self.onmessage = ({ data: { canvas, width, height } }) => {
const ctx = canvas.getContext('2d');
function render(t) {
// full render loop here — no main thread involved
self.requestAnimationFrame(render);
}
self.requestAnimationFrame(render);
};
Once transferred, the canvas cannot be accessed from the main thread. Communication back (e.g. scene change commands) goes via postMessage.
The practical benefit is main-thread protection: heavy Perlin noise generation, particle updates, and pixel buffer writes cannot jank the UI. Web.dev benchmarks show 4x faster individual operations (0.80ms → 0.20ms for image capture) and, more importantly, animation loops that remain smooth even when the main thread is artificially overloaded.
For a Bible study app where the atmospheric scene is a decorative background (not interactive), this is an attractive pattern: the render worker runs completely independently and never competes with scroll events, text selection, or note-editor interactions.
SvelteKit's Vite bundler handles workers via new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }). No special configuration needed.
| Criterion | Canvas 2D | WebGL2 | WebGPU | CSS-only | SVG | OffscreenCanvas |
|---|---|---|---|---|---|---|
| Browser support | Universal | Universal | ~70% (2026) | Universal | Universal | 97%+ |
| Pixel-level control | Full | Full (shader) | Full (shader) | None | None | Same as underlying |
| Procedural terrain | Yes (CPU) | Yes (GPU) | Yes (GPU) | No | No | Yes (CPU) |
| Particle systems (500) | Marginal | Excellent | Excellent | Impractical | Poor | Yes (same as base) |
| Post-processing passes | Manual | Native | Native | Partial | Partial | Yes |
| Dynamic lighting | Partial | Full | Full | No | No | Same |
| Scene crossfade | globalAlpha | framebuffer | framebuffer | CSS opacity | CSS opacity | Same |
| SvelteKit integration | Simple | Moderate | Moderate | Trivial | Simple | Moderate |
| Bundle size | 0 KB | 0–30 KB | 0 KB | 0 KB | 0 KB | 0 KB |
| Learning curve | Low | High | Very high | Low | Low | Low |
| Main thread safety | No | No | No | Yes | Yes | Yes |
| Mobile battery cost | Low | Low–Medium | Low | Very low | Low | Low |
Given the scene requirements (procedural terrain, 20–500 particles, dynamic lighting, post-processing, crossfades), and the constraint that this is a supporting feature in a Bible study app (not a game), the recommendation is:
Canvas 2D for terrain + particles: Render Perlin-noise terrain to an offscreen canvas at scene load time (not every frame). For each frame, blit the terrain, draw particles via drawImage or fillRect. For 20–100 ambient dust particles this is more than adequate. Only add WebGL if profiling shows a bottleneck.
CSS custom properties for sky gradient: Animate the multi-stop sky gradient via @property and CSS transitions. Zero JS cost, smooth crossfades.
CSS vignette overlay: A fixed div with background: radial-gradient(transparent 60%, rgba(0,0,0,0.7)). No GPU cost.
OffscreenCanvas for the render loop: Transfer the canvas to a worker to fully protect the Bible study UI from any render jank. This is low-effort (the canvas code moves verbatim) and high-value.
If the target scene set includes dense campfire sparks (300–500 particles), glowing light radii, or pixel-perfect dithering post-processing, port the render layer to WebGL2. A minimal WebGL2 setup with:
...delivers a complete MonoGame-equivalent pipeline with no framerate concerns.