OpenGL ES 2.0 笔记 #1:SDL2 窗口

541 阅读6分钟

SDL2 是一个跨平台的游戏开发框架,提供窗口管理、OpenGL、2D 图形、音频、输入设备(键盘/鼠标/手柄...)等底层控制 API。我这里主要用到它的窗口管理、OpenGL 渲染和事件处理功能。

包含 SDL2 和 ES 2 头文件:

#include <SDL2/SDL.h>
#include <GLES2/gl2.h>

Build 程序时,需分别链接库 sdl2glesv2

user1@pi4b1:~$ pkg-config --libs sdl2
-lSDL2
user1@pi4b1:~$ pkg-config --libs glesv2
-lGLESv2

注意库名称与库文件之间的拼写区别。在 meson 脚本中:

dep = [dependency('sdl2'), dependency('glesv2')]
exe = executable('hello_window', 'hello_window.c', dependencies: dep,
install : true)

第一件事是初始化 SDL2。另外,程序退出前需释放 SDL2 资源:

int main(int argc, char **argv)
{
    if (0 != SDL_Init(SDL_INIT_VIDEO))
    {
        printf("SDL init failed. \n");
        return -1;
    }

    ...

    SDL_Quit();
    return 0;
}

SDL2 要求 main() 函数必须如上定义完整参数列表,原因是,为实现不同平台的统一,SDL2 在某些平台上提供了平台特定的入口函数,SDL2 提供的入口函数则会进一步调用程序定义的入口函数 SDL_main(),而 main() 实际上只是通过宏定义的 SDL_main() 函数的“别名”。例如在 Win32 平台,SDL2 提供 WinMain() 入口函数,WinMain() 将会调用程序的 main() “函数”(实则 SDL_main() 函数)。SDL_main() 函数声明/签名已确定,main() 必须与之严格保持一致,即如上面代码片段中的那样。关于这一点,可阅读 SDL_main.h 以详细了解。

SDL_Init() 参数指定需要启用的功能模块。窗口管理(包括 OpenGL)及事件处理为 Video 模块的功能,用 SDL_INIT_VIDEO flag 指定。如果还需要其他功能模块,则需同时指定其对应的 flag,例如 Timer 模块:

SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER)

接下来创建窗口。对于 OpenGL 支持,必须在创建窗口之前设定 OpenGL 相关属性:

#define INIT_SCREEN_WIDTH 320
#define INIT_SCREEN_HEIGHT 320

...

SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 2);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);

SDL_Window *win = SDL_CreateWindow("Hello", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, 
    INIT_SCREEN_WIDTH, INIT_SCREEN_HEIGHT,
    SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE);

这里指定了 OpenGL “context profile” 为 OpenGL ES,以及版本号 2.0。SDL_GL_DOUBLEBUFFER 指 double buffer,OpenGL ES 强制要求,SDL2 默认启用。

函数 SDL_CreateWindow() 参数依次指定窗口标题、位置、大小,以及 flag。注意窗口大小指 client 区域,标题栏和边框等不计算在内。要支持 OpenGL 必须指定 SDL_WINDOW_OPENGL flag。SDL_WINDOW_RESIZABLE 表示窗口大小可变(用户用鼠标调整/最大化/还原),若不指定则为固定大小。

接下来创建 OpenGL context:

SDL_GLContext context = SDL_GL_CreateContext(win);

在成功创建 OpenGL context 后,就可进行 OpenGL 渲染了。

SDL2 有一个事件队列,以便应用程序响应和处理窗口及用户输入(如鼠标/键盘)等事件。SDL2 事件处理是“轮询”方式,一般放在一个循环体内进行,反复获取事件并处理。退出事件循环则可退出程序:

int main(int argc, char **argv)
{
    ...

    while (1)
    {
        SDL_Event e;
        if (0 == SDL_PollEvent(&e))
        {
            continue;
        }

        // ESC: quit
        if (SDL_KEYUP == e.type && SDLK_ESCAPE == e.key.keysym.sym)
        {
            break;
        }
        
        if (SDL_WINDOWEVENT == e.type)
        {
            // Window closed
            if (SDL_WINDOWEVENT_CLOSE == e.window.event)
            {
                break;
            }
        }
        
        ...
    }

    SDL_Quit();
    return 0;
}

在事件循环内,首先调用 SDL_PollEvent() 函数从事件队列获取事件,如果没有事件(队列为空),则继续循环;如果获取到事件则对其进行处理。上面代码检查如果用户按了 ESC 按键,则跳出循环,程序将会退出。同样地,如果用户关闭窗口,也会接收到相应的事件,将跳出循环以退出程序。

至此,程序的总体流程结构已经形成。下面增加 OpenGL 渲染相关的部分。

具体而言,要做 3 件事:(1)初始化(创建窗口、OpenGL context)完成之后,设定 OpenGL viewport。(2)当窗口大小变化时,重新指定 viewport。(3)在事件循环内进行 OpenGL 渲染,即:每次循环渲染一帧。

Viewport 可理解为 OpenGL 的绘制区域。OpenGL 是 3D 图形 API,3D 模型最终转换(投影)为 2D 图像,viewport 就是 2D 图像的显示区域。通过调用 glViewport() 函数,指定 viewport 矩形的位置(左下角坐标)和大小(宽/高)。Viewport 位置和大小单位为像素。

代码更新如下:

...

glViewport(0, 0, INIT_SCREEN_WIDTH, INIT_SCREEN_HEIGHT);

while (1)
{
    render();
    SDL_GL_SwapWindow(win);

    SDL_Event e;
    if (0 == SDL_PollEvent(&e))
    {
        continue;
    }
    
    ...
    
    if (SDL_WINDOWEVENT == e.type)
    {
        ...
        
        if (SDL_WINDOWEVENT_RESIZED == e.window.event)
        {
            on_resize(e.window.data1, e.window.data2);
        }
    }
    
    ...
}

看到,在代码第 3 行,在进入事件循环之前设置 viewport。在事件循环内,首先进行 OpenGL 渲染(第 7, 8 行),之后进行事件处理。若窗口大小变化,获取到相应的事件通知,则调用 on_resize() 重新设置 viewport。on_resize() 定义很简单:

static void on_resize(int32_t width, int32_t height)
{
    glViewport(0, 0, width, height);
}

这里把整个窗口的显示区域设置为 viewport。

函数 render() 进行实际的 OpenGL 渲染。

OpenGL ES 渲染方式是 double buffer,即:有 2 个图形缓冲区,真正的 OpenGL 渲染发生在称为 back buffer 的后台缓冲区上。渲染过程中,back buffer 的内容不可见。当渲染完成后,交换 2 个缓冲,back buffer 成为 front buffer,其内容显示到显示设备上;而原来的 front buffer 则成为下一次渲染的 back buffer。因此,每渲染一帧后,都需要交换 front/back buffer。

OpenGL 本身并未定义如何交换缓冲区,这由具体平台机制来实现。如果平台支持 EGL,则可通过 EGL 交换缓冲区。对于 SDL2,调用函数 SDL_GL_SwapWindow() 交换缓冲区。

render() 定义为

static void render()
{
    glClearColor(.2f, .3f, .3f, 1.f);
    glClear(GL_COLOR_BUFFER_BIT);
}

这里调用 2 个 OpenGL 命令,将屏幕清除为深靛青色。

完整代码在 gitlab.com/sihokk/lear…

程序运行效果为:

Screenshot from 2024-02-01 11-43-07.png

看到标题栏上括号里面的字。我是在 Ubuntu 开发电脑上 SSH 远程运行 Raspberry Pi 上的程序,程序窗口在我开发电脑上。这称为 X11 Forwarding(我认为这是 X11 相比 Wayland 的一个优点)。X11 是 C/S 架构,在我的环境中,开发电脑作 server,而 Raspberry Pi 是 client。括号里面的字就是 Raspberry Pi 的主机名。

在终端模拟器中通过 ssh 进行远程连接时,指定 -Y 选项以启用 X11 Forwarding:

ssh -Y ...

在 VS Code 中,像下面这样配置 SSH 连接启用 X11 Forwarding。这样,在 VS Code 终端中远程运行 GUI 程序,程序窗口将会显示在本地桌面上。SSH 配置文件路径为 ~/.ssh/config

Host pi4b1
    HostName pi4b1.local
    User user1
    ForwardX11 yes
    ForwardX11Trusted yes