Rendering & Engine
How OpenGPEX renders images up to 16K resolution without freezing the browser — with two distinct rendering paths (screen preview and file export), three-lane export auto-routing, 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) PixelService.render.shapeToBlob
rAF → StageComposer.render → detectLane → Three-Lane Routing
│ │
▼ ▼
painter.drawLayerInstance Lane A / Lane B / Lane C
(same code shared) ◄──────────── (Lane C 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 | 16,384×16,384+ |
| 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: Three-Lane Architecture (Path B)
All export/download operations flow through a unified facade PixelService.render.shapeToBlob(), which auto-routes to one of three lanes based on content characteristics:
shapeToBlob(frame, shape, opts)
│
├── detectLane(frame, shape, opts) ← Pure function, independently testable
│
├── Lane A: 16-bit Single-Layer Direct (vips one-shot)
│ → vips exportHighRes: raw blob → crop? → resize? → write
│ → Full 16-bit precision, painter NOT involved
│
├── Lane B: 16-bit Multi-Layer Composite (vips composite)
│ → vips composite16bit: multiple layers → blend → write
│ → Full 16-bit precision, adjustments via applyAdjustments16bit()
│
└── Lane C: 8-bit Fallback (Engine Worker + files.encode)
→ merger.mergeLayersWithShape in Worker
→ painter.drawLayerInstance (same code as screen path!)
→ ImageBitmap transfer → encode (PNG/JPEG/WebP/BMP/TIFF/AVIF)
Lane Selection Logic (detectLane)
| Condition | Result |
|---|---|
exportBitDepth ≠ 16 |
Lane C |
| Format not TIFF/PNG | Lane C (vips only writes 16-bit to TIFF/PNG) |
| Non-rect shape + 16-bit | Auto-downgrade to 8-bit → Lane C |
Single layer + hasRaw + bitDepth>8 |
Lane A |
Any layer has hasRaw |
Lane B |
| Fallback | Lane C |
Layered Render API
render.flatten(frame, shape, opts) ← Core: returns full WorkerResult
render.shapeToBlob(frame, shape, opts) ← Thin wrapper: extracts blob/bitmap
render.frameToBlob(frame, opts) ← Sugar: constructs fullShape → shapeToBlob
render.flattenLayers(layers, frame, opts) ← For merge/rasterize commands
All lanes return a unified WorkerResult (blob + bitmap + hash + tileMeta), enabling direct asset injection without extra decode round-trips.
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
Four physically isolated Workers, orchestrated by the main thread:
| Worker | Lifecycle | Purpose |
|---|---|---|
| Engine Worker | Singleton, always running | Compositing, merge, rasterize, filter (Lane C) |
| Vips Worker | Lazy + 30s idle auto-terminate | 16-bit composite, TIFF/AVIF encode (Lane A/B) |
| Resvg Worker | On-demand, terminate after use | SVG → PNG rasterization |
| GS Worker | On-demand, terminate after use | EPS → PNG via Ghostscript |
Workers never communicate directly — all cross-worker coordination flows through the main thread.
Precision Model & Backend Routing
| Precision | Backend | Output |
|---|---|---|
| 8-bit | Canvas2dBackend (OffscreenCanvas, GPU-accelerated) | PNG / ImageBitmap |
| 16-bit | VipsBackend (wasm-vips, float) | uint16 TIFF/PNG |
| 32-bit | VipsBackend (wasm-vips, float) | float32 TIFF/EXR |
Backend selection is transparent to callers — the facade auto-routes based on requested precision.
Isomorphic Painter
A single pure function drawLayerInstance() is shared between:
- The main-thread Canvas2dEngine (screen preview)
- The Engine Worker's EngineProvider (file export, Lane C)
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 | 16/32-bit compositing + TIFF/HEIC/AVIF encoding | 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