OpenGL渲染与几何内核那点事-项目实践理论补充(一-3-(6):从“搬砖”到“无人仓”:一个CAD极客的OpenGL性能压榨史,连AI都看呆了——给图形学新手的VBO/VAO全攻略)
@TOC
代码仓库入口:
- github源码地址。
- gitee源码地址。
系列文章规划:
- OpenGL渲染与几何内核那点事-项目实践理论补充(一-1-(8)-番外篇:当你的 CAD 遇上“活”的零件)
- OpenGL渲染与几何内核那点事-项目实践理论补充(一-2-(1)-当你的CAD想“联网”时:从单机绘图到多人实时协作)
- OpenGL渲染与几何内核那点事-项目实践理论补充(一-2-(2)-当你的CAD需要处理“百万个螺栓”时:从内存爆炸到丝般顺滑)
- OpenGL渲染与几何内核那点事-项目实践理论补充(一-3-(1):你的 CAD 终于能联网协作了,但渲染的“内功心法”到底是什么?)
- OpenGL渲染与几何内核那点事-项目实践理论补充(一-3-(2):当你的CAD学会“偷懒”:从“一笔一画”到“一键生成”的OpenGL渲染进化史)
- OpenGL渲染与几何内核那点事-项目实践理论补充(一-3-(3):GPU 着色器进化史:从傻瓜相机到 AI 画师,你的显卡里藏着一场战争)
巨人的肩膀:
- deepseek
- gemini
你的CAD渲染器终于不卡了,但还能再快吗?
你的CAD软件已经能流畅显示几百个零件了。用户拖动视角时,帧率稳定在60帧,你正沾沾自喜。直到某天,一个大客户发来一个包含5万个螺栓的装配体文件,你的渲染循环瞬间掉到15帧。
你打开性能分析器,发现CPU有一个核心一直在100%忙碌,而GPU却在悠闲地喝茶。你猛然意识到:瓶颈不在显卡,而在CPU给GPU“喂数据”的方式太落后了。
你决定从头梳理——图形程序到底是怎么把一堆顶点数据塞给显卡的?为什么你的方式如此低效?
于是你穿越回OpenGL诞生之初,重走了一遍“物流革命”之路。
第一阶段:原始的“搬砖工”模式(Immediate Mode)
第一个开发者:我想画个三角形,最简单的方法是什么?
你翻开1992年的OpenGL 1.0手册,看到了这样的代码:
glBegin(GL_TRIANGLES);glVertex3f(0.0f,0.5f,0.0f);// 顶点0glVertex3f(-0.5f,-0.5f,0.0f);// 顶点1glVertex3f(0.5f,-0.5f,0.0f);// 顶点2glEnd();你心想:这也太直观了吧!每画一个顶点就调用一次函数,就像用画笔在屏幕上点。对于刚学图形编程的人来说,这简直是恩赐。
于是你的CAD渲染器里充满了这样的代码:
// 遍历所有实体,每个实体的每个面都这样画for(auto&entity:entities){for(auto&triangle:entity.mesh.triangles){glBegin(GL_TRIANGLES);glColor3f(triangle.color.r,triangle.color.g,triangle.color.b);glVertex3f(triangle.v0.x,triangle.v0.y,triangle.v0.z);glVertex3f(triangle.v1.x,triangle.v1.y,triangle.v1.z);glVertex3f(triangle.v2.x,triangle.v2.y,triangle.v2.z);glEnd();}}问题很快暴露
当你试图渲染5000个三角形(这在CAD里只是一两个复杂零件的量)时,帧率掉到了个位数。
原因剖析:
- 每个
glVertex3f都是一次CPU到GPU的函数调用。5000个三角形就是15000次调用。 - CPU发指令的速度受限于总线延迟,而GPU处理三角形的速度远超这个。
- GPU大部分时间在等待CPU说“下一个顶点是…”
这就像你要盖一座摩天大楼,但每次只能用手从远处的砖厂搬一块砖过来。工人(GPU)大部分时间在等砖头,效率极低。
你意识到:这种“立即模式”只适合教学演示,绝不能用于工业级软件。
第二阶段:批量运输(Vertex Arrays)
第二个开发者:既然一块一块搬太慢,我把砖头装一车再送过去!
你研究OpenGL 1.1引入的顶点数组(Vertex Arrays),代码变成了这样:
// 先把所有顶点数据装进CPU内存的数组floatvertices[]={// 三角形10.0f,0.5f,0.0f,-0.5f,-0.5f,0.0f,0.5f,-0.5f,0.0f,// 三角形2 ...};// 启用顶点数组功能glEnableClientState(GL_VERTEX_ARRAY);// 告诉OpenGL数据在哪glVertexPointer(3,GL_FLOAT,0,vertices);// 一次性绘制多个三角形glDrawArrays(GL_TRIANGLES,0,vertexCount);glDisableClientState(GL_VERTEX_ARRAY);改进之处:
- 函数调用次数从O(N)降为O(1)。
- 数据可以预先组织好,GPU可以批量处理。
你把CAD渲染器改成这样后,5000个三角形的帧率从8帧提升到了25帧。你欣喜若狂。
新问题:每一帧都在“重新发货”
但当你把模型增加到5万个三角形时,帧率又掉到了15帧。你分析发现:
while(!glfwWindowShouldClose(window)){// 每一帧!glVertexPointer(3,GL_FLOAT,0,vertices);// 重新告诉GPU数据在哪glDrawArrays(GL_TRIANGLES,0,vertexCount);// 触发CPU到GPU的数据拷贝}致命缺陷:虽然一次发一车,但每一帧你都要从CPU内存(RAM)把这车砖头重新拉到GPU显存(VRAM)去。5万个三角形 = 45万个浮点数 = 1.8MB数据。60帧/秒 = 108MB/秒的总线传输。
这在PCIe 3.0时代可能还行,但当你面对500万个三角形(现代CAD装配体的常规规模)时,每秒需要传输10GB数据,这已经接近总线带宽极限了。更何况,这些几何数据根本没变过!
你开始思考:既然砖头(顶点数据)几乎不变,为什么不能直接把它们存放在显卡的仓库(显存)里?
第三阶段:建立仓库——VBO(Vertex Buffer Objects)
第三位开发者:OpenGL 1.5给了我答案
你发现OpenGL 1.5引入了VBO(Vertex Buffer Object)。它的核心思想是:在GPU显存中开辟一块专属区域,数据只上传一次,之后渲染时GPU直接从自己的“私人仓库”取货,CPU完全解放。
你的代码演变成这样:
// 1. 创建VBO(在显存中申请一块地)GLuint vbo;glGenBuffers(1,&vbo);// 2. 绑定VBO(告诉OpenGL接下来操作这个仓库)glBindBuffer(GL_ARRAY_BUFFER,vbo);// 3. 上传数据(一次性的!)glBufferData(GL_ARRAY_BUFFER,sizeof(vertices),vertices,GL_STATIC_DRAW);// 4. 渲染循环while(!glfwWindowShouldClose(window)){glBindBuffer(GL_ARRAY_BUFFER,vbo);// 切换到我的仓库glVertexPointer(3,GL_FLOAT,0,0);// 告诉GPU数据格式(还是在解说!)glDrawArrays(GL_TRIANGLES,0,vertexCount);}性能飞跃:
- 数据传输从“每帧108MB”降为“整个生命周期只传一次”。
- 5万个三角形的帧率从15帧飙升到55帧。
- GPU现在可以全速运行,因为它不用再等CPU慢悠悠地拷贝数据。
你把CAD渲染器全部改用VBO,加载一个500MB的汽车模型,内存占用降了一半(因为显存里只存一份,RAM里的原始数据可以释放),渲染帧率稳定在60帧。
VBO的细节优化:仓库分类管理
作为追求极致的开发者,你开始研究glBufferData的最后一个参数——usage hint(使用提示)。这不是装饰,而是给显卡驱动的重要信号:
| Hint值 | 含义 | 适用场景 | 驱动可能的优化 |
|---|---|---|---|
GL_STATIC_DRAW | 数据设置一次,多次使用 | 静态模型(如建筑物、螺栓) | 放在最快速的显存区域 |
GL_DYNAMIC_DRAW | 数据经常修改,多次使用 | 变形动画、动态地形 | 放在CPU可快速写入的区域 |
GL_STREAM_DRAW | 数据每帧修改,只用一次 | 粒子系统、临时几何 | 使用环形缓冲区,避免分配开销 |
你根据场景正确设置:
- 螺栓库:
GL_STATIC_DRAW - 用户正在拖拽的夹点:
GL_DYNAMIC_DRAW - 临时显示的选择框:
GL_STREAM_DRAW
这一小改动,让拖拽夹点时的延迟从30ms降到8ms,用户直呼“跟AutoCAD一样顺滑”。
新痛点浮现:每次渲染都要“解说”数据格式
你又发现了一个烦人的事情:
glBindBuffer(GL_ARRAY_BUFFER,vbo);glVertexPointer(3,GL_FLOAT,0,0);// 解说坐标glEnableClientState(GL_VERTEX_ARRAY);glBindBuffer(GL_ARRAY_BUFFER,normalVBO);glNormalPointer(GL_FLOAT,0,0);// 再解说法线glEnableClientState(GL_NORMAL_ARRAY);glBindBuffer(GL_ARRAY_BUFFER,colorVBO);glColorPointer(3,GL_FLOAT,0,0);// 再解说颜色glEnableClientState(GL_COLOR_ARRAY);每个模型都有位置、法线、颜色、纹理坐标等多个属性,每次绘制前你都得重新解说一遍:
- “坐标从第0字节开始,每12字节一个顶点”
- “法线从第0字节开始,每12字节一个”
- …
问题本质:
- 这种“解说”(
glVertexPointer/glEnableClientState)是有驱动开销的——驱动要验证参数合法性、设置硬件寄存器。 - 如果你的场景有1000个不同的零件(每个零件都有自己的VBO组合),每帧你就要解说1000次。驱动层的CPU占用飙到30%。
- 更糟的是,这些解说词永远不会变——一个螺栓的顶点格式从生到死都是固定的。
你心想:能不能把这些解说词也录下来,存到GPU状态机里?
第四阶段:自动化管理——VAO(Vertex Array Objects)
第四位开发者:OpenGL 3.0带来了终极方案
OpenGL 3.0核心模式引入了VAO(Vertex Array Object)。它不是一个新功能,而是一个状态缓存容器——专门用来记录VBO的绑定关系和属性指针格式。
工作机制就像“录制宏”:
// 初始化阶段:创建并配置VAO(只做一次!)GLuint vao;glGenVertexArrays(1,&vao);glBindVertexArray(vao);// 开始录制// 录制:绑定VBO并设置属性指针glBindBuffer(GL_ARRAY_BUFFER,vbo);glVertexAttribPointer(0,3,GL_FLOAT,GL_FALSE,3*sizeof(float),(void*)0);glEnableVertexAttribArray(0);glBindBuffer(GL_ARRAY_BUFFER,normalVBO);glVertexAttribPointer(1,3,GL_FLOAT,GL_FALSE,3*sizeof(float),(void*)0);glEnableVertexAttribArray(1);// ... 录制颜色、纹理坐标等glBindVertexArray(0);// 停止录制// ===== 渲染循环:简洁到令人发指 =====while(!glfwWindowShouldClose(window)){glBindVertexArray(vao);// 一行恢复所有状态!glDrawArrays(GL_TRIANGLES,0,vertexCount);glBindVertexArray(0);}性能与架构的双重革命
你把CAD渲染器迁移到VAO后,观察到:
CPU侧性能提升:
- 绘制调用的驱动开销从“每次解说15个函数调用”降为“一次VAO绑定”。
- 1000个零件的场景,CPU渲染线程占用从35%降到8%。
- 这意味着你可以在主线程做更多事情——比如更精细的视锥剔除、物理模拟。
代码质量提升:
- 初始化代码和渲染代码彻底分离。
- 渲染循环里只有
glBindVertexArray和glDrawXXX,极简且不易出错。 - 新增一个模型只需创建对应的VAO,渲染时无脑切换。
内存管理优化:
- VAO本身在驱动层只占用很小的状态内存(通常是几KB),但它的价值在于减少了CPU的指令流。
VAO的“高端玩法”:交错存储与多VAO策略
作为精英开发者,你不再满足于基础用法。你开始研究:
1. 交错顶点数据(Interleaved Attributes)
之前你为位置、法线、颜色分别建VBO,导致GPU要跳转三次显存地址才能读完一个顶点的所有属性——缓存命中率低。
你把所有属性打包到一个VBO中,交错排列:
structVertex{floatpx,py,pz;// 位置floatnx,ny,nz;// 法线floatr,g,b;// 颜色};// 一个VBO存所有glBufferData(GL_ARRAY_BUFFER,sizeof(Vertex)*count,vertices,GL_STATIC_DRAW);// VAO录制时,用stride和offset精确定位glVertexAttribPointer(0,3,GL_FLOAT,GL_FALSE,sizeof(Vertex),(void*)offsetof(Vertex,px));glVertexAttribPointer(1,3,GL_FLOAT,GL_FALSE,sizeof(Vertex),(void*)offsetof(Vertex,nx));glVertexAttribPointer(2,3,GL_FLOAT,GL_FALSE,sizeof(Vertex),(void*)offsetof(Vertex,r));这样,GPU读取一个顶点时,所有属性都在同一缓存行里,访存效率提升30%。
2. 多个VAO的切换策略
你的CAD场景有1000个不同零件,每个都有自己的VAO。渲染时:
for(auto&part:visibleParts){glBindVertexArray(part.vao);glDrawElements(GL_TRIANGLES,part.indexCount,GL_UNSIGNED_INT,0);}你甚至可以根据材质排序VAO的渲染顺序,减少着色器切换开销。
3. VAO与实例化渲染结合
对于5万个相同的螺栓,你不再创建5万个VAO(那会耗尽驱动内存),而是一个VAO + 实例化:
glBindVertexArray(boltVao);glDrawElementsInstanced(GL_TRIANGLES,boltIndexCount,GL_UNSIGNED_INT,0,50000);50000个螺栓,一次DrawCall搞定。你的CAD渲染器现在可以流畅显示百万级零件的装配体。
总结对照表:物流革命的四个时代
| 阶段 | 技术 | 数据存储位置 | 每帧CPU→GPU传输 | 驱动调用开销 | 适用场景 |
|---|---|---|---|---|---|
| V1 | Immediate Mode | CPU寄存器 | 每个顶点一次 | 极高 | 教学演示(已废弃) |
| V2 | Vertex Arrays | CPU内存(RAM) | 整个数组每帧拷贝 | 高 | 简单2D游戏(过时) |
| V3 | VBO | GPU显存(VRAM) | 一次性 | 中(需每次解说格式) | 中小规模3D场景 |
| V4 | VAO | GPU状态机 | 一次性 | 极低 | 现代OpenGL核心,任何工业级应用 |
一句话精髓:
- VBO是数据仓库:把砖头(顶点)永久存放在工地(显存)。
- VAO是仓库管理员:它记得每堆砖头的摆放规则(顶点格式),你只需喊一声“3号仓库”,所有配置自动到位。
深度扩展:VBO/VAO完全技术手册
(以下内容为专业开发者进阶必读,涵盖API细节、性能调优、陷阱与最佳实践)
1. VBO深度剖析
1.1 核心API详解
函数 参数说明 作用 glGenBuffers(GLsizei n, GLuint* buffers)n: 生成数量,buffers: 返回的ID数组在驱动层分配VBO句柄 glBindBuffer(GLenum target, GLuint buffer)target: 绑定目标(GL_ARRAY_BUFFER、GL_ELEMENT_ARRAY_BUFFER等),buffer: VBO ID将VBO设置为当前操作的缓冲区 glBufferData(GLenum target, GLsizeiptr size, const void* data, GLenum usage)size: 字节大小,data: 数据指针(可为nullptr),usage: 使用提示分配显存并(可选)上传数据 glBufferSubData(GLenum target, GLintptr offset, GLsizeiptr size, const void* data)offset: 偏移量,size: 更新大小更新VBO的部分数据(避免重新分配) glMapBuffer(GLenum target, GLenum access)/glUnmapBufferaccess:GL_READ_ONLY/GL_WRITE_ONLY/GL_READ_WRITE将VBO映射到CPU地址空间,实现零拷贝写入 glDeleteBuffers(GLsizei n, const GLuint* buffers)释放VBO显存 1.2 绑定目标(Target)详解
GL_ARRAY_BUFFER:存储顶点属性(位置、法线、颜色等)。GL_ELEMENT_ARRAY_BUFFER:存储索引(用于glDrawElements),减少重复顶点。GL_UNIFORM_BUFFER:存储Uniform变量,多个着色器共享。GL_SHADER_STORAGE_BUFFER:通用的着色器读写缓冲区(OpenGL 4.3+),用于计算着色器。GL_PIXEL_PACK_BUFFER/GL_PIXEL_UNPACK_BUFFER:异步纹理传输。GL_COPY_READ_BUFFER/GL_COPY_WRITE_BUFFER:GPU内部数据拷贝。1.3 Usage Hint的硬件影响
Hint 驱动行为推测 最佳显存位置 写入性能 GL_STATIC_DRAW数据只写一次,GPU读多次 设备本地内存(VRAM) 慢(只写一次无所谓) GL_DYNAMIC_DRAW数据反复写,GPU反复读 可写合并内存(Write-Combined) 较快 GL_STREAM_DRAW数据写一次,GPU读一次(然后丢弃) 环形缓冲区 最快(但容量小) GL_STATIC_READGPU写一次,CPU读多次 系统内存映射区 CPU读取快 GL_DYNAMIC_READGPU反复写,CPU反复读 系统内存 CPU读写平衡 1.4 内存对齐与顶点属性
GPU访问顶点数据时,要求属性在VBO中满足特定的对齐规则。例如,
vec3通常对齐到16字节(而非12字节),因为GPU的向量寄存器是128位宽的。不正确的对齐会导致隐式的内存拷贝,降低性能。最佳实践:
// 错误:vec3紧密排列,第二个顶点的位置从第12字节开始structBadVertex{floatpx,py,pz;// 12字节floatnx,ny,nz;// 12字节(错位!)};// 正确:使用alignas或显式填充structalignas(16)GoodVertex{floatpx,py,pz,pad1;// 16字节floatnx,ny,nz,pad2;// 16字节};1.5 无绑定渲染(Bindless)——超越VBO
OpenGL 4.5+ 和 Vulkan 支持Bindless Textures/Buffers,允许着色器通过64位句柄直接访问任意VBO,无需先
glBindBuffer。这进一步减少了驱动开销,是实现完全GPU-Driven渲染的基石。#extension GL_NV_shader_buffer_load : enable layout(std430, binding = 0) buffer VertexBuffer { vec4 positions[]; } vertexBuffers[]; // 在着色器中通过索引访问任意VBO vec4 pos = vertexBuffers[bufferIndex].positions[vertexId];2. VAO深度剖析
2.1 核心API详解
函数 作用 glGenVertexArrays(GLsizei n, GLuint* arrays)创建VAO句柄 glBindVertexArray(GLuint array)绑定VAO,后续的顶点属性设置将记录到此VAO中 glDeleteVertexArrays(GLsizei n, const GLuint* arrays)删除VAO 关键点:VAO必须与VBO配合使用。VAO本身不存储数据,它只存储:
glEnableVertexAttribArray/glDisableVertexAttribArray的状态glVertexAttribPointer设置的格式(包括stride、offset、type)- 绑定的
GL_ELEMENT_ARRAY_BUFFER(索引缓冲区)2.2 顶点属性指针(VertexAttribPointer)完全解析
glVertexAttribPointer(GLuint index,// 着色器中layout(location=index)GLint size,// 每个顶点分量数(1,2,3,4)GLenum type,// GL_FLOAT, GL_INT, GL_UNSIGNED_BYTE等GLboolean normalized,// 是否归一化(用于颜色等)GLsizei stride,// 两个顶点之间的字节跨度constvoid*pointer);// 首个属性在VBO中的偏移量归一化(Normalized)详解:
- 当
type为GL_UNSIGNED_BYTE且normalized=GL_TRUE时,值0-255映射为0.0-1.0浮点数。这对于顶点颜色非常有用——用一个字节存储颜色分量,节省75%显存。- 当
type为GL_INT且normalized=GL_TRUE时,用于传递骨骼索引等。2.3 整数顶点属性(glVertexAttribIPointer)
对于传递到着色器
int/uint类型输入的属性(如材质ID、骨骼索引),必须使用glVertexAttribIPointer而非glVertexAttribPointer,否则数据会被错误地转换为浮点数。glVertexAttribIPointer(location,size,GL_UNSIGNED_INT,stride,offset);2.4 双重精度属性(glVertexAttribLPointer)
OpenGL 4.1+ 支持将
double精度顶点数据直接传入着色器的dvec2/dvec3/dvec4输入,用于需要高精度的科学计算或地理信息系统(GIS)。2.5 分离属性格式(Separate Attribute Format)
OpenGL 4.3+ 引入了
glVertexAttribFormat和glVertexAttribBinding,将数据格式与缓冲区绑定解耦。这允许一个VAO使用多个VBO时更灵活:// 设置属性0的格式(但不指定从哪个VBO取数据)glVertexAttribFormat(0,3,GL_FLOAT,GL_FALSE,0);// 将属性0绑定到“绑定槽0”glVertexAttribBinding(0,0);// 将VBO绑定到绑定槽0glBindVertexBuffer(0,vbo,0,sizeof(Vertex));2.6 VAO的性能陷阱与调试
- 陷阱1:在Core Profile下,
glVertexAttribPointer在没有VAO绑定时会产生OpenGL错误。必须先在VAO内操作。- 陷阱2:VAO会捕获
GL_ELEMENT_ARRAY_BUFFER的绑定。这意味着切换VAO时,索引缓冲区也随之切换。如果你不小心在错误的地方绑定了EBO,会导致难以调试的崩溃。- 调试技巧:使用
glGetIntegerv(GL_VERTEX_ARRAY_BINDING, ¤tVAO)检查当前VAO。使用RenderDoc或NVIDIA Nsight查看VAO的完整状态。2.7 VAO与多线程渲染
在OpenGL中,VAO(和所有GL对象)不是线程安全的。多线程渲染的两种模式:
- 共享上下文(Shared Context):多个上下文共享VAO(和纹理、VBO),但需要自行同步。
- 无状态渲染(Stateless Rendering):使用Direct State Access(DSA)扩展(OpenGL 4.5+),在不绑定VAO的情况下设置状态,更适合命令列表式的多线程构建。
2.8 VAO的未来:消失还是进化?
在Vulkan和DirectX 12中,不再有VAO的概念,取而代之的是Pipeline State Object(PSO)+Vertex Input State。输入装配阶段的状态被完全烘焙到PSO中,切换不同顶点格式时需要切换PSO。这种方式虽然更繁琐,但消除了驱动层的实时验证开销,实现了极致的CPU性能。OpenGL的VAO可以看作是PSO在驱动层的一种“软实现”。
3. 从OpenGL到Vulkan的演进视角
OpenGL概念 Vulkan对应 说明 VBO VkBuffer存储顶点数据 VAO VkPipeline+VkPipelineVertexInputStateCreateInfo顶点输入格式烘焙到管线状态 glDrawArraysvkCmdDraw记录到命令缓冲区 隐式状态机 显式命令缓冲区 Vulkan要求应用管理所有状态 关键差异:在Vulkan中,你无法在运行时动态改变顶点格式(除非创建新管线)。这迫使开发者预先规划好所有顶点布局,虽然初期工作量大,但避免了运行时验证开销,更适合高性能CAD渲染。
4. 工业级CAD渲染器的最佳实践清单
- 永远使用VAO+VBO组合(Core Profile强制要求)。
- 为静态几何体使用
GL_STATIC_DRAW。- 为动态编辑的几何体使用
glMapBuffer或glBufferSubData,避免glBufferData重新分配。- 交错存储顶点属性,提升缓存局部性。
- 为相同材质/格式的模型复用VAO(如果顶点布局相同,可以绑定不同VBO到同一个VAO——但需小心,VAO会记录VBO绑定,建议一个VAO对应一组固定的VBO组合)。
- 使用
glDrawElements减少顶点重复,尤其是CAD模型中有大量共享边的情况。- 开启
GL_PRIMITIVE_RESTART,用单个索引缓冲区绘制多个不连续条带。- 使用无绑定纹理(Bindless)减少纹理切换开销。
- 采用多重间接绘制(Multi-Draw Indirect),将DrawCall参数放在GPU缓冲区中,实现CPU零介入的绘制。
- 调试时用
glObjectLabel给VAO/VBO命名,方便在RenderDoc中定位。掌握了这些,你就从一个“会调用API的开发者”蜕变为真正理解GPU驱动开销与流水线优化的图形架构师。你的CAD渲染器,终于能在百万面片的工业模型中稳定跑满60帧,甚至有余力开启实时光线追踪。
如果想了解一些成像系统、图像、人眼、颜色等等的小知识,快去看看视频吧 :
- 抖音:数字图像哪些好玩的事,咱就不照课本念,轻轻松松谝闲传
- 快手:数字图像哪些好玩的事,咱就不照课本念,轻轻松松谝闲传
- B站:数字图像哪些好玩的事,咱就不照课本念,轻轻松松谝闲传
- 认准一个头像,保你不迷路:
- 认准一个头像,保你不迷路:
您要是也想站在文章开头的巨人的肩膀啦,可以动动您发财的小指头,然后把您的想要展现的名称和公开信息发我,这些信息会跟随每篇文章,屹立在文章的顶部哦
