ImageSharp/ImageSharp.Drawing/Processing/Backends/DEFAULT_RASTERIZER.md
2026-08-03 22:31:27 +02:00

17 KiB

DefaultRasterizer

DefaultRasterizer is the CPU polygon scanner used by the retained fill path in ImageSharp.Drawing. Its job is narrow but central: take already-prepared geometry, convert that geometry into fixed-point edge contributions, and emit coverage rows that the CPU backend can turn into pixels.

This rasterizer is based on ideas and implementation techniques from the Blaze project:

This document explains the rasterizer as a newcomer needs to understand it:

  • where the rasterizer fits relative to DrawingCanvas and DefaultDrawingBackend
  • what problem the rasterizer is solving inside the CPU backend
  • why the rasterizer is split into retained geometry building and band execution
  • what retained geometry, bands, and coverage mean in this architecture
  • how scan conversion stays separate from brush shading and frame ownership

Where The Rasterizer Fits

DefaultRasterizer sits below DrawingCanvas, the typed canvas implementation, and DefaultDrawingBackend.

The canvas records commands, the batcher prepares them into DrawingCommandBatch ranges, and DefaultDrawingBackend chooses the row-oriented execution plan for each retained CPU scene. DefaultRasterizer then handles the narrower geometry-to-coverage problem inside that CPU execution path.

That means the rasterizer does not select the backend, own the destination frame, or interpret the public drawing API directly. It receives already-prepared geometry through the CPU backend pipeline, and the backend later routes its coverage into whichever frame exposes the CPU region for the flush.

The Main Problem

The CPU backend does not want to rediscover shape geometry every time it touches a destination row.

If row execution had to start from raw prepared paths every time, the backend would repeatedly need to:

  • walk contours
  • split segments against row-band boundaries
  • compute left-of-band winding influence
  • rebuild scan-conversion state for the same shape over and over

That would push expensive geometry work into the hottest part of CPU rendering.

So the rasterizer solves a different problem:

it builds retained rasterizable geometry once, then executes compact band-local scanning work many times, cheaply.

That two-phase design is the core idea behind DefaultRasterizer.

The Core Idea

The rasterizer is a retained fixed-point polygon scanner.

Its central idea is:

build band-local retained line data once, then execute fixed-point scan conversion from that retained data

This is why the rasterizer has two very different modes of work:

  1. retained geometry building
  2. band execution

The first phase is a preparation phase. The second is the hot execution phase.

If that distinction is clear, the code becomes much easier to follow.

The Most Important Terms

Rasterizer

DefaultRasterizer is the geometry-to-coverage engine.

It is responsible for:

  • converting prepared geometry into retained scan-conversion data
  • rasterizing retained band data with fixed-point arithmetic
  • emitting coverage rows

It is not responsible for:

  • brush color generation
  • destination frame ownership
  • layer composition
  • deciding which scene items should execute

Those problems belong to the CPU backend and FlushScene.

Retained Geometry

Retained geometry is the rasterizer's prepared execution payload.

In this codebase, retained geometry means:

"the fixed-point, band-local line data and start-cover seeds needed to rasterize one prepared shape later without revisiting its original contour data"

That retained form is stored in RasterizableGeometry.

Band

A band is one small vertical slice of a shape's retained geometry.

The rasterizer does not keep one giant scene-wide edge table. It stores data in row bands so execution can stay local and bounded.

Rasterizable Geometry

RasterizableGeometry is the retained representation of one prepared shape.

It stores:

  • clipped local bounds
  • band-local metadata
  • retained line arrays
  • optional start-cover seeds for bands that need carry-in winding

This is the retained object that the CPU backend keeps in FlushScene.

Rasterizable Band

A RasterizableBand is the execution-time view over one retained band of one retained shape.

It is the immediate input to ExecuteRasterizableBand(...).

Context

DefaultRasterizer.Context is the mutable fixed-point scanning state used during band execution.

It is a ref struct because it is tied directly to worker-owned scratch spans and should not escape the execution scope.

Coverage

Coverage is the rasterizer's output.

The rasterizer does not decide final pixel colors. It decides how much geometric coverage each pixel receives. The backend later passes that coverage to a BrushRenderer<TPixel>, which decides how the destination pixels should be shaded.

Pipeline Placement

The rasterizer sits in the middle of the CPU backend pipeline.

Upstream:

  • CompositionCommand preparation produces prepared geometry
  • the typed canvas implementation and DrawingCanvasBatcher<TPixel> have already selected and called the CPU backend
  • FlushScene decides which items are visible and when they execute

Downstream:

  • the rasterizer emits row coverage
  • DefaultDrawingBackend routes that coverage into BrushRenderer<TPixel>.Apply(...)
flowchart TD
    A[Prepared geometry] --> B[DefaultRasterizer.CreateRasterizableGeometry]
    B --> C[RasterizableGeometry]
    C --> D[Build RasterizableBand view]
    D --> E[ExecuteRasterizableBand]
    E --> F[Coverage rows]
    F --> G[Brush renderer]

That placement is important. The rasterizer is neither the public drawing model nor the final shading model. It is the geometry-to-coverage step between them.

Why The Rasterizer Has Two Phases

The rasterizer separates:

  1. building retained geometry
  2. executing retained geometry

Phase 1: retained geometry building

CreateRasterizableGeometry(...) converts prepared geometry into a retained representation that is cheap to execute later.

This phase:

  • walks prepared contours
  • converts coordinates into fixed-point
  • clips or splits segments as needed for band boundaries
  • records visible line pieces into retained line storage
  • records left-of-band winding influence into start-cover tables

The output is RasterizableGeometry.

Phase 2: band execution

ExecuteRasterizableBand(...) is the hot execution entry point.

It does not revisit the original contour data. It receives a RasterizableBand view over retained data and performs the minimum work needed to emit coverage rows for that band.

sequenceDiagram
    participant Exec as ExecuteRasterizableBand
    participant Ctx as Context
    participant Emit as Coverage Row Handler

    Exec->>Ctx: Reconfigure(...)
    Exec->>Ctx: SeedStartCovers(...)
    Exec->>Ctx: Rasterize retained lines
    Exec->>Ctx: EmitCoverageRows(...)
    Ctx-->>Emit: coverage rows
    Exec->>Ctx: ResetTouchedRows()

That separation is one of the key reasons the retained fill path performs well. Expensive geometry work happens once; execution consumes compact band-local data.

Fixed-Point Precision

The rasterizer works in 24.8 fixed-point coordinates.

That means:

  • 1 pixel = 256 fixed-point units
  • FixedShift = 8
  • FixedOne = 256

This gives the scanner subpixel precision while keeping the hot execution path integer-based. Geometry may begin as floating-point path data, but once a retained line reaches the scan-conversion core it is treated as fixed-point state.

Coverage is converted back into normalized float values only at the emission boundary.

Why Bands Exist

The rasterizer does not retain one monolithic edge table. It retains geometry in vertical row bands.

That matters because it keeps execution local and bounded.

When a segment crosses multiple bands, the linearizer splits it so each band receives only the portion it must scan. If a segment influences winding inside the visible band from the left side, that influence is folded into a start-cover seed rather than keeping an invisible off-screen line around forever.

This gives the backend several important properties:

  • execution only touches the band it is currently composing
  • left-of-band winding can be precomputed
  • scratch requirements stay bounded
  • row-oriented execution consumes compact band-local payloads
flowchart TD
    A[Contour segment] --> B{Touches one band?}
    B -- Yes --> C[Store visible line in that band]
    B -- No --> D[Split across band boundaries]
    D --> E[Store band-local visible pieces]
    D --> F[Accumulate start-cover seeds where needed]

Retained Geometry: What Gets Stored

RasterizableGeometry stores the retained data needed to rasterize a prepared shape later.

That includes:

  • the local bounds of the prepared shape
  • band count and band-local metadata
  • retained line arrays for each band
  • optional start-cover arrays for bands that need carry-in winding

The retained line arrays use specialized storage formats such as:

  • LineArrayX16Y16
  • LineArrayX32Y16

These are storage-oriented types. They exist to retain compact fixed-point line segments so execution does not need to revisit contour data.

The Linearizer

The linearizer is the retained-geometry builder. It is generic over line-array storage, but the conceptual work is the same across variants.

Its responsibilities are:

  • traverse prepared contours
  • apply the residual transform per-point as contours are read
  • clip work to retained bounds
  • convert coordinates into fixed-point
  • decide whether a segment is contained or must be split
  • store visible line pieces
  • accumulate start covers for left-of-band influence

For a newcomer, the most important thing to understand is that the linearizer is not the hot coverage emitter. It is the preparation step that turns arbitrary contour geometry into a stable retained scanning payload.

Residual transform application

The prepared LinearGeometry passed to CreateRasterizableGeometry(...) carries scale-baked points — the effective X/Y scale of the drawing matrix has already been absorbed into the flattened contour, so curve subdivision happens at device-scale precision. The remaining rotation, shear, translation, and perspective is handed to the rasterizer as a separate Matrix4x4 residual, which the linearizer applies per-point where the contour is read: at segment emission time in ProcessContained / ProcessUncontained for fills, and at bounds / closure / contour-segment construction sites in the stroke linearizer.

This split keeps the scale-baked geometry cacheable across frames (text and panning workloads reuse the same bake at a fixed zoom) while letting per-frame rotation or translation ride through the rasterizer without re-subdividing curves.

Contained lines

A contained line is one whose fixed-point endpoints already fit the assumptions of the current retained band representation. Those lines can be pushed directly into retained storage after the required fixed-point and band-boundary handling.

Split lines

When a line crosses band boundaries, the linearizer splits it so each band receives only the contribution it needs to scan.

Start-cover seeding

When a line contributes winding inside the visible band but lies partially to the left of the visible X range, the retained geometry stores that influence in a start-cover array instead of retaining an off-screen line.

This is one of the most important ideas in the retained design:

  • visible geometry becomes retained lines
  • invisible left-of-band winding becomes retained start-cover seeds

The Execution Context

DefaultRasterizer.Context is the mutable fixed-point scanning state used during band execution.

It owns per-band mutable state such as:

  • bitVectors
  • coverArea
  • startCover
  • rowMinTouchedColumn
  • rowMaxTouchedColumn
  • rowHasBits
  • rowTouched
  • touchedRows

This state is reused across bands by reconfiguration, not by reallocation.

flowchart LR
    A[WorkerScratch] --> B[Context]
    B --> C[Rasterize retained lines]
    C --> D[Mutate coverArea and bit vectors]
    D --> E[Emit coverage rows]
    E --> F[Reset touched rows]

The Context bridges retained geometry and emitted coverage.

How Coverage Accumulation Works

The rasterizer uses the classic area-and-cover formulation.

When a fixed-point line is rasterized, it is broken into cell contributions. Those contributions eventually reach AddCell(...), which updates:

  • delta cover
  • delta area

Rows also track sparse touched-column information through bit vectors, so the emitter can avoid scanning the full width of empty rows.

flowchart TD
    A[Rasterize fixed-point line] --> B[Decompose into touched cells]
    B --> C["AddCell(row, column, deltaCover, deltaArea)"]
    C --> D[Update coverArea]
    C --> E[Mark bitVectors]
    C --> F[Track touched rows and bounds]
    C --> G{column < 0?}
    G -- Yes --> H[Fold into startCover]
    G -- No --> I[Keep visible cell contribution]

This is why the rasterizer can honor fill rules later. It accumulates signed contributions first and applies the fill rule during coverage emission.

Coverage Emission

EmitCoverageRows(...) converts the accumulated fixed-point state into row spans.

For each touched row, the emitter:

  1. starts from the seeded startCover
  2. walks the row's touched columns using the bit vectors
  3. updates the running cover from deltaCover
  4. combines running cover and deltaArea into signed area
  5. converts signed area into normalized coverage using the selected fill rule
  6. coalesces equal-coverage spans
  7. writes only non-zero spans into the reusable scanline buffer
  8. invokes the row callback
flowchart LR
    A[Touched row] --> B[Walk set bits]
    B --> C[Reconstruct cover and area]
    C --> D[Apply fill rule]
    D --> E[Coalesce equal coverage]
    E --> F[Write compact scanline spans]
    F --> G[Invoke row handler]

The rasterizer therefore emits only rows that actually received contributions and only the non-zero spans within those rows.

Fill Rules

The rasterizer supports both NonZero and EvenOdd.

NonZero

The accumulated signed area is treated as winding magnitude. Coverage is the clamped absolute value of that area.

EvenOdd

The accumulated area is wrapped into the even-odd domain before coverage is produced. This gives parity-based behavior without changing the earlier scan-conversion logic.

The fill rule is therefore an emission-time decision, not a geometry-preprocessing decision.

Antialiased And Aliased Modes

The rasterizer can emit either continuous or thresholded coverage.

  • Antialiased mode keeps the continuous coverage produced by the area-and-cover math
  • Aliased mode thresholds that continuous coverage using AntialiasThreshold

The scan-conversion core stays the same in both modes. Only the final conversion from area to emitted coverage changes.

Why Self-Intersections Work

The rasterizer can handle self-intersections because it does not require geometric boolean normalization before rasterization. It accumulates signed contributions and then applies the selected fill rule during emission.

That means overlapping or self-crossing contours are resolved by:

  • area-and-cover integration
  • winding or parity mapping

instead of by an earlier polygon-boolean pass.

How The Rasterizer Stays Separate From The Backend

The rasterizer and the backend solve different problems.

The rasterizer decides:

  • how geometry contributes coverage
  • which rows and columns within a band are touched
  • how much coverage each emitted span has

The backend decides:

  • which scene items execute
  • which retained band is being scanned
  • which destination slice receives the coverage
  • which brush renderer consumes the emitted spans

That separation is one of the main architectural advantages of the current CPU path.

Reading Guide

If you are new to this part of the library, read the rasterizer in this order:

  1. DrawingCanvas.cs
  2. DrawingCanvas{TPixel}.cs
  3. DrawingCanvasBatcher{TPixel}.cs
  4. DefaultDrawingBackend.cs
  5. FlushScene.cs
  6. CreateRasterizableGeometry(...) in DefaultRasterizer.cs
  7. Linearizer<TL> and the concrete linearizers in DefaultRasterizer.Linearizer.cs
  8. retained line types in DefaultRasterizer.RetainedTypes.cs
  9. ExecuteRasterizableBand(...) in DefaultRasterizer.cs
  10. Context in DefaultRasterizer.cs

That order mirrors the data lifecycle:

canvas intent -> prepared geometry -> retained storage -> band execution -> coverage emission

The Mental Model To Keep

The easiest way to reason about DefaultRasterizer is this:

it is a retained fixed-point polygon scanner that transforms prepared geometry into compact band-local line payloads, then turns those payloads into row coverage spans.

If that model stays clear, the rest of the code becomes easier to read:

  • the canvas and backend docs explain how execution reaches the CPU path
  • the linearizer explains where retained line data comes from
  • RasterizableGeometry explains what is stored
  • the Context explains how retained data becomes coverage
  • the backend explains how coverage becomes pixels