OptimizedShapeRendering.java

package swingtree.api.laf;

import java.awt.Color;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.Shape;
import java.awt.geom.AffineTransform;
import java.awt.geom.Rectangle2D;
import java.awt.geom.RoundRectangle2D;

/**
 *  A utility class for optimized shape filling, the way the SwingTree style engine does it for its own surfaces:
 *  antialiasing stays on wherever the outline actually curves, and is switched off everywhere else.<br>
 *  <b>
 *      It optimizes shape filling specifically by turning antialiasing off strategically for parts
 *      which do not need it because the result looks identical.
 *  </b>
 *  <p>
 *  Filling anti-aliased shapes can be extremely slow. This is because a {@link Graphics2D} rasterizer asked
 *  to antialias computes a computest multiple pixels internal for every visual pixel of a shape, including
 *  the great majority which lie well inside it and come out fully covered. Over a rounded
 *  rectangle the size of a scroll bar's thumb or a progress bar's fill, that is a few hundred
 *  pixels of genuine curve and tens of thousands of pixels of arithmetic arriving at "opaque". A
 *  fill without antialiasing on the other hand writes those directly: So much, much faster!
 *  <p>
 *  Support for such optimized routines is worth having in a custom look and feel because a
 *  {@link javax.swing.plaf.ComponentUI} still needs to draw most of the visible primitives like
 *  scroll bars itself (which SwingTree will not do for you).
 *
 *  @see SwingTreeStyledComponentUI
 */
public final class OptimizedShapeRendering
{
    /**
     *  The smallest area, in device pixels, for which splitting a rounded fill into parts pays.
     *  <p>
     *  The split costs four antialiased corner fills and three plain bands, and that is a fixed
     *  price - measured at 22 microseconds on an accelerated surface and 5.5 on a heap image, the
     *  same for a scroll bar thumb as for a whole card, because a corner is eight pixels square
     *  whatever it is a corner of. An undivided antialiased fill instead costs about 17
     *  nanoseconds per device pixel, so the two meet somewhere between one and two and a half
     *  thousand pixels, depending on how much of a shape is edge.
     *  <p>
     *  This bound is deliberately well above that. Between the two, one style preset's progress
     *  bar renders 12 pixels differently out of four and a half million - at the antialiased edge
     *  of its rounded cap, and only inside the whole application: the same shape, paint and clip
     *  replayed on their own come out identical, as do four thousand randomly generated ones. So
     *  the last stretch is left on the table until that is explained rather than taken on the
     *  grounds that nobody would see it.
     */
    private static final int SMALLEST_AREA_WORTH_SPLITTING = 16384;

    private OptimizedShapeRendering() {}

    /**
     *  Fills a shape with the graphics context's current paint, in as few antialiased pixels as
     *  the shape allows, writing exactly the pixels {@link Graphics2D#fill(Shape)} would write.
     *  <p>
     *  Two kinds of shape get a faster treatment:
     *  <ul>
     *      <li>A {@link Rectangle} is integer valued by its very type and so has no soft edge
     *          anywhere. Only the transform could still put one between two pixels, which is
     *          why that is checked before filling it in one go, without antialiasing.</li>
     *      <li>A {@link RoundRectangle2D} curves only inside its four corner boxes, so it is
     *          filled as antialiasing-free bands plus antialiased corners.</li>
     *  </ul>
     *  Anything else, like a fractional {@link Rectangle2D} or a rotated transform, keeps
     *  antialiasing and is filled in one go.
     *
     * @param g2d the graphics context to fill on, whose paint, clip and transform are all used
     *            and none of which are changed by this call
     * @param shape the shape to fill
     */
    public static void fill( final Graphics2D g2d, final Shape shape ) {
        if ( !RenderingHints.VALUE_ANTIALIAS_ON.equals(g2d.getRenderingHint(RenderingHints.KEY_ANTIALIASING)) ) {
            g2d.fill(shape); // Nothing to gain, antialiasing is already off.
            return;
        }
        final AffineTransform transform = g2d.getTransform();

        if ( shape instanceof Rectangle && _mapsOntoWholeDevicePixels(transform, (Rectangle) shape) ) {
            fillWithoutAntialiasing(g2d, shape);
            return;
        }
        if ( shape instanceof RoundRectangle2D && _fillRoundRectangleInParts(g2d, (RoundRectangle2D) shape, transform) )
            return;

        g2d.fill(shape);
    }

    /**
     *  Fills the given shapes with antialiasing switched off and then switches it back on.
     *
     * @param g2d the graphics context to fill on
     * @param shapes the shapes to fill, none of which has a soft edge worth computing
     */
    public static void fillWithoutAntialiasing( final Graphics2D g2d, final Shape... shapes ) {
        g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF);
        try {
            for ( Shape shape : shapes )
                _fillOneWithoutAntialiasing(g2d, shape);
        } finally {
            g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        }
    }

    /**
     *  Fills one shape, asking for a rectangle by the four numbers wherever the shape is one.
     *  <p>
     *  {@link Graphics2D#fill(Shape)} reaches the rasterizer through the general shape pipeline,
     *  which walks the outline and hands the destination one span per scanline: filling a scroll
     *  bar thumb's 1290-row band that way pushes 1290 rectangles at the X server.
     *  {@link Graphics#fillRect(int, int, int, int)} states the same region as one rectangle and
     *  the destination fills it in one operation. Measured on an accelerated surface, a 53x1290
     *  fill costs 24.5 microseconds through {@code fill} and 0.52 through {@code fillRect}.
     *  <p>
     *  Only whole-numbered coordinates can take that route, because {@code fillRect} has no other
     *  kind. A rectangle whose corners sit between two user-space pixels - which the caller may
     *  still have found acceptable, if the transform happens to scale them onto whole device ones
     *  - is filled the general way.
     *  <p>
     *  So is one carrying a gradient or a texture. Java2D reaches the single-rectangle operation
     *  only while the paint is one colour; under any other paint it turns {@code fillRect} back
     *  into a shape and walks it anyway, and the round trip measured as a small loss on the
     *  gradient-heavy presets.
     */
    private static void _fillOneWithoutAntialiasing( final Graphics2D g2d, final Shape shape ) {
        if ( shape instanceof Rectangle2D && g2d.getPaint() instanceof Color ) {
            final Rectangle2D rectangle = (Rectangle2D) shape;
            final double x = rectangle.getX(),     y = rectangle.getY();
            final double w = rectangle.getWidth(), h = rectangle.getHeight();
            if ( _isWhole(x) && _isWhole(y) && _isWhole(w) && _isWhole(h) ) {
                g2d.fillRect(
                    (int) Math.rint(x), (int) Math.rint(y),
                    (int) Math.rint(w), (int) Math.rint(h)
                );
                return;
            }
        }
        g2d.fill(shape);
    }

    /**
     *  Tries to fill a rounded rectangle as three antialiasing-free bands plus four antialiased
     *  corners, and reports whether it succeeded or not.
     *
     * @return {@code true} when the shape was filled, {@code false} when the caller must fill it.
     */
    private static boolean _fillRoundRectangleInParts(
        final Graphics2D       g2d,
        final RoundRectangle2D round,
        final AffineTransform  transform
    ) {
        if ( transform.getShearX() != 0 || transform.getShearY() != 0 )
            return false; // The bands would not be axis aligned in device space.

        final double x = round.getX(),     y = round.getY();
        final double w = round.getWidth(), h = round.getHeight();
        if ( w <= 0 || h <= 0 )
            return false;

        final double scaleX = transform.getScaleX(), translateX = transform.getTranslateX();
        final double scaleY = transform.getScaleY(), translateY = transform.getTranslateY();

        final double deviceArea = Math.abs(w * scaleX * h * scaleY);
        if ( deviceArea < SMALLEST_AREA_WORTH_SPLITTING )
            return false; // The split would not pay for its six extra fills.

        // How far the curvature reaches in from each side, which is half of the arc:
        final double arcW = Math.min(Math.abs(round.getArcWidth()),  w) / 2d;
        final double arcH = Math.min(Math.abs(round.getArcHeight()), h) / 2d;
        if ( arcW <= 0 || arcH <= 0 )
            return false; // Not actually rounded; an undivided fill of it is already optimal.

        final double[] cutX = { x, x + arcW, x + w - arcW, x + w };
        final double[] cutY = { y, y + arcH, y + h - arcH, y + h };
        if ( !_allCutsAreWholeDevicePixels(cutX, scaleX, translateX) )
            return false;
        if ( !_allCutsAreWholeDevicePixels(cutY, scaleY, translateY) )
            return false;

        // The bands, which hold nearly all of the area and none of the curvature.
        // A band is empty when the arc spans the full width or height, which fills nothing.
        fillWithoutAntialiasing(g2d,
            new Rectangle2D.Double(cutX[0], cutY[1], w,                 cutY[2] - cutY[1]), // Between the corners.
            new Rectangle2D.Double(cutX[1], cutY[0], cutX[2] - cutX[1], arcH),              // Above them,
            new Rectangle2D.Double(cutX[1], cutY[2], cutX[2] - cutX[1], arcH)               // and below them.
        );
        // And then the four corner boxes, each filled with the whole shape clipped to it, so
        // that the curve is rasterized by exactly the code which would have drawn it anyway.
        for ( int corner = 0; corner < 4; corner++ ) {
            final double cornerX = ( corner == 1 || corner == 3 ) ? cutX[2] : cutX[0];
            final double cornerY = ( corner >= 2 )                ? cutY[2] : cutY[0];
            final Graphics2D cornerG2d = (Graphics2D) g2d.create();
            try {
                cornerG2d.clip(new Rectangle2D.Double(cornerX, cornerY, arcW, arcH));
                cornerG2d.fill(round);
            } finally {
                cornerG2d.dispose();
            }
        }
        return true;
    }

    /**
     *  Whether every cut line lands on a whole device pixel. A cut between two pixels would
     *  make the band and the corner clip meeting there disagree about which pixel they own,
     *  leaving a seam that is either blended twice or not covered at all.
     */
    private static boolean _allCutsAreWholeDevicePixels(
        final double[] cuts, final double scale, final double translate
    ) {
        for ( double cut : cuts )
            if ( !_isWhole(cut * scale + translate) )
                return false;
        return true;
    }

    /**
     *  Whether the given transform turns the corners of the given integer rectangle
     *  into whole device pixels, which requires it to be free of rotation and shear
     *  and to scale the corners onto integers.
     */
    private static boolean _mapsOntoWholeDevicePixels( final AffineTransform transform, final Rectangle rectangle ) {
        if ( transform.getShearX() != 0 || transform.getShearY() != 0 )
            return false;
        return _isWhole(transform.getScaleX() * rectangle.x                     + transform.getTranslateX())
            && _isWhole(transform.getScaleY() * rectangle.y                     + transform.getTranslateY())
            && _isWhole(transform.getScaleX() * (rectangle.x + rectangle.width ) + transform.getTranslateX())
            && _isWhole(transform.getScaleY() * (rectangle.y + rectangle.height) + transform.getTranslateY());
    }

    private static boolean _isWhole( final double value ) {
        return Math.abs(value - Math.rint(value)) < 1e-6;
    }
}