Twinside / Rasterific

A drawing engine in Haskell
BSD 3-Clause "New" or "Revised" License
140 stars 11 forks source link

Not full black pixel on crossing #36

Closed Twinside closed 6 years ago

Twinside commented 6 years ago

see diagrams/diagrams-rasterific#42 for more details

robx commented 6 years ago

It feels like there's something fundamentally wrong here, not sure on what side. I've traced the example stroke_pixel.png through a bit, this is what I see at one of the questionable pixels in Shading.hs:solidColor (after changing the background to white):

idx 124 cov 128 icov 127
    old PixelRGBA8 255 255 255 255
    new PixelRGBA8 127 127 127 255
idx 124 cov 128 icov 127
    old PixelRGBA8 127 127 127 255
    new PixelRGBA8 63 63 63 255

So it seems each half black pixel "doubles" the blackness. That's not what I want here of course.

But! Imagine you're drawing the same line twice, so that you get twice the same half of a pixel. In that case do you really want the pixel to be fully black? A 3/4 black pixel like we get here intuitively seems kind of sensible to me.

robx commented 6 years ago

(For reference, here's the PR introducing that test: https://github.com/Twinside/Rasterific/pull/41)

Twinside commented 6 years ago

Ok I've investigated, and the behavior is normal in this engine. This is part of the anti-aliasing, and anti-aliased edges are alpha blended hence the halving behavior you have seen, we don't accumulate sub-pixel coverage anywhere, but in internal structure while drawing.

So what it means, to get read of a non-full black pixel, you have (or manage to get Diagrams) draw the bunch of lines in a whole call, to reuse your unit test:

strokePixelTest :: (forall g. Stroker g) -> IO ()
strokePixelTest stroker =
    produceImageAtSize 10 5 "stroke_pixel.png"
        $ withTexture (uniformTexture black)
        $ drawing
  where
    drawing = sequence_ $
          [ stroker 1 JoinRound (CapStraight 0, CapStraight 0) (lineFromPath p)
          | p <- ps ]
    ps = -- line of 1, 2, 3 pixels
         [ [ V2 1 1.5, V2 2 1.5 ]
         , [ V2 3 1.5, V2 5 1.5 ]
         , [ V2 6 1.5, V2 9 1.5 ]
         -- line of halved lines
         --  [ V2 1 3.5, V2 1.5 3.5 ]
         -- , [ V2 1.5 3.5, V2 2 3.5 ]
         , [ V2 1 3.5, V2 1.5 3.5, V2 2 3.5  ]

         -- , [ V2 3 3.5, V2 4 3.5 ]
         -- , [ V2 4 3.5, V2 5 3.5 ]
         , [ V2 3 3.5, V2 4 3.5, V2 5 3.5  ]

         -- , [ V2 6 3.5, V2 7.5 3.5 ]
         -- , [ V2 7.5 3.5, V2 9 3.5 ]
         , [ V2 6 3.5, V2 7.5 3.5, V2 9 3.5 ]
         ]

By stroking both lines beginning at half points in the same call, the engine will perform correct sub-pixel accumulation and provide correct result.

As a small test, I've tried to reproduce a similar test with the HTML5 Canvas API, to see the result:

<!DOCTYPE html>
<html>
    <head><title>canvas</title></head>
    <body><canvas id="canvas" width="30" height="30"></canvas></body>
    <script>
        var canvas = document.getElementById('canvas');
        var ctx = canvas.getContext('2d');

        const xOffset = 0.5;
        ctx.fillStyle = 'black';
        ctx.beginPath();
        ctx.lineWidth = 1;
        ctx.moveTo(1 + xOffset, 3);
        ctx.lineTo(1.5 + xOffset, 3);
        ctx.stroke();

        ctx.beginPath();
        ctx.lineWidth = 1;
        ctx.moveTo(1.5 + xOffset, 3);
        ctx.lineTo(2 + xOffset, 3);
        ctx.stroke();

        const offset = 0.5;
        ctx.beginPath();
        ctx.moveTo(1 + xOffset, 10 + offset);
        ctx.lineWidth = 1;
        ctx.lineTo(1.5 + xOffset, 10 + offset);
        ctx.moveTo(1.5 + xOffset, 10 + offset);
        ctx.lineTo(2 + xOffset, 10 + offset);
        ctx.stroke();
    </script>
</html>

So with Firefox (I think it's Cairo for backend) and Chrome (Skia) all I could get was: browser_render

So clearly not black :/

robx commented 6 years ago

Thanks for checking. Yes, I can reproduce this with firefox canvas.

On the other hand I get perfect black pixels with diagrams' cairo backend https://github.com/diagrams/diagrams-cairo. I don't immediately see diagrams-cairo doing anything fancy, so I'm really curious what's going on here...

Twinside commented 6 years ago

I think I get it

Looking at diagrams-rasterific:

renderPath :: TypeableFloat n => Path V2 n -> [[R.Primitive]]
renderPath p = -- Implementation not important, the type is important

-- Stroke both dashed and solid lines.
mkStroke :: TypeableFloat n => n ->  R.Join -> (R.Cap, R.Cap) -> Maybe (R.DashPattern, n)
      -> [[R.Primitive]] -> RenderR ()
mkStroke (realToFrac -> l) j c d primList =
  maybe (mapM_ (R.stroke l j c) primList)
        (\(dsh, off) -> mapM_ (R.dashedStrokeWithOffset (realToFrac off) dsh l j c) primList)

What looks weird and completly fit the previous analysis is the mapM_ use in mkStroke, we stroke every line separately instead of doing it "in group". Looking at diagrams-cairo path rendering:

instance Renderable (Path V2 Double) Cairo where
  render _ p = C $ do
    cairoPath p
    f <- getStyleAttrib getFillTexture
    s <- getStyleAttrib getLineTexture
    ign <- use ignoreFill
    setTexture f
    when (isJust f && not ign) $ liftC C.fillPreserve
    setTexture s
    liftC C.stroke

We call stroke only once for the whole path. I guess there is a need to patch diagrams-rasterific :]

Twinside commented 6 years ago

Pull request proposed to diagrams-rasterific, closing this ticket

robx commented 6 years ago

Great, thank you! (I can confirm this: If I split the offending path into separate paths, I get the same gray pixels with cairo.)