坐标系就是确定一组数据位置的标尺。按按照维数分为2维平面坐标系和3维空间坐标系。其实2维坐标系也是z=0的3维坐标系的特例。
坐标系有三要素,一是原点,二是方向,三是单位大小。如果两个坐标系这三点完全一样,那么这两个坐标系就完全相同。关于坐标系和点的关系,我们可以这么理解:点本身是固定的,但在不同坐标系下的表示是不同的。那么为什么要定义那么多的坐标系呢,答案是为了描述方便。比如描述一个圆,如果把坐标系原点放到圆心,那么对圆的描述就是 x2+y2=r2。而如果原点不在圆心,那么圆描述就成了:(x-x0)2+(y-y0)2=r2。
既然可以找到描述形状方便的坐标系,那么问题也来了。比如要同时描述两个形状,如两个圆,而且这两个圆是有相对位置的,比如是自行车的两个轮子。
尽管两个圆各自在自己的坐标系里都能很方便的描述,但是要建立两者之间的关系时,却遇到了麻烦。因为要计算两个圆的位置关系,必须把两个圆放到同一个坐标系下描述才行。所以就引出了坐标系变换的概念。在此例中,可以把第二个圆也放到第一个坐标系下描述,方法就是把第二个坐标系放到第一个坐标系中合适的位置(两个坐标系的关系),然后根据两个坐标系的关系,推算出第二个圆在第一个坐标系中的描述。
这种方式对于CAD中的任务分隔特别重要,比如做汽车设计的公司,可以把不同的部件分配给不同的人来做。设计人员接到任务后,自由选择合适的坐标系来描述负责的部件。等所有部件设计完成以后,再把所有的部件转换的整车坐标系上。坐标系间的转换(2维和3维)是非常有规律的,有数学基础的人可以自己推导公式,没有数学基础的也没有关系,各种图形库都已经把坐标系变换公式做成了函数API供程序调用。比如OpenGL提供了三维坐标系间的各种变换API,GDI+则提供了2维坐标的变换API。需要了解的是,坐标系间的变换,一般是通过矩阵运算完成的,感兴趣的读者可以参考任何讲解OpenGL坐标变换算法的书籍,重要的是矩阵运算可以通过硬件流水线完成,这就是图形显示中的显卡硬件加速的一部分。当然矩阵运算不光应用于坐标系转换,还广泛运用于其他计算领域,因此有人提出了用GPU代替CPU来进行大规模科学计算的方案。
作为Windows中图形显示的关键部件,GDI+代表了Windows下2维图形API。三维则是D3D的领域了。图形API要提供的函数大概是两类,一是绘图函数,二是坐标系转换函数。GDI+提供了很多绘图函数,如DrawRectangle,DrawEclipse,DrawString等等。所有这些函数中都需要位置或大小参数,对于这些参数含义的理解是很重要的。
一是参数的单位是什么?位置参数的坐标系是什么?答案很有意思:不确定。因为这些东西有调用者自由确定。那么GDI+怎么根据这些不确定的参数绘制图形呢?答案是调用者要提供自己定义的坐标系和PAGE坐标系的关系。
Page坐标系附属在某一个窗口或控件上,是一个固定的坐标系,原点位于窗口的左上角,x轴方向向右,y轴方向向下。单位为cm,inch或pixel,根据实际情况设定。GDI+提供了Page坐标系和World坐标系间的转换API。含义是把world坐标系放到Page坐标系合适的位置。回到前面讲过的汽车分部件设计的例子,此处Page坐标系就是最后的整车坐标系,GID+提供的就是把各个部件(GDI+绘制函数绘制的图形)连同其坐标系一起放到整车(Page)坐标系里。
这是很合理的方式。在利用GDI+作图时也要按照这种思路来做。具体说来,先把整个图形分解成各个小的图形,在画某一个小的图形时不要考虑它最终在Page坐标系的位置,只要按照你自己设想的坐标系来调用GDI+的绘图函数就可以了。
当所有的图形都绘制完毕后,在把这些小的图形统统放到Page坐标系里。具体就是,调用绘制小图形的代码之前调用GDI+的xxxTransform()系列函数把小图形的建模坐标系放置到Page坐标系里,在绘制小图形的代码之后,调用ResetTransform()。
讲到这里,也许大家会有疑问了,GDI+最后是如何把Page坐标系的图形绘制到屏幕上的呢,这就是显示器的Device坐标系。
对于Page坐标系和Device坐标系的转换,应用程序员不需要了解了,GDI+已经把这部分隐藏了。
利用GDI+绘制如下图形:
仔细看上面的图形,不难发现,此图形有6部分组成:头,左臂,右臂,身体,左腿,右腿。分别把各个部分分给6个设计师去建模,然后把各个模型连同其建模坐标系一起放到到Page坐标系中。如下图:
private PointF pHead;
private PointF pBody;
private PointF pLeftArm;
private PointF pRightArm;
private PointF pLeftLeg;
private PointF pRightLeg;
private SizeF sHead = new SizeF(30, 30); //头大小30cm
private SizeF sBody = new SizeF(50, 70); //身体大小
private SizeF sArm = new SizeF(10, 60); //胳膊大小
private SizeF sLeg = new SizeF(20, 70); //腿大小
public void DrawHead(PaintEventArgs e)
{
e.Graphics.DrawEllipse(Pens.Red, -sHead.Width / 2.0f, -sHead.Height / 2.0f, sHead.Width, sHead.Height);
}
public void DrawBody(PaintEventArgs e)
{
e.Graphics.DrawRectangle(Pens.Black, 0, 0, sBody.Width, sBody.Height);
}
public void DrawLeftArm(PaintEventArgs e)
{
e.Graphics.DrawRectangle(Pens.Black, 0, 0, sArm.Width, sArm.Height);
}
public void DrawRightArm(PaintEventArgs e)
{
e.Graphics.DrawRectangle(Pens.Black, 0, 0, sArm.Height, sArm.Width);
}
public void DrawLeftLeg(PaintEventArgs e)
{
e.Graphics.DrawRectangle(Pens.Black, 0, 0, sLeg.Width, sLeg.Height);
}
public void DrawRightLeg(PaintEventArgs e)
{
e.Graphics.DrawRectangle(Pens.Black, 0, 0, sLeg.Height, sLeg.Width);
}
private void Form1_Paint(object sender, PaintEventArgs e)
{
pHead = new PointF(this.Width / 2.0f, 100f); //放置头坐标系
e.Graphics.TranslateTransform(pHead.X, pHead.Y);
DrawHead(e); //调用负责头建模的代码
e.Graphics.ResetTransform(); //重置矩阵
pBody = new PointF(pHead.X - sBody.Width/2.0f, pHead.Y+sHead.Height/2.0f);
e.Graphics.TranslateTransform(pBody.X, pBody.Y);
DrawBody(e);
e.Graphics.ResetTransform();
pLeftArm = pBody;
e.Graphics.TranslateTransform(pLeftArm.X, pLeftArm.Y);
e.Graphics.RotateTransform(45);
DrawLeftArm(e);
e.Graphics.ResetTransform();
pRightArm = new PointF(pBody.X + sBody.Width, pBody.Y);
e.Graphics.TranslateTransform(pRightArm.X, pRightArm.Y);
e.Graphics.RotateTransform(45);
DrawRightArm(e);
e.Graphics.ResetTransform();
pLeftLeg = new PointF(pBody.X, pBody.Y + sBody.Height);
e.Graphics.TranslateTransform(pLeftLeg.X, pLeftLeg.Y);
e.Graphics.RotateTransform(45);
DrawLeftLeg(e);
e.Graphics.ResetTransform();
pRightLeg = new PointF(pBody.X + sBody.Width, pBody.Y + sBody.Height);
e.Graphics.TranslateTransform(pRightLeg.X, pRightLeg.Y);
e.Graphics.RotateTransform(45);
DrawRightLeg(e);
e.Graphics.ResetTransform();
}
}
由于GDI存在的时间很长,而GDI+诞生后为了兼容,一部分函数采用了与GDI相同的名称,甚至参数,参数的含义在MSDN中也按照原来的解释,导致了一些容易让读者误解的地方。
我们知道,GDI+的绘图函数使用的坐标系是由建模人员随意定义的,以函数
publicvoid DrawRectangle(
Pen pen,
float x,
float y,
float width,
float height
)
为例,MSDN中对x,y解释如下:
x
Type: System. Single
The x-coordinate of the upper-left corner of the rectangle to draw.
y
Type: System. Single
The y-coordinate of the upper-left corner of the rectangle to draw.
然而此处的x,y真的是矩形左上角坐标吗?换句话说,左上是从什么角度看的。如下图:
在A1坐标系中,DrawRectange()函数中的x,y确实代表了矩形的左上角。A2和A3是A1经过旋转以后得到的坐标系,此时x,y在建模者看来显然是右下角和左下角。而对于更常见的笛卡尔右手坐标系B来说,(x,y)也不是左上角。左上角的概念仅在Page坐标系中成立。关于(x,y)的正确解释应该是:
矩形四个角中,x,y值都最小的那个角的坐标。
5.2.1不带MatrixOrder参数的变换函数顺序执行的理解
前面提到过其实对坐标系的转换就是矩阵的乘法运算。这里面涉及到了变换的顺序和矩阵乘法的顺序的问题。讲解之前一定要确立坐标系变换的视角:把建模坐标系放置到Page坐标系中,从Page坐标系一步步变化成最终坐标系。
可以通过两步完成:
(1)平移至虚线位置;
TranslateTransform(0, dy);
(2)旋转一定角度。
RotateTransform(45);
如果我们把(1)(2)顺序反过来,那么最终的结果如下图:
所以说先平移、旋转的顺序很重要。当前的操作是在前一步完成之后的新坐标系中进行的。对应于矩阵算法相当于左乘。
5.2.2 带MatrixOrder参数的函数的理解
publicvoid TranslateTransform(
float dx,
float dy,
MatrixOrder order
)
在MSDN中对于MatrixOrder解释为:
其实际效果是,Prepend相当于无此参数的版本,也就是说此次调用是在前面操作结果的基础上操作的;相当于矩阵左乘。
Append相当于此次操作先于前面的操作起作用,也就是先进行当前的操作,在此基础上进行此次调用前面的操作。相当于矩阵右乘。在OpenGL中都是采用Append模式的。
如此看来,上述英文解释正好相反了。这个不能说是错,可能是从不同的视角来看问题,我们的视角是:从Page坐标系一步步变化成最终坐标系。
这个属性比较特殊。MSDN的解释为:
获取或设置此Graphics的几何世界变换的副本。
获取的是Graphics变换矩阵的副本,而不是变换矩阵本身,也就是每次获取时,新建一个和变换矩阵成员值相同的矩阵对象,并返回。
问题是设置。设置的不是副本,而是Graphics变换矩阵本身。
内部实现伪代码
class Graphics
{
private Matrix transform;
public Matrix Transform
{
get { return transform.Clone(); }
set { transform=value; }
}
}
如果要通过矩阵乘法进行坐标转换,那么代码如下:
e.Graphics.Transform = Graphics.Transform.Multiply(newMatrix(....))
GDI+提供了平移、旋转、缩放等函数供我们调用,来把Page坐标系一步步改造成最终的建模坐标系在Page中的表现形态。对于绝大多数情况,这些函数足够了,但是看如下:
无论怎么平移和旋转,Page坐标系也无法编程上面红色显示的坐标系。这是因为Page坐标系属于左手坐标系,而红色所示是右手坐标系。在数学上,大多数情况使用的都是右手坐标系,模型也是建立在右手坐标系上,那么如何把Page转化为右手坐标系呢。也就是把y轴反向。答案是通过直接操纵Graphics的变换矩阵。
e.Graphics.Transform = e.Graphics.Transform.Multiply(newMatrix(1, 0, 0, -1, 0, 0));//y反向
上述代码就实现了y轴反向的目的。
如果你对变换矩阵很了解,直接操作矩阵左右乘法,与调用相关的GDI+变换函数功效完全相同,可以实现任意变换。
(后注:Y轴反方向可以通过g.ScaleTransform(1, -1)完成)