アンチエイリアス付きの自由線描画(1)

アンチエイリアス付きの自由線描画
ドットでの自由線描画はすでにやりましたが、ここからは、アンチエイリアス付きでの自由線描画をやっていきます。
やり方
ドットでの自由線描画時は、直線の各位置に円を描画することで、太い線になるように見せていました。
アンチエイリアス付きでも、基本的な考え方は同じです。

アンチエイリアス付きの場合は、オーバーサンプリングによって、仮想的なサブピクセルの座標上に円を描き、その平均値を実際の描画濃度とします。

つまり、直線の各点上で、オーバーサンプリングによる円塗りつぶしを描画するということです。
スクリーンショット


C キーでイメージをクリア、
T キーで半径・間隔・濃度をある値に変更します。
ソースコード
025_aaline1.c
#include <math.h>
#include "sptk.h"

#define WIDTH  300
#define HEIGHT 300

SPTK_IMAGE *winimg;
SPTK_IMAGE32 *blendimg,*layerimg;

SPTK_POINT ptLast;
SPTK_PIX_RGBA drawcol = {{0,0,0,64}};
double line_radius = 3,
    line_interval = 0.15,
    line_last_t = 0;

/** 円を描画 */

void drawpoint(double x,double y,double radius)
{
    int nx1,ny1,nx2,ny2,ix,iy,jx,jy,cnt;
    double xx,yy,rr;
    SPTK_PIX_RGBA col;
    
    /* 描画先の範囲 */
    
    nx1 = (int)floor(x - radius);
    ny1 = (int)floor(y - radius);
    nx2 = (int)floor(x + radius);
    ny2 = (int)floor(y + radius);
    
    /* 描画先ループ */
    
    rr = radius * radius;
    
    for(iy = ny1; iy <= ny2; iy++)
    {
        for(ix = nx1; ix <= nx2; ix++)
        {
            /* オーバーサンプリング */
            
            cnt = 0;
            
            for(jy = 0; jy < 8; jy++)
            {
                yy = iy - y + jy * (1.0 / 8);
            
                for(jx = 0; jx < 8; jx++)
                {
                    xx = ix - x + jx * (1.0 / 8);
                    
                    if(xx * xx + yy * yy < rr)
                        cnt++;
                }
            }
            
            /* 結果 */
            
            col = drawcol;
            col.a = drawcol.a * cnt >> 6;
            
            sptk_image32_setpixel_rgba(layerimg, ix, iy, &col);
        }
    }
}

/** 直線描画 */

void drawline_aa(double x1,double y1,double x2,double y2,
    double radius,double *last_t)
{
    double dx,dy,len,t,t_interval,x,y,dtmp;
    
    /* 線の長さ */
    
    dx = x2 - x1;
    dy = y2 - y1;
    
    len = sqrt(dx * dx + dy * dy);
    if(len == 0) return;
    
    /* 線形補間 */
    
    t_interval = line_interval / len;
    
    t = *last_t / len;
    
    while(t < 1.0)
    {
        x = x1 + dx * t;
        y = y1 + dy * t;
        
        drawpoint(x, y, radius);
    
        /* 次の位置 */
    
        dtmp = radius * t_interval;
        if(dtmp < 0.0001) dtmp = 0.0001;
    
        t += dtmp;
    }
    
    *last_t = len * (t - 1.0);
}

/** 画面更新 */

void update_screen(SPTK_RECTWH *imgrc,int time)
{
    SPTK_RECTWH rc;
    
    if(imgrc)
        rc = *imgrc;
    else
        rc.x = 0, rc.y = 0, rc.w = WIDTH, rc.h = HEIGHT;
    
    sptk_image32_fill_plaid(blendimg, rc.x, rc.y, rc.w, rc.h);
    sptk_image32_blend_normal(blendimg, layerimg, rc.x, rc.y, rc.w, rc.h);

    sptk_image32_blt_image(blendimg, rc.x, rc.y, rc.w, rc.h, winimg, rc.x, rc.y);
    sptk_update(NULL, rc.x, rc.y, rc.w, rc.h, time);
}

/** ウィンドウハンドル */

void winhandle(SPTK_EVENT *ev)
{
    SPTK_RECT rc;
    SPTK_RECTWH rcwh;
    int r;

    switch(ev->type)
    {
        case SPTK_EVENT_BTTDOWN:
            if(ev->mouse.btt == SPTK_MOUSEBTT_LEFT)
            {
                ptLast.x = ev->mouse.x;
                ptLast.y = ev->mouse.y;
                            
                sptk_grab(NULL);
            }
            break;
        case SPTK_EVENT_BTTUP:
            if(ev->mouse.btt == SPTK_MOUSEBTT_LEFT && sptk_isgrab())
                sptk_ungrab();
            break;
        case SPTK_EVENT_MOUSEMOVE:
            if(sptk_isgrab())
            {            
                drawline_aa(ptLast.x, ptLast.y, ev->mouse.x, ev->mouse.y,
                        line_radius, &line_last_t);
            
                /* 更新 */

                rc.x1 = ptLast.x;
                rc.y1 = ptLast.y;
                rc.x2 = ev->mouse.x;
                rc.y2 = ev->mouse.y;
                
                sptk_rect_swap(&rc);
                
                r = (int)(line_radius + 0.5);
                
                rc.x1 -= r;
                rc.y1 -= r;
                rc.x2 += r;
                rc.y2 += r;
                
                if(!sptk_rect_clip_wh(&rc, WIDTH, HEIGHT))
                {
                    sptk_rect_to_rectwh(&rcwh, &rc);
                    
                    update_screen(&rcwh, 0);
                }
                
                ptLast.x = ev->mouse.x;
                ptLast.y = ev->mouse.y;
            }
            break;
        
        case SPTK_EVENT_WINDOW_KEYDOWN:
            if(ev->key.code == 'C')
            {
                /* クリア */
                
                sptk_image32_clear(layerimg, NULL);
                update_screen(NULL, -1);
            }
            else if(ev->key.code == 'T')
            {
                line_radius = 8;
                line_interval = 2.0;
                drawcol.a = 96;
            }
            break;
        case SPTK_EVENT_WINDOW_CLOSE:
            sptk_quit();
            break;
    }
}

int main()
{
    sptk_init("test", WIDTH, HEIGHT);
    sptk_window_set_handle(winhandle);
    
    winimg = sptk_window_get_image();
    
    blendimg = sptk_image32_create(WIDTH, HEIGHT);
    layerimg = sptk_image32_create(WIDTH, HEIGHT);
    
    sptk_image32_clear(layerimg, NULL);
    
    update_screen(NULL, -1);
    
    sptk_run();
    
    sptk_image32_free(blendimg);
    sptk_image32_free(layerimg);

    return 0;
}
解説
layerimg が描画先のレイヤイメージ、blendimg が合成後のイメージです。

line_radius が描画する線の太さ (円の半径)、line_interval が点の間隔です。
直線の描画
drawline_aa() が、アンチエイリアス付きの直線を描画する関数です。

void drawline_aa(double x1,double y1,double x2,double y2,
    double radius,double *last_t)
{
    double dx,dy,len,t,t_interval,x,y,dtmp;
    
    /* 線の長さ */
    
    dx = x2 - x1;
    dy = y2 - y1;
    
    len = sqrt(dx * dx + dy * dy);
    if(len == 0) return;
    
    /* 線形補間 */
    
    t_interval = line_interval / len;
    
    t = *last_t / len;
    
    while(t < 1.0)
    {
        x = x1 + dx * t;
        y = y1 + dy * t;
        
        drawpoint(x, y, radius);
    
        /* 次の位置 */
    
        dtmp = radius * t_interval;
        if(dtmp < 0.0001) dtmp = 0.0001;
    
        t += dtmp;
    }
    
    *last_t = len * (t - 1.0);
}

ドットでの直線描画時は「ブレゼンハム」などのアルゴリズムを使いましたが、それらはあくまで、整数で演算するためのアルゴリズムです。
今回は、浮動小数点の座標で描画していきますので、直線のアルゴリズムとしては、「始点から、線の傾き分の値を加算していく」という基本的なやり方で行っていきます。

まずは、dx, dy に線の傾きの値、len に線の長さを入れています。
円塗りつぶしの時に少しやりましたが、原点 (0, 0) から点 (x, y) までの線の長さは、「(x * x + y * y) の平方根」を求めることで得られます。

線の長さが 0 なら描画する必要はないので、終了します。
直線の処理
次に、直線の処理部分を説明します。

ドットの直線描画時は、直線の各 1px ごとに円を描画していましたが、今回は座標が浮動小数点ですから、そういうわけにもいきません。
それに、線の太さが大きい場合は、いちいち 1px ごとに円を描画していたら重くなります。
間隔
これを解決するためには、パラメータとして、「点と点の間の間隔」という値を用意します。

ここでの「間隔」というのは、直線上で描画される、それぞれの点 (円) の間の間隔です。
間隔を空けずに連続した円を描画することで、それが直線に見えるようにします。
その間隔の幅を、パラメータで指定できるようにします。

間隔が大きい場合は、点と点の距離が離れて、円が点々と連なっているように見えますが、間隔を小さくすると、それぞれの点が重なって、結果的に直線に見えるようになります。

というわけで、直線の処理では、この間隔ごとに円を描画していくことになります。
では、実際の直線処理に戻ります。
実際の処理
まずは、直線の始点を 0.0、直線の終点を 1.0 とし、この 0.0〜1.0 の間で、指定された間隔ごとに円を描画します。

t = 0;
while(t < 1.0)
{
    x = x1 + dx * t;
    y = y1 + dy * t;
    
    t += interval;
}

直線上の 0.0〜1.0 内における各位置の座標は、上記の計算で求められます。
あとは、t に間隔分の値を足していけば、次の描画位置まで進めることができます。

しかし、ここで t間隔のパラメータ値をそのまま加算してはいけません。
なぜなら、ここでの 0.0〜1.0 というのは、描画する直線の長さに対する相対的な値だからです。
描画する直線の長さは毎回異なりますから、それに対する割合で指定してしまうと、実際の間隔は各直線ごとに異なることになります。

それを回避するためには、間隔値を直線の長さで割って、1px に対する絶対的な間隔幅に変換します。

t += interval / len

これで間隔は一定になりますが、このままではまだ不十分です。
上記の状態では、interval は描画先の 1px に対する間隔幅になってしまうので、これにさらに線の太さ (円の半径) を掛けることで、円の半径に対する間隔幅にします。

t += interval / len * radius

こうすると、円の半径が小さい時は間隔も比例して狭まり、半径が大きい時は間隔も大きくなります。
また、筆圧の変化などで点ごとに半径が変わった場合でも、間隔は円の大きさに対して一定の幅となります。

この場合、間隔値が 1.0 で半径の長さと同じになるので、2.0 にすると点と点の間の間隔は直径分となって、円が数珠つなぎになります。
直線の処理2
ここまでの処理で、直線上の各位置に円を描画できますが、これだけではまだ不完全です。

自由線の場合は、「前回の位置から現在の位置までを直線で引く」ということを繰り返して、繋がった線を描画します。
この時、始点を常に 0.0 として直線を描画すると、前回の直線描画時に余った (未処理の) 間隔の分がずれるので、間隔が正しく一定になりません。

というわけで、自由線描画の場合はもう少し手を加える必要があります。

t のループは、値が 1.0 以上になった場合 (終点を越えた場合) に終了しますが、ここで、1.0 を超えた分は、次の直線描画時に持ち越す必要があります。
持ち越す値は「t - 1.0」ですが、この値は現在の直線の長さに対する割合ですから、この値をそのまま持って行くと、次の直線時との整合性が取れなくなります。
そこで、この値に線の長さを掛けることで、ピクセル単位の値として、絶対値に変換します。

last_t = (t - 1.0) * len;

これを、次の直線描画時に渡します。

次の直線描画時は、渡された値を t の初期値とすることで、前回の余った間隔分を進めます。
しかし、渡された値はピクセル単位に変換されていますから、今度はそれを直線の長さで割って、現在の直線の長さに対する割合値に直します。

t = last_t / len;

これで、直線処理部分は完成です。
後は、直線上の各位置にアンチエイリアス付きの円塗りつぶしを描画します。
円を描画
drawpoint() が、イメージ上の指定位置に円を描画する関数です。
アンチエイリアス付きにするため、オーバーサンプリングを使っています。

void drawpoint(double x,double y,double radius)
{
    int nx1,ny1,nx2,ny2,ix,iy,jx,jy,cnt;
    double xx,yy,rr;
    SPTK_PIX_RGBA col;
    
    /* 描画先の範囲 */
    
    nx1 = (int)floor(x - radius);
    ny1 = (int)floor(y - radius);
    nx2 = (int)floor(x + radius);
    ny2 = (int)floor(y + radius);
    
    /* 描画先ループ */
    
    rr = radius * radius;
    
    for(iy = ny1; iy <= ny2; iy++)
    {
        for(ix = nx1; ix <= nx2; ix++)
        {
            /* オーバーサンプリング */
            
            cnt = 0;
            
            for(jy = 0; jy < 8; jy++)
            {
                yy = iy - y + jy * (1.0 / 8);
            
                for(jx = 0; jx < 8; jx++)
                {
                    xx = ix - x + jx * (1.0 / 8);
                    
                    if(xx * xx + yy * yy < rr)
                        cnt++;
                }
            }
            
            /* 結果 */
            
            col = drawcol;
            col.a = drawcol.a * cnt >> 6;
            
            sptk_image32_setpixel_rgba(layerimg, ix, iy, &col);
        }
    }
}

まずは、描画先の範囲を求めます。
(x, y) を中心として半径 radius の円を描画するので、

nx1 = (int)floor(x - radius);
ny1 = (int)floor(y - radius);
nx2 = (int)floor(x + radius);
ny2 = (int)floor(y + radius);

これで左上、右下の位置が求められます。

floor() は、小数点以下切り捨てです。
ここでは、ピクセルの左上の座標値が欲しいので、「0.5 なら 0」「-0.3 なら -1.0」というような値になります。

あとは、円の内外判定を各サブピクセルの座標で行って、そこのピクセルの濃度を、描画色の濃度に適用します。
ここではサブピクセル数をにしているので、/ 64 は >> 6 として、割っています。

今回は浮動小数点で計算していますが、これまでにやったように、固定小数点数を使えばもっと高速化できます。

また、今回はサブピクセル数は固定値にしましたが、実際の実装時は、描画する円のサイズによってサブピクセル数を変更できるようにした方が、効率が良くなります。
半径が小さい場合はサブピクセル数を増やし、半径が大きい場合はサブピクセル数を減らした方が、品質と速度を保てます。
色の塗り方について
今回は、重ね塗りで色を描画しているので、描画濃度を最大 (255) にして描画すると、アンチエイリアス部分が重なって輪郭が汚くなるため、描画濃度を下げています。

輪郭を綺麗に保ちたい場合は、描画先の色のアルファ値と、描画する色のアルファ値を比較して、描画先より濃度が濃ければアルファ値を上書きする、という塗り方にすれば OK です。