Launch OpenGPEX

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):

  1. setTransform(matrix) — World matrix
  2. ctx.filter = getAdjustmentsData() — Brightness/contrast/saturation via CSS filter
  3. globalAlpha — Opacity × fill
  4. globalCompositeOperation — Blend mode
  5. applyClipSequence() — Vector masks (with feathered clip support)
  6. 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