AS3 pixel perfect drawing?

3k views Asked by At

When I'm use:

var shape:Shape = new new Shape();
shape.graphics.lineStyle(2,0);
shape.graphics.lineTo(10,10);
addChild(shape);

I get the black line I want, but I also get grey pixels floating around next to them. Is there a way to turn off whatever smoothing/anti-aliasing is adding the fuzzy pixels?

3

There are 3 answers

1
Triynko On BEST ANSWER

Yes, it is possible to draw pixel-perfect shapes, even with anti-aliasing on. Pixel-hinting is a must. The other half of the equation is to actually issue the drawing commands with whole-pixel coordinates.

For example, you can draw a pixel-perfectly-symmetrical rounded-rectangle with 4px radius curves with the following code. Pay careful attention to what the code is doing, particularly how the offsets relate to the border thickness.

First, keep in mind that when you're drawing filled shapes, the rasterization occurs up to, but no including the right/lower edges of the outline. So to draw a 4x4 pixel filled square, you can just call drawRect(0,0,4,4). That covers pixels 0,1,2,3,4 (5 pixels), but since it doesn't rasterize the right and lower edges, it ends up being 4 pixels. On the other hand, if you're drawing just the outline (without filling it), then you need to call drawRect(0,0,3,3), which will cover pixels 0,1,2,3, which is 4 pixels. So you actually need slightly different dimensions for the fill vs the outline to get pixel-perfect sizes.

Suppose you wanted to draw a button that's 50px wide, 20px tall, with a 4px radius on its rounded edges, which are 2px thick. In order to ensure that exactly 50x20 pixels are covered, and the outside edge of the 2px thick line buts up against the edge pixels without overflowing, you have to issue the drawing command exactly like this. You must use pixel hinting, and you must offset the rectangle by 1px inward on all sides (not half a pixel, but exactly 1). That places the center of the line exactly between pixels 0 and 1, such that it ends up drawing the 2px wide line through pixels 0 and 1.

Here is an example method that you can use:

public class GraphicsUtils
{
    public static function drawFilledRoundRect( g:Graphics, x:Number, y:Number, width:Number, height:Number, ellipseWidth:Number = 0, ellipseHeight:Number = 0, fillcolor:Number = 0xFFFFFF, fillalpha:Number = 1, thickness:Number = 0, color:Number = 0, alpha:Number = 1, pixelHinting:Boolean = false, scaleMode:String = "normal", caps:String = null, joints:String = null, miterLimit:Number = 3 )
    {
        if (!isNaN( fillcolor))
        {
            g.beginFill( fillcolor, fillalpha );
            g.drawRoundRect( x, y, width, height, ellipseWidth, ellipseHeight );
            g.endFill();
        }
        if (!isNaN(color))
        {
            g.lineStyle( thickness, color, alpha, pixelHinting, scaleMode, caps, joints, miterLimit );
            g.drawRoundRect( x, y, width, height, ellipseWidth, ellipseHeight );
        }
    }
}

Which you'd want to call like this:

var x:Number = 0;
var y:Number = 0;
var width:Number = 50;
var height:Number = 20;
var pixelHinting:Boolean = true;
var cornerRadius:Number = 4;
var fillColor:Number = 0xffffff; //white
var fillAlpha:Number = 1;
var borderColor:Number = 0x000000; //black
var borderAlpha:Number = 1;
var borderThickness:Number = 2;
GraphicsUtils.drawFilledRoundRect( graphics, x + (borderThickness / 2), y + (borderThickness / 2), width - borderThickness, height - borderThickness, cornerRadius * 2, cornerRadius * 2, fillColor, fillAlpha, borderThickness, borderColor, borderAlpha, pixelHinting );

That will produce a pixel-perfectly-symmetrical 2px thick filled rounded rectangle that covers exactly a 50x20 pixel region.

Its very important to notice that using a borderThickness of zero is somewhat non-sensical, and will result in an rectangle oversized by 1 pixel, because it's still drawing a one-pixel wide line, but it's failing to subtract the width (since its zero), hence you'll get an oversized rectangle.

In summary, use the algorithm above, where you add half the border thickness to the x and y coordinates, and subtract the whole border thickness from the width and height, and always use a minimum thickness of 1. That will always result in a rectangle with a border that occupies and does not overflow a pixel region equivalent to the given width and height.

If you want to see it in action, just copy and paste the following code block into a new AS3 Flash Project on the main timeline and run it, as is, since it includes everything necessary to run:

import flash.display.StageScaleMode;
import flash.display.StageAlign;
import flash.events.Event;
import flash.utils.getTimer;
import flash.display.Sprite;
import flash.display.Graphics;

stage.scaleMode = flash.display.StageScaleMode.NO_SCALE;
stage.align = flash.display.StageAlign.TOP_LEFT;
stage.frameRate = 60;
draw();

function draw():void
{
    var x:Number = 10;
    var y:Number = 10;
    var width:Number = 50;
    var height:Number = 20;
    var pixelHinting:Boolean = true;
    var cornerRadius:Number = 4;
    var fillColor:Number = 0xffffff; //white
    var fillAlpha:Number = 1;
    var borderColor:Number = 0x000000; //black
    var borderAlpha:Number = 1;
    var borderThickness:Number = 2;

    var base:Number = 1.6;
    var squares:int = 10;
    var rows = 4;
    var thicknessSteps:Number = 16;
    var thicknessFactor:Number = 4;

    var offset:Number;
    var maxBlockSize:Number = int(Math.pow( base, squares ));
    var globalOffset:Number = maxBlockSize; //leave room on left for animation
    var totalSize:Number = powerFactorial( base, squares );
    var colors:Array = new Array();
    for (i = 1; i <= squares; i++)
        colors.push( Math.random() * Math.pow( 2, 24 ) );

    for (var j:int = 0; j < thicknessSteps; j++)
    {
        var cycle:Number = int(j / rows);
        var subCycle:Number = j % rows;
        offset = cycle * totalSize;
        y = subCycle * maxBlockSize;
        borderThickness = (j + 1) * thicknessFactor;
        for (var i:int = 0; i < squares; i++)
        {
            cornerRadius = Math.max( 8, borderThickness );
            borderColor = colors[i];
            x = globalOffset + offset + powerFactorial( base, i ); //int(Math.pow( base, i - 1 ));
            width = int(Math.pow( base, i + 1 ));
            height = width;
            if (borderThickness * 2 > width) //don't draw if border is larger than area
                continue;
            drawFilledRoundRect( graphics, x + (borderThickness / 2), y + (borderThickness / 2), width - borderThickness, height - borderThickness, cornerRadius * 2, cornerRadius * 2, fillColor, fillAlpha, borderThickness, borderColor, borderAlpha, pixelHinting );
        }
    }

    var start:uint = flash.utils.getTimer();
    var duration:uint = 5000;
    var sprite:Sprite = new Sprite();
    addChild( sprite );
    var gs:Graphics = sprite.graphics;
    addEventListener( flash.events.Event.ENTER_FRAME,
    function ( e:Event ):void
    {
        var t:uint = (getTimer() - start) % duration;
        if (t > (duration / 2))
            borderThickness = ((duration-t) / (duration/2))  * thicknessSteps * thicknessFactor;
        else
            borderThickness = (t / (duration/2)) * thicknessSteps * thicknessFactor;
        //borderThickness = int(borderThickness);
        cornerRadius = Math.max( 8, borderThickness );
        borderColor = colors[squares - 1];
        x = 0;
        y = 0;
        width = int(Math.pow( base, squares ));
        height = width;
        if (borderThickness * 2 > width) //don't draw if border is larger than area
            return;
        gs.clear();
        drawFilledRoundRect( gs, x + (borderThickness / 2), y + (borderThickness / 2), width - borderThickness, height - borderThickness, cornerRadius * 2, cornerRadius * 2, fillColor, fillAlpha, borderThickness, borderColor, borderAlpha, pixelHinting );
    }, false, 0, true );
}

function powerFactorial( base:Number, i:int ):Number
{
    var result:Number = 0;
    for (var c:int = 0; c < i; c++)
    {
        result += int(Math.pow( base, c + 1 ));
    }
    return result;
}

function drawFilledRoundRect( g:Graphics, x:Number, y:Number, width:Number, height:Number, ellipseWidth:Number = 0, ellipseHeight:Number = 0, fillcolor:Number = 0xFFFFFF, fillalpha:Number = 1, thickness:Number = 0, color:Number = 0, alpha:Number = 1, pixelHinting:Boolean = false, scaleMode:String = "normal", caps:String = null, joints:String = null, miterLimit:Number = 3 )
{
    if (!isNaN( fillcolor))
    {
        g.beginFill( fillcolor, fillalpha );
        g.drawRoundRect( x, y, width, height, ellipseWidth, ellipseHeight );
        g.endFill();
    }
    if (!isNaN(color))
    {
        g.lineStyle( thickness, color, alpha, pixelHinting, scaleMode, caps, joints, miterLimit );
        g.drawRoundRect( x, y, width, height, ellipseWidth, ellipseHeight );
    }
}
1
redhotvengeance On

Try turning on pixelHinting:

shape.graphics.lineStyle(2, 0, 1, true);

More about pixelHinting here.

0
Peter Hall On

You can't turn off antialiasing completely. If you want a sharp, pixelated line then unfortunately you have to draw pixel by pixel, using a Bitmap and setPixel()