OpenGL渲染与几何内核那点事-项目实践理论补充(一-3-(2):当你的CAD学会“偷懒”:从“一笔一画”到“一键生成”的OpenGL渲染进化史)

@TOC

代码仓库入口:


系列文章规划:

巨人的肩膀:

  • deepseek
  • gemini

当你的CAD学会"偷懒":从"一笔一画"到"一键生成"的OpenGL渲染进化史


故事续章:你的CAD能导入百万面片了,但一旋转就卡成PPT

你刚刚攻克了内存管理,CAD能加载500MB的STL文件了。你兴奋地向老板演示:一个精美的汽车外壳模型在屏幕上缓缓旋转。

"等等,"老板皱起眉头,"为什么转起来像幻灯片?你看隔壁Blender,同样是百万面片,人家转得跟德芙一样丝滑。"

你愣住了。你不是已经用了最先进的C++、最牛的数据结构了吗?为什么渲染还是卡?

你打开性能分析器,发现一个惊人的事实:你的CPU 70%的时间不是在计算几何,而是在等待和GPU说话。

每一帧,你都这样画图:

cpp 复制代码
for(int i = 0; i < 1000000; i++) {  // 一百万个三角形
    glBegin(GL_TRIANGLES);
    glVertex3f(...);  // 传一个点给GPU
    glVertex3f(...);  // 再传一个点
    glVertex3f(...);  // 再传一个点
    glEnd();
}

你每画一个三角形,就要和GPU打三次招呼。一百万个三角形,就是一千万次"喂饭"。CPU就像一个忙前忙后的保姆,GPU这个"大厨"却大部分时间在等菜下锅。

你意识到,你用的这套方法,是OpenGL "上古时代" 的遗产。要解决性能问题,你必须理解OpenGL这三十年的进化史------这不仅仅是一堆API的堆砌,而是一场关于 "数据如何从CPU搬到GPU,并最终变成像素" 的认知革命。


第一阶段:固定渲染管线------GPU是"黑盒管家"

时间 :1992年 ~ 2004年(OpenGL 1.x)
核心逻辑:管家式服务,你下命令,我干活。

回到你刚学图形学的日子。老师教你的第一行OpenGL代码大概长这样:

cpp 复制代码
glBegin(GL_TRIANGLES);
    glColor3f(1.0f, 0.0f, 0.0f);  // 告诉GPU:接下来用红色
    glVertex3f(-0.5f, -0.5f, 0.0f); // 画第一个点
    glVertex3f( 0.5f, -0.5f, 0.0f); // 画第二个点
    glVertex3f( 0.0f,  0.5f, 0.0f); // 画第三个点
glEnd();

这种方式叫 "立即模式"(Immediate Mode)。OpenGL就像一个全能管家,你每下一个命令,它就立刻执行:

  • glColor3f → "好的,我把笔换成红色。"
  • glVertex3f → "好的,我在这个位置点一个点。"
  • glBegin/glEnd → "好的,我帮你把这三个点连成一个三角形。"

为什么当时用这个?

因为简单。你不用管GPU内部怎么工作,不用写什么着色器代码,不用理解显存。就像你给餐厅服务员点菜:"来一盘红烧肉,多放糖。"你不需要知道厨房里怎么炒。

为什么你画STL模型时"不行了"?

你的汽车外壳有100万个三角形。如果用立即模式,每一帧你都要:

  1. CPU循环100万次。
  2. 每次循环调用3次glVertex3f和若干状态设置函数。
  3. 每次函数调用都是一次 "CPU→GPU通信"

总通信次数:100万 × 3 = 300万次函数调用,每帧。

而且这300万次调用发生在 每一帧 (60fps就是每秒1.8亿次调用)。CPU光忙着给GPU传话,根本没时间做别的事。GPU大部分时间在等CPU说完下一句话------这就是 "CPU瓶颈"

固定管线的另一个问题:你没法"自定义"。

光照怎么算?OpenGL内置了Phong模型。阴影怎么画?对不起,固定管线不支持动态阴影。你想实现一个酷炫的描边效果?想根据法线方向动态改变颜色?固定管线说:"我只提供红烧肉和酸菜鱼,别的菜不会做。"

这就是为什么你发现,用老方法画出来的STL模型,永远是那种"塑料感"------因为光照算法是写死的。

深度扩展:固定渲染管线的技术全景

1. 固定管线的内部流程

OpenGL 1.x的渲染管线是一个严格顺序的黑盒:

复制代码
顶点数据 → 变换与光照(T&L) → 图元装配 → 光栅化 → 纹理采样 → 雾效/混合 → 帧缓冲

每一阶段都由硬件固定逻辑完成,开发者只能通过glEnable/glDisable和少量参数控制。

2. 关键函数剖析

函数 作用 性能代价
glBegin/glEnd 标记一个图元批次的开始和结束 每次调用触发GPU状态机切换
glVertex3f 提交一个顶点(立即被GPU消费) CPU-GPU总线传输开销极大
glNormal3f 设置当前顶点法线(用于光照) 与顶点一一对应,带宽加倍
glTexCoord2f 设置当前顶点纹理坐标 同上

3. 立即模式的内存模型

数据流是"推"模式:CPU主动将每个顶点的数据"推"给GPU。没有显存驻留概念,每一帧都重新推送。这在处理几百个顶点时无感,但工业级模型动辄百万顶点,立刻成为灾难。

4. 显示列表------早期优化尝试

为了缓解立即模式的性能问题,OpenGL 1.1引入了 显示列表(Display List)

cpp 复制代码
GLuint list = glGenLists(1);
glNewList(list, GL_COMPILE);
// ... 在这里画图 ...
glEndList();
// 渲染时:glCallList(list);

显示列表将一组GL命令 录制 下来,存储在GPU驱动层(甚至部分在显存中)。后续渲染只需glCallList,减少了函数调用开销。

局限性

  • 录制后无法修改(静态数据)。
  • 不同厂商实现差异大,某些驱动下反而更慢。
  • 无法利用GPU并行处理的优势(因为数据还是一个个顶点传)。

5. 为什么固定管线最终被淘汰?

  • 硬件发展:GPU从固定功能单元演进为通用并行处理器(SIMD架构),可编程性成为必然。
  • 画质需求:电影级光照、阴影、后处理特效(HDR、Bloom、SSAO)无法用固定公式表达。
  • 性能极限:CPU-GPU通信带宽成为瓶颈,必须将数据常驻显存。

6. 固定管线在今天的残余

OpenGL 3.0引入了 弃用机制(Deprecation) ,OpenGL 3.2+的 Core Profile 完全移除了glBegin/glEnd、显示列表、矩阵堆栈(glMatrixModeglTranslatef等)。但你依然可以通过 Compatibility Profile 使用它们,主要用于维护上古代码。

7. 从固定管线到可编程管线的思维转变

概念 固定管线思维 可编程管线思维
数据 一帧一帧喂给GPU 初始化时上传到显存,常驻
计算 GPU内置公式 开发者编写Shader,完全可控
坐标系 内置模型视图/投影矩阵栈 手动管理矩阵,通过Uniform传递
光照 glLightfv设置参数 在Shader中实现任意光照模型

第二阶段:可编程管线与着色器------GPU从"黑盒"变"白板"

时间 :2004年 ~ 2010年(OpenGL 2.x ~ 3.x早期)
核心逻辑:权力下放,我给你一个"白板",你自己写GPU程序。

"如果能自己控制每个顶点怎么变换、每个像素怎么上色,该多好?"------这是2000年代图形开发者的共同心声。

于是,GLSL(OpenGL Shading Language,着色器语言) 诞生了。从此,GPU不再是只能做固定几道菜的厨师,而是一个可以执行你编写的任意程序的 并行计算机

顶点着色器:改变几何的魔法

你写了一个最简单的顶点着色器:

glsl 复制代码
// vertex_shader.glsl
#version 120
attribute vec3 position;  // 从CPU传来的顶点坐标

void main() {
    gl_Position = gl_ModelViewProjectionMatrix * vec4(position, 1.0);
}

它告诉你:每个顶点都要经过这个程序处理,用内置的模型视图投影矩阵把它变换到屏幕上。

片段着色器:逐像素的绘画板

你又写了一个片段着色器:

glsl 复制代码
// fragment_shader.glsl
#version 120

void main() {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 所有像素都是红色
}

现在,你可以控制每一个像素的颜色了。想根据光照计算颜色?把光源位置作为 uniform 变量传进来,在片段着色器里计算漫反射、高光。想加纹理?传一张图进来,用 texture2D 采样。

你尝试用这套新方法渲染你的STL模型

  1. 你还是用 glVertexPointer 把顶点数组地址告诉OpenGL。
  2. 你还是用 glDrawArrays 一次性提交所有顶点。
  3. 但你现在可以通过Shader实现金属质感、边缘光、甚至卡通渲染。

然而,帧率还是没上去。

你百思不得其解:我已经用了Shader,为什么还是卡?

你用GPU性能分析工具一看,恍然大悟:虽然你一次提交了所有顶点(glDrawArrays),但这些顶点数据 还是存在CPU内存里。每一帧,GPU都要通过PCIe总线从CPU内存把数据拷过来。

100万个顶点 × 每个顶点(位置3个float + 法线3个float)= 24MB数据。

每帧拷24MB,60fps就是每秒1.44GB的PCIe带宽占用。

虽然比立即模式的"每次拷一个点"快了很多,但每一帧都重复拷贝 依然是巨大的浪费。你的STL模型在加载后从未改变,为什么不能让它 常驻在GPU显存里

深度扩展:可编程管线的技术全景

1. 可编程管线的阶段划分

OpenGL 2.0(2004年)正式引入GLSL 1.10,标志着可编程管线的开始。但此时仍兼容固定管线。OpenGL 3.0(2008年)开始标记大量函数为"弃用",OpenGL 3.2(2009年)引入 Core Profile,彻底移除固定管线功能。

2. 着色器类型详解

着色器 作用 输入 输出
顶点着色器 处理每个顶点的空间变换、属性传递 顶点属性(位置、法线、UV等) gl_Positionout变量
片段着色器 处理每个像素(片段)的最终颜色 插值后的in变量、Uniform、纹理 gl_FragColor(或out变量)
几何着色器(OpenGL 3.2+) 在图元级别生成/销毁顶点 完整图元(点/线/三角形) 零个或多个新图元
曲面细分着色器(OpenGL 4.0+) 动态细分曲面,增加几何细节 面片(Patch) 细分后的顶点
计算着色器(OpenGL 4.3+) 通用GPU计算,不直接参与渲染 任意缓冲区/纹理 任意缓冲区/纹理

3. GLSL语言核心概念

  • 存储限定符
    • attribute:顶点属性(仅顶点着色器,GLSL 1.30后改为in)。
    • uniform:全局常量,所有顶点/片段共享(如变换矩阵、光源位置)。
    • varying:顶点着色器输出,经插值后传给片段着色器(GLSL 1.30后改为out/in)。
  • 内置变量
    • gl_Position:顶点着色器必须写入的裁剪空间坐标。
    • gl_FragCoord:片段着色器可读取的屏幕空间坐标。
    • gl_FragDepth:可写入的自定义深度值。
  • 数据类型vec2/3/4mat2/3/4sampler2DsamplerCube等。

4. 客户端顶点数组------过渡方案

在VBO普及之前,开发者使用 客户端顶点数组(Client-side Vertex Arrays)

cpp 复制代码
glEnableClientState(GL_VERTEX_ARRAY);
glVertexPointer(3, GL_FLOAT, 0, vertexArray);
glDrawArrays(GL_TRIANGLES, 0, vertexCount);

这比立即模式快很多,因为只需一次函数调用。但数据仍存在CPU内存,每帧都被DMA到GPU。这是你第二阶段遇到的问题根源

5. Uniform变量与状态管理

着色器通过Uniform变量接收外部数据。设置Uniform需要先获取其位置:

cpp 复制代码
GLint loc = glGetUniformLocation(program, "modelMatrix");
glUniformMatrix4fv(loc, 1, GL_FALSE, &matrix[0][0]);

Uniform值在多次Draw Call之间保持不变,直到被显式修改。这鼓励开发者按材质/模型分组渲染,减少状态切换。

6. 从固定管线迁移到可编程管线的典型陷阱

  • 矩阵堆栈消失glMatrixModeglLoadIdentityglTranslatef等全部失效,必须自己用数学库(如GLM)构建矩阵,并通过Uniform上传。
  • 光照状态失效glLightfvglMaterialfv不再有效,必须在Shader中实现光照方程。
  • 纹理环境消失glTexEnvi的混合模式需在片段着色器中手动计算。

7. 为什么可编程管线仍然不够现代?

核心问题:数据仍在CPU内存和GPU显存之间来回拷贝 。即使你用glVertexPointer一次性提交,每次glDrawArrays都会触发一次DMA传输。对于静态模型(如STL),这是极大的浪费。解决方案是将数据 一次性上传并驻留显存------这就是VBO/VAO的使命。


第三阶段:现代OpenGL------GPU的"内存革命"

时间 :2008年至今(OpenGL 3.0+ Core Profile)
核心逻辑:内存管理至上,数据一次存储,多次使用。

你终于找到了病根:重复的CPU→GPU数据传输

处方也很明确:让数据"住"在GPU里

这就需要引入现代OpenGL的三大核心概念:

VBO:GPU显存里的"数组"

VBO(Vertex Buffer Object,顶点缓冲对象) 就是OpenGL在显存里给你开辟的一块"数组"。你可以把STL模型的所有顶点、法线、纹理坐标一次性拷贝进去,然后它就 一直待在显存里,直到你告诉OpenGL"可以删了"。

cpp 复制代码
// 1. 创建VBO
GLuint vbo;
glGenBuffers(1, &vbo);

// 2. 绑定VBO到GL_ARRAY_BUFFER目标
glBindBuffer(GL_ARRAY_BUFFER, vbo);

// 3. 把CPU内存里的顶点数据拷贝到GPU显存
glBufferData(GL_ARRAY_BUFFER, dataSize, vertexData, GL_STATIC_DRAW);

GL_STATIC_DRAW 告诉OpenGL:"这数据以后不会经常改,你尽管优化。"

从此,每次渲染时,CPU只需要说一句:

cpp 复制代码
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glDrawArrays(GL_TRIANGLES, 0, vertexCount);

GPU直接从自己的显存里读取顶点数据,完全不占用PCIe总线。你的STL模型终于可以流畅旋转了!

VAO:数据"说明书"

但很快你又遇到了新麻烦。你的程序里有很多不同的模型:有的只有位置信息,有的还有法线、颜色、纹理坐标。每次渲染前,你都要用一大堆 glVertexAttribPointer 告诉OpenGL:"第一个VBO里存的是位置,偏移量是0,每个顶点3个float;第二个VBO里存的是法线,偏移量是......"。

这很繁琐,而且容易出错。

于是,VAO(Vertex Array Object,顶点数组对象) 登场了。它就像一个 "数据说明书",记录了:

  • 哪些VBO被绑定了
  • 每个VBO里的数据是怎么排布的(位置?法线?颜色?)
  • 从哪里开始读,步长是多少
cpp 复制代码
// 初始化时:
GLuint vao;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);  // 开始"录制"

glBindBuffer(GL_ARRAY_BUFFER, vbo_position);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);
glEnableVertexAttribArray(0);

glBindBuffer(GL_ARRAY_BUFFER, vbo_normal);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, 0);
glEnableVertexAttribArray(1);

glBindVertexArray(0);  // 结束"录制"

渲染时,只需要一行:

cpp 复制代码
glBindVertexArray(vao);
glDrawArrays(GL_TRIANGLES, 0, vertexCount);

VAO帮你记住了一切配置,代码变得干净、高效。

你的Huhb3D-Viewer标准化流程

现在,你已经完全掌握了现代OpenGL的精髓。在你的STL查看器项目中,你建立了这样的标准流程:

初始化阶段(程序启动时,只执行一次):

  1. 解析STL文件,提取所有顶点坐标和法向量。
  2. 创建并绑定VAO。
  3. 创建VBO,将顶点数据上传至显存(GL_STATIC_DRAW)。
  4. 配置顶点属性指针(glVertexAttribPointer)。
  5. 解绑VAO。

渲染阶段(每一帧):

  1. 绑定Shader程序(glUseProgram)。
  2. 设置Uniform(如MVP矩阵、光源位置)。
  3. 绑定VAO(glBindVertexArray)。
  4. 调用glDrawArrays绘制。
  5. 解绑VAO(可选)。

CPU和GPU各司其职,互不拖累。你的CAD终于可以像商业软件一样流畅处理百万级面片了。

深度扩展:现代OpenGL(Core Profile)技术全景

1. Core Profile vs Compatibility Profile

OpenGL 3.2引入了Profile机制:

  • Core Profile:移除所有弃用功能,强制使用现代方式(VBO/VAO/Shader)。这是新项目的唯一正确选择。
  • Compatibility Profile:保留固定管线函数,允许混用,但不保证性能最优,且未来可能被移除。

2. VBO深度剖析

2.1 内存类型与性能建议

glBufferData的最后一个参数usage是性能提示,驱动据此决定数据放在哪类显存:

提示值 含义 适用场景 显存位置建议
GL_STATIC_DRAW 数据一次修改,多次使用 静态模型(如STL) 显存(VRAM)
GL_DYNAMIC_DRAW 数据多次修改,多次使用 动画顶点 驱动管理,可能用可写VRAM
GL_STREAM_DRAW 数据每次渲染都修改 粒子系统 系统内存,DMA到GPU

2.2 更新VBO数据的正确姿势

如果需要动态更新VBO内容(如变形动画),应使用:

cpp 复制代码
glBufferSubData(GL_ARRAY_BUFFER, offset, size, newData);

这比glBufferData重新分配更高效(避免显存重分配)。

更高级的用法是 双缓冲VBO + fence同步,避免CPU写入时GPU正在读取造成的停顿。

2.3 顶点数据的交错布局(Interleaved)vs 分离布局(Separate)

  • 分离布局:位置VBO + 法线VBO + UV VBO。
  • 交错布局 :一个VBO存储 [pos, normal, uv] 交错排列。

现代GPU更偏好交错布局,因为缓存局部性更好。配置时设置正确的stride即可。

3. VAO深度剖析

3.1 VAO内部状态机

VAO保存的内容包括:

  • glEnableVertexAttribArray / glDisableVertexAttribArray
  • glVertexAttribPointer的所有参数(包括绑定的VBO)
  • glVertexAttribDivisor(实例化渲染用)
  • 索引缓冲(EBO)绑定状态

3.2 常见陷阱

  • 忘记绑定VAO就配置属性:VAO必须在绑定状态下"录制"配置,否则配置将丢失或写入错误的VAO。
  • 在VAO未绑定时调用glEnableVertexAttribArray:这是全局状态,容易污染其他VAO。
  • 多个VAO共享同一个VBO:合法且高效。例如,多个子模型共享同一个顶点数据VBO,但用不同的VAO描述不同属性组合。

4. 索引缓冲与EBO

对于封闭网格,大部分顶点被多个三角形共享。如果直接用glDrawArrays,共享顶点会重复存储。EBO解决这个问题:

cpp 复制代码
GLuint ebo;
glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indexSize, indices, GL_STATIC_DRAW);
// 绘制时:
glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_INT, 0);

EBO也是VAO状态的一部分。绑定VAO后,EBO绑定状态也被记录下来。

5. DSA:直接状态访问(OpenGL 4.5+)

传统VBO/VAO操作需要先绑定到目标,再操作。这导致大量状态切换。OpenGL 4.5引入 DSA(Direct State Access),允许直接操作对象:

cpp 复制代码
glCreateBuffers(1, &vbo);
glNamedBufferStorage(vbo, size, data, GL_DYNAMIC_STORAGE_BIT);
glVertexArrayVertexBuffer(vao, bindingIndex, vbo, offset, stride);

DSA大幅简化代码,减少错误,是未来方向。但需要考虑兼容性(macOS最高支持OpenGL 4.1)。

6. 多VBO与实例化渲染

对于大量重复物体(如1000个螺栓),实例化渲染是必杀技:

cpp 复制代码
// 在VAO中设置每个实例的属性(如变换矩阵)
glVertexAttribDivisor(instanceMatrixLoc, 1);
// 绘制1000个实例
glDrawArraysInstanced(GL_TRIANGLES, 0, vertexCount, 1000);

GPU自动为每个实例复制一份几何数据,并传入不同的实例属性。这正是你CAD中绘制大量相同零件时的最佳实践。

7. 同步与性能陷阱

  • 隐式同步glGet*系列函数会强制CPU等待GPU完成所有之前命令,造成流水线停顿。应完全避免在渲染循环中调用。
  • 状态切换开销glBindVertexArrayglUseProgramglBindTexture等状态切换有一定开销。应尽量按状态分组渲染(例如所有红色材质物体一起绘制)。
  • Draw Call数量 :每次glDrawArrays/glDrawElements都是一次Draw Call。现代GPU能承受数千次,但上万次仍会瓶颈。应使用批处理(Batching)合并多个模型。

8. 向前兼容与现代替代方案

OpenGL正逐渐被 VulkanDirect3D 12 取代。这些新API提供了更底层的显存控制、更少的驱动开销、更好的多线程支持。但学习曲线陡峭。对于大多数CAD应用,OpenGL Core Profile依然是最平衡的选择------性能足够,生态成熟,跨平台好。

9. 在Huhb3D-Viewer中的实践建议

  • 加载阶段:STL文件不包含索引,顶点是独立的(每个三角形3个独立顶点)。你可以直接构建VBO,无需EBO(除非你做顶点去重优化)。
  • 多模型管理 :每个STL文件对应一个VAO + VBO对。用std::unordered_map<string, ModelData>管理。
  • Shader设计:至少需要简单的顶点着色器(MVP变换+法线传递)和片段着色器(基于法线的简单光照)。
  • 性能验证:用RenderDoc抓帧,确认VBO数据只上传一次,Draw Call数量合理,没有隐式同步。

尾声:你现在站在了巨人的肩膀上

你从"每帧给GPU喂饭"的立即模式,一路走到"数据常驻显存"的现代OpenGL。你终于理解了,为什么你写的第一个STL查看器卡成PPT------你用了三十年前的设计模式去处理二十一世纪的数据规模。

现在,当你写 glGenBuffersglBindVertexArray 时,你不再是盲目地复制粘贴代码,而是在做一次精心设计的数据编排:

  • 哪些数据只传一次? → 静态VBO。
  • 哪些数据每帧要变? → Uniform或动态VBO。
  • 如何组织数据让GPU读写最快? → 交错布局、对齐优化。
  • 如何减少状态切换? → 按材质/Shader分组渲染。

而OpenGL的进化史,也让你看到一条软件工程的铁律:任何抽象层的出现,都是为了解决上一个抽象层在特定规模下暴露的痛点。 从固定管线的"方便"到可编程管线的"灵活",再到现代管线的"高效",每一步都是对硬件能力、数据规模、开发者需求的深刻回应。

未来,当你面对Vulkan的复杂配置,或是研究AI渲染(如DLSS)时,你会感激今天你对OpenGL底层机制的理解------因为万变不离其宗,计算机图形学的核心永远是"数据如何高效地变成像素"


相关推荐
郝学胜-神的一滴6 小时前
中级OpenGL教程 001:从Main函数到相机操控的完整实现
c++·程序人生·unity·图形渲染·unreal engine·opengl
AIminminHu1 天前
OpenGL渲染与几何内核那点事-项目实践理论补充(一-3-(1):你的 CAD 终于能联网协作了,但渲染的“内功心法”到底是什么?)
人工智能·opengl
zhooyu5 天前
利用叉乘判断OpenGL中的左右关系
c++·3d·opengl
zhooyu17 天前
GLM中lerp实现线性插值
c++·opengl
智算菩萨18 天前
【OpenGL】10 完整游戏开发实战:基于OpenGL的2D/3D游戏框架、物理引擎集成与AI辅助编程指南
人工智能·python·游戏·3d·矩阵·pygame·opengl
智算菩萨18 天前
【OpenGL】6 真实感光照渲染实战:Phong模型、材质系统与PBR基础
开发语言·python·游戏引擎·游戏程序·pygame·材质·opengl
梵尔纳多22 天前
视角的移动以及模型的平移,旋转,缩放
c++·图形渲染·opengl
( ⩌ - ⩌ )1 个月前
OpenCV实现视频采集
opencv·计算机视觉·opengl
alvin_20051 个月前
python之OpenGL应用(五)变换
python·opengl