-
Notifications
You must be signed in to change notification settings - Fork 50
overview in Chinese
#概述图
Puresoft3D的系统结构非常简单粗暴,所以画系统框架图或者类图都没什么意义,那么不如来看一下管线图吧?
这张管线图比现代图形API和GPU的架构要简单得多,所以我就忽略那些常识性的东西,只说一说特殊之处。
##插值“着色器”
第一个特殊之处是插值“着色器”。OpenGL有顶点着色器、几何体着色器,和片元着色器,但它没有插值“着色器”对吧?这是因为它有自己的GLSL编译器!它可以为从顶点着色器输出的待插值量,自动生成插值程序。然而我们这个小项目本身就不很复杂,并且也不具有强烈的实用价值,所以花大量时间开发着色器语言和编译器是不现实的。因此,我将线性插值工作分为两部分,大部分可抽象出来的由框架(管线)完成,而一些因具体着色器程序输出不同而不同的部分,留给着色器程序的开发者完成,但你不要担心,实际上这一小部分插值程序,也是非常套路化的,不需要花费精力去思考即可完成。
如果不太明白我在说什么,那么简单举个例子便一目了然。
假设我们有一个顶点着色器,一次输入一个顶点(坐标及其他属性),处理后输出两份数据:剪裁空间的顶点坐标,以及顶点的颜色RGBA值。假设可以用如下结构体表示:
typedef struct
{
float position[4]; // 坐标
float colour[4]; // 颜色
} VP_OUT;
首先我们称“position”为“标准输出”,这是因为所有顶点着色器都要输出坐标,所有坐标输出都是float[4],在框架层面就可以断定的。
然后我们不得不称“colour”为“用户数据”,暗示框架既不可能理解“colour”是什么东西,也不会知道它的数据结构、类型等信息,只有特定着色器的开发者知道。
你可能会想,那为何不把“颜色”也定义为“标准输出”?因为即便如此,开发者将来可能又需要从顶点着色器输出一个“法线”,然后又想增加一个“切线”,可能还会增加一个“纹理坐标”……框架不可能预测所有的可能性。所以我们只能将“position”定为“标准输出”,而所有其他的定为“用户数据”。
虽然框架不知道除了“position”之外的数据都是什么,只知道那是一包用户数据,但是所有从顶点着色器输出的数据都必须经过线性插值,才能送进片元着色器。如何才能对框架不能理解的数据进行插值呢?答案自然是解铃还需系铃人,由顶点着色器程序的开发者协助完成插值程序。具体如何“协助”,在后面的WIKI里有详细描述,但在这里我最好再简单举个例子,以便帮助你趁热打铁的理解一下。
假设在线性插值过程中,有一步需要将三个顶点的三份“VP_OUT”分别乘以三个顶点的权重并相加,从而得到由三个顶点围成的三角形内部某一点的插值。如下面伪码所示:
VP_OUT inputVertexData[3]; // 三个顶点的三份顶点着色器输出数据
float contribute[3]; // 三个顶点的三个权重值
VP_OUT interpVertexData; // 插值获得的三角形某内点的数据
// 插值
interpVertexData.colour[0] = inputVertexData[0].colour[0] * contribute[0] + inputVertexData[1].colour[0] * contribute[1] + inputVertexData[2].colour[0] * contribute[2];
interpVertexData.colour[1] = inputVertexData[0].colour[1] * contribute[0] + inputVertexData[1].colour[1] * contribute[1] + inputVertexData[2].colour[1] * contribute[2];
interpVertexData.colour[2] = inputVertexData[0].colour[2] * contribute[0] + inputVertexData[1].colour[2] * contribute[1] + inputVertexData[2].colour[2] * contribute[2];
interpVertexData.colour[3] = inputVertexData[0].colour[3] * contribute[0] + inputVertexData[1].colour[3] * contribute[1] + inputVertexData[2].colour[3] * contribute[2];
对于上面的插值场合,为了便于拆分框架部分和plug-in部分程序,框架会先对“VP_OUT”的声明进行如下变形:
typedef struct
{
float colour[4];
} USER_DATA;
typedef struct
{
float position[4];
USER_DATA userData;
} VP_OUT;
然后定义如下基类虚函数,等待具体的插值“着色器”来实现:
virtual void interpolateByContributes(USER_DATA* out1, const USER_DATA* in3, const float* contributes3) = 0;
最后着色器开发者需要在具体的插值“着色器”里编写如下代码:
void myInterpProcessor::interpolateByContributes(USER_DATA* out1, const USER_DATA* in3, const float* contributes3)
{
out1->colour[0] = in3[0].colour[0] * contribute[0] + in3[1].colour[0] * contribute[1] + in3[2].colour[0] * contribute[2];
out1->colour[1] = in3[0].colour[1] * contribute[0] + in3[1].colour[1] * contribute[1] + in3[2].colour[1] * contribute[2];
out1->colour[2] = in3[0].colour[2] * contribute[0] + in3[1].colour[2] * contribute[1] + in3[2].colour[2] * contribute[2];
out1->colour[3] = in3[0].colour[3] * contribute[0] + in3[1].colour[3] * contribute[1] + in3[2].colour[3] * contribute[2];
}
可见,由着色器开发者实现的部分,只有需要插值的数据不同,但计算形式不变。因此对于着色器开发者来说负担并不大,一般只需要复制粘贴一下,然后改一改变量名即可。
##线程模型
另一件值得一提的事是线程模型。如上图所示,我们的管线里有一个顶点处理线程,和多个片元处理线程。实际上顶点处理就在调用者线程里执行。这么设计的原因恐怕比你想象的要简单 —— 我可怜的开发环境里只有一个双核四线程的CPU。那么我有两个选择,要么公平的将四个线程均分给顶点处理和片元处理,要么就像我现在这么设计。但是均分不一定有它看起来的那么公平,因为实际中片元处理的计算量往往要比顶点处理的计算量大。其中一个显然的原因便是CPU端的主存吞吐量本来就比不过特殊优化过的GPU端的显存吞吐量,然而片元处理却往往需要大量读取材质buffer以及更新目标buffer,从而带来难以解决的效率瓶颈。所以最终我决定还是尽量多的把线程留给片元处理。