Skip to content

A pixel gets the same value in whatever region it is drawn - #37

Merged
timzifer merged 2 commits into
mainfrom
fix/clip-independent-edges
Oct 13, 2026
Merged

timzifer merged 2 commits into
mainfrom
fix/clip-independent-edges

Conversation

@timzifer

Copy link
Copy Markdown
Owner

Fixes #36. For timzifer/cera#211.

A scene drawn in bands or tiles is now, byte for byte, the scene drawn at once.

The four causes, and what became of them

1. Edges (raster.go). AddLine cut a segment at the clip and rounded the ends of what was left to 1/256 pixel; the slope came from the rounded ends. The same segment had another slope in every clip that cut it elsewhere.

An edge is now the line of its segment in device space — c + y·dxdy, evaluated at the device y and moved to the clip in integers, so it is the same position for every clip — and the rows of it that are drawn. Nothing is cut any more:

  • An edge that leaves the clip at a side is wide and drawn by rasterWide/cellLineWide: what lies left of the clip is summed into its first cell (as the edge on the border was), what lies right of it is dropped, and each cell inside gets what a wider clip gives it.
  • Edges that are not nearly level (|dx/dy| <= plainSlope, all but a quarter of a degree) are plain: their x needs no bounding (xPlain), their ends are kept, and they cross cells by the arithmetic there was (cellLine).
  • A nearly level edge is bounded by the ends of its segment (xAt), because half a step of y moves its line far, and it crosses cells where its line does (rowLine, cellLineFlat) and not from where it enters the row: such an end may have been moved by a caller that shortened the segment outside the clip — the stroker's chord for a culled curve — and TestStrokeCullExact holds that against the uncut stroke.

2. Runs (blit.go, simd_*.go). SolidBlitter.BlitRun with a coverage below 255 scaled the color and composited it, two roundings where BlitCoverage has one (lerpx). How the sweep cuts a row into runs and coverage depends on the clip: its first cells are coverage where the whole row has a run. An opaque color is now blended by runLerp, the arithmetic of covOpaque, with AVX2 and AVX-512 kernels.

3. Analytic strokes and culled curves (stroke.go). Which rows of a segment are filled analytically is set by the vertices of the whole polyline. A curve outside the clip that takes its chord has other vertices, and rows of its neighbour inside the clip changed sides; the two sides round differently. Curves are no longer culled on the analytic path. Subpaths still are, and the outline path culls as before.

4. Analytic rows (strip.go, rasterStrip). They stepped n·c by nx from the first pixel drawn, which is the clip's. Every pixel is now measured from its own place: nx·x + g with x counted exactly (rowCtx.rowAt). This was one pixel in the corpus.

Tests

  • TestFillTiles: 300 paths (lines, curves, nearly level and nearly upright segments, points far away) filled in rows, columns, tiles and single pixels against the fill at once. Fails on main at the first path.
  • TestCanvasTiles: 400 scenes of fills and strokes (hairlines to wide, all caps and joins, dashes) in opaque and translucent colors and shaders, under path and rectangle clips, over a backdrop that is not plain.
  • TestRunLikeCoverage: a run of every coverage against the same coverage pixel by pixel, for colors and shaders.
  • The suite, with -race and with GOEXPERIMENT=simd; staticcheck.
  • From cera, with a local replace: the first pages of 965 files of the pdf.js corpus at 72 dpi, at once against bands of 100 rows and tiles of 256 pixels — 85 files differed, none does. cera's own suite passes unchanged.

Speed

Old against new, precompiled, alternating, best of six (the machine is noisy; single runs spread by a quarter):

new / old
BenchmarkScenes, the scenes that are all edges (uncached glyphs, short strokes, hatches, mixed drawing; 12) 1.04 (1.00 to 1.09)
— of them uncached glyphs 1.05 to 1.09
BenchmarkStrokeFixedCost 1.05
BenchmarkDenseDash 1.06
BenchmarkRasterizeSparseBands 1.05
BenchmarkWideFill 0.98
cera, first pages of the pdf.js corpus at 144 dpi, one worker (962 pages) 1451 ms → 1426 ms

What it costs is paid per edge, in AddLine (the ends of the line are evaluated instead of converted) and by the larger edge (48 bytes for 40); per row a plain edge costs what it did. On pages it is not measurable. A first version that bounded every edge and took every crossing from the line cost 25 % per stroke; plain is what brought it back.

A scene drawn in bands or tiles was not, byte for byte, the scene drawn
at once: one or two levels in a few antialiased pixels, depending on
where the clip cut. Of the first pages of 965 files of the pdf.js corpus
drawn by cera at 72 dpi, 85 differed between a render at once and one in
bands of 100 rows or tiles of 256 pixels; none does now. Four causes,
independent of one another:

Edges. AddLine cut a segment at the clip and rounded the ends of what
was left to 1/256 pixel; the slope came from the rounded ends, so the
same segment had another slope in every clip that cut it elsewhere. An
edge is now the line of its segment in device space (c + y·dxdy,
evaluated at device y and moved to the clip in integers) with the rows
of it that are drawn. Nothing is cut: an edge that leaves the clip at a
side is drawn by rasterWide, which sums what lies left of the clip into
its first cell and drops what lies right of it, cell by cell as a wider
clip would have it. Edges that are not nearly level (|dx/dy| <= 256)
are "plain": their x needs no bounding and they cross cells by the
arithmetic there was. Nearly level ones are bounded by the ends of
their segment and cross cells where their line does, since such an end
may have been moved by a caller that shortened the segment outside the
clip (the stroker's chords for culled curves).

Runs. SolidBlitter.BlitRun with a coverage below 255 scaled the color
and composited it, two roundings where BlitCoverage has one. How a row
is cut into runs and coverage depends on the clip. An opaque color is
now blended by runLerp, the arithmetic of covOpaque (scalar, AVX2 and
AVX-512 kernels).

Analytic strokes and culled curves. Which rows of a segment are filled
analytically is set by the vertices of the whole polyline; a curve
outside the clip that takes its chord has other vertices, and rows of
its neighbour inside the clip changed sides. Curves are not culled on
the analytic path any more (subpaths still are).

Analytic rows. They stepped n·c by nx from the first pixel drawn, which
is the clip's; every pixel is now measured from its own place, by an
exact count of x.

Tests: TestFillTiles (paths in rows, columns, tiles and single pixels
against the fill at once), TestCanvasTiles (scenes of fills, strokes,
clips, colors and shaders), TestRunLikeCoverage.

Speed, old against new, best of six alternating runs: the scenes of
BenchmarkScenes that are all edges cost 4 % more (uncached glyphs 5 to
9 %, short strokes and hatches 3 to 6 %), BenchmarkStrokeFixedCost 5 %,
wide fills nothing. The first pages of the pdf.js corpus drawn by cera
at 144 dpi take 1451 ms before and 1426 ms after: no difference that
can be measured.

Fixes #36
A compiler may fuse a multiplication and an addition into one operation
with one rounding (arm64, amd64 at GOAMD64=v3), and need not do so alike
wherever the same expression stands. The x of an edge is computed in
AddLine, which keeps it at the ends, and again row by row; the cells an
edge writes to are in range because its x lies between the ends. The
conversions make every one of these the same arithmetic on every
machine, and the centres of analytic rows with them.
@timzifer
timzifer merged commit e9b0b26 into main Oct 13, 2026
13 checks passed
@timzifer
timzifer deleted the fix/clip-independent-edges branch October 13, 2026 10:49
timzifer added a commit to timzifer/cera that referenced this pull request Oct 13, 2026
The release that has the change (timzifer/stilus#37), in place of its
commit.
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.

A path drawn in a band or a tile is not bit for bit the path drawn at once

1 participant