计算机图形学基础-习题2

本次习题用到了c++库easyX,安装方法与文档参见其官网:https://easyx.cn/
使用的编辑器是visual studio 2022,创建语言类型为C++的空项目,并在资源管理器中源文件文件夹中创建.cpp文件,但使用的大部分是C语言语法,一些偷懒的地方用了C++都是可以用C实现的。
运行错误的原因或许有:后缀没有写为.cpp、easyX的库引用不当、easyX安装不当等。

以下答案仅为个人作答,非标准答案。

1、描述直线扫描的DDA算法、中点画线算法和Bresenham算法,并用程序实现Bresenham算法。
答:
一、DDA算法:也称数值微分法。
计算直线的斜率K,对于本文涉及的K,都有如下要求:|K|大于1则y、x地位互换,使其小于等于1。

以y=kx+b为例,通过自变量x的固定增长,计算对应的因变量y,并对y进行四舍五入,最终得到目标像素的坐标,通过渲染像素的函数(比如glDrawPixel、putpixel之类的函数)渲染对应的像素。

四舍五入的方法例如当y为浮点数类型时,(int)(y+0.5),通过类型转换的截断来达成round(y)的效果。

自变量固定增长,也可说是固定步进,比如说每次循环都增加1,对应的更新y的值。x、y一般从0开始编号。

以两点 P0(0, 0) 与 P1(5, 2) 构成的直线L的绘制为例,计算如下:
float k = 2/5 = 0.4

x (int)(y+0.5) y+0.5
0 0 0+0.5
1 0 0.4+0.5
2 1 0.8+0.5
3 1 1.2+0.5
4 2 1.6+0.5
5 2 2.0+0.5

DDA算法程序及结果示意图:以带有easyX库的C为例:

点击查看代码。虽然使用.cpp的文件后缀,但我使用C语言的语法以及easyX库中的函数
#include<graphics.h>
#include<conio.h>

void DDALine(int x0, int y0, int x1, int y1, int color)
{
	int x;
	float dx, dy, y, k;
	dx = x1 - x0;
	dy = y1 - y0;
	k = dy / dx, y = y0;
	for (x = x0; x <= x1; x++)
	{
		putpixel(x, int(y + 0.5), color);
		y = y + k;
	}
}

int main()
{
	initgraph(640, 480);
	DDALine(0, 0, 500, 200, WHITE);
	DDALine(0, 200, 500, 0, WHITE);
	_getch();	//按任意键继续
	closegraph();	//关闭绘图窗口
	
	return 0;
}

image

对于显示器,坐标原点在左上角,x向→方向增长,y向 ↓ 方向增长。但我们讨论时,用比较常见的原点在画布中心、x向→增长、y向 ↑ 增长来讨论。

现在计算机显示器的分辨率越来越大,我的笔记本电脑屏幕分辨率是2560×1600, 所以在这里看得不是很明显,如果有条件。可以自己实现并运行代码,绘制图形后,使用放大镜或类似的应用来观察图形,会发现这条线有阶梯一般的东西,也可称为“锯齿”。

二、中点画线法:
当斜率K大于0、小于1时,不难发现,画直线段时,对于当前像素P(xp, yp)下一个像素有两种可选择点:正右方P1(xp+1, yp)与右上方P2。(xp+1, yp+1)。

因此,假设P1、P2的中点M,Q为直线y=kx+b与垂线x=xp+1的交点,那么,当交点Q在中点M上方时,我们自然而然地会选择绘制右上方的P2位置的像素,当交点Q在中点下方时,我们会选择绘制正右方的P1位置的像素。

我们可能会对“两个紧挨着的像素的中点M”“直线与垂线交点Q”感到疑惑,因为像素不是一个个紧挨着的小方块么?这如何找找点?对于我们来说,小方块是最小的显示单位,但对于数学工具来说,大家都是一个平面上的东西。像素小方块在坐标系中以面积为1×1并且边界对齐坐标轴整数存在,寻找中点M,可以直接取P1与P2公共边的中点,而垂线可以选取为过M垂直与x轴的直线。

你或许会觉得“取中点作垂线,那对应的x坐标出现小数部分含有0.5”有悖于x=xp+1,那我们可以把每一个像素的中心都提取出来,构成一个方形网格,如图所示,把绿色网格看作是像素们的抽象,那么最左下角的网格点的坐标就是(0,0),对应着像素(0, 0),在这个坐标表示下,“+1”就与“像素走一步”可以对应了。你可以把绿色网格看作是像素的索引坐标系,用来寻找对应的像素。但注意,我们计算直线依然是在左边的坐标系中表示出来。

Capture_20260113_102134

从原理来看,只要判断交点Q与中点M的位置关系即可。对于直线方程F(x, y)=ax+by+c=0,我们知道,如果代入不同的x、y不满足这个方程,使得F大于0或者小于0,那么代入的x、y所表示的坐标点应当不在对应的直线上,而且,当计算得到的F大于0时,代入的x、y表示的点应当在直线的上方,F小于0时,代入的点在直线的下方。

所以我们可以把中点M的坐标代入F函数:

d=F(M)=F(xp+1, yp+0.5)=a(xp+1)+b(yp+0.5)+c=axp+byp+c+(a+0.5b)

d大于0的时候,说明M在直线上方,说明应该绘制正右方的P1像素,反之则应该绘制右上方的P2像素。当F(M)=0时,P1、P2皆可,我们约定此时取P1为下一个要绘制的像素。

可是我们发现,用上面的式子计算需要4个加法和2个乘法。对于这个线性函数F,为了提高计算效率,我们可以采用增量计算,这个增量就是d,这样就不用每次都计算一次完整的F,而是直接把d加给当前的函数值,作为下一次循环的函数值进行判断即可。

那么,这个d怎么算呢?我们知道,当前状况下,下一个像素的选择是P1或者P2,那么我们只要计算这两个情况下的增量d1和d2就可以了:

--如果当前的d=F(M)=F(xp+1, yp+0.5)计算后为大于等于0,那就是要选择正右方的像素P1(xp+1, yp),所以我们可以计算选择这个像素后的d值,即d=F(xp+1+1, yp+0.5),则有:
d1=F(xp+1+1, yp+0.5)-F(xp+1, yp+0.5)=a

--如果当前的d计算后小于0,那就是要选择右上方的像素P2(xp+1, yp+1),所以我们可以计算选择这个像素后的d值,即d=F(xp+1+1, yp+1+0.5),则有:
d2=F(xp+1+1, yp+1+0.5)-F(xp+1, yp+0.5)=a+b

那么d的初值d0=F(x0+1, y0+0.5)=F(x0, y0)+(a+0.5b)

对于像素而言。“0.5”是一个麻烦的存在,或者说,小数都是很麻烦的存在,我们应该怎么办呢?请不要忘记,我们有数学工具,对于y=kx+b,有2y=k(2x)+2b,两者在坐标系上表示同样的直线。同样地,对于F=ax+by+c,有2F=2ax+2by+2c,那么我们此后可以:

取d0=2d0,得到d0=2ax0+2by0+2c+(2a+b),这样写看起来有点奇怪,但我相信你知道d0=2d0中的、右边的d0是指上面那个“F(x0, y0)+(a+0.5b)”。

显然,这不会改变d的正负,也不会影响函数F原本的增长状况,而使得算法仅包含整数运算。

关于书中的思考题:步长为2或以上的算法和像素取法。我的想法是,对于k的绝对值小于1的直线,两步之内y的变化量不可能超过2。

重新回看上面那段文字,你知道哪些是几何坐标,哪些是像素索引坐标吗?为什么不明确说出来呢?

从点(0,0)到(500,200)经过的直线段中点画线法算法程序及结果示意图:

点击查看代码
void MidPointLine(int x0, int y0, int x1, int y1, int color)
{
	int a, b, d1, d2, d, x, y;
	a = y0 - y1;
	b = x1 - x0;
	d = 2 * a + b;
	d1 = 2 * a;
	d2 = 2 * (a + b);
	x = x0;
	y = y0;
	putpixel(x, y, color);
	while(x<x1)
	{
		if (d < 0)
		{
			x++;
			y++;
			d += d2;
		}
		else
		{
			x++;
			d += d1;
		}
		putpixel(x, y, color);
	}
}

image

三、Bresenham算法:
类似中点法,由误差项符号决定下一个像素取右边点还是右上点。过各行各列像素中心构造一组虚拟网格线,按直线从起点到终点的顺序计算直线与各垂直网格线的交点,然后确定该列像素中与此交点最近的像素。

对于任意直线上的两点都有:斜率k=Δy/Δx=(y2-y1)/(x2-x1)。在k的绝对值小于1的条件下,我们以x步进1来逐个确定要渲染的像素,所以有x2-x1=1。整理一下几个式子后,我们得到了y2-y1=k。即y2=y1+k。进一步地,在k大于0而小于1时,y2只有相对于y1不变或者+1的可能,是否增加1取决于误差项d。

对于设定的误差项d,在直线的起始点处设定d=0,也就是认为直线的起始点在像素的中心。x每前进一步(也就是要走到下一个像素,可以理解为+1),d的值相应递增直线的斜率k,即d=d+k。当d≥0.5时,直线与xi+1列垂直网格的交点最接近于当前像素(xi, yi)的右上方像素(xi+1, yi+1),因此,下一个渲染像素就是右上方像素,同时d要相应减去1作为下一次计算的新基点。反之,d<0.5时,更接近于(xi+1, yi),d不必减去1,而是在增加k后作为下一次计算的新基点。为了便于计算,令e=d-0.5,因此,e初值为-0.5,增量同样为k。当e≥0,相当于d≥0.5;当e<0,相当于d<0.5。

点击查看代码,输入的点为(0, 0)和(500, 200)
void BresenhamLine(int x0, int y0, int x1, int y1, int color)
{
	int x, y, dx, dy;
	float k, e;
	dy = y1 - y0, dx = x1 - x0;
	k = (float)dy / (float)dx;
	e = -0.5;
	x = x0, y = y0;

	int i;
	for (i = 0; i <= dx; i++)
	{
		putpixel(x, y, color);
		x++;
		e = e + k;
		if (e >= 0)
		{
			y++;
			e = e -1;
		}
	}
}

image

将e的公式稍微修改,e‘=2e*dx,把e’替换进原来的计算公式中,就能够消去0.5.同时约分掉K中的dx,因为要考虑dx、dy的大小关系和它们各自的正负,为了更方便,我们可以新建变量abs来表示它们的绝对值,当然,这里也可以直接更新dx、dy,我的写法只是为了比较容易看。

整数优化bresenham
void IntegerBresenhamLine(int x0, int y0, int x1, int y1, int color)
{
	int x, y, dx, dy ,e;
	dy = y1 - y0, dx = x1 - x0;
	x = x0, y = y0;	

	//确定步进方向
	int xdec = (dx > 0 ? 1 : -1);
	int ydec = (dy > 0 ? 1 : -1);

	//求绝对值
	int abs_dy = (dy > 0 ? dy : -dy);
	int abs_dx = (dx > 0 ? dx : -dx);

	//根据dy与dx大小关系判断k大于1与否来确定是否需要y、x地位互换
	int flag = 0;
	if (abs_dy > abs_dx)//k的绝对值大于1
	{
		int tmp = abs_dx;
		abs_dx = abs_dy;
		abs_dy = tmp;

		tmp = xdec;
		xdec = ydec;
		ydec = tmp;

		tmp = x;
		x = y;
		y = tmp;

		flag = 1;
	}

	e = -abs_dx;
	int i;
	for (i = 0; i <= abs_dx; i++)
	{
		if (flag)
		{
			putpixel(y, x, color);//绘制时更换为原x, y对应
		}
		else
		{
			putpixel(x, y, color);
		}
		x += xdec;
		e = e + 2 * abs_dy;
		if (e >= 0)
		{
			y += ydec;
			e = e - 2 * abs_dx;
		}
	}
}

2、用中点画线法扫描转换从点(1,0)到(4,7)经过的直线段,并给出每一步的判别值。

答:首先确定直线是否符合转换条件,k=(7-0)/(4-1)=7/3>1,所以在应用算法时,应该互换x、y的地位:

点击查看代码
d = a + 2 * b;
d1 = 2 * b;
d2 = 2 * (a + b);
while (y < y1)
{
	if (d < 0)
	{
		y++;
		d += d1;
	}
	else
	{
		y++;
		x++;
		d += d2;
	}
	putpixel(x, y, color);
}

则:
a=-7, b=3, d1 = 2*b=6, d2=2*(a+b)=-8

像素坐标y 像素坐标x 判别值d
0 1 -
1 1 -7+2*3=-1
2 2 -1+6=5
3 2 5-8=-3
4 3 -3+6=3
5 3 3-8=-5
6 4 -5+6=1
7 4 1-8=-7

image

补充:圆弧算法
首先,从圆上取四条对称线将圆分为同样的八个圆弧,这样以来,只需要计算一段圆弧的像素位置,就可以画出整个圆弧了,算法如下:

点击查看代码
void CirclePoints(int x, int y, int color)
{
	putpixel(x, y, color);
	putpixel(y, x, color);
	putpixel(-x, y, color);
	putpixel(y, -x, color);
	putpixel(x, -y, color);
	putpixel(-y, x, color);
	putpixel(-x, -y, color);
	putpixel(-y, -x, color);
}

此后我们只需要确定一段圆弧的绘制就可以绘制出完整的圆。那么如何确定圆弧要绘制的像素点呢?
中点画圆法:类似于中点画线法,有一圆心在原点处的圆x2+y2=r2,那么设函数F(x, y)=x2+y2-r2,圆上的点自然使得F的值为0,带入中点M有F(M)=F(xp+1, yp-0.5)=(x+1)2+(y-0.5)2-r2
又因为我们只需要算一段圆弧,我们可以取比较顺眼的一段,就取从点(0, r)开始、往顺时针方向走的一段圆弧吧。那下一个像素的选择就是正右方的P1右下方的P2了。当d=F(M)<0时,说明点M在圆内,那就应该取P1为下一步要绘制的像素点,此时的增量d1=F(xp+2, yp-0.5)-d=2xp+3;当d=F(a, b)>0时,说明点(M)在圆外,d2=2(xp-yp)+5。我们把d=0的情况合并到大于0的情况中一起处理。
第一个像素是(0, r),顺时针方向进行像素点选择,那么d的初始值d0=F(0+1, r-0.5)=1.25-r

算法程序如下:

点击查看代码
void MidPointCircle(int r, int color)
{
	int x = 0, y = r;
	float d = 1.25 - r;

	CirclePoints(x, y, color);
	while (x <= y)
	{
		if (d < 0)
		{
			d = d + 2 * x + 3;
		}
		else
		{
			d = d + 2 * (x - y) + 5;
			y--;
		}
		x++;
		CirclePoints(x, y, color);
		Sleep(14);//只是为了有种动画的效果
	}
}

结果示意图如下:
image

显然这个代码不能做任意圆心的圆,所以可以加入圆心偏移量,同时我们可以将其调整为全整数运算,改为如下代码:

点击查看代码
void CirclePoints(int x, int y, int center_x, int center_y, int color)
{
	putpixel(center_x + x, center_y + y, color);
	putpixel(center_x + y, center_y + x, color);
	putpixel(center_x - x, center_y + y, color);
	putpixel(center_x + y, center_y - x, color);
	putpixel(center_x + x, center_y - y, color);
	putpixel(center_x - y, center_y + x, color);
	putpixel(center_x - x, center_y - y, color);
	putpixel(center_x - y, center_y - x, color);
}

void MidPointCircle(int center_x, int center_y, int r, int color)
{
	int x = 0, y = r;
	int d = 5 - r;

	CirclePoints(x, y, center_x, center_y, color);
	while (x <= y)
	{
		if (d < 0)
		{
			d = d + 8 * x + 12;
		}
		else
		{
			d = d + 8 * (x - y) + 20;
			y--;
		}
		x++;
		CirclePoints(x, y, center_x, center_y, color);
		Sleep(14);
	}
}

结果示意图:
MidPointCircle

3、描述多边形扫描转换的扫描线算法,并写出伪代码。
man,what can i say。

多边形扫描转换的扫描线算法:首先这是一个填充算法。

其次,先介绍一下什么是扫描转换。所谓扫描转换就是将多边形的顶点表示转换为点阵表示。
顶点表示就是用多边形的顶点序列来表示多边形,在输入的数据中就是包含一系列有先后连接顺序的顶点的顶点集。
点阵表示就是把多边形占据的所有像素都包括进来的像素集,比如把屏幕想象成一个二维数组,然后想象成矩阵的样式,被多边形占据的像素就存储1(或者多边形在此像素的颜色RGB),不被占据的就存储0(或者背景颜色的RGB)。

扫描线算法就是把屏幕想象成一个二维平面,然后拆分成一条条平行于x轴的线,用不同的y来标识,就比如y=0、y=1、y=2...这样就把绘制多边形的工作变成了重复“给当前扫描线计算要绘制的像素并绘制”,构成一个工作循环。
对于一条扫描线,多边形的填充过程可以分为以下四个步骤。
1、求扫描线与多边形的交点
2、把交点按x值递增排序
3、配对。像是0号和1号之间的像素需要填色,但是1号和2号之间不需要填色。这也说明了交点是成对的。
4、填色。

为了更加高效的绘制图形,我们可以用一个活性链表AET表示当前工作中的扫描线,然后活用之前学习的增量思想,只维护这一个AET来走遍所有的扫描线。
但是明确哪些边和当前扫描线相交、不相交、产生的交点情况又是一个麻烦的工作。所以我们构建一个边表来记录多边形的哪些边最先在哪些扫描线上出现交点,这里我们按y增的方向来遍历扫描线,所以我们构建的这个边表就需要是二维的了,一方面记录扫描线的编号,一方面记录多边形的边最先在哪条扫描线上出现交点。可以称它为新边表NET,按照扫描线号作为下标(从0开始编号),那就创建一个顺序数组,然后这个数组的元素是指向与多边形相交的边,那么,多边形的边只在NET中出现一次。这样一来,需要对AET进行边的增加的时候,从构建好的NET里取用就好。

让我们回到“增量”这个话题上来,先前我们明白了如何用“增量”把固定变化率的乘法操作变为加法操作,那么同样的,在边都是直的情况下,我们也可以构建这样的方法,而不同边的变化率有所不同,所以我们需要记录下来。同时,为了判断这条边在那条扫描线以后不会再有可能出现,我们需要记录这条边的最大y。而判断这条边什么时候出现,我们已经有NET来记录了。那么还有一个要注意的,这次我们是“固定增长1的y”计算对应变化的x,所以变化率实际上是斜率k的倒数,也就是dx/dy。有了y的范围、x对应y的变化率,我们还需要一个东西才能描述完整的边,那就是边的x维度的终点或起点,我觉一般人都会选择起点的吧?所以我们还有一个要记录的数据就是边的x的起始值。

这样以后,我们就可以构建一个大致的工作流程:随着扫描线号的递增,AET记录的边与当前扫描线交点的信息不断变化,不同边的x的比较,确定了在当前扫描线下交点的先后关系,随后x对应变化delta_x作为下一条扫描线的新的x,当一些边在此时应该出现第一次的交点,就从NET中把它加入,当一些边已经经历了所有的交点,就把它从AET中删除,就这样不断推进直到遍历完所有的扫描线号。

伪代码可以参考书中的算法程序2.6,我个人的算法与书中算法后面提示的交点取舍有所不同,各位同学在实现时可以照着书上的“具体实现时,只需检查顶点的两条边的另外两个端点的y值,按这两个y值中大于交点y值的个数是由0、1、2来决定”。

算法的代码,已经学习过C++的同学们可以使用C++来实现,并使得输入的顶点集的顶点数量可以任意
//Polygon Scan Line Algorithm
typedef struct vertex {
	int x;
	int y;
}vertex;

typedef struct Edge {
	float x;
	float delta_x;//约定为-b/a,且当a=0时,delta_x=0;
	int y_max;
	struct Edge* next;
}Edge, *ScanLine;

Edge* CreatEdgeNode(float x, float delta_x, int y_max)//创建边结点
{
	Edge* node = (Edge*)malloc(sizeof(Edge));
	if (node == NULL)
	{
		perror("node");
		return NULL;
	}
	node->x = x;
	node->delta_x = delta_x;
	node->y_max = y_max;
	node->next = NULL;

	return node;
}

ScanLine* CreatNET(vertex vertex_set[5], int y_canvas)
{
	ScanLine* NET = (ScanLine*)malloc(sizeof(ScanLine) * (y_canvas));
	if (NET == NULL)
	{
		perror("NET");
		return NULL;
	}

	int i = 0;
	//初始化NET
	for (i = 0; i < y_canvas; i++)
	{
		NET[i] = NULL;
	}
	
	
	for (i = 0; i < 5; i++)
	{
		float x1 = vertex_set[i].x, x2 = vertex_set[(i + 1) % 5].x;
		int y1 = vertex_set[i].y, y2 = vertex_set[(i + 1) % 5].y;
		int y_min, y_max;
		float x = 0;
		//根据输入的顺序顶点集计算各边的y_min
		if (y1 > y2)
		{
			y_min = y2;
			x = x2;
			y_max = y1;
		}
		else if(y1 < y2)
		{
			y_min = y1;
			x = x1;
			y_max = y2;
		}
		else//水平线,不属于填充的工作范围,跳过
		{
			continue;
		}
		//计算各边的delta_x
		float delta_x = (x2 - x1) / (float)(y2 - y1);

		//创建边结点
		Edge* edge = CreatEdgeNode(x, delta_x, y_max);
		
		//将边插入NET
		if (NET[y_min] == NULL)
		{
			NET[y_min] = edge;//CreatEdgeNode函数中的next默认为NULL
		}
		else 
		{
			Edge* position = NET[y_min];
			while (position->next != NULL)
			{
				position = position->next;
			}
			position->next = edge;
		}
	}

	return NET;
}



void PolyFill(vertex vertex_set[5], int x_canvas, int y_canvas, int color)
{
	ScanLine * NET = CreatNET(vertex_set, y_canvas);

	ScanLine AET = CreatEdgeNode(0, 0, 0);
	
	int x = 0;//一般而言,可以加入刷新画布的功能,但这不是重点。要写则需要用到x_canvas
	int y = 0;
	for (y = 0; y < y_canvas;y++)
	{
		for (x = 0; x < x_canvas; x++)
		{
			putpixel(x, y, BLACK);//默认背景色为黑色
		}
	}
	
	int empty_flag = 1; 
	Edge* ap = AET;
	for (y = 0; y < y_canvas; y++)
	{
		if (NET[y] == NULL && empty_flag == 1)
		{
			continue;
		}
		//因为使用NET作为if判断条件,而绘制中我们不改变NET的内容,所以需要防止本应绘制的部分被跳过
		
		//continue没生效说明从该条扫描线开始,就是需要填充的部分
		//别忘了:后面操作后,如果AET->next为空,就把empty_flag复位
		empty_flag = 0;

		//用free,清理失效的AET的边
		ap = AET;
		while (ap->next != NULL)
		{
			if (ap->next->y_max <= y)
			{
				if (ap->next->next != NULL)
				{
					Edge* tmp = ap->next->next;
					free(ap->next);
					ap->next = tmp;
					continue;
				}
				else
				{
					free(ap->next);
					ap->next = NULL;//说明AET该处理的处理完毕了,退出循环即可
					break;
				}
			}
			ap = ap->next;
		}

			//冒泡维护x升序排序
			ap = AET;
			while (ap->next != NULL && ap->next->next != NULL)
			{
				if ((ap->next->x) - (ap->next->next->x) > 0.00001) //若后者的x小于前者的x,那么前后调换位置
				{
					Edge* tmp = ap->next;
					ap->next = ap->next->next;
					tmp->next = ap->next->next;
					ap->next->next = tmp;

					ap = AET;
					continue;
				}
				ap = ap->next;
			}

		//AET插入新边
		Edge* np = NET[y];
		while (np != NULL)//不必担心每次循环都会调用,NET边表中,只有寥寥数个位置能满足np!=NULL
		{
			int flag = 1;
			ap = AET;

			while (ap->next != NULL)
			{
				if ((ap->next->x) - (np->x) > 0.00001) //若np的x小于ap的next的x,那么np就要插在ap与ap的next之间
				{
					Edge* edge = CreatEdgeNode(np->x, np->delta_x, np->y_max);
					edge->next = ap->next;
					ap->next = edge;
					flag = 0;
					break;
				}
				else if ((np->x) - (ap->next->x) <= 0.00001)//若浮点在误差范围内相等,应该按照delta_x来判断
				{
					if ((ap->next->delta_x) - (np->delta_x) > 0.00001)
					{
						Edge* edge = CreatEdgeNode(np->x, np->delta_x, np->y_max);
						edge->next = ap->next;
						ap->next = edge;
						flag = 0;
						break;
					}
				}
				ap = ap->next;
			}
			if (flag == 1)//实际上,出了上面的while循环后,ap->next必然为空
			{
				ap->next = CreatEdgeNode(np->x, np->delta_x, np->y_max);
			}
			np = np->next;
		}


		if (AET->next != NULL)
		{	
			//AET中按顺序成对的两边,它们之间的位置填充color
			ap = AET;
			int x1, x2;//这是确定像素位置的坐标x,不能用float,所以后面要强制类型转换
			while (ap->next != NULL && ap->next->next != NULL)
			{
				x1 = (int)(ap->next->x + 0.5);//四舍五入,无需多言
				x2 = (int)(ap->next->next->x + 0.5);//AET中的边一定成对
				x1++;//左闭右开绘制所以千万记得+1
				while (x1 < x2)	//左闭右开区间绘制,可能你会疑惑,刚好相等不就漏了?
				{				//刚好相等的情况下,说明那是描边的工作,不是填充的工作。
					putpixel(x1, y, color);
					x1++;
				}
				ap = ap->next->next;
			}

			//AET中的边的x加上delta_x增量
			ap = AET;
			while (ap->next != NULL)
			{
				ap->next->x = ap->next->x + ap->next->delta_x;
				ap = ap->next;
			}
		}

		//重置empty_flag
		if (AET->next == NULL)
		{
			empty_flag = 1;
		}

		np = NET[y];
		while (np != NULL)
		{
			Edge* tmp = np->next;
			free(np);
			np = tmp;
		}
		Sleep(14);//做一个类似动画的效果
	}

	//绘制结束后free掉NET和AET
	free(NET);
	free(AET);//绘制结束后AET只剩一个哨兵位,当然,这里没考虑超出画布的处理
	
}
输入的顶点集
vertex vertex_set1[5] = {
	{100, 100 },
	{500, 200 },
	{500, 400 },
	{250, 600 },
	{100, 500 },
}; 

vertex vertex_set2[5] = {
	{80, 200 },
	{520, 200 },
	{150, 500 },
	{300, 0 },
	{450, 500 },
};
PolyFill(vertex_set1, 1000, 1000, WHITE);
system("pause");
PolyFill(vertex_set2, 1000, 1000, WHITE);

效果示例:
ScanLinePolyFill20260125_114757

2026.3.1
摸鱼一个半月,归来仍是菜鸟。
3. 补充内容:

边界标志算法:
基本思想:在帧缓冲器中对多边形的每条边进行直线扫描转换,也就是确定哪些像素是多边形的边,并打上“这个是边”的标志,然后再遍历整个屏幕的扫描线,在一条扫描线中从左往右检查像素时,第一次遇见边标志为真的像素,说明从这里到第二次遇见边像素为止,都是要渲染的像素,以此类推,奇数次遇见和偶数次遇见的操作。

具体的代码就不写了,写个伪代码
void EdgeMarkFill(polygon, color)
{
	对输入的多边形polygon的每一条边使用直线扫描转换。//bresenham、midpoint之类的都可以
	for(每条与多边形polygon相交的扫描线y)
	{
		inside = false;
		for(扫描线y上的每个像素x)
		{
			if(x被打上边标志)
			{ inside = (!inside); }
			if(inside)
			{ drawpixel(x, y, color);}
			else
			{ drawpixel(x, y, BACK_GROUND);}
		}
	}
}

区域填充算法的递归算法与扫描线算法:
首先,这里讨论的“区域”是使用点阵形式表示的填充图形,是像素的集合。
区域采用内点表示和边界表示两种形式,前者是“区域”内像素渲染同一种颜色,后者是区域的边界像素渲染同一种颜色。
区域填充算法就是将区域内一点赋予指定的颜色,然后将该颜色扩展到整个区域的过程。
既然是要扩展,那就要连通才能扩展,滴在一个缸子里的墨水染黑不了另一个缸子里的清水,所以只有连通区域,才能将种子点的颜色扩展到区域内的其他点。
所谓种子点,就是被选中的、第一个赋予新颜色的点。

区域可以分为四连通区域和八连通区域。
所谓四连通,就是目标点可以向上、下、左、右四个方向扩展,而八连通就是多了左上、左下、右上、右下四个可拓展的方向。

  四连通   八连通
1  2 3  1 2 3
  ↑     ↖↑↗
4← 5→6  4←5→6
  ↓     ↙↓↘
1  2 3  7 8 9

如下,不同的连通规则对于填充的结果是有影响的:
image

区域填充的递归算法:
我们先考虑一下最简单的情况:只有一个像素需要填充,我们直接putpixel对应的种子像素就可以了。
那么,这个最简单的情况,应该就是我们递归触底的情况。
然后再考虑一下复杂一点的情况,一个3×3的区域都是应当填充的像素,种子点就是正中间的像素。我们先考虑四连通,从上方位先检查、顺时针一个个检查填充,检查到需要填充的,就递归深入一层。
至此,我们已经有了大致的递归思路。
那么,我们该如何检查需要填充的区域呢?
先前说过,我们讨论的区域是已经用点阵形式表示的填充图形,所以我们处理的对象是已经使用内点表示或者边界表示的像素集合,也就是说,边界像素是一种颜色,内部像素是另一种颜色。我们只需要确认待检查的像素的当前颜色oldcolor是不是之前的内部像素的颜色,然后给它换成新的颜色newcolor,就可以了。如果是检查像素颜色是不舍边界颜色boundarycolor,那就是判断是不是newcolor后再进一步判断是不是boundarycolor就好。

点击查看代码
void FloodFill4(int x, int y, int oldcolor, int newcolor)
{
	if (getpixel(x, y) == oldcolor)
	{
		putpixel(x, y, newcolor);
		FloodFill4(x, y + 1, oldcolor, newcolor);//上
		FloodFill4(x + 1, y, oldcolor, newcolor);//右
		FloodFill4(x, y - 1, oldcolor, newcolor);//下
		FloodFill4(x - 1, y, oldcolor, newcolor);//左
	}
}

很明显,这种方法占用的空间太大,很容易栈溢出
image

区域填充的扫描线算法:
虽然递归思路开销很大,但是总归是可行的。从输出的结果来考虑,我们是要填充一个区域,区域也有形状,只要确定好边界填充区域不也是可以一行一行填呢?那是不是可以参考前面扫描线填充的方法呢?

假设像素集采用内点表示的方法表示填充区域。当我们确定一个种子像素,并给以新的颜色,我们可以先判断这个种子像素所在行(或者列,我默认使用行)的所有像素是不是需要渲染为新的颜色,然后再看上一行与下一行,就这样不断向上、向下两个方向去检查有没有需要渲染新颜色的像素。
那么我们的步骤可以设计为:
1、置空堆栈,种子像素入栈。
2、出栈,检查出栈像素所在扫描线。如果栈内没有元素,就结束填充作业。
3、确定种子像素所在扫描线,从种子像素为出发点,向左与右逐个检查像素是否需要更新。并记录填充的左右边界xl与xr。
4、设置当前扫描线的上、下两行的新的种子像素,将可能的新的种子像素入栈。在4连通的情况下,填充完当前扫描线就要确认上下是否都是连通的,所以只需确认上下扫描线中[xl, xr]的部分有没有oldcolor就好。

2026.3.8 算了,复试摆了,反正目标老师手下招满了。专心学图形学了,华莱士,你的不怎么忠诚的员工来了!

还是没忍住用了C++的容器
typedef struct {	//种子点类型
	int x;
	int y;
}seed;

//懒得手写一个栈的结构,直接用C++现成的容器了
void ScanLineFill4(int x, int y, COLORREF oldcolor, COLORREF newcolor)//colorref是表示颜色的一种数据类型
{
	std::stack<seed> s;//创建栈,默认是空栈

	seed pt;//创建种子点
	pt.x = x;
	pt.y = y;

	s.push(pt);//种子点入栈

	int xl, xr;//xl与xr分别为当前扫面线填充部分的左边界和右边界

	while (!s.empty())//栈不为空时
	{
		pt = s.top();//拷贝栈顶元素
		s.pop();
		//top引用栈顶元素,但不移除,所以后面要多加一行pop
		//如果是自己写的栈结构,可以把pop整合为“拷贝栈顶元素并移除原栈顶元素”
		x = pt.x;
		y = pt.y;


		//处理第一个种子点所在的扫描线
		while (getpixel(x, y) == oldcolor)//向右填充,包含种子点自身的坐标
		{
			putpixel(x, y, newcolor);
			x++;
		}
		xr = x - 1;

		x = pt.x - 1;//种子点向左一步的像素的x坐标
		while (getpixel(x, y) == oldcolor)//向左填充
		{
			putpixel(x, y, newcolor);
			x--;
		}
		xl = x + 1;

		bool SpanNeedFill = FALSE;//用来记录需要填充的区段,一条扫描线中可能有多个离散的区段要填充
		//用这个来标记并将各个区段的种子点压入栈内
		
		//处理上面的一条扫描线
		x = xl;
		y = y + 1;
		while (x <= xr)
		{
			SpanNeedFill = FALSE;

			while (getpixel(x, y) == oldcolor)//找到一个需要填充的区段
			{
				SpanNeedFill = TRUE;
				x++;
			}
			if (SpanNeedFill)
			{
				pt.x = x - 1;
				pt.y = y;
				s.push(pt);
				SpanNeedFill = FALSE;//重置标记,继续寻找本扫描线下的、可能的其它填充区段
			}

			while (getpixel(x, y) != oldcolor && x <= xr)//跳过非填充区段
			{
				x++;
			}
		}

		//处理下面的一条扫描线
		x = xl;
		y = y - 2;//前面+1了所以这里多-1,那就是-2
		while (x <= xr)//处理方式一样
		{
			SpanNeedFill = FALSE;

			while (getpixel(x, y) == oldcolor)//找到一个需要填充的区段
			{
				SpanNeedFill = TRUE;
				x++;
			}
			if (SpanNeedFill)
			{
				pt.x = x - 1;
				pt.y = y;
				s.push(pt);
				SpanNeedFill = FALSE;//重置标记,继续寻找本扫描线下的、可能的其它填充区段
			}

			while (getpixel(x, y) != oldcolor && x <= xr)//跳过非填充区段
			{
				x++;
			}
		}

	}
	
	//写一半发现把堆栈丢了,咳咳。那么下面这种方法行不行就给各位自己尝试了
	// 
	// //填充完当前扫描线就要确认上下是否都是连通的,所以只需确认上下扫描线中[xl, xr]的部分有没有oldcolor就好
	//	//之后的工作就是对第一条扫描线的上和下方向分别开始填充,逻辑虽然相似,但是并不能合在一起
	//	//但我们可以复制后改一下自变量的增长方向

	//	//向上处理
	//x = xl;
	//y = y + 1;
	//while (x <= xr)
	//{
	//	if (getpixel(x, y) != oldcolor)//判断是否连通
	//	{
	//		x++;
	//		continue;
	//	}

	//	//能运行到这里就说明区域连通
	//	//可能有以下两种情况
	//	//1、刚好是y+1扫描线的左边界
	//	if (getpixel(x - 1, y) != oldcolor)
	//	{
	//		while (getpixel(x, y) == oldcolor)//填充
	//		{
	//			putpixel(x, y, newcolor);
	//			x++;
	//		}
	//	}
	//	else//2、点(xl, y+1)的左边还有需要填充的像素
	//	{
	//		x--;//向左继续移动,直到找到左边界
	//		//这里我没做边界保护,如果有需要可以自己加一个
	//	}
	//}
	////向下处理
	//这种方法有一些相对上面的方法处理不了的情况,所以不太行
}
输入的数据
	vertex vertex_set1[5] = {
		{80, 200 },
		{300, 0 },
		{520, 200 },
		{450, 500 },
		{150, 500 },
	}; 
	
	vertex vertex_set2[5] = {
		{80, 200 },
		{520, 200 },
		{150, 500 },
		{300, 0 },
		{450, 500 },
	};

PolyFill(vertex_set2, 1000, 1000, WHITE);
system("pause");
IntegerBresenhamLine(80, 200, 520, 200, GREEN);
system("pause");
IntegerBresenhamLine(520, 200, 150, 500, GREEN);
system("pause");
IntegerBresenhamLine(150, 500, 300, 0, GREEN);
system("pause");
IntegerBresenhamLine(300, 0, 450, 500, GREEN);
system("pause");
IntegerBresenhamLine(450, 500, 80, 200, GREEN);
system("pause");
ScanLineFill4(300, 50, WHITE, RED);
system("pause");
ScanLineFill4(150, 220, WHITE, RED);
system("pause");
ScanLineFill4(200, 450, WHITE, RED);
system("pause");
ScanLineFill4(400, 450, WHITE, RED);
system("pause");
ScanLineFill4(450, 220, WHITE, RED);
system("pause");

PolyFill(vertex_set1, 1000, 1000, WHITE);
system("pause");	
IntegerBresenhamLine(80, 200, 520, 200, GREEN);
system("pause");
IntegerBresenhamLine(520, 200, 150, 500, GREEN);
system("pause");
IntegerBresenhamLine(150, 500, 300, 0, GREEN);
system("pause");
IntegerBresenhamLine(300, 0, 450, 500, GREEN);
system("pause");
IntegerBresenhamLine(450, 500, 80, 200, GREEN);
system("pause");
ScanLineFill4(300, 50, WHITE, RED);
system("pause");
ScanLineFill4(150, 220, WHITE, RED);
system("pause");
ScanLineFill4(200, 450, WHITE, RED);
system("pause");
ScanLineFill4(400, 450, WHITE, RED);
system("pause");
ScanLineFill4(450, 220, WHITE, RED);
system("pause");

如果你使用了之前的方法来画线段、填充图形,接着再用这个方法来改变图形内部的颜色,你或许会遇见一些意外情况,比如说多填充了其它部分、废了些功夫才想出来找到一个正确的种子点之类的情况。
我的想法是,可能是我之前的扫面填充算法有问题,把不该渲染的像素渲染了,抢占了描边的工作所以让那部分像素连在一起了。
2026.3.24 在进复试的焦虑中选择复试摆烂,然后我终于找到问题了,我在绘制的时候用了左闭右开的区间绘制,然后忘了把左边的线段的x+1。真是神了。扫描填充那里的gif我就不更新了,更新了改的代码,然后新的效果一并放在这里。顺带更新了整数优化的bresenham并且添加了K的绝对值大于1的处理。

1、画五角星,然后描边,再改变各个三角形的填充颜色
2、画五角形,然后画出五角星轮廓,再填充各个三角形
描边我用的是绿色,GIF看着有的边不是绿色是紫色是录制的问题
ScanLineFill4

4.字符串裁剪可按哪三个精度进行?
串精度:字符串超出窗口直接整个不绘制。
字符精度:字符框超出窗口的,不进行绘制。
笔画精度:也称像素精度,把字符的笔画分解为直线段,进行直线裁剪处理。
如下图例:

3.29 复试结束,坐了两天火车回来,路上顺带看了一下消隐,书上一些方法写得比较简略,之后再看看吧。

关于裁剪:
直线段裁剪:
Cohen-Sutherland:
中点分割:
梁友栋-Barskey:
多边形裁剪:
基于divide and conquer(分治)策略的Sutherland-Hodgman算法:

5.为了在显示器等输出设备上输出字符,系统中必须装备有相应的字库。字库中存储了每个字符的形状信息,字库分为哪两种类型?各有什么特点?

分为点阵型和矢量型。
点阵型的字符记录在一个位图中,像是一个二维矩阵,各个元素中记录1和0来区分字符的笔画是否经过该位置,1则绘制字符颜色,0则绘制背景颜色。
矢量型的字符则是记录笔画的信息。什么是笔画的信息呢?首先我们从现实中写字来看,我们写字的时候有起点和终点,然后还有从起点到终点的轨迹,还有这个轨迹在各个部分的粗细。那么放在计算机中,我们就是类似地绘制这样的线段,记录构成该字符的各个笔画的点位、轮廓(曲线、填充)。

6.简述裁剪方法和重点裁剪方法的思想,并指出中点裁剪方法的改进之处及这种改进的理由。

7.试描述Liang-Barskey裁剪算法,并说明在什么情况下它比中点法和Cohen-Sutherland快及原因。

8.解释走样和反走样的概念,并描述反走样的主要方法。

9.描述消隐的扫描线Z-Buffer算法,并与其他两种Z-Buffer算法进行比较。

10.比较书中列举的几种消隐算法的优缺点。

posted on 2026-01-21 22:45  千木禾瀚  阅读(17)  评论(0)    收藏  举报