卷二 Context 与平台层:被跳过的"第零步"
难度
★★☆视角[CPU]优先级P0(2.1/2.4)+P1(2.2/2.3)+P2(2.5)99% 的 OpenGL 教程开篇就是
glClear,从来不解释**glClear之前发生了什么**。本卷补上这一段——没有 Context,OpenGL 一行代码都跑不起来。
2.1 没有 Context,OpenGL 不存在[CPU][图×1]
视角
[CPU][Drv]优先级P0
一个会让程序崩溃的实验
intmain(){glClear(GL_COLOR_BUFFER_BIT);// 段错误 / undefined behaviorreturn0;}为什么崩?因为根本没有 OpenGL Context,glClear这个函数指针在很多平台上甚至还没被加载。
OpenGL 的所有gl*调用都隐式地操作"当前线程的当前 Context"。如果没有 Context,所有调用都是 undefined。
Context 的逻辑定义
回忆卷一 1.3 那张状态表。Context 就是一份完整的状态机实例 + 一组对象命名空间。
┌────────────────────────────────┐ │ OpenGL Context A │ ├────────────────────────────────┤ │ 状态部分: │ │ ARRAY_BUFFER_BINDING = 5 │ │ CURRENT_PROGRAM = 12 │ │ ... │ ├────────────────────────────────┤ │ 对象命名空间: │ │ Buffer IDs: {1,2,3,5,7} │ │ Texture IDs: {1,4,8} │ │ Shader IDs: {2,3,9,12} │ │ ... │ ├────────────────────────────────┤ │ 关联资源: │ │ 显存分配 │ │ 命令队列 │ └────────────────────────────────┘两个 Context 之间:
- 状态完全独立(A 里
glEnable(GL_DEPTH_TEST)不影响 B) - 句柄命名空间默认独立(A 的 buffer ID 5 和 B 的 buffer ID 5 是两个不同对象)
- 显存对象默认不可共享(除非创建时指定共享,详见后面)
“当前 Context” 为何是线程局部的?
OpenGL 规范规定:每个线程在任意时刻最多有一个"当前 Context"。
// 伪代码:OpenGL 的实现概念__thread GLContext*current_context=NULL;// TLS(线程局部存储)voidglClear(GLbitfield mask){if(current_context==NULL)/* undefined */;current_context->clear(mask);}为什么是 per-thread 而不是 per-process?
- 多窗口程序:一个进程可以有多个窗口,每个窗口对应一个 Context,分别在不同线程渲染。如果是 per-process,多窗口不可能。
- 后台资源加载:主线程渲染时,工作线程可以另开一个共享 Context 上传纹理。per-thread 让这种模式天然成立。
为什么不允许"一个 Context 同时在多线程里用"?
- Context 内部状态量巨大(几百个槽位 + 命令队列),多线程访问需要锁,性能崩溃
- OpenGL API 设计为同步(你以为同步),加锁后所有调用变得串行,并发优势消失
- 这是 OpenGL 的根本性限制,也是 Vulkan 出现的核心动机之一
多 Context 共享对象
默认隔离,但创建 Context 时可以指定"和已有 Context A 共享对象命名空间"。共享后:
- Buffer / Texture / Shader / Sampler / Sync / Renderbuffer:跨 Context 共享
- VAO / Framebuffer / Program Pipeline / Query / Transform Feedback:不共享(容器型对象,每个 Context 自己一份)
为什么 VAO 不共享?因为 VAO 内部记录的是"绑定关系"(哪个 attribute 绑定到哪个 VBO),这种关系本质上属于"使用方"而非"数据方"。
典型用法:主线程渲染,工作线程加载——把工作线程 Context 设为"和主线程共享",工作线程glTexImage2D上传完的纹理,主线程立即可用。
[码]一个最常见的 bug
// 主线程GLuint vbo;glGenBuffers(1,&vbo);// 在 Context A 里分配的 ID// 工作线程(Context B,未与 A 共享)glBindBuffer(GL_ARRAY_BUFFER,vbo);// 这个 ID 在 B 里指向另一个对象(或不存在)// ↑ undefined behavior,但不会立即报错根因:句柄是当前 Context 命名空间下的整数,跨 Context 传递句柄等于在不同字典里查同一个 key。
2.2 平台胶水层对照[CPU]
视角
[CPU]优先级P1
为什么 OpenGL 自己不管窗口?
读到这里你可能会问:既然 Context 这么重要,那"创建 Context"的 API 应该在哪?
OpenGL 规范的回答是:不在我这。
glCreateContext这个函数不存在。OpenGL 规范只规定"Context 的行为",不规定 Context 怎么创建。创建工作交给"平台胶水层"。
为什么这么设计?
单一职责原则:
- 渲染 API 应该跨平台(因为 GPU 在所有 OS 上长得差不多)
- 但窗口系统完全平台特定(X11、Win32、Cocoa、Wayland 互不兼容)
- 把"窗口系统接口"塞进 OpenGL 规范,会让规范无法跨平台
所以 Khronos 把这块独立成了一组伴侣规范:
| 平台 | 胶水层 |
|---|---|
| Windows | WGL(wglCreateContext等) |
| Linux X11 | GLX(glXCreateContext等) |
| macOS | CGL(CGLCreateContext等,已与 OpenGL 一起 deprecated) |
| 移动端 + 跨平台 | EGL(eglCreateContext等) |
这层胶水的真实职责
无论叫什么名字,这层都做四件事:
- 从操作系统拿到一个"可绘制的窗口/Surface"(HWND、X Window、CALayer、ANativeWindow)
- 协商像素格式(RGBA 几位、深度几位、模板几位、是否多重采样)
- 创建 OpenGL Context并把它和上面的 Surface 关联起来
- 提供
SwapBuffers:把后缓冲交给合成器/显示
注意第 4 点:SwapBuffers不是 OpenGL 的 API——它是平台胶水层的 API。卷二 2.4 会展开。
为什么实战中常用 GLFW / SDL / Qt?
GLFW、SDL2、Qt 这些库封装了 WGL/GLX/CGL/EGL 的差异,提供一套跨平台 API。你写:
GLFWwindow*window=glfwCreateWindow(800,600,"demo",NULL,NULL);glfwMakeContextCurrent(window);GLFW 在 Windows 上调 WGL,在 Linux 上调 GLX,在 macOS 上调 CGL,在 Android 上调 EGL。这就是它们的全部价值。
2.3 EGL 深拆(移动端必修)[CPU][图×1]
视角
[CPU]优先级P1
桌面端通常 GLFW / SDL 一行搞定,没人去碰 WGL/GLX 细节。但移动端不行——Android 原生开发、相机滤镜、视频编辑、SurfaceTexture 渲染——你必须裸调 EGL。
EGL 的四件套
EGL 把"准备一个能跑 OpenGL 的环境"拆成四个对象,逻辑上层层依赖:
EGLDisplay ← 代表一块"可渲染设备"(屏幕/GPU) │ ├──► EGLConfig ← 像素格式协商结果(RGBA8 + Depth24 + Stencil8 + ...) │ ├──► EGLSurface ← 实际可被绘制的目标(窗口/Pbuffer/Pixmap) │ └──► EGLContext ← OpenGL ES 的状态机本体逐个拆:
Display
EGLDisplay dpy=eglGetDisplay(EGL_DEFAULT_DISPLAY);eglInitialize(dpy,NULL,NULL);逻辑含义:你想用哪台"显示设备"。绝大多数手机只有一块 GPU,传EGL_DEFAULT_DISPLAY即可。多 GPU 系统(如外接显示器)可以指定。
Config
EGLint attribs[]={EGL_RENDERABLE_TYPE,EGL_OPENGL_ES2_BIT,EGL_RED_SIZE,8,EGL_GREEN_SIZE,8,EGL_BLUE_SIZE,8,EGL_ALPHA_SIZE,8,EGL_DEPTH_SIZE,24,EGL_STENCIL_SIZE,8,EGL_NONE,};EGLConfig cfg;EGLint n;eglChooseConfig(dpy,attribs,&cfg,1,&n);逻辑含义:你告诉 EGL"我需要 RGBA8 + 24 位深度 + 8 位模板的格式",EGL 在硬件支持的所有格式里筛出兼容的。注意:返回的 cfg 不一定精确等于你要的——它是"满足或超过你要求的最佳匹配"。
Surface
Surface 是"可被绘制的目标",分三种:
| 类型 | 用途 |
|---|---|
| Window Surface | 关联到一个原生窗口(Android 的ANativeWindow、iOS 的CAEAGLLayer),渲染结果给屏幕 |
| Pbuffer Surface | “Pixel Buffer”,离屏的、不可见的渲染目标,用于后台计算 |
| Pixmap Surface | 关联到一个原生位图,几乎已废弃 |
EGLSurface surface=eglCreateWindowSurface(dpy,cfg,native_window,NULL);Context
EGLint ctx_attribs[]={EGL_CONTEXT_CLIENT_VERSION,3,EGL_NONE};EGLContext ctx=eglCreateContext(dpy,cfg,EGL_NO_CONTEXT,ctx_attribs);这才是 OpenGL ES 状态机本体。Surface 和 Context 是分开的——同一个 Context 可以轮流绑定到不同 Surface 上渲染。
eglMakeCurrent的四元绑定
eglMakeCurrent(dpy,draw_surface,read_surface,ctx);把(Display, DrawSurface, ReadSurface, Context)这个四元组绑定到当前线程。
为什么 Draw 和 Read 分开?因为有些操作(如glReadPixels、glCopyTexImage2D)从"读 Surface"读,而绘制写到"画 Surface"。多数场景两者相同,但分离的设计允许"画到 A,从 B 拷"这种高级用法。
EGL 完整初始化模板(背下来)
EGLDisplay dpy=eglGetDisplay(EGL_DEFAULT_DISPLAY);eglInitialize(dpy,NULL,NULL);EGLint attribs[]={EGL_RENDERABLE_TYPE,EGL_OPENGL_ES3_BIT,EGL_SURFACE_TYPE,EGL_WINDOW_BIT,EGL_RED_SIZE,8,EGL_GREEN_SIZE,8,EGL_BLUE_SIZE,8,EGL_ALPHA_SIZE,8,EGL_DEPTH_SIZE,24,EGL_NONE,};EGLConfig cfg;EGLint num;eglChooseConfig(dpy,attribs,&cfg,1,&num);EGLSurface surf=eglCreateWindowSurface(dpy,cfg,native_window,NULL);EGLint ctx_attribs[]={EGL_CONTEXT_CLIENT_VERSION,3,EGL_NONE};EGLContext ctx=eglCreateContext(dpy,cfg,EGL_NO_CONTEXT,ctx_attribs);eglMakeCurrent(dpy,surf,surf,ctx);// 现在可以调用 glXxx 了2.4 双缓冲与呈现链[CPU][GPU][图×1]
视角
[CPU][GPU]优先级P0
单缓冲为什么不行?
设想一个最朴素的方案:渲染完直接写到屏幕显示的那块内存。
GPU 写入 ────┐ ▼ 显示内存 ────► 显示器扫描读取 ▲ GPU 写入 ────┘问题:显示器扫描和 GPU 写入不同步。显示器一行一行扫,从上到下;GPU 写入是按几何形状写。它们撞在一起时,屏幕上半部分是新画面、下半部分是旧画面——这就是撕裂(Tearing)。
双缓冲
解决方案:两块内存,轮流用。
┌────────────────┐ GPU 渲染 ─────► │ Back Buffer │ └────────────────┘ │ Swap (交换/翻页) │ ▼ ┌────────────────┐ 显示器扫描 ◄──── │ Front Buffer │ └────────────────┘- Front Buffer:当前正在被显示器扫描显示
- Back Buffer:GPU 正在往里画
- 一帧画完,调用
SwapBuffers→ 两者交换角色
SwapBuffers的真实语义:
它不是"把图像传给显示器"。它是告诉合成器/驱动:我画完了,下次扫描时请用这块作为 Front。
具体怎么"交换"取决于实现:
- Page Flip:直接交换两个 framebuffer 的指针(零拷贝,最快)
- Blit:把 Back 的内容拷贝到 Front(旧硬件 / 某些情况)
- Composition:把 Back 提交给系统合成器(移动端、Wayland、macOS 等大多如此)
VSync 是什么?
问题升级:即使有双缓冲,如果在显示器扫描"中途"做了 Swap,新的 Front 立刻被扫描,扫描位置以下还是旧画面——撕裂依然发生。
VSync(垂直同步):让 Swap 等到显示器扫完整帧(垂直消隐期)再发生。
代价:
- 稳定无撕裂
- 帧率被钉到屏幕刷新率(60Hz 屏 → 最多 60fps)
- 如果一帧画不完:错过本次消隐期,下次再说,实际帧率会跌到 30fps(不是 59fps)
现代变体:自适应同步(G-Sync / FreeSync / VRR)让显示器刷新率跟随 GPU,撕裂和卡顿同时缓解。
移动端的特殊性
移动端的"Surface"几乎都不直接对应物理屏幕。中间隔了一个系统合成器(Android SurfaceFlinger / iOS Core Animation):
你的 OpenGL ──► EGL Surface ──► 合成器 ──► 物理屏幕 ↑ 状态栏、导航栏、其他 App、动画 一起合成成最终画面eglSwapBuffers不是把图给屏幕,是把交换权移交给合成器。合成器决定"什么时候、和别的窗口一起、怎么合成"。
这个事实的推论:
- 你的 60fps 不一定真到屏幕(合成器可能丢帧、降频)
eglSwapBuffers可能阻塞——如果 BackBuffer 数量不够、合成器没用完旧的,你会卡在这- 移动端"流畅度"问题往往是合成链路问题,不是你的渲染问题
三缓冲
如果双缓冲在卡顿时跌到 30fps 不可接受,加第三块 buffer:
渲染中 ──► Buffer C │ ▼ 等待显示 ──► Buffer B │ ▼ 正在显示 ──► Buffer AGPU 永远有一块可写的,不必等显示完成。代价是多一帧延迟 + 多占一块显存。移动端几乎都用三缓冲。
2.5 跨平台与版本陷阱[CPU]
视角
[CPU]优先级P2本节是"踩坑预防针",不读不影响主线,但踩了坑你会回来。
Core Profile vs Compatibility Profile
OpenGL 3.2 起引入了两个 Profile:
| Profile | 内容 |
|---|---|
| Core | 删除了所有 1.x / 2.x 旧 API(glBegin/glEnd、固定管线、矩阵栈、display list 等) |
| Compatibility | 保留所有旧 API,向后兼容 |
为什么要这么搞?
- 旧 API 性能差(CPU 大量介入)、与现代 GPU 模型不匹配
- 但海量旧代码不能立刻废弃
实战陷阱:你 follow 一个 LearnOpenGL 教程,要求 GL 3.3 Core,但你创建 Context 时没指定 Profile,驱动给你一个 Compatibility Context,于是教程里没用到的旧 API 也能跑——直到你换到一台更严格的机器,全崩。
GLFW 的写法:
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR,3);glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR,3);glfwWindowHint(GLFW_OPENGL_PROFILE,GLFW_OPENGL_CORE_PROFILE);glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT,GL_TRUE);// macOS 要求最后一句 macOS 必须有,否则 Core Context 创建失败。
macOS 的特殊困境
| 限制 | 影响 |
|---|---|
| 最高只到 OpenGL 4.1 | 没有 Compute Shader(4.3+)、没有 DSA(4.5+)、没有间接绘制(4.3+) |
| 已被官方 deprecated | 苹果未来某个 macOS 版本可能彻底移除 |
| 只支持 Core Profile(3.2 起) | 用 Compatibility 写的代码跑不起来 |
结论:macOS 上的 OpenGL 是"凑合用"模式。如果项目以 macOS 为主要目标,请认真考虑 Metal 或 MoltenVK。
OpenGL ES 版本分水岭
移动端最重要的决策:最低支持哪个 ES 版本。
| 版本 | 关键能力 |
|---|---|
| ES 2.0 | 可编程顶点 + 片元着色器;没有VAO(默认)、UBO、3D 纹理(核心)、Compute、几何/曲面细分 |
| ES 3.0 | 加入 VAO、UBO、3D 纹理、MRT、变换反馈、ETC2、PBO、glDrawArraysInstanced |
| ES 3.1 | 加入 Compute Shader、间接绘制、Image Load/Store、SSBO |
| ES 3.2 | 加入几何着色器、曲面细分、KHR_debug入核心 |
实战决策:
- 想覆盖最广泛的旧设备 → ES 2.0(你会非常痛苦)
- 现代主流 →ES 3.0是甜点(2026 年 95%+ 设备支持)
- 想用 Compute / 现代特性 → ES 3.1+(约 80%+ 设备)
ES 2 → ES 3 升级时最容易踩的坑:ES 2 的 GLSL 用attribute / varying,ES 3 用in / out。代码不能照搬。
跨版本兼容性的实战写法
#version 300 es precision mediump float; in vec3 v_color; out vec4 fragColor; void main() { fragColor = vec4(v_color, 1.0); }注意三件事:
- 第一行
#version必须是文件第一行,前面连空格都不能有 - OpenGL ES 的 fragment shader 必须显式声明
precision(详见卷五 5.1) - ES 的版本号与桌面 GLSL 不同:ES 3.0 是
300 es,桌面 3.3 是330 core
本卷自检
读完本卷,你应该能回答:
- 不调用
eglMakeCurrent直接调glClear会怎样? - 主线程渲染、工作线程上传纹理,需要做什么准备?
- 为什么
SwapBuffers不是 OpenGL 的 API? - 撕裂的物理根源是什么?VSync 怎么解决?
- 为什么 macOS 上 OpenGL 项目要在 GLFW 那加
FORWARD_COMPAT提示?
下一卷我们进入主战场:渲染管线全景——所有上面准备好的 Context 和数据,到底是怎么变成屏幕上的像素的。