Skip to content

Fix SIMD OverwriteBlend clearing pixels the path does not cover - #593

Merged
treeform merged 4 commits into
treeform:masterfrom
Graveflo:coverage-overwrite
Sep 13, 2026
Merged

treeform merged 4 commits into
treeform:masterfrom
Graveflo:coverage-overwrite

Conversation

@Graveflo

Copy link
Copy Markdown
Contributor

FYI

  • discovered and implemented by AI (GPT-5.6-Sol & Opus 5)

Summary

blendLineCoverageOverwrite is specified by its scalar body to write only
where coverage is non-zero:

for i in 0 ..< len:
  let coverage = coverages[i]
  if coverage != 0:
    line[i] = rgbx * coverage

The SSE2, AVX2 and NEON implementations do not honour that. Where coverage is
zero they write rgbx * 0 — transparent black — over the backdrop.

The vectors this affects are the antialiased edges of the shape being drawn, so
an OverwriteBlend fill or stroke erases whatever was already on the image, in
bands the width of a SIMD vector, wherever it has an edge. Drawing a 40 point
star onto an opaque 400x300 image destroys 15234 pixels it does not cover.

Reproduction

import pixie

let
  paint = newPaint(SolidPaint)
  path = newPath()
paint.color = color(1, 0, 0, 1)
path.circle(circle(vec2(64, 32), 40))

# Coverage: an opaque paint filled onto a transparent image with NormalBlend
# leaves rgbx * coverage, which is what OverwriteBlend should store where
# coverage is non-zero.
let expected = newImage(128, 64)
expected.fillPath(path, paint)

let image = newImage(128, 64)
image.fill(rgbx(0, 0, 255, 255))
paint.blendMode = OverwriteBlend
image.fillPath(path, paint)

var uncovered, wrong: int
for y in 0 ..< 64:
  for x in 0 ..< 128:
    if expected[x, y].a == 0:
      inc uncovered
      if image[x, y] != rgbx(0, 0, 255, 255):
        inc wrong
echo "uncovered: ", uncovered, "  wrongly overwritten: ", wrong

Before

overwrite_demo_star_simd overwrite_demo_stroke_simd overwrite_demo_text_simd

After

overwrite_demo_star_simd overwrite_demo_stroke_simd overwrite_demo_text_simd

After scalar fix

he trapezoid partial-coverage loops in fillShapes wrote
blender(backdrop, source) unconditionally. source is rgbx * area for a
continuous area, so a thin enough sliver rounds it to fully transparent and
OverwriteBlend stores that over the backdrop. fillCoverage cannot hit this
because its coverage is already quantised to 1/255.

The guard matches what fillCoverage's general branch already does — skip zero
coverage for every mode except MaskBlend, which has to clear:

    clearsUncovered = blendMode == MaskBlend
...
                if source.a != 0 or clearsUncovered:
                  image.data[dataIndex] = blender(backdrop, source)

With both commits an OverwriteBlend fill destroys no uncovered pixels at all,
on any build.

overwrite_demo_star_simd

@treeform

Copy link
Copy Markdown
Owner

Thanks for this fix! The SIMD changes fixed the uncovered-pixel artifacts in my tests. I found one regression in the scalar guard: checking source.a also skips pixels covered by transparent paint, which breaks fractional clearRect() calls and some low-opacity overwrites.

I've added a small follow-up commit that checks quantized path coverage independently of paint alpha. It keeps your SIMD changes and existing tests, and adds two regression checks:

  • A fractional clearRect() must clear every covered pixel and leave the surrounding pixels alone.
  • Overwrite fills must preserve uncovered pixels and replace covered pixels across circles, stars, triangles, and rectangles, using opaque, translucent, and transparent paint.

The tests fail on master for the original SIMD bug, fail on the previous PR version for the clearing regression, and pass with the update. Local C, C++, SSE2-only, and non-SIMD checks pass; CI is next.

@treeform
treeform merged commit 30bb510 into treeform:master Sep 13, 2026
3 checks passed
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.

2 participants