如何使用Bresenham线绘制算法进行亚像素偏移?

4

布雷森汉姆直线算法是众所周知的且实现相当简单。

虽然有更先进的方法来绘制抗锯齿线条,但我想编写一个函数,基于浮点坐标绘制单像素宽度的非抗锯齿线条。

这意味着虽然第一个和最后一个像素将保持不变,但在它们之间绘制的像素将根据两个端点的子像素位置而偏移。

原则上,这不应该太复杂,因为我认为可以使用子像素偏移量来计算绘制线条时要使用的初始误差值,而所有其他部分的算法保持不变。

没有子像素偏移:

X###
    ###X

假设右侧点的亚像素位置接近顶部,线条可能会看起来像这样:

例如,带有亚像素偏移:

X######
       X

有没有一种经过验证的方法可以绘制考虑到子像素坐标的线条?

注意:

  • 这似乎是一个常见的操作,例如我看到OpenGL驱动程序考虑了这一点 - 使用GL_LINE,但是从快速搜索中没有找到任何答案 - 可能使用了错误的搜索术语?
  • 乍一看,这个问题看起来可能是重复的:
    精确的子像素线绘制算法(光栅化算法)
    然而,那个问题询问如何绘制宽线,而这个问题询问如何偏移单像素线。
  • 如果没有标准方法,我将尝试编写此答案。

我想可以使用固定点比例因子,并从起始点的小数部分评估初始误差项。不过,我看不出与更简单的算法(例如直接计算函数或使用固定点 DDA)相比Bresenham算法的优势在哪里?除非您的平均线很短,否则针对设置进行优化似乎不太可能获得胜利,而且无论如何,在计算误差种子时,我也难以避免进行除法运算。 - doynax
虽然固定点DDA可能是一个不错的解决方案,但我想确认一下,在问题描述中应用Bresenham方法时施加偏差是否因某种原因不可行。我不能对线条的数量和长度做出太多假设,但我希望保持大致相同的性能,除了初始设置会稍微慢一些。 - ideasman42
我猜你会用e = frac(x1) * (y2 - y1) + frac(y1) * (x2 - x1)来进行种子填充,尽管使用定点坐标可以避免舍入误差。我的观点是,DDA内循环(基本上是加法和移位)通常比Bresenham更快,缺点是有一个小的初始化开销,除非你的线段非常短或者除法非常昂贵,否则这个开销通常是无关紧要的。 - doynax
3个回答

8

我刚遇到了同样的挑战,我可以确认你期望的是可行的。

首先,回到算法的最简形式:(忽略分数,它们后面会消失)

x = x0
y = y0
dx = x1 - x0
dy = y1 - y0
error = -0.5
while x < x1:
    if error > 0:
        y += 1
        error -= 1
    paint(x, y)
    x += 1
    error += dy/dx

这意味着,对于整数坐标,我们在像素边界的上方半个像素位置开始(error = -0.5),对于每个x增加一个像素,我们都会增加理想的y坐标(因此当前的error也会增加)dy/dx

首先让我们看看如果不再强制x0y0x1y1为整数时会发生什么:(这也假设坐标相对于每个像素的左下角而非像素中心1,因为一旦支持亚像素位置,可以将半个像素宽度加到x和y上以返回像素居中的逻辑)。

x = x0
y = y0
dx = x1 - x0
dy = y1 - y0
error = (0.5 - (x0 % 1)) * dy/dx + (y0 % 1) - 1
while x < x1:
    if error > 0:
        y += 1
        error -= 1
    paint(x, y)
    x += 1
    error += dy/dx

唯一的变化是初始误差计算。新值来自简单的三角函数,用于在像素中心时计算y坐标。值得注意的是,您可以使用相同的思路将线条的起始位置剪裁为某个范围内,这是在开始优化时可能会遇到的另一个挑战。
现在我们只需要将其转换为仅限整数的算术运算。对于小数输入,我们需要一些固定的乘数(“scale”),并且除法可以通过将它们乘出来来处理,就像标准算法一样。
# assumes x0, y0, x1 and y1 are pre-multiplied by scale
x = x0
y = y0
dx = x1 - x0
dy = y1 - y0
error = (scale - 2 * (x0 % scale)) * dy + 2 * (y0 % scale) * dx - 2 * dx * scale
while x < x1:
    if error > 0:
        y += scale
        error -= 2 * dx * scale
    paint(x / scale, y / scale)
    x += scale
    error += 2 * dy * scale

请注意,xydxdy与输入变量(scale)保持相同的比例因子,而error具有更复杂的比例因子:2 * dx * scale。这使它能够吸收其原始公式中的除法和分数,但意味着我们需要在使用它的任何地方都应用相同的比例尺度。
显然,在这里进行优化的空间很大,但这就是基本算法。如果我们假设比例是2的幂(2^n),我们可以开始使事情变得更加高效:
dx = x1 - x0
dy = y1 - y0
mask = (1 << n) - 1
error = (2 * (y0 & mask) - (2 << n)) * dx - (2 * (x0 & mask) - (1 << n)) * dy
x = x0 >> n
y = y0 >> n
while x < (x1 >> n):
    if error > 0:
        y += 1
        error -= 2 * dx << n
    paint(x, y)
    x += 1
    error += 2 * dy << n

与原始版本一样,这只适用于第八象限(x≥y,x>0,y≥0)。扩展到所有情况的通常规则仍然适用,但请注意,由于坐标不再位于像素中心(即反射变得更加复杂),存在一些额外的问题。
您还需要注意整数溢出:输入变量的精度是“错误”的两倍,并且范围最多是线段长度的两倍。因此,请根据您的输入、精度和变量类型规划好相关参数!
1: 坐标相对于最靠近0,0的角落而言。在类似OpenGL的坐标系统中,这是左下角,但具体情况取决于您的特定场景。

这是一个很棒的答案。有一点让我困惑的是提到了像素的“左上角”。这假设y轴向下,但不幸的是对我来说并非如此。 - Jyaif
@Jyaif 谢谢,我添加了一条注释,既可以澄清事情,也可能让它们更加混乱!我最初是为嵌入式硬件编写的,其中显示器使用0,0作为左上角,但我认为更常见的用法是人们在操作系统中使用OpenGL风格的坐标系统,这时确实是底部左侧。 - Dave

3
我遇到了类似的问题,需要添加亚像素端点,还需要确保所有与线相交的像素都被绘制。我不确定我的解决方案是否对OP有帮助,因为已经过去了4年以上,而且还有这样一句话:“这意味着第一个和最后一个像素将保持不变……”对我来说,这实际上是个问题(稍后会详细说明)。希望这对其他人有所帮助。

我不确定这是否可以被视为Bresenham算法,但它非常相似。我将解释一下第一象限的情况。假设你希望在像素网格上从点(Px,Py)到(Qx,Qy)绘制一条直线,网格宽度为W。网格宽度W > 1允许子像素端点。

对于在第一象限中的直线,起始点很容易计算,只需取(Px,Py)的floor值。如后面所述,这仅适用于Qx >= Px & Qy >= Py

endpoints floored

现在你需要找到下一个要去的像素。有三种可能性:(x+1,y), (x,y+1), & (x+1,y+1)。为了做出这个决定,我使用定义如下的二维叉积:

enter image description here

  • 如果这个值是负数,向量b在向量a的右侧/顺时针方向。
  • 如果这个值是正数,向量b在向量a的左侧/逆时针方向。
  • 如果这个值是零,向量b和向量a指向相同的方向。

为了决定下一个像素点,比较线段P-Q [下图中的红色]与点P和右上角像素(x+1,y+1)之间的连线(下图中的蓝色)的叉积。

cross-product diagram

P和右上角像素之间的向量可以计算如下:

BLUE vector calculation

因此,我们将使用二维叉积的值: full cross product maths

  • 如果这个值是负数,下一个像素将是 (x,y+1)。
  • 如果这个值是正数,下一个像素将是 (x+1,y)。
  • 如果这个值恰好为零,下一个像素将是 (x+1,y+1)。

这对于起始像素来说很好,但其他像素将没有一个点在其内部。幸运的是,在初始点之后,对于蓝色向量,您不需要一个点在像素内部。您可以像这样继续扩展它: 当前像素外的向量 蓝色向量从线的起始点开始,并更新为每个像素的(x+1,y+1)。选择哪个像素的规则相同。如您所见,红色向量在蓝色向量的右侧。因此,下一个像素将是绿色像素的右侧像素。
每个像素的叉积值需要根据您选择的像素进行更新。 dx dy 如果下一个像素是(x+1),则添加dx,如果像素是(y+1),则添加dy。如果像素到达(x+1,y+1),则两者都要添加。

重复此过程,直到到达结束像素(Qx / W, Qy / W)。

综合起来,得到以下代码:

    int dx = x2 - x2;
    int dy = y2 - y1;
    int local_x = x1 % width;
    int local_y = y1 % width;
    int cross_product = dx*(width-local_y) - dy*(width-local_x);
    int dx_cross = -dy*width;
    int dy_cross = dx*width;

    int x = x1 / width;
    int y = y1 / width;
    int end_x = x2 / width;
    int end_y = y2 / width;
    while (x != end_x || y != end_y) {
        SetPixel(x,y,color);
        int old_cross = cross_product;
        if (old_cross >= 0) {
            x++;
            cross_product += dx_cross;
        }
        if (old_cross <= 0) {
            y++;
            cross_product += dy_cross;
        }
    }

让它适用于所有象限是通过反转本地坐标和一些绝对值来实现的。以下是适用于所有象限的代码:

    int dx = x2 - x1;
    int dy = y2 - y1;
    int dx_x = (dx >= 0) ? 1 : -1;
    int dy_y = (dy >= 0) ? 1 : -1;
    int local_x = x1 % square_width;
    int local_y = y1 % square_width;
    int x_dist = (dx >= 0) ? (square_width - local_x) : (local_x);
    int y_dist = (dy >= 0) ? (square_width - local_y) : (local_y);
    int cross_product = abs(dx) * abs(y_dist) - abs(dy) * abs(x_dist);
    dx_cross = -abs(dy) * square_width;
    dy_cross = abs(dx) * square_width;

    int x = x1 / square_width;
    int y = y1 / square_width;
    int end_x = x2 / square_width;
    int end_y = y2 / square_width;

    while (x != end_x || y != end_y) {
        SetPixel(x,y,color);
        int old_cross = cross_product;
        if (old_cross >= 0) {
            x += dx_x;
            cross_product += dx_cross;
        }
        if (old_cross <= 0) {
            y += dy_y;
            cross_product += dy_cross;
        }
    }

然而,存在一个问题!在某些情况下,这段代码不会停止。要理解为什么,您需要仔细研究哪些条件被视为线和像素之间的交点。
何时会准确地绘制像素?
我说过,我需要绘制所有与线相交的像素。但是在边缘情况下存在一些歧义。
以下是在Qx >= Px & Qy >= Py的情况下,绘制像素的所有可能交点的列表:

all possible intersections

  • A - 如果一条线完全穿过了像素,那么该像素就会被绘制。
  • B - 如果一条垂直的线完全穿过了像素,那么该像素就会被绘制。
  • C - 如果一条水平的线完全穿过了像素,那么该像素就会被绘制。
  • D - 如果一条垂直的线恰好触及像素的左边,那么该像素就会被绘制。
  • E - 如果一条水平的线恰好触及像素的底部,那么该像素就会被绘制。
  • F - 如果一条线段的端点从像素内部开始沿着(+,+)方向前进,那么该像素就会被绘制。
  • G - 如果一条线段的端点恰好位于像素的左侧并且朝着(+,+)方向前进,那么该像素就会被绘制。
  • H - 如果一条线段的端点恰好位于像素的底部并且朝着(+,+)方向前进,那么该像素就会被绘制。
  • I - 如果一条线段的端点恰好位于像素的左下角并且朝着(+,+)方向前进,那么该像素就会被绘制。

这里有一些像素与该线段不相交: invalid intersections

  • A' - 如果一条线显然不会与像素相交,那么像素不会被绘制。

  • B' - 如果一条垂直线显然不会与像素相交,那么像素不会被绘制。

  • C' - 如果一条水平线显然不会与像素相交,那么像素不会被绘制。

  • D' - 如果一条垂直线恰好接触像素的右侧,那么像素不会被绘制。

  • E' - 如果一条水平线恰好接触像素的顶部,那么像素不会被绘制。

  • F' - 如果一条线段的端点恰好从像素的右上角开始沿着(+,+)方向前进,那么像素不会被绘制。

  • G' - 如果一条线段的端点恰好从像素的顶部开始沿着(+,+)方向前进,那么像素不会被绘制。

  • H' - 如果一条线段的端点恰好从像素的右侧开始沿着(+,+)方向前进,那么像素不会被绘制。

  • I' - 如果一条线段恰好接触像素的任意一个角落,那么像素不会被绘制。这适用于所有角落。


那些规则适用于你所期望的方式(只需翻转图像)适用于其他象限。我需要强调的问题是当端点恰好位于像素边缘时。看看这个例子: misleading endpoint 这就像上面的图G',只不过y轴被翻转了,因为Qy < Py。有4x4个红点,因为W等于4,使得像素尺寸为4x4。每个4个点都是线条唯一可以触及的端点。所画的线从(1.25, 1.0)到某个地方。
这说明为什么说计算像素端点为线段端点的下取整是不正确的(至少是按照我定义的像素线交点)。该端点的下取整像素坐标似乎为(1,1),但很明显,线条从未真正与该像素相交。它只是接触它,所以我不想画它。

不要将线的端点向下取整,而是需要在x和y维度上将最小端点向下取整,并将最大端点减1向上取整。

因此,这里是完成这种向下/向上取整的完整代码:

    int dx = x2 - x1;
    int dy = y2 - y1;
    int dx_x = (dx >= 0) ? 1 : -1;
    int dy_y = (dy >= 0) ? 1 : -1;
    int local_x = x1 % square_width;
    int local_y = y1 % square_width;
    int x_dist = (dx >= 0) ? (square_width - local_x) : (local_x);
    int y_dist = (dy >= 0) ? (square_width - local_y) : (local_y);
    int cross_product = abs(dx) * abs(y_dist) - abs(dy) * abs(x_dist);
    dx_cross = -abs(dy) * square_width;
    dy_cross = abs(dx) * square_width;

    int x = x1 / square_width;
    int y = y1 / square_width;
    int end_x = x2 / square_width;
    int end_y = y2 / square_width;

    // Perform ceiling/flooring of the pixel endpoints
    if (dy < 0)
    {
        if ((y1 % square_width) == 0)
        {
            y--;
            cross_product += dy_cross;
        }
    }
    else if (dy > 0)
    {
        if ((y2 % square_width) == 0)
            end_y--;
    }

    if (dx < 0)
    {
        if ((x1 % square_width) == 0)
        {
            x--;
            cross_product += dx_cross;
        }
    }
    else if (dx > 0)
    {
        if ((x2 % square_width) == 0)
            end_x--;
    }

    while (x != end_x || y != end_y) {
        SetPixel(x,y,color);
        int old_cross = cross_product;
        if (old_cross >= 0) {
            x += dx_x;
            cross_product += dx_cross;
        }
        if (old_cross <= 0) {
            y += dy_y;
            cross_product += dy_cross;
        }
    }

这段代码本身尚未经过测试,但它是从我的GitHub项目稍作修改后得到的,在该项目中已进行了测试。

2

假设您想从P1 = (x1, y1) 绘制一条线到P2 = (x2, y2) ,其中所有的数字都是 浮点数 像素坐标。

  1. Calculate the true pixel coordinates of P1 and P2 and paint them: P* = (round(x), round(y)).

  2. If abs(x1* - x2*) <= 1 && abs(y1* - y2*) <= 1 then you are finished.

  3. Decide whether it is a horizontal (true) or a vertical line (false): abs(x1 - x2) >= abs(y1 - y2).

  4. If it is a horizontal line and x1 > x2 or if it is a vertical line and y1 > y2: swap P1 with P2 (and also P1* with P2*).

  5. If it is a horizontal line you can get the y-coordinates for all the x-coordinates between x1* and x2* with the following formula:

     y(x) = round(y1 + (x - x1) / (x2 - x1) * (y2 - y1))
    

    If you have a vertical line you can get the x-coordinates for all the y-coordinates between y1* and y2* with this formula:

     x(y) = round(x1 + (y - y1) / (y2 - y1) * (x2 - x1))
    

这里有一个演示,您可以尝试在第12行上尝试不同的点。


感谢您提供的演示,但其中涉及了完全使用浮点数运算。我对使用仅限整数算术的 Bresenham 直线绘制算法很感兴趣,除了初始设置需要基于浮点值计算一个偏差外。就我所见,这应该是可能的。如果我的直觉是错误的,即不可能/不实际 - 那么也许这个答案展示了更好的方法。 - ideasman42
修复了演示。 - maraca

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接