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、显示列表、矩阵堆栈(glMatrixMode、glTranslatef等)。但你依然可以通过 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_Position、out变量
片段着色器 处理每个像素(片段)的最终颜色 插值后的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/4、mat2/3/4、sampler2D、samplerCube等。

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. 从固定管线迁移到可编程管线的典型陷阱

  • 矩阵堆栈消失 :glMatrixMode、glLoadIdentity、glTranslatef等全部失效,必须自己用数学库(如GLM)构建矩阵,并通过Uniform上传。
  • 光照状态失效 :glLightfv、glMaterialfv不再有效,必须在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完成所有之前命令,造成流水线停顿。应完全避免在渲染循环中调用。
  • 状态切换开销 :glBindVertexArray、glUseProgram、glBindTexture等状态切换有一定开销。应尽量按状态分组渲染(例如所有红色材质物体一起绘制)。
  • Draw Call数量 :每次glDrawArrays/glDrawElements都是一次Draw Call。现代GPU能承受数千次,但上万次仍会瓶颈。应使用批处理(Batching)合并多个模型。

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

OpenGL正逐渐被 Vulkan 和 Direct3D 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------你用了三十年前的设计模式去处理二十一世纪的数据规模。

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

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

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

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


相关推荐
扶尔魔ocy20 小时前
【QT OpenGL】知识点编目及传送门
qt·opengl·osg
扶尔魔ocy14 天前
【qt openGL入门笔记】OSG实现飞机模型在网格图上飞行并进行态势标绘(四)
qt·opengl·osg
:mnong15 天前
Web 三维可视化技术对比:ParaView Web vs 本项目 wasm + Three.js
前端·webgl·opengl·opencl·科学可视化
扶尔魔ocy16 天前
【qt openGL入门笔记】OSG实现显示飞机模型飞行的点迹(二)
qt·opengl·osg
扶尔魔ocy16 天前
【qt openGL入门笔记】OSG实现飞机模型在网格图上飞行(三)
opengl·osg
扶尔魔ocy17 天前
【qt openGL入门笔记】OSG实现飞机模型在网格图上飞行
qt·opengl·osg
扶尔魔ocy21 天前
【qt openGL入门笔记】FPS相机
qt·opengl
扶尔魔ocy21 天前
qt openGL入门笔记】FPS相机和轨道相机
qt·opengl
扶尔魔ocy23 天前
【qt openGL入门笔记】基础光照
qt·opengl
扶尔魔ocy23 天前
【qt openGL入门笔记】坐标系统和摄像机
qt·opengl