揭秘OpenGL状态机:绑定点的秘密
第一步:定义上下文—— 也就是那台机床// // GL 对象定义(仓库里的原料)// classBufferObject{publicbyte[]data;// 实际存储的数据publicintsize;}classTextureObject{publicbyte[]pixels;publicintwidth,height;publicTextureFormatformat;// 采样时的过滤方式等参数publicFilterModefilter;publicColorSample(Vector2uv){// 简化的采样实现intx(int)(uv.x*width);inty(int)(uv.y*height);returnReadPixel(x,y);}}classProgramObject{publicVertexShadervs;publicFragmentShaderfs;// 这个 Program 的所有 uniform 当前值publicDictionaryint,objectuniformValuesnew();// uniform 名字 - location 的映射publicDictionarystring,intuniformLocationsnew();}// // GL 上下文 —— 核心中的核心// 这就是那台机床,所有工位都在这里// classGLContext{// 工位区(Binding Points)// Buffer 工位:每个 target 有一个当前绑定的对象 idpublicDictionaryBufferTarget,uintboundBuffersnew(){{BufferTarget.ARRAY_BUFFER,0},// 0 表示空{BufferTarget.ELEMENT_ARRAY_BUFFER,0},{BufferTarget.UNIFORM_BUFFER,0},// ... 其他 target};// 纹理工位:双层结构!// 外层:工位编号(0, 1, 2, ...);内层:每种 texture target 分别绑publicDictionaryint,DictionaryTextureTarget,uinttextureUnitsnew();// 当前操作指针 —— 决定后续 glBindTexture 作用于哪个工位publicintactiveTextureUnit0;// Shader 工位:同一时间只能有一个 Program 生效publicuintcurrentProgram0;// Framebuffer 工位publicuintcurrentFramebuffer0;// 0 默认屏幕// 状态区(其他 GL 状态)publicbooldepthTestEnabledfalse;publicDepthFuncdepthFuncDepthFunc.LESS;publicboolblendEnabledfalse;publicBlendFactorsrcBlend,dstBlend;publicCullModecullModeCullMode.BACK;// ... 一大堆状态// 仓库区(所有 GL 对象)publicDictionaryuint,BufferObjectallBuffersnew();publicDictionaryuint,TextureObjectallTexturesnew();publicDictionaryuint,ProgramObjectallProgramsnew();publicuintnextObjectId1;// 下一个可分配的 id}// // 全局唯一的 GL 上下文// staticclassGL{publicstaticGLContextctxnewGLContext();}看到没?整个 OpenGL 的所谓状态机,就是这么一个数据结构。第二步:实现glGenXXX(创建原料放进仓库)// 创建 buffer 对象:仅仅是在仓库里登记一个新 idstaticvoidglGenBuffers(intn,outuint[]ids){idsnewuint[n];for(inti0;in;i){uintidctx.nextObjectId;ctx.allBuffers[id]newBufferObject();// 创建一个空 bufferids[i]id;}}// 同理staticvoidglGenTextures(intn,outuint[]ids){idsnewuint[n];for(inti0;in;i){uintidctx.nextObjectId;ctx.allTextures[id]newTextureObject();ids[i]id;}}关键点:glGenBuffers只是分配 id,并没有分配数据存储。数据存储要等到glBufferData才发生。第三步:实现glBindXXX(把原料摆到工位)// 把某个 buffer 摆到某个工位上staticvoidglBindBuffer(BufferTargettarget,uintid){// 就一行:修改工位的字段ctx.boundBuffers[target]id;}// 纹理绑定 —— 注意双层结构staticvoidglActiveTexture(intunit){// 仅仅改当前操作指针,不做别的// unit 是 GL_TEXTURE0 n 这种形式,这里简化为 intctx.activeTextureUnitunit;}staticvoidglBindTexture(TextureTargettarget,uintid){// 用当前指针决定往哪个工位摆intunitctx.activeTextureUnit;// 如果这个工位还没初始化过,先建一个字典if(!ctx.textureUnits.ContainsKey(unit))ctx.textureUnits[unit]newDictionaryTextureTarget,uint();// 摆上去ctx.textureUnits[unit][target]id;}// Shader 绑定staticvoidglUseProgram(uintid){ctx.currentProgramid;}// FBO 绑定staticvoidglBindFramebuffer(FramebufferTargettarget,uintid){ctx.currentFramebufferid;}注意看glBindTexture的实现,你会瞬间理解双层插槽是怎么回事:activeTextureUnit决定哪个工位target决定这个工位的哪个子格子id是要摆的对象如果忘记调用glActiveTexture,activeTextureUnit保持上次的值,新纹理就跑到错误的工位去了。这就是那个经典 bug 的根源。第四步:实现glBufferData/glTexImage2D(通过工位间接操作)这里是最能体现绑定点设计的地方:// 上传数据到 buffer// 注意:参数里没有 buffer id !staticvoidglBufferData(BufferTargettarget,byte[]data,BufferUsageusage){// 第一步:通过工位找到当前绑定的 buffer iduintidctx.boundBuffers[target];if(id0){// 没绑定任何 buffer,报错thrownewGLError(No buffer bound to target);}// 第二步:从仓库找到真正的对象BufferObjectbufferctx.allBuffers[id];// 第三步:操作它buffer.datadata;buffer.sizedata.Length;}// 上传纹理数据 —— 同样通过工位间接操作staticvoidglTexImage2D(TextureTargettarget,intlevel,TextureFormatformat,intwidth,intheight,byte[]pixels){// 第一步:通过当前 texture unit target找到 idintunitctx.activeTextureUnit;uintidctx.textureUnits[unit][target];// 第二步:从仓库找到对象TextureObjecttexctx.allTextures[id];// 第三步:操作它tex.pixelspixels;tex.widthwidth;tex.heightheight;tex.formatformat;}看清楚了吗?glBufferData的参数里根本没有 buffer id!它是通过当前工位上摆着谁来间接找到对象的。这就是绑定点设计的本质:所有操作 先摆好工位 → 再操作当前工位上的东西。第五步:实现glUniform1i(告诉 Shader “去哪个工位取纹理”)// 查询 uniform 的 locationstaticintglGetUniformLocation(uintprogram_id,stringname){varprogramctx.allPrograms[program_id];if(program.uniformLocations.TryGetValue(name,outintloc))returnloc;return-1;}// 设置 uniform 的值staticvoidglUniform1i(intlocation,intvalue){// 用当前绑定的 programvarprogramctx.allPrograms[ctx.currentProgram];program.uniformValues[location]value;}staticvoidglUniformMatrix4fv(intlocation,Matrix4x4mat){varprogramctx.allPrograms[ctx.currentProgram];program.uniformValues[location]mat;}看起来很简单,但魔法即将发生——请看下面 Shader 采样纹理时是怎么用这个整数的。第六步:实现glDrawElements(按下加工按钮)这是最精彩的一段,你会看到工位在这里被一次性全部读取:staticvoidglDrawElements(PrimitiveModemode,intcount,IndexTypetype,intoffset){// 阶段 1:从工位读取所有原料 // 顶点数据uintvboIdctx.boundBuffers[BufferTarget.ARRAY_BUFFER];varvboctx.allBuffers[vboId];// 索引数据uinteboIdctx.boundBuffers[BufferTarget.ELEMENT_ARRAY_BUFFER];vareboctx.allBuffers[eboId];// Shadervarprogramctx.allPrograms[ctx.currentProgram];// Framebuffer(输出目标)varframebufferGetFramebuffer(ctx.currentFramebuffer);// 各种状态vardepthTestctx.depthTestEnabled;varblendctx.blendEnabled;// ...// 阶段 2:顶点着色器阶段 varindicesParseIndices(ebo.data,type,count);varvertexOutputsnewListVertexOutput();foreach(intindexinindices){// 从 VBO 里读一个顶点VertexInputinputReadVertex(vbo,index);// 把 uniform 值传给 shaderprogram.vs.uniformsprogram.uniformValues;// 执行 vertex shader,输出裁剪空间位置VertexOutputoutputprogram.vs.Execute(input);vertexOutputs.Add(output);}// 阶段 3:光栅化 片元着色器 foreachtriangleinvertexOutputs:foreach pixel coveredbytriangle:// 插值得到当前像素的属性(uv、normal 等)FragmentInputfragInputInterpolateAttributes(triangle,pixel);// 给 fragment shader 一个采样纹理的能力// 这个能力需要访问 ctx.textureUnits !program.fs.uniformsprogram.uniformValues;program.fs.sampleTextureSampleTextureImpl;// ← 关键// 执行 fragment shaderColorcolorprogram.fs.Execute(fragInput);// 深度测试、混合、写入 framebufferif(depthTest!DepthTestPass(pixel,framebuffer))continue;if(blend)colorBlend(color,framebuffer.ReadColor(pixel));framebuffer.WriteColor(pixel,color);}// 关键:Fragment Shader 采样纹理的实现 staticColorSampleTextureImpl(intsamplerValue,Vector2uv){// samplerValue 就是通过 glUniform1i 设置进来的整数// 它是纹理工位编号intunitsamplerValue;// 从上下文的纹理工位里,取出实际的纹理 idif(!ctx.textureUnits.ContainsKey(unit))returnColor.Black;// 工位为空uinttexIdctx.textureUnits[unit][TextureTarget.TEXTURE_2D];TextureObjecttexctx.allTextures[texId];// 采样returntex.Sample(uv);}第七步:Shader 里怎么调用采样?伪 Shader 代码:classMyFragmentShader:FragmentShader{publicoverrideColorExecute(FragmentInputinput){// u_diffuse 对应 CPU 侧 glUniform1i(loc, 0) 设置的值// 也就是 uniforms[loc_of_u_diffuse] 0intdiffuseSamplerValue(int)uniforms[loc_of_u_diffuse];// 0intnormalSamplerValue(int)uniforms[loc_of_u_normal];// 1// 通过采样函数(它内部访问 ctx.textureUnits)ColordiffusesampleTexture(diffuseSamplerValue,input.uv);// ↑ 内部逻辑:去 ctx.textureUnits[0] 找纹理 —— 是墙面ColornormalsampleTexture(normalSamplerValue,input.uv);// ↑ 内部逻辑:去 ctx.textureUnits[1] 找纹理 —— 是法线图// ... 光照计算returnfinalColor;}}整条链路清晰了:CPU: glActiveTexture(GL_TEXTURE0) → ctx.activeTextureUnit 0 glBindTexture(TEXTURE_2D, 墙面) → ctx.textureUnits[0][TEXTURE_2D] 墙面_id glUniform1i(u_diffuse_loc, 0) → program.uniforms[u_diffuse_loc] 0 Shader 执行时: sampleTexture(u_diffuse, uv) → 拿到 u_diffuse 的值 0 → 去 ctx.textureUnits[0][TEXTURE_2D] 找 id → 从 ctx.allTextures[id] 取纹理 → 采样“整数 0” 就是那根穿针引线的连线。第八步:完整使用示例来一个画一个带纹理的三角形的完整流程:// 初始化阶段 // 1. 创建 VBOglGenBuffers(1,outuint[]vbos);uintvbovbos[0];glBindBuffer(ARRAY_BUFFER,vbo);// 摆到工位glBufferData(ARRAY_BUFFER,vertexData,...);// 通过工位往对象里写数据// 2. 创建纹理glGenTextures(1,outuint[]texs);uinttextexs[0];glActiveTexture(0);// 指针指向 0 号工位glBindTexture(TEXTURE_2D,tex);// 摆到 0 号工位glTexImage2D(TEXTURE_2D,0,RGBA8,512,512,pixels);// 3. 创建 Shader ProgramuintprogramCompileAndLinkShader(vsSource,fsSource);// 渲染阶段 // 4. 摆好所有工位glUseProgram(program);glBindBuffer(ARRAY_BUFFER,vbo);glActiveTexture(0);glBindTexture(TEXTURE_2D,tex);// 5. 告诉 shader 采样器用哪个工位intlocglGetUniformLocation(program,u_diffuse);glUniform1i(loc,0);// u_diffuse 你去 0 号纹理工位取纹理// 6. 按下加工按钮glDrawElements(TRIANGLES,3,...);通过这段模拟代码,你能推导出的所有真理1. 为什么glBindXXX便宜,glBufferData/glTexImage2D贵?看代码就知道:glBindXXX只是修改一个字段(ctx.boundBuffers[target] id)glBufferData要真正拷贝数据 / 上传到 GPU 内存在真实驱动里,glBind也不是完全免费,因为切换绑定会导致驱动做状态验证、缓存失效,等真的 draw 时需要重新提交。但相比传数据,Bind 便宜多了。2. 为什么 SetPass Call 比 DrawCall 重要?看glDrawElements内部,它读取ctx.currentProgram。Program 切换会导致驱动做一大堆事情:重新绑定所有 Shader 参数验证 attribute 布局可能触发 shader 重编译(如果之前有变体没准备好)所以合批的核心是:尽量让连续的 draw 使用同一个 program,减少glUseProgram的切换次数。3. 为什么 SRP Batcher 快?SRP Batcher 的原理是把每个物体的 uniform 数据(如 model matrix)放到一个大UBO里,画不同物体时只切换UBO 的偏移(便宜),而不是一个个glUniform上传(贵)。对应到模拟代码里:传统方式:每次 draw 前调用几十次glUniformXXX→ 每次都在改program.uniformValues[loc]SRP Batcher:只改一次 UBO 的绑定偏移 → 一次搞定所有 uniform4. 为什么切换 RenderTarget 特别贵?glBindFramebuffer看起来只是改ctx.currentFramebuffer,但在真实的 TBDR 硬件上,它触发一个巨大的操作:把当前 Tile 缓存的数据 Store 到系统内存,然后 Load 新 RT 的数据到 Tile 缓存。这个操作在我们的模拟里被GetFramebuffer()掩盖了,但在真实 GPU 上是几百 KB 到 MB 级别的内存搬运。5. 为什么 VAO 能打包状态?VAO 本质上是把某几个字段状态额外存了一份:classVertexArrayObject{// VAO 记录了:publicuintelementArrayBuffer;// ELEMENT_ARRAY_BUFFER 的绑定publicDictionaryint,VertexAttribattribPointers;// 顶点属性布局}staticvoidglBindVertexArray(uintvaoId){varvaoctx.allVAOs[vaoId];// 还原这一批状态ctx.boundBuffers[ELEMENT_ARRAY_BUFFER]vao.elementArrayBuffer;ctx.vertexAttribsvao.attribPointers;}一次glBindVertexArray 还原一整批工位状态,这就是打包的效果。更深一层:真实驱动的复杂度我上面写的是理想模型。真实的 OpenGL 驱动比这复杂得多,主要复杂度来自:1. CPU 命令 vs GPU 执行是异步的glDrawElements并不是立即执行绘制。它只是把命令放到一个 GPU 命令队列里,真正的执行在几毫秒后由 GPU 完成。这就是为什么glReadPixels(要读回结果)极慢 —— 它会强制 CPU 等 GPU。2. 驱动会做延迟状态提交(Lazy State)驱动内部不会每次glBindXXX就立刻通知 GPU。它会攒到glDraw前才一次性提交变化的状态。这是驱动优化的常见套路。3. 显存管理glBufferData上传数据涉及将 CPU 内存搬到显存。移动端(UMA)可能是零拷贝,PC 上要走 PCIe 总线传输。4. Shader 编译glCreateProgramglLinkProgram只是编译到中间表示,真正的 GPU 机器码编译发生在第一次使用这个 Program 时(所谓的 “shader warmup”)。这就是 Unity Shader 变体预热的原因。建议你做的一个练习拿这段模拟代码,自己用 C# 完整写一遍(或者用 C)。不需要真的画出东西,只要打印日志,能看到状态变化就行。比如实现glBindBuffer时打印:[GL] glBindBuffer(ARRAY_BUFFER, 3) → boundBuffers[ARRAY_BUFFER] 3 [GL] glBufferData(ARRAY_BUFFER, 1024 bytes) → wrote to buffer id3这个过程你走完,再打开真实的 LearnOpenGL 教程,你会有种看穿一切的感觉。所有教程里让你困惑的这个 API 为什么要这样调用,都会瞬间变得理所当然。最后一句你现在建立起来的这个心智模型 ——OpenGL 一堆全局状态 一堆通过全局状态间接工作的 API—— 是 Vulkan、DX12 之所以要推翻OpenGL 的核心原因。因为这种隐式全局状态在多线程、多队列、复杂场景下会成为性能瓶颈。Vulkan 让一切显式化、无状态化、可预打包。从 OpenGL 学到 Vulkan,就是从有黑板的教室到每个人自己带本子的进化。

相关新闻

基于SpringBoot+Vue+小程序的剧本杀管理系统的设计与实现

基于SpringBoot+Vue+小程序的剧本杀管理系统的设计与实现

一、 项目背景与意义随着“剧本杀”等沉浸式社交娱乐活动的兴起,线下门店和线上平台对高效、智能的管理系统需求日益迫切。传统的人工预约、剧本分发、角色分配和结算方式效率低下,易出错,且难以满足玩家对游戏体验和社交互动的更高要求。因此…

2026/10/18 10:26:24 阅读更多 →
2026主流个人知识库工具深度测评

2026主流个人知识库工具深度测评

一、前言数字化时代下,知识积累不再依赖传统纸质记录,在线个人知识库已经成为个人学习、职场办公、内容创作的核心工具。无论是日常学习积累、职场资料归档,还是内容博主干货沉淀、素材整理,一款好用的在线知识库软件,…

2026/10/18 10:26:38 阅读更多 →
基于Spirng+vue+小程序的校园二手平台改管理系统设计与实现

基于Spirng+vue+小程序的校园二手平台改管理系统设计与实现

一、 项目背景与意义 随着高校学生规模的扩大和消费观念的转变,校园内闲置物品(如教材、电子产品、生活用品等)的流转需求日益旺盛。传统的线下交易或QQ群、微信群等非正式渠道存在信息不对称、交易效率低、缺乏信任保障等问题。因此&#x…

2026/10/17 6:21:05 阅读更多 →

最新新闻

CentOS源码编译Python 3.12:避开OpenSSL和系统Python冲突的完整指南

CentOS源码编译Python 3.12:避开OpenSSL和系统Python冲突的完整指南

在 CentOS 上装 Python 3.12,说难不难,说简单也真有不少坑。这篇文章不绕弯子,直接讲我从下载源码到装完跑通的全过程,包括那些文档里不会写的坑,比如 OpenSSL 版本不对导致 ssl 模块装不上、装完 python3 命令还指向旧…

2026/10/18 10:26:43 阅读更多 →
爱普生机器人直连三菱PLC:基于MC协议自定义通信驱动实战

爱普生机器人直连三菱PLC:基于MC协议自定义通信驱动实战

工业现场把爱普生机器人和三菱PLC放在同一个电控柜里配合干活,是再常见不过的场景。但机器人怎么从PLC里高效、批量、实时地拿数据,往往是整个项目里最磨人的一环。IO硬接线简单粗暴,点位一多就变成蜘蛛网;买协议转换网关&#xf…

2026/10/18 10:26:43 阅读更多 →
PSS参数整定不再靠试凑:MFO算法+Simulink实现自动化优化与对比

PSS参数整定不再靠试凑:MFO算法+Simulink实现自动化优化与对比

简介:面向电力系统动态稳定性研究的MATLAB原创资源包,提供基于多目标遗传算法(MFO)的电力系统稳定器(PSS)参数优化实现。压缩包共29个文件、149KB,其中16个m脚本负责核心算法与仿真控制&#xf…

2026/10/18 10:26:43 阅读更多 →
Postman面试指南:从环境变量到Newman的接口测试体系

Postman面试指南:从环境变量到Newman的接口测试体系

最近帮团队做了好几轮后端和测试方向的面试,几乎每一位候选人的简历里都写着"熟练使用Postman",但能围绕Postman面试问题聊到点子上的,确实不多。多数人停在"会发请求、会看返回"这个层面,一旦被问到"你…

2026/10/18 10:26:43 阅读更多 →
Opus 5.5:用代码定义动效视频的数学本质

Opus 5.5:用代码定义动效视频的数学本质

1. “用代码拍视频”不是玄学:Opus 5.5 动效视频的本质是一场精准的时序控制实验你有没有试过在剪辑软件里拖动关键帧滑块,反复微调0.3秒的缩放曲线,只为让一个文字弹入的节奏“刚好不生硬”?我试过——在某次交付前夜&#xff0c…

2026/10/18 10:26:43 阅读更多 →
手机号脱敏实战:正则表达式与多语言实现方案

手机号脱敏实战:正则表达式与多语言实现方案

手机号脱敏这块需求,几乎所有做业务系统开发的人都碰到过。无论是用户列表展示、订单详情、聊天记录,还是后台审计日志,总有个地方需要把手机号中间四位打上星号再展示出来。今天就把这个需求从方案选型到代码实现、再到生产环境里的各种坑&a…

2026/10/18 10:25:43 阅读更多 →

日新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/18 0:00:55 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/18 0:00:55 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/18 0:00:55 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/18 0:00:55 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/18 0:00:55 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/18 0:00:55 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 0:55:02 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/17 17:22:31 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 14:23:36 阅读更多 →