NeHe OpenGL第三十七课:卡通映射
卡通映射:
什么是卡通了,一个轮廓加上少量的几种颜色。使用一维纹理映射,你也可以实现这种效果。
看到人们仍然e-mail我请求在文章中使用我方才在GameDev.net上写的源代码,还看到文章的第二版(在那每一个API附带源码)不是在中途完成之前连贯的结束。我已经把这篇指南一并出租给了NeHe(这实际上是
写文章的最初意图)因此你们所有的OpenGL领袖可以玩转它。对模型的选择表示抱歉,但是我最近一直在玩Quake 2。
注释:这篇文章的源代码可以在这里找到:
http://www.gamedev.net/reference/programming/features/celshading.
这篇指南实际上并不解释原理,仅仅解释代码。在上面的连接中可以发现为什么它能工作。现在不断地大声抱怨STOP E-MAILING ME REQUESTS FOR SOURCE CODE!!!!
首先,我们需要包含一些额外的头文件。第一个(math.h)我们可以使用sqrtf (square root)函数,第二个用来访问文件。
#include <math.h>
#include <stdio.h>
现在我们将定义一些结构体来帮助我们存贮我们的数据(保存好几百浮点数组)。第一个是tagMATRIX结构体。如果你仔细地看,你将看到我们正象包含一个十六个浮点数的1维数组~一个2维4×4数族一样存储那个矩阵
。这下至OpenGL存储它的矩阵的方式。如果我们使用4x4数组,这些值将发生错误的顺序。
typedef struct tagMATRIX // 保存OpenGL矩阵的结构体
{
float Data[16]; // 由于OpenGL的矩阵的格式我们使用[16
}
MATRIX;
第二是向量的类。 仅存储X,Y和Z的值
typedef struct tagVECTOR // 存储一个单精度向量的结构体
{
float X, Y, Z; // 向量的分量
}
VECTOR;
第三,我们持有顶点的结构。每一个顶点仅需要它的法线和位置(没有纹理的现行纵坐标)信息。它们必须以这样的次序被存放,否则当它停止装载文件的事件将发生严重的错误(我发现艰难的情形:(教我分块出租我
的代码。)。
typedef struct tagVERTEX // 存放单一顶点的结构
{
VECTOR Nor; // 顶点法线
VECTOR Pos; // 顶点位置
}
VERTEX;
最后是多边形的结构。我知道这是存储顶点的愚蠢的方法,要不是它完美工作的简单的缘故。通常我愿意使用一个顶点数组,一个多边形数组,和包括一个在多边形中的3个顶点的指数,但这比较容易显示你想干什么。
typedef struct tagPOLYGON // 存储单一多边形的结构
{
VERTEX Verts[3]; // 3个顶点结构数组
}
POLYGON;
优美简单的材料也在这里了。为每一个变量的一个解释考虑那个注释。
bool outlineDraw = true; // 绘制轮廓的标记
bool outlineSmooth = false; // Anti-Alias 线段的标记
float outlineColor[3] = { 0.0f, 0.0f, 0.0f }; // 线段的颜色
float outlineWidth = 3.0f; // 线段的宽度
VECTOR lightAngle; // 灯光的方向
bool lightRotate = false; // 是否我们旋转灯光的标记
float modelAngle = 0.0f; // 模型的Y轴角度
bool modelRotate = false; // 旋转模型的标记
POLYGON *polyData = NULL; // 多边形数据
int polyNum = 0; // 多边形的编号
GLuint shaderTexture[1]; // 存储纹理ID
这是得到的再简单不过的模型文件格式。 最初的少量字节存储在场景中的多边形的编号,文件的其余是tagPOLYGON结构体的一个数组。正因如此,数据在没有任何需要去分类到详细的顺序的情况下被读出。
BOOL ReadMesh () // 读“model.txt” 文件
{
FILE *In = fopen ("Data\\model.txt", "rb"); // 打开文件
if (!In)
return FALSE; // 如果文件没有打开返回 FALSE
fread (&polyNum, sizeof (int), 1, In); // 读文件头,多边形的个数
polyData = new POLYGON [polyNum]; // 分配内存
fread (&polyData[0], sizeof (POLYGON) * polyNum, 1, In);// 把所有多边形的数据读入
fclose (In); // 关闭文件
return TRUE; // 工作完成
}
一些基本的数学函数而已。DotProduct计算2个向量或平面之间的角,Magnitude函数计算向量的长度,Normalize函数缩放向量到一个单位长度。
inline float DotProduct (VECTOR &V1, VECTOR &V2) //计算两个向量之间的角度
{
return V1.X * V2.X + V1.Y * V2.Y + V1.Z * V2.Z;
}
inline float Magnitude (VECTOR &V) // 计算向量的长度
{
return sqrtf (V.X * V.X + V.Y * V.Y + V.Z * V.Z);
}
void Normalize (VECTOR &V) // 创建一个单位长度的向量
{
float M = Magnitude (V);
if (M != 0.0f) // 确保我们没有被0隔开
{
V.X /= M;
V.Y /= M;
V.Z /= M;
}
}
这个函数利用给定的矩阵旋转一个向量。请注意它仅旋转这个向量——与向量的位置相比它算不了什么。它用来当旋转法线确保当我们在计算灯光时它们停留在正确的方向上。
void RotateVector (MATRIX &M, VECTOR &V, VECTOR &D) // 利用提供的矩阵旋转一个向量
{
D.X = (M.Data[0] * V.X) + (M.Data[4] * V.Y) + (M.Data[8] * V.Z);
D.Y = (M.Data[1] * V.X) + (M.Data[5] * V.Y) + (M.Data[9] * V.Z);
D.Z = (M.Data[2] * V.X) + (M.Data[6] * V.Y) + (M.Data[10] * V.Z);
}
引擎的第一个主要的函数…… 初始化,按所说的精确地做。我已经砍掉了在注释中不再需要的代码段。
// 一些GL 初始代码和用户初始化从这里开始
BOOL Initialize (GL_Window* window, Keys* keys)
{
这3个变量用来装载着色文件。在文本文件中为了单一的线段线段包含了空间,虽然shaderData存储了真实的着色值。你可能奇怪为什么我们的96个值被32个代替了。好了,我们需要转换greyscale 值为RGB以便
OpenGL能使用它们。我们仍然可以以greyscale存储这些值,但向上负载纹理时我们至于R,G和B成分仅仅使用同一值。
char Line[255]; // 255个字符的存储量
float shaderData[32][3]; // 96个着色值的存储量
g_window = window; g_keys = keys;
FILE *In = NULL; // 文件指针
当绘制线条时,我们想要确保很平滑。初值被关闭,但是按“2”键,它可以被toggled on/off。
glShadeModel (GL_SMOOTH); // 使用色彩阴影平滑
glDisable (GL_LINE_SMOOTH); // 线条平滑初始化不可用
glHint (GL_PERSPECTIVE_CORRECTION_HINT, GL_NICEST); // 提高计算精度
glClearColor (0.7f, 0.7f, 0.7f, 0.0f); // 设置为灰色背景
glClearDepth (1.0f); // 设置深度缓存值
glEnable (GL_DEPTH_TEST); // 启用深度测试
glDepthFunc (GL_LESS); // 设置深度比较函数
glShadeModel (GL_SMOOTH); // 启用反走样
glDisable (GL_LINE_SMOOTH);
glEnable (GL_CULL_FACE); // 启用剔除多边形功能
我们使 OpenGL灯光不可用因为我们自己做所以的灯光计算。
glDisable (GL_LIGHTING); // 使 OpenGL 灯光不可用
这里是我们装载阴影文件的地方。它简单地以32个浮点值ASCII码存放(为了轻松修改),每一个在separate线上。
In = fopen ("Data\\shader.txt", "r"); // 打开阴影文件
if (In) // 检查文件是否打开
{
for (i = 0; i < 32; i++) // 循环32次
{
if (feof (In)) // 检查文件是否结束
break;
fgets (Line, 255, In); // 获得当前线条
这里我们转换 greyscale 值为 RGB, 正象上面所描述的。
// 从头到尾复制这个值
shaderData[i][0] = shaderData[i][1] = shaderData[i][2] = atof (Line);
}
fclose (In); // 关闭文件
}
else
return FALSE;
现在我们向上装载这个纹理。同样它清楚地规定,不要使用任何一种过滤在纹理上否则它看起来奇怪,至少可以这样说。GL_TEXTURE_1D被使用因为它是值的一维数组。
glGenTextures (1, &shaderTexture[0]); // 获得一个自由的纹理ID
glBindTexture (GL_TEXTURE_1D, shaderTexture[0]); // 绑定这个纹理。 从现在开始它变为一维
// 使用邻近点过滤
glTexParameteri (GL_TEXTURE_1D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri (GL_TEXTURE_1D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
// 设置纹理
glTexImage1D (GL_TEXTURE_1D, 0, GL_RGB, 32, 0, GL_RGB , GL_FLOAT, shaderData);
现在调整灯光方向。我已经使得它向下指向Z轴正方向,这意味着它将正面碰撞模型
lightAngle.X = 0.0f;
lightAngle.Y = 0.0f;
lightAngle.Z = 1.0f;
Normalize (lightAngle);
读取Mesh文件,并返回
return ReadMesh (); // 读取Mesh文件,并返回
}
与上面的函数相对应…… 卸载,删除由Initalize 和 ReadMesh 创建的纹理和多边形数据。
void Deinitialize (void)
{
glDeleteTextures (1, &shaderTexture[0]); // 删除阴影纹理
delete [] polyData; // 删除多边形数据
}
主要的演示循环。所有这些用来处理输入和更新角度。控制如下:
<SPACE> =锁定旋转
1 = 锁定轮廓绘制
2 = 锁定轮廓 anti-aliasing
<UP> =增加线宽
<DOWN> = 减小线宽
void Update (DWORD milliseconds) // 这里执行动作更新
{
if (g_keys->keyDown [' '] == TRUE) // 空格是否被按下
{
modelRotate = !modelRotate; // 锁定模型旋转开/关
g_keys->keyDown [' '] = FALSE;
}
if (g_keys->keyDown ['1'] == TRUE) // 1是否被按下
{
outlineDraw = !outlineDraw; // 切换是否绘制轮廓线
g_keys->keyDown ['1'] = FALSE;
}
if (g_keys->keyDown ['2'] == TRUE) // 2是否被按下
{
outlineSmooth = !outlineSmooth; // 切换是否使用反走样
g_keys->keyDown ['2'] = FALSE;
}
if (g_keys->keyDown [VK_UP] == TRUE) // 上键增加线的宽度
{
outlineWidth++;
g_keys->keyDown [VK_UP] = FALSE;
}
if (g_keys->keyDown [VK_DOWN] == TRUE) // 下减少线的宽度
{
outlineWidth--;
g_keys->keyDown [VK_DOWN] = FALSE;
}
if (modelRotate) // 是否旋转
modelAngle += (float) (milliseconds) / 10.0f; // 更新旋转角度
}
你一直在等待的函数。Draw 函数做每一件事情——计算阴影的值,着色网孔,着色轮廓,等等,这是它作的。
void Draw (void)
{
TmpShade用来存储当前顶点的色度值。所有顶点数据同时被计算,意味着我们只需使用我们能继续使用的单个的变量。
TmpMatrix, TmpVector 和 TmpNormal同样被用来计算顶点数据,TmpMatrix在函数开始时被调整一次并一直保持到Draw函数被再次调用。TmpVector 和 TmpNormal则相反,当另一个顶点被处理时改变。
float TmpShade; // 临时色度值
MATRIX TmpMatrix; // 临时 MATRIX 结构体
VECTOR TmpVector, TmpNormal; // 临时 VECTOR结构体
清除缓冲区矩阵数据
glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 清除缓冲区
glLoadIdentity (); // 重置矩阵
首先检查我们是否想拥有平滑的轮廓。如果是,我们就打开anti-alaising 。否则把它关闭。简单!
if (outlineSmooth) // 检查我们是否想要 Anti-Aliased 线条
{
glHint (GL_LINE_SMOOTH_HINT, GL_NICEST); // 启用它们
glEnable (GL_LINE_SMOOTH);
}
else // 否则不启用
glDisable (GL_LINE_SMOOTH);
然后我们设置视口。我们反向移动摄象机2个单元,之后以一定角度旋转模型。注:由于我们首先移动摄象机,这个模型将在现场旋转。如果我们以另一种方法做,模型将绕摄象机旋转。
我们之后从OpenGL中取最新创建的矩阵并把它存储在 TmpMatrix。
glTranslatef (0.0f, 0.0f, -2.0f); // 移入屏幕两个单位
glRotatef (modelAngle, 0.0f, 1.0f, 0.0f); // 绕Y轴旋转这个模型
glGetFloatv (GL_MODELVIEW_MATRIX, TmpMatrix.Data); // 获得产生的矩阵
戏法开始了。首先我们启用一维纹理,然后启用着色纹理。这被OpenGL用来当作一个look-up表格。我们之后调整模型的颜色(白色)我选择白色是因为它亮度高并且描影法比其它颜色好。我建议你不要使用黑色:)
// 卡通渲染代码
glEnable (GL_TEXTURE_1D); // 启用一维纹理
glBindTexture (GL_TEXTURE_1D, shaderTexture[0]); // 锁定我们的纹理
glColor3f (1.0f, 1.0f, 1.0f); // 调整模型的颜色
现在我们开始绘制那些三角形。尽管我们看到在数组中的每一个多边形,然后旋转它的每一个顶点。第一步是拷贝法线信息到一个临时的结构中。因此我们能旋转法线,但仍然保留原来保存的值(没有精确降级)。
glBegin (GL_TRIANGLES); // 告诉 OpenGL 我们即将绘制三角形
for (i = 0; i < polyNum; i++) // 从头到尾循环每一个多边形
{
for (j = 0; j < 3; j++) // 从头到尾循环每一个顶点
{
TmpNormal.X = polyData[i].Verts[j].Nor.X; // 用当前顶点的法线值填充TmpNormal结构
TmpNormal.Y = polyData[i].Verts[j].Nor.Y;
TmpNormal.Z = polyData[i].Verts[j].Nor.Z;
第二,我们通过初期从OpenGL中攫取的矩阵来旋转这个法线。我们之后规格化因此它并不全部变为螺旋形。
// 通过矩阵旋转
RotateVector (TmpMatrix, TmpNormal, TmpVector);
Normalize (TmpVector); // 规格化这个新法线
第三,我们获得那个旋转的法线的点积灯光方向(称为lightAngle,因为我忘了从我的旧的light类中改变它)。我们之后约束这个值在0——1的范围。(从-1到+1)
// 计算色度值
TmpShade = DotProduct (TmpVector, lightAngle);
if (TmpShade < 0.0f)
TmpShade = 0.0f; // 如果负值约束这个值到0
第四,对于OpenGL我们象忽略纹理坐标一样忽略这个值。阴影纹理与一个查找表一样来表现(色度值正成为指数),这是(我认为)为什么1D纹理被创造主要原因。对于OpenGL我们之后忽略这个顶点位置,并不断重
复,重复。至此我认为你已经抓到了概念。
glTexCoord1f (TmpShade); // 规定纹理的纵坐标当作这个色度值
// 送顶点
glVertex3fv (&polyData[i].Verts[j].Pos.X);
}
}
glEnd (); // 告诉OpenGL 完成绘制
glDisable (GL_TEXTURE_1D); // 1D 纹理不可用
现在我们转移到轮廓之上。一个轮廓能以“它的相邻的边,一边为可见,另一边为不可见”定义。在OpenGL中,这是深度测试被规定小于或等于(GL_LEQUAL)当前值的地方,并且就在那时所有前面的面被精选。我们同
样也要混合线条,以使它看起来不错:)
那么,我们使混合可用并规定混合模式。我们告诉OpenGL与着色线条一样着色backfacing多边形,并且规定这些线条的宽度。我们精选所有前面多边形,并规定测试深度小于或等于当前的Z值。在这个线条的的颜色被
规定后,我们从头到尾循环每一个多边形,绘制它的顶点。我们仅需忽略顶点位置,而不是法线或着色值因为我们需要的仅仅是轮廓。
// 轮廓代码
if (outlineDraw) // 检查看是否我们需要绘制轮廓
{
glEnable (GL_BLEND); // 使混合可用
// 调整混合模式
glBlendFunc (GL_SRC_ALPHA ,GL_ONE_MINUS_SRC_ALPHA);
glPolygonMode (GL_BACK, GL_LINE); // 绘制轮廓线
glLineWidth (outlineWidth); // 调整线宽
glCullFace (GL_FRONT); // 剔出前面的多边形
glDepthFunc (GL_LEQUAL); // 改变深度模式
glColor3fv (&outlineColor[0]); // 规定轮廓颜色
glBegin (GL_TRIANGLES); // 告诉OpenGL我们想要绘制什么
for (i = 0; i < polyNum; i++) // 从头到尾循环每一个多边形
{
for (j = 0; j < 3; j++) // 从头到尾循环每一个顶点
{
// 送顶点
glVertex3fv (&polyData[i].Verts[j].Pos.X);
}
}
glEnd (); // 告诉 OpenGL我们已经完成
这样以后,我们就把它规定为以前的状态,然后退出
glDepthFunc (GL_LESS); // 重置深度测试模式
glCullFace (GL_BACK); // 重置剔出背面多边形
glPolygonMode (GL_BACK, GL_FILL); // 重置背面多边形绘制方式
glDisable (GL_BLEND); // 混合不可用
}
}
原文及其个版本源代码下载:
http://nehe.gamedev.net/data/lessons/lesson.asp?lesson=37