在本系列文章中,我将使用 C 创建经典街机游戏太空入侵者的克隆,仅使用几个依赖项。 在这篇文章中,我将设置所需的 OpenGL 着色器来绘制外星精灵!
基于 CPU 的渲染
GPU擅长对大量数据执行操作。 权衡是对 GPU 进行编程并不像对 CPU 进行编程那么容易。 对于我在这里构建的简单空间入侵者克隆,使用缓冲区渲染 CPU 上的所有内容更容易,即表示游戏屏幕上像素的一块内存。 然后可以将缓冲区作为纹理传递给 GPU,并绘制到计算机屏幕上。
struct Buffer
{
size_t width, height;
uint32_t* data;
};
Buffer 有一定的宽度和高度。 我们将每个像素表示为 uint32_t,这允许我们为每个像素存储 4 个 8 位颜色值。 在这里,我们将只使用 24 位,分别代表红色、绿色和蓝色通道。 尽管有人可能认为使用 uint8_t 会更好,但我使用 32 位值,因为它使索引更容易。 对于整数类型,我们还需要包含以下标准头,
#include <cstdint>
为了帮助我们将颜色定义为 uint32_t 值,我们定义了以下函数,
uint32_t rgb_to_uint32(uint8_t r, uint8_t g, uint8_t b)
{
return (r << 24) | (g << 16) | (b << 8) | 255;
}
它将最左边的 24 位分别设置为 r、g 和 b 值。 最右边的 8 位设置为 255,虽然我前面提到过,但没有使用 alpha 通道。 如果您想了解更多信息,可以阅读本教程。 然后我们创建一个函数将缓冲区清除为某种颜色,
void buffer_clear(Buffer* buffer, uint32_t color)
{
for(size_t i = 0; i < buffer->width * buffer->height; i)
{
buffer->data[i] = color;
}
}
该函数遍历所有像素并将每个像素设置为给定的颜色。
在主函数中,我们现在初始化缓冲区,
uint32_t clear_color = rgb_to_uint32(0, 128, 0);
Buffer buffer;
buffer.width = buffer_width;
buffer.height = buffer_height;
buffer.data = new uint32_t[buffer.width * buffer.height];
buffer_clear(&buffer, clear_color);
这将创建一个 buffer_width 宽度和 buffer_height 高度的缓冲区,并将颜色设置为 clear_color,即绿色。
OpenGL 着色器
创建缓冲区后,我们现在需要设置 OpenGL 以便能够在屏幕上显示缓冲区。在现代 OpenGL 中,大部分职责已从 OpenGL 驱动程序转移到必须编写由 GPU 执行的程序的用户。这些程序称为着色器。此外,OpenGL 定义了渲染管线,着色器可以在管线的不同阶段执行。 Legacy OpenGL 也被称为“固定管线”,因为管线的阶段不是可编程的,而是固定的。两种最重要的着色器类型是顶点着色器和片段着色器。顶点着色器处理顶点数据的处理。最常见的是,它们用于将对象转换为屏幕空间坐标。在几个阶段之后,由顶点着色器处理的图元被光栅化。在这个阶段,片段着色器处理光栅化生成的片段。片段着色器可以输出深度值、可能的模板值和颜色值。顶点着色器和片段着色器是渲染管道运行所需的唯一着色器。虽然你可以用着色器制作漂亮的东西,但在这里我们将构建一对简单的着色器,它们将输出我们在上一节中创建的缓冲区的内容。
对于顶点着色器,我们想要生成一个覆盖屏幕的四边形。不过,有一个技巧可以让我们生成一个全屏三角形,而无需将任何顶点数据传递给着色器。你可以在这篇文章中阅读更多关于这个技巧的信息。这里我只显示我在程序中使用的代码,
const char* vertex_shader =
"\n"
"#version 330\n"
"\n"
"noperspective out vec2 TexCoord;\n"
"\n"
"void main(void){\n"
"\n"
" TexCoord.x = (gl_VertexID == 2)? 2.0: 0.0;\n"
" TexCoord.y = (gl_VertexID == 1)? 2.0: 0.0;\n"
" \n"
" gl_Position = vec4(2.0 * TexCoord - 1.0, 0.0, 1.0);\n"
"}\n";
对于片段着色器,我们只需要对缓冲纹理进行采样并输出采样结果即可,
const char* fragment_shader =
"\n"
"#version 330\n"
"\n"
"uniform sampler2D buffer;\n"
"noperspective in vec2 TexCoord;\n"
"\n"
"out vec3 outColor;\n"
"\n"
"void main(void){\n"
" outColor = texture(buffer, TexCoord).rgb;\n"
"}\n";
注意顶点着色器的输出,TexCoord,现在是片段着色器的输入。 尽管顶点着色器不需要任何顶点数据传递给它,但我们仍然需要告诉它我们要绘制三个顶点。 为此,我们创建了一个顶点数组对象(VAO),
GLuint fullscreen_triangle_vao;
glGenVertexArrays(1, &fullscreen_triangle_vao);
glBindVertexArray(fullscreen_triangle_vao);
粗略地说,VAO 是 OpenGL 中的一种结构,它将顶点数据的格式与顶点数据一起存储。
最后,需要将两个着色器编译成 GPU 可以理解的代码并链接到一个着色器程序中。
GLuint shader_id = glCreateProgram();//Create vertex shader
{
GLuint shader_vp = glCreateShader(GL_VERTEX_SHADER); glShaderSource(shader_vp, 1, &vertex_shader, 0);
glCompileShader(shader_vp);
validate_shader(shader_vp, vertex_shader);
glAttachShader(shader_id, shader_vp); glDeleteShader(shader_vp);
}//Create fragment shader
{
GLuint shader_fp = glCreateShader(GL_FRAGMENT_SHADER); glShaderSource(shader_fp, 1, &fragment_shader, 0);
glCompileShader(shader_fp);
validate_shader(shader_fp, fragment_shader);
glAttachShader(shader_id, shader_fp); glDeleteShader(shader_fp);
}glLinkProgram(shader_id);if(!validate_program(shader_id))
{
fprintf(stderr, "Error while validating shader.\n");
glfwTerminate();
glDeleteVertexArrays(1, &fullscreen_triangle_vao);
delete[] buffer.data;
return -1;
}
在上面的代码块中,首先使用 glCreateProgram 创建了一个着色器程序。 单个着色器使用函数 glCreateShader 创建,并使用 glCompileShader 编译。 使用 glAttachShader 将它们附加到程序后,可以将它们删除。 该程序使用 glLinkProgram 链接。 OpenGL 在编译过程中会输出各种信息,例如 一个 C 编译器,但是我们需要截取这些信息。 为此,我创建了两个简单的函数,validate_shader 和 validate_program,
void validate_shader(GLuint shader, const char* file = 0)
{
static const unsigned int BUFFER_SIZE = 512;
char buffer[BUFFER_SIZE];
GLsizei length = 0; glGetShaderInfoLog(shader, BUFFER_SIZE, &length, buffer); if(length > 0)
{
printf("Shader %d(%s) compile error: %s\n",
shader, (file ? file: ""), buffer);
}
}bool validate_program(GLuint program)
{
static const GLsizei BUFFER_SIZE = 512;
GLchar buffer[BUFFER_SIZE];
GLsizei length = 0; glGetProgramInfoLog(program, BUFFER_SIZE, &length, buffer); if(length > 0)
{
printf("Program %d link error: %s\n", program, buffer);
return false;
} return true;
}
缓冲纹理
要将图像数据传输到 GPU,我们使用 OpenGL 纹理。 与 VAO 的情况一样,纹理也是一个对象,它与图像数据一起保存有关数据格式的信息。 我们首先使用 glGenTextures 函数生成纹理,
GLuint buffer_texture;
glGenTextures(1, &buffer_texture);
并指定图像格式和一些关于纹理采样行为的标准参数,
glBindTexture(GL_TEXTURE_2D, buffer_texture);
glTexImage2D(
GL_TEXTURE_2D, 0, GL_RGB8,
buffer.width, buffer.height, 0,
GL_RGBA, GL_UNSIGNED_INT_8_8_8_8, buffer.data
);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
在这里,我们指定图像应该使用 8 位 rgb 格式在内部表示纹理。 调用 glTexImage2D 的最后三个参数指定我们传递给纹理的数据的像素格式; 每个像素都采用 rgba 格式,并表示为 4 个无符号 8 位整数。 前两个 glTexParameteri 调用告诉 GPU 在读取像素时不应用任何过滤(平滑)。 最后两个调用告诉它,如果它试图读取纹理边界之外的内容,它将使用边缘处的值。
我们现在需要将纹理附加到片段着色器中的 uniform sampler2D 变量。 OpenGL 有许多可以附加制服的纹理单元。 我们使用 glGetUniformLocation 获得了统一在着色器中的位置(统一位置可以看作是一种“指针”),并使用 glUniform1i 将统一设置为纹理单元'0',
GLint location = glGetUniformLocation(shader_id, "buffer");
glUniform1i(location, 0);
缓冲区显示
我们终于设置了在屏幕上显示缓冲区所需的一切。 就在游戏循环之前,我们禁用深度测试并绑定我们之前创建的顶点数组,
glDisable(GL_DEPTH_TEST);
glBindVertexArray(fullscreen_triangle_vao);
如果我们现在打电话
glDrawArrays(GL_TRIANGLES, 0, 3);
创建的窗口的内容应该是绿色的。
精灵绘图
在上一篇文章中,我们已经创建了一个窗口并在其上显示了一些颜色。 以同样的结果结束这篇文章会有点无聊,所以让我们画点东西。
我首先定义一个简单的精灵,
struct Sprite
{
size_t width, height;
uint8_t* data;
};
这只是一堆堆分配的数据,以及精灵的宽度和高度。 sprite 表示为位图,即每个像素由一个位表示,1 表示 sprite 像素“打开”。 然后我们创建一个函数来用指定的颜色在缓冲区中绘制精灵,
void buffer_sprite_draw(
Buffer* buffer, const Sprite& sprite,
size_t x, size_t y, uint32_t color
){
for(size_t xi = 0; xi < sprite.width; xi)
{
for(size_t yi = 0; yi < sprite.height; yi)
{
size_t sy = sprite.height - 1 y - yi;
size_t sx = x xi;
if(sprite.data[yi * sprite.width xi] &&
sy < buffer->height && sx < buffer->width)
{
buffer->data[sy * buffer->width sx] = color;
}
}
}
}
该函数只是遍历精灵像素并在指定坐标处绘制“on”像素(如果它们在缓冲区范围内)。 或者,可以用我们定义缓冲区的相同方式来表示精灵,即每个像素都是一个 32 位 rgba 值,alpha 值用于透明度/混合。
在主函数中,我们创建了一个外星精灵,
Sprite alien_sprite;
alien_sprite.width = 11;
alien_sprite.height = 8;
alien_sprite.data = new uint8_t[11 * 8]
{
0,0,1,0,0,0,0,0,1,0,0, // ..@.....@..
0,0,0,1,0,0,0,1,0,0,0, // ...@...@...
0,0,1,1,1,1,1,1,1,0,0, // ..@@@@@@@..
0,1,1,0,1,1,1,0,1,1,0, // .@@.@@@.@@.
1,1,1,1,1,1,1,1,1,1,1, // @@@@@@@@@@@
1,0,1,1,1,1,1,1,1,0,1, // @.@@@@@@@.@
1,0,1,0,0,0,0,0,1,0,1, // @.@.....@.@
0,0,0,1,1,0,1,1,0,0,0 // ...@@.@@...
};
要在 (112, 128) 位置绘制红色精灵,我们调用,
buffer_sprite_draw(&buffer, alien_sprite,
112, 128, rgb_to_uint32(128, 0, 0));
为了准备接下来的步骤,我们清除缓冲区并在游戏循环的每一帧中绘制精灵。 为了更新 OpenGL 纹理,我们调用 glTexSubImage2D
glTexSubImage2D(
GL_TEXTURE_2D, 0, 0, 0,
buffer.width, buffer.height,
GL_RGBA, GL_UNSIGNED_INT_8_8_8_8,
buffer.data
);
如果您编译并运行这篇文章的最终代码,在这里,您应该会看到绿色背景中间的红色纹理。
结论
在这篇文章中,我们设置了一些管道,以便我们可以在 CPU 上绘制精灵并将它们显示在我们在上一篇文章中创建的窗口上。 在现代 OpenGL 中,这是一项相当大的工作,因为我们接触了顶点缓冲区对象、着色器和纹理。 设置好我们的绘图程序后,我们现在可以开始专注于更令人兴奋的事情,例如编写游戏逻辑。
Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved