草庐IT

扫描线算法完全解析

金戈大王 2023-09-16 原文

引言

自从今年春天选修了计算机图形学课程,这朵乌云就在头顶盘旋不散。始终弄不明白计算机图形学到底在研究什么,所谓的Imaging、Modeling、Rendering和Animation各自又是什么意思。总觉得课上讲的实在太过抽象,实践的经历太少,到最后也不过是囫囵吞枣,不知其味。

虽然不再从事计算机图形学相关研究,但为了弥补这些遗憾,最近我又重拾这些知识,更深入细致地学习一遍图形学基础知识。在学习完多边形扫描转换中的扫描线算法后,我决心亲自写代码实现它,并动笔写下这一篇文章。

扫描线算法是干什么用的

我们如何在计算机程序中存储几何图形呢?比如多边形?最容易想到的方法就是保存多边形的顶点坐标。只要按顺序保存了多边形各个顶点的坐标,这个多边形就唯一确定了。另一方面,显示器是如何显示几何图形的呢?显示设备通常提供一个帧缓冲存储器(俗称显存),可以把它当做二维数组,该数组存储的值与屏幕上显示的每一像素的颜色一一对应。那么问题来了,如何把程序中的几何图形转换成显存中的几何图形?这就是扫描线算法的用途。

总结下来:扫描线算法把几何图形在计算机中的顶点表示法转换成点阵表示法。需要注意的是转换成点阵表示法后其实是对多边形进行了填充,而不是只有轮廓。

基本思想

由于多边形千变万化,要想填充多边形内部的所有像素,需要找到一种合适的规则,能够沿着一个方向,一个像素不漏地把多边形内部填满,同时不污染多边形外部。于是我们发明了一条水平方向的扫描线,它从y=0开始,判断与多边形的交点,这些交点把扫描线分成了若干段,我们需要判断哪些段在多边形内部,哪些段在多边形外部,然后把内部的部分着色,完成后,令y=y+1,即扫描线上移一格,重复之前的操作,直到扫描线不再与多边形的任何部分相交。

举例说明,下图所示为多边形P1P2P3P4P5P6,而且同时画出了扫描线扫描过程中经过的四个位置y=1、y=2、y=6和y=7。

扫描线算法的难点在于如何判断扫描线被多边形分割后哪些部分在多边形内部,哪些部分在多边形外部。

让我们仔细观察上图,答案就在图中。对于未经过顶点的扫描线,如y=6,总是与多边形有偶数个交点,而且位于多边形内部的片段和位于多边形外部的片段交替存在。对于经过了顶点的扫描线,如y=1、y=2和y=7,与多边形的交点既可能是偶数,也可能是奇数。但是如果我们进一步划分,这些经过了顶点的扫描线有些经过了极值顶点,如y=1和y=7,它们的交点个数是奇数;而有些经过了非极值顶点,如y=2,它们的交点个数是偶数。这样的话,不如做一个特殊处理,把所有极值顶点当成两个点,就可以保证扫描线与多边形的交点总是偶数。

当然,把交点个数凑成偶数是有意义的,凑成偶数后就可以把这些交点从左到右两两配对,配对成功的两个点之间的像素全部着色。例如,上图的扫描线y=6与多边形的交点序列是ABCD,从左到右两两配对为AB和CD,然后将AB之间着色,CD之间着色。

基本思想就是这样,其实很容易理解。不过用代码实现起来并不那么容易,需要考虑很多细节。

代码实现详解

首先需要提醒的是,扫描线算法的基本思想很简单,但不同的实现方式会有很大的效率差异。因此,如何设计数据结构及算法才能使扫描线算法以最快的速度完成,才是接下来我们需要面对的问题。

从以下几个方面考虑:

问题1:如何计算当前扫描线与多边形的交点?

直观做法:根据多边形的顶点求出各个边的方程,然后将扫描线的纵坐标代入方程求出横坐标,搞定!

遗憾的是,这需要对每条扫描线遍历所有边,性能肯定是吃不消的。我们需要寻找一种只遍历一次边的方法。同时,使用方程求交点会用到乘法或除法运算,对性能也是一种伤害。因此,我们提出了下面两个数据结构。

  • 边表(Edge Table)
    所有边按下端点的y坐标归类。


    边表

    其中,每条边包含四个成员,分别是


  • 活动边表(Active Edge Table)
    与当前扫描线相交的边称为活动边,把它们按与扫描线交点x坐标(x相等时按∆x)递增排序。
    扫描线y=6时的活动边表如下


    活动边表

    与边表类似,每条边同样包含四个成员,分别是


下面的代码定义了边表和活动边表。

//定义用于边表ET和活动边表AET的通用类Edge
class Edge
{
public:
    int ymax;
    float x;
    float dx;
    Edge* next;
};
//边表
Edge *ET[windowHeight];
//活动边表
Edge *AET;

利用这两个数据结构就很容易计算出每条扫描线与多边形的交点了。我们只需要令扫描线从下往上扫描,已知每条边的下端点x坐标和∆x,就可以使用增量法计算出这条边上所有点的x坐标。具体方法放到后面叙述。

问题2:如何解决前面提到的极值顶点按照两个处理的问题?

如果两条边相交,它们的交点就是一个顶点。事实上,这个点本来就是按照两个点处理的,因为它分别属于两条边。那么这个问题其实应该转换成:如何把非极值顶点按照一个处理?解决办法是把顶点处从上面断开,如下图所示。缺口在y坐标上的长度是1个像素,所以并不会产生不利影响。



在后面的代码实现中,我们会在建立边表ET时判断非极值顶点并对其做特殊处理,请稍加注意。

问题3:如何快速配对交点并着色?

活动边表AET中存储了所有与当前扫描线相交的边及其交点坐标。配对的工作就自然变得很简单,只需要遍历一遍AET即可。

解决了这三个问题,我们就可以给出下面的算法流程:

  1. 将扫描线初始y坐标设为ET中非空元素的最小序号。
  2. 将AET初始化为空。
  3. 循环执行以下步骤,直到ET和AET都变为空。
    (1) 如果 ET[y] 非空,则将其中的所有边取出并插入到AET中,按x(若x相等则按∆x)递增方向排序。
    (2) 若AET非空,将AET中的边按顺序两两配对并填色。
    (3) 删去AET中满足y=ymax的边。
    (4) 对于AET中所有边,赋值x = x + ∆x。
    (5) y = y + 1,扫描线上移一像素。

依照该流程,我在Linux下使用c++实现了这个算法。由于无法真正实现向帧缓冲存储器填色,因此代码中使用了OpenGL,但仅使用它画点。

完整代码如下。

#include <iostream>
#include <vector>
#include "GL/glut.h"

using namespace std;

/**
 * @brief 定义用于边表ET和活动边表AET的通用类Edge
 */
class Edge
{
public:
    int ymax;
    float x;
    float dx;
    Edge* next;
};

/**
 * @brief 定义用于表示像素点坐标的类Point
 */
class Point
{
public:
    int x;
    int y;
    Point(int x, int y)
    {
        this->x = x;
        this->y = y;
    }
};


/////////////////////请使用对应Demo/////////////////////
/**
 * 窗体宽高
 */
/**
 * 取消以下两行的注释以使用demo1
 */
// const int windowWidth = 18;
// const int windowHeight = 12;
/**
 * 取消以下两行的注释以使用demo2
 */
// const int windowWidth = 180;
// const int windowHeight = 120;
/**
 * Demo3, Demo4, Demo5
 */
const int windowWidth = 1800;
const int windowHeight = 1200;

/**
 * 多边形顶点
 */
/**
 * 取消下行注释以使用demo1
 */
//vector<Point> vertices = { Point(2, 5), Point(2, 10), Point(9, 6), Point(16, 11), Point(16, 4), Point(12, 2), Point(7, 2) };
/**
 * 取消下行注释以使用demo1
 */
//vector<Point> vertices = { Point(20, 50), Point(20, 100), Point(90, 60), Point(160, 110), Point(160, 40), Point(120, 20), Point(70, 20) };
/**
 * 取消下行注释以使用demo3多边形
 */
//vector<Point> vertices = { Point(200, 500), Point(200, 1000), Point(900, 600), Point(1600, 1100), Point(1600, 400), Point(1200, 200), Point(700, 200) };
/**
 * 取消下行注释以使用demo4箭头
 */
//vector<Point> vertices = { Point(395, 887), Point(479, 998), Point(1199, 433), Point(1101, 867), Point(1294, 715), Point(1417, 171), Point(857, 163), Point(668, 314), Point(1111, 321) };
/**
 * Demo5闪电
 */
vector<Point> vertices = { Point(566, 970), Point(685, 1020), Point(754, 683), Point(985, 768), Point(1037, 481), Point(1208, 546), Point(1233, 179), Point(1140, 440), Point(951, 386), Point(899, 662), Point(668, 562) };

void init(void)
{
    glClearColor(1.0, 1.0, 1.0, 0.0);
    glMatrixMode(GL_PROJECTION);
    gluOrtho2D(0.0, windowWidth, 0.0, windowHeight);    
}

void polygonScan()
{
    //计算最高点的y坐标
    int maxY = 0;
    for (int i = 0; i < vertices.size(); i++)
    {
        if (vertices[i].y > maxY)
        {
            maxY = vertices[i].y;
        }
    }
    //初始化边表ET和活动边表AET
    Edge *ET[windowHeight];
    for (int i = 0; i < maxY; i++)
    {
        ET[i] = new Edge();
        ET[i]->next = nullptr;
    }
    Edge* AET = new Edge();
    AET->next = nullptr;

    //清空显示窗口并设置画点颜色为红色
    glClear(GL_COLOR_BUFFER_BIT);
    glColor3f(1.0, 0.0, 0.0);
    glBegin(GL_POINTS);

    //建立边表ET
    for (int i = 0; i < vertices.size(); i++)
    {
        //取出当前点1前后相邻的共4个点,点1与点2的连线作为本次循环处理的边,另外两个点点0和点3用于计算奇点
        int x0 = vertices[(i - 1 + vertices.size()) % vertices.size()].x;
        int x1 = vertices[i].x;
        int x2 = vertices[(i + 1) % vertices.size()].x;
        int x3 = vertices[(i + 2) % vertices.size()].x;
        int y0 = vertices[(i - 1 + vertices.size()) % vertices.size()].y;
        int y1 = vertices[i].y;
        int y2 = vertices[(i + 1) % vertices.size()].y;
        int y3 = vertices[(i + 2) % vertices.size()].y;
        //水平线直接舍弃
        if (y1 == y2)
            continue;
        //分别计算下端点y坐标、上端点y坐标、下端点x坐标和斜率倒数
        int ymin = y1 > y2 ? y2 : y1;
        int ymax = y1 > y2 ? y1 : y2;
        float x = y1 > y2 ? x2 : x1;
        float dx = (x1 - x2) * 1.0f / (y1 - y2);
        //奇点特殊处理,若点2->1->0的y坐标单调递减则y1为奇点,若点1->2->3的y坐标单调递减则y2为奇点
        if (((y1 < y2) && (y1 > y0)) || ((y2 < y1) && (y2 > y3)))
        {
            ymin++;
            x += dx;
        }
        //创建新边,插入边表ET
        Edge *p = new Edge();
        p->ymax = ymax;
        p->x = x;
        p->dx = dx;
        p->next = ET[ymin]->next;
        ET[ymin]->next = p;
    }

    //扫描线从下往上扫描,y坐标每次加1
    for (int i = 0; i < maxY; i++)
    {
        //取出ET中当前扫描行的所有边并按x的递增顺序(若x相等则按dx的递增顺序)插入AET
        while (ET[i]->next)
        {
            //取出ET中当前扫描行表头位置的边
            Edge *pInsert = ET[i]->next;
            Edge *p = AET;
            //在AET中搜索合适的插入位置
            while (p->next)
            {
                if (pInsert->x > p->next->x)
                {
                    p = p->next;
                    continue;
                }
                if (pInsert->x == p->next->x && pInsert->dx > p->next->dx)
                {
                    p = p->next;
                    continue;
                }
                //找到位置
                break;
            }
            //将pInsert从ET中删除,并插入AET的当前位置
            ET[i]->next = pInsert->next;
            pInsert->next = p->next;
            p->next = pInsert;
        }

        //AET中的边两两配对并填色
        Edge *p = AET;
        while (p->next && p->next->next)
        {
            for (int x = p->next->x; x < p->next->next->x; x++)
            {
                glVertex2i(x, i);
            }
            p = p->next->next;
        }

        //删除AET中满足y=ymax的边
        p = AET;
        while (p->next)
        {
            if (p->next->ymax == i)
            {
                Edge *pDelete = p->next;
                p->next = pDelete->next;
                pDelete->next = nullptr;
                delete pDelete;
            }
            else
            {
                p = p->next;
            }
        }

        //更新AET中边的x值,进入下一循环
        p = AET;
        while (p->next)
        {
            p->next->x += p->next->dx;
            p = p->next;
        }
    }
    glEnd();
    glFlush();

    // 释放边表和活动边表占用的内存
    for (int i = 0; i < maxY; i++)
    {
        Edge* ptr = ET[i];
        if (ptr != nullptr)
        {
            delete ptr;
            ptr = nullptr;
        }
    }
    Edge* p = AET;
    while (p)
    {
        Edge* tmp = p->next;
        delete p;
        p = tmp;
    }
}

int main(int argc, char **argv) {
    glutInit(&argc, argv);
    glutInitDisplayMode(GLUT_SINGLE | GLUT_RGB);
    glutInitWindowPosition(50, 100);
    glutInitWindowSize(windowWidth, windowHeight);
    glutCreateWindow("Polygon Scan Demo");
    init();
    glutDisplayFunc(polygonScan);
    glutMainLoop();
    return 0;
}

代码中给出了几个图形示例,包括三个不同大小的多边形、一个箭头和一个闪电。运行效果如下:

多边形
箭头
闪电

完整工程点击这里下载GitHub/PolygonScan

这是一个Linux下的CMake工程,请首先安装依赖库

sudo apt install cmake
sudo apt install freeglut3-dev

然后进入工程目录(将下面的<project_dir>换成你下载的目录位置),按照如下方式编译运行

cd <project_dir>
mkdir build
cd build
cmake ..
make
./PolygonScan

即可看到效果。

结语

扫描线算法是多边形扫描转换中的常用算法,它的特点是效率高,但算法较为复杂。本文给出了一个简单的扫描线算法,只是用作简单的示例。而对于实际情况,由于多边形的复杂性,比如自交多边形,即两条边具有顶点之外的交点,这种多边形无法使用扫描线算法,可能需要先拆分成若干个非自交多边形之后再处理。而且,简单的扫描线算法效果并不理想,从上面给出的三张效果图可以看出,边的锯齿状很严重,需要额外进行抗锯齿处理。

最后,由于作者本人也不是计算机图形学专业,文中不免有错误之处,请大家及时指出。

参考资料

浙江大学计算机图形学 耿建玲
opengl实现直线扫描算法和区域填充算法 fore_seer
《计算机图形学 第四版》Donald Hearn, M.Pauline Baker, Warren R.Carithers
中国科学院大学计算机图形学 夏时洪
重庆大学计算机绘图 杨梦宁,徐玲

有关扫描线算法完全解析的更多相关文章

  1. Ruby 解析字符串 - 2

    我有一个字符串input="maybe(thisis|thatwas)some((nice|ugly)(day|night)|(strange(weather|time)))"Ruby中解析该字符串的最佳方法是什么?我的意思是脚本应该能够像这样构建句子:maybethisissomeuglynightmaybethatwassomenicenightmaybethiswassomestrangetime等等,你明白了......我应该一个字符一个字符地读取字符串并构建一个带有堆栈的状态机来存储括号值以供以后计算,还是有更好的方法?也许为此目的准备了一个开箱即用的库?

  2. ruby - 解析 RDFa、微数据等的最佳方式是什么,使用统一的模式/词汇(例如 schema.org)存储和显示信息 - 2

    我主要使用Ruby来执行此操作,但到目前为止我的攻击计划如下:使用gemsrdf、rdf-rdfa和rdf-microdata或mida来解析给定任何URI的数据。我认为最好映射到像schema.org这样的统一模式,例如使用这个yaml文件,它试图描述数据词汇表和opengraph到schema.org之间的转换:#SchemaXtoschema.orgconversion#data-vocabularyDV:name:namestreet-address:streetAddressregion:addressRegionlocality:addressLocalityphoto:i

  3. ruby - 用逗号、双引号和编码解析 csv - 2

    我正在使用ruby​​1.9解析以下带有MacRoman字符的csv文件#encoding:ISO-8859-1#csv_parse.csvName,main-dialogue"Marceu","Giveittohimóhe,hiswife."我做了以下解析。require'csv'input_string=File.read("../csv_parse.rb").force_encoding("ISO-8859-1").encode("UTF-8")#=>"Name,main-dialogue\r\n\"Marceu\",\"Giveittohim\x97he,hiswife.\"\

  4. ruby - 完全离线安装RVM - 2

    我打算为ruby​​脚本创建一个安装程序,但我希望能够确保机器安装了RVM。有没有一种方法可以完全离线安装RVM并且不引人注目(通过不引人注目,就像创建一个可以做所有事情的脚本而不是要求用户向他们的bash_profile或bashrc添加一些东西)我不是要脚本本身,只是一个关于如何走这条路的快速指针(如果可能的话)。我们还研究了这个很有帮助的问题:RVM-isthereawayforsimpleofflineinstall?但有点误导,因为答案只向我们展示了如何离线在RVM中安装ruby。我们需要能够离线安装RVM本身,并查看脚本https://raw.github.com/wayn

  5. ruby-on-rails - 我更新了 ruby​​ gems,现在到处都收到解析树错误和弃用警告! - 2

    简而言之错误:NOTE:Gem::SourceIndex#add_specisdeprecated,useSpecification.add_spec.Itwillberemovedonorafter2011-11-01.Gem::SourceIndex#add_speccalledfrom/opt/local/lib/ruby/site_ruby/1.8/rubygems/source_index.rb:91./opt/local/lib/ruby/gems/1.8/gems/rails-2.3.8/lib/rails/gem_dependency.rb:275:in`==':und

  6. 区块链之加解密算法&数字证书 - 2

    目录一.加解密算法数字签名对称加密DES(DataEncryptionStandard)3DES(TripleDES)AES(AdvancedEncryptionStandard)RSA加密法DSA(DigitalSignatureAlgorithm)ECC(EllipticCurvesCryptography)非对称加密签名与加密过程非对称加密的应用对称加密与非对称加密的结合二.数字证书图解一.加解密算法加密简单而言就是通过一种算法将明文信息转换成密文信息,信息的的接收方能够通过密钥对密文信息进行解密获得明文信息的过程。根据加解密的密钥是否相同,算法可以分为对称加密、非对称加密、对称加密和非

  7. ruby - 用 YAML.load 解析 json 安全吗? - 2

    我正在使用ruby2.1.0我有一个json文件。例如:test.json{"item":[{"apple":1},{"banana":2}]}用YAML.load加载这个文件安全吗?YAML.load(File.read('test.json'))我正在尝试加载一个json或yaml格式的文件。 最佳答案 YAML可以加载JSONYAML.load('{"something":"test","other":4}')=>{"something"=>"test","other"=>4}JSON将无法加载YAML。JSON.load("

  8. ruby - 如何使用 Nokogiri 解析纯 HTML 表格? - 2

    我想用Nokogiri解析HTML页面。页面的一部分有一个表,它没有使用任何特定的ID。是否可以提取如下内容:Today,3,455,34Today,1,1300,3664Today,10,100000,3444,Yesterday,3454,5656,3Yesterday,3545,1000,10Yesterday,3411,36223,15来自这个HTML:TodayYesterdayQntySizeLengthLengthSizeQnty345534345456563113003664354510001010100000344434113622315

  9. python - 帮我找到合适的 ruby​​/python 解析器生成器 - 2

    我使用的第一个解析器生成器是Parse::RecDescent,它的指南/教程很棒,但它最有用的功能是它的调试工具,特别是tracing功能(通过将$RD_TRACE设置为1来激活)。我正在寻找可以帮助您调试其规则的解析器生成器。问题是,它必须用python或ruby​​编写,并且具有详细模式/跟踪模式或非常有用的调试技术。有人知道这样的解析器生成器吗?编辑:当我说调试时,我并不是指调试python或ruby​​。我指的是调试解析器生成器,查看它在每一步都在做什么,查看它正在读取的每个字符,它试图匹配的规则。希望你明白这一点。赏金编辑:要赢得赏金,请展示一个解析器生成器框架,并说明它的

  10. ruby-on-rails - Ruby on Rails 3 中的类方法——我完全迷路了! - 2

    背景here.在上面的链接中,给出了以下示例:classauthor.id)endend除了这种语法对于像我这样的初学者来说很陌生——我一直认为类方法是用defself.my_class_method定义的——我在哪里可以找到关于类的文档RubyonRails中的方法?据我所知,类方法总是在类本身(MyClass.my_class_method)上调用,但如果R​​ails中的类方法是可链接的,似乎必须进行其他操作在这里!编辑:我想我通过对类方法的语法发表评论有点被骗了。我真的想问Rails如何使类方法可链接—我了解方法链接的工作原理,但不知道Rails如何允许您链接类方法而无需实际返

随机推荐