Rendering & Engine
How OpenGPEX renders images at 4K+ resolution without freezing the browser — with two distinct rendering paths (screen preview and file export), a unified single-path compositor, Worker-based pixel computation, and an isomorphic paint function shared across all paths.
Dual Rendering Paths
OpenGPEX has two fundamentally different rendering chains that share a common isomorphic painter:
┌──────── User ────────┐
│ │
(drag/slider/canvas) (click download)
│ │
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ Path A: Screen Live │ │ Path B: File Export │
│ (Editor Preview) │ │ (Download/Save) │
└────────┬───────────┘ └────────┬───────────┘
│ │
▼ ▼
Canvas2dEngine (Main Thread) compositeFrame(frame, roi, {precision:8})
rAF → StageComposer.render → Canvas2dBackend (Engine Worker)
│ │
▼ ▼
painter.drawLayerInstance result.toBlob() → files.encode → download
(same code shared) ◄──────── (compositor also uses painter)
Screen Live Rendering (Path A)
The real-time preview pipeline runs entirely on the main thread via requestAnimationFrame:
User interaction → CanvasStage.tsx (rAF) → StageComposer.render()
→ Canvas2dEngine.beginFrame/pushCommand/flush
→ drawLayerDirect (per layer)
→ painter.drawLayerInstance (atomic leaf)
Offscreen Composition (Blend Isolation)
When a layer requires blend isolation (bitmap masks, non-standard blend modes, blur), the engine routes through an offscreen canvas:
| Condition | Triggers Offscreen | Reason |
|---|---|---|
| Bitmap mask exists | ✅ | Needs destination-in/out isolation |
| Blur > 0 | ✅ | Neighborhood operator can't apply per-tile |
| BlendMode + tiled layer | ✅ | Prevents tile-seam double-blending |
| BlendMode + linear TRC | ✅ | Requires manual linear-light blending via ImageData |
| BlendMode + non-tiled + gamma | ❌ | Single drawImage has no seam issues |
Linear-Light Blending
For 16-bit documents (Frame.trc = 'linear'), the engine performs physically-correct linear-light mixing via manual pixel operations — matching Photoshop CC+ behavior and ensuring WYSIWYG consistency with the 16-bit export path.
Tiled Rendering
Traditional web editors load entire images as a single bitmap (causing OOM at ~8K). OpenGPEX splits images into 256×256 pixel tiles and only loads those visible in the current viewport.
| Property | Value |
|---|---|
| Tile size | 256×256 px |
| Active tiles in viewport | 40–60 (constant, regardless of image size) |
| Cache limit | 500 tiles (LRU, auto-eviction via bitmap.close()) |
| Max supported resolution | 4,096×4,096+ |
| Transfer method | Transferable ImageBitmap (zero-copy) |
Mipmap Pyramid
The Worker pre-generates multiple resolution levels (100%, 50%, 25%, 12.5%). The engine selects the appropriate level based on camera zoom — ensuring no aliasing when zoomed out and sharp details when zoomed in.
File Export: Unified Single-Path Architecture (Path B)
All export/download operations use a single compositing path — compositeFrame() via Canvas2dBackend in the Engine Worker, followed by format encoding:
download command (commands.ts)
│
▼
compositeFrame(frame, roi, { precision: 8 })
│
▼
CompositorHandler → Canvas2dBackend (Engine Worker)
→ painter.drawLayerInstance (same code as screen path!)
→ OffscreenCanvas 8-bit composited result
│
▼
result.toBlob()
│
▼
files.encode(bitmap, format, opts)
├── PNG/JPG/WebP/BMP: browser-native convertToBlob
├── TIFF: vips-worker encodeTiff
└── AVIF: vips-worker encodeAvif
│
▼
pixels.utils.download(blob, filename)
Design Principles
- Single compositing backend — All composition goes through
Canvas2dBackend(8-bit). No routing decisions, no lane selection. - Extreme simplicity — The export command is just
compositeFrame+files.encode+download. No strategy objects, no route resolution. - WebGPU upgrade path — When WebGPU lands, only the backend implementation changes (
Canvas2dBackend→WebGpuBackend). The export command, encoding pipeline, and painter remain identical. 16-bit compositing will be re-enabled natively via GPU float16 textures.
Compositing API
compositeFrame(frame, roi, opts) ← Full frame composite with region-of-interest
flattenLayers(layers, frame, opts) ← For merge/rasterize commands
Results include a unified WorkerResult (blob + bitmap + hash + tileMeta), enabling direct asset injection without extra decode round-trips.
Note: 16-bit export compositing (formerly Lane A/B via VipsBackend) has been removed in the current architecture. It will return when WebGPU enables native float16/float32 compositing without the complexity of a separate vips-based compositor.
Engine: Zero Main-Thread Pixel Computation
All pixel operations are dispatched through a thin PixelFacade to Workers via specialized dispatchers:
| Dispatcher | Responsibility |
|---|---|
| Composite | Layer merging, blend modes, export composition |
| Filter | Color adjustments (curves, levels, channel mixer) |
| Decode | Image decoding + bitmap caching |
| Resample | Image scaling/resampling |
| Rasterize | Text/vector → bitmap conversion |
Worker Architecture
Three physically isolated Workers, orchestrated by the main thread:
| Worker | Lifecycle | Purpose |
|---|---|---|
| Engine Worker | Singleton, always running | Compositing, merge, rasterize, filter, TIFF/AVIF encode/decode + ICC (via lazy-loaded wasm-vips singleton) |
| Resvg Worker | On-demand, terminate after use | SVG → PNG rasterization |
| GS Worker | On-demand, terminate after use | EPS → PNG via Ghostscript |
Note: wasm-vips (used for TIFF/AVIF encoding and ICC color conversion) runs as a lazy-loaded singleton inside the Engine Worker — not as a separate process. It initializes on first use and shares the Worker's lifetime.
Workers never communicate directly — all cross-worker coordination flows through the main thread.
Precision Model
| Precision | Backend | Status |
|---|---|---|
| 8-bit | Canvas2dBackend (OffscreenCanvas, GPU-accelerated) | ✅ Active — all compositing |
| 16-bit | (planned: WebGpuBackend, float16 textures) | ⏸ Paused — awaiting WebGPU |
| 32-bit | (planned: WebGpuBackend, float32 textures) | ⏸ Paused — awaiting WebGPU |
Currently all composition runs through a single Canvas2dBackend at 8-bit precision. The architecture preserves a precision field in the composite request for future WebGPU activation.
Isomorphic Painter
A single pure function drawLayerInstance() is shared between:
- The main-thread Canvas2dEngine (screen preview)
- The Engine Worker's Canvas2dBackend (file export compositing)
This guarantees pixel-perfect consistency between what users see on screen and what gets exported.
Painter responsibilities (in execution order):
setTransform(matrix)— World matrixctx.filter = getAdjustmentsData()— Brightness/contrast/saturation via CSS filterglobalAlpha— Opacity × fillglobalCompositeOperation— Blend modeapplyClipSequence()— Vector masks (with feathered clip support)drawLayerContent()— fillRect / fillText / drawImage (tiles or single source)
Hard constraint: painter.ts may only import pure types and geometry utilities — never Worker bridges, caches, or anything that would pull Worker module graphs into the main thread bundle.
Three Coordinate Spaces
| Space | Origin | Use Case |
|---|---|---|
| Screen | Browser viewport top-left | Mouse events, DOM positioning |
| World | Canvas (0,0) at zoom=1 | Layer positioning, canvas math |
| Local | Layer's own top-left corner | Mask coordinates, brush strokes |
Transform chain: Screen ↔ (camera matrix) ↔ World ↔ (layer matrix) ↔ Local
The GeometryService provides coordinate conversion methods used throughout the interaction and rendering systems.
WASM Modules
| Module | Purpose | Loading |
|---|---|---|
| wasm-vips | TIFF/HEIC/AVIF encode/decode + ICC color conversion | Lazy, single shared instance |
| libraw-wasm | RAW format decoding (CR2/NEF/ARW/DNG) | Lazy, on first import |
| resvg-wasm | SVG rasterization | Lazy, on first import |
All WASM modules run inside the Worker, never blocking the main thread.
Last updated: 2026-08-08