Skip to content

perf: blend 8-bit image layers directly, index their visible runs, and add ToYCbCr - #14

Merged
odevine merged 5 commits into
mainfrom
perf/layer-compositing
Oct 7, 2026
Merged

odevine merged 5 commits into
mainfrom
perf/layer-compositing

Conversation

@odevine

@odevine odevine commented Oct 7, 2026

Copy link
Copy Markdown
Owner

This adds a way to give a layer an 8-bit image without converting it to a float buffer first, an index that lets a mostly transparent image skip its empty stretches, and a conversion from a buffer to the planes a JPEG stores. Nothing existing changes shape, and output from the existing paths is bit-identical.

Why

A program that composites many documents from decoded PNGs paid for a document-sized float32 buffer per layer per render, converting every pixel and then blending every pixel. On a 3264 by 4440 document that is 232 MB per layer. The profile of a downstream renderer at 16 workers put most of its time in that conversion and in allocation churn, and a further share in converting the finished buffer to 8 bits for a JPEG encoder that would convert it again.

What changes

Layer.Image and Layer.LoadImage take an 8-bit image as a layer's content, used when Content and Load are both nil. An *image.NRGBA with no mask, effects or clip-to-below is blended straight into the document by the new blend.CompositeNRGBA, which reads each pixel through the sRGB table and does the same arithmetic as the buffer path. Any other image type, or a layer that has to write to its content, is converted first, so those results cannot change. Canvas never writes to an image, so one decoded image can be shared by any number of layers and concurrent renders.

blend.Index wraps an *image.NRGBA in a blend.Indexed that records the runs of visible columns in each row, merging runs less than 32 transparent pixels apart. blend.CompositeIndexed blends only those runs. Indexed embeds the image, so it goes through Layer.Image and LoadImage unchanged. A single first and last column per row would not help a hollow frame, since every middle row has a border strip at each end, which is why the index keeps several runs per row.

Buffer.ToYCbCr(background ...color.Color) encodes a buffer as the full-range BT.601 planes JPEG stores, with chroma averaged over 2x2 blocks, as an *image.YCbCr that image/jpeg writes without converting again. Alpha is flattened over the optional background, black when it is missing. It reads sRGB codes from a 64 KiB table, which is several times faster than the transfer function and can differ by one code from the exact encoding near a rounding boundary. It is not dithered, unlike ToImage.

ToImage for 8 and 16 bits now converts in parallel row bands, and internal/parallel gains RowsAligned for bands that keep 2x2 blocks whole. Output does not depend on GOMAXPROCS.

Measurements

On an Apple M3 Max, on a print-size scene of eight image-backed layers:

Benchmark Before After
One goroutine, renders/s 7.0 29.4
16 goroutines, renders/s 24.1 41.6
Allocated per render 1.07 GB 232 MB
Hollow 2048 by 2048 frame, plain/indexed 3.26 ms 0.94 ms
ToImage(8), 3264 by 4440, 1 core / 16 34.7 ms 4.2 ms
ToYCbCr, 3264 by 4440, 16 cores 0.9 ms

In a downstream card renderer that hands its layers over as images, a parallel render and YCbCr conversion went from 14.8 cards/s to about 54 with direct blending, and to about 58 with the index. Impasto's own scene gains little from the index because its large opaque panels dominate.

Two ideas were built, measured and dropped, so they are not in this PR: reusing the backdrop buffer through a pool, which allocated 3.9 MB instead of 232 MB per render but made no measurable difference to speed, and limiting each blend's parallelism inside a batch, which was no faster than the default.

Testing

The direct and indexed paths are compared bit for bit with converting through raster.FromImage and compositing the buffer, across all 16 blend modes, several opacities and origins, sub-images, and each fallback. A deliberate off-by-one in the run bounds fails those tests. ToYCbCr is checked against an independent reference on even and odd sizes and over several backgrounds, and ToYCbCr and ToImage are checked to give the same result at different GOMAXPROCS values. New godoc examples cover Index, CompositeNRGBA, ToYCbCr and Layer.Image, and the docs for canvas, blend and raster describe the new behavior.

The benchmarks added here, BenchmarkRenderImages and its direct, indexed and parallel variants, are the ones behind the table above. The existing cardScene and BenchmarkMemoryCard* benchmarks were renamed to describe the scene rather than a card.

Compatibility

All additions. Layer gains two fields, blend gains CompositeNRGBA, Index, Indexed and CompositeIndexed, and raster gains Buffer.ToYCbCr. The only visible change to existing behavior is that ToImage is faster.

🤖 Generated with Claude Code

Add BenchmarkRenderImages and BenchmarkRenderImagesParallel, which
render a print-size scene of eight 8-bit image layers (opaque panels,
hollow frames and small strips in Normal, Multiply and Overlay modes)
and report renders per second and allocations. They give the layer
ingest and buffer reuse work a baseline to measure against.

Rename the layered memory benchmarks and their scene to describe the
scene rather than a card.
Add Layer.Image and Layer.LoadImage, which take an 8-bit image as a
layer's content. An *image.NRGBA layer with no mask, effects or
clip-to-below is blended straight into the document by the new
blend.CompositeNRGBA, which reads each pixel through the sRGB table
and skips transparent pixels, and in Normal mode writes opaque pixels
without a multiply. Other image types, and layers that write to their
content, are converted first.

The result is bit-identical to converting the image with
raster.FromImage and compositing the buffer, for every mode and
opacity. Canvas never writes to an image, so one decoded image can be
shared across layers and concurrent renders.

On a print-size scene of eight image layers, one render goes from 7.0
to 29.4 per second and sixteen at once from 24.1 to 41.6, and
allocation per render falls from 1.07 GB to 232 MB.
Add Buffer.ToYCbCr, which encodes a buffer as the 8-bit full-range
BT.601 planes a JPEG stores, with chroma averaged over 2x2 blocks, so
image/jpeg can write it without converting again. Alpha is dropped by
flattening over an optional background color, black when it is missing.
The conversion reads the sRGB code from a 64 KiB table, which is several
times faster than the transfer function and can differ by one code from
the exact encoding near a rounding boundary. It is not dithered.

Convert ToImage's 8-bit and 16-bit output in parallel row bands, and
add internal/parallel.RowsAligned for bands that keep 2x2 blocks whole.
Output does not depend on GOMAXPROCS.

On a 3264x4440 buffer, ToYCbCr takes 0.9 ms on 16 cores, and ToImage(8)
goes from 34.7 ms on one core to 4.2 ms on 16.
Add blend.Index, which reads an *image.NRGBA once and records the runs
of columns with any alpha in each row, merging runs less than 32
transparent pixels apart, and blend.CompositeIndexed, which blends only
those runs. The result is bit-identical to CompositeNRGBA, since a
pixel with no alpha leaves the backdrop alone in every mode.

Indexed embeds the image, so a canvas layer takes one through Image or
LoadImage unchanged and blends it directly. A layer that needs its
content written to converts it as a plain image.

On a 2048 by 2048 frame with a 60 pixel border, blending takes 0.94 ms
against 3.26 ms. Building the index for a 3264 by 4440 image takes
7.5 ms once.
Move the image generators and the exact float comparison that the blend,
canvas and raster tests each wrote their own copy of into
internal/testimg. Cover the branches the image paths left open (opacity
above one for an indexed image, an image too wide to convert, a
transparent background for ToYCbCr, every sRGB code in blend's table),
add tests for the raster buffer and ingest functions that had none, and
add godoc examples for Index, CompositeNRGBA, ToYCbCr and Layer.Image.

Drop Indexed.Runs, which nothing used.
@odevine
odevine merged commit 347658a into main Oct 7, 2026
5 checks passed
@odevine
odevine deleted the perf/layer-compositing branch October 7, 2026 20:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant