1.GPU是如何知道要渲染对象
2.CPU 怎么知道 GPU 渲染完毕
3.GPU 的显存数据是什么时机上传的
1.GPU是如何知道要渲染对象
csharp
复制代码
GPU是典型的"被动执行设备", 自己不会主动渲染, 所有渲染任务都由CPU通过"命令缓冲区(Command Buffer)"下方, 流程分
四步:
1).CPU准备"渲染材料" + "执行指令"
游戏每帧, CPU会先完成"应用阶段"的工作
a.准备数据: 计算模型的世界坐标, 骨骼动画顶点, 材质参数(比如颜色, 纹理采样器), 将这些数据整理成GPU能识别的格式
b.准备指令: 明确告诉GPU"要做什么", 比如:
- 绑定哪个Shader程序
- 绑定哪个纹理/顶点缓冲区
- 执行DrawCall(绘制命令, 比如"绘制这个模型的1000个三角形")
- 设置渲染目标(比如把画面画到帧缓冲区, 还是RenderTexture)
csharp
复制代码
2).CPU把"指令 + 数据地址"打包到命令缓冲区
CPU不会直接和GPU对话, 而是通过图形驱动, 把上述"指令 + 数据在显存的地址"写入一块GPU可以访问的内存区域(命令缓冲
区)
a.命令缓冲区里不存原始数据(比如顶点, 纹理), 只存"数据的显存地址"和"操作指令"(比如: 绑定地址0x123的纹理)
b.Unity的CommandBuffer类, 就是对底层命令缓冲区的封装 - 可以手动创建CommandBuffer, 添加绘制指令, 再提交给GPU
实现自定义渲染逻辑
csharp
复制代码
3).CPU把命令缓冲区提交到GPU的命令队列
当CPU把一帧的渲染指令都打包进命令缓冲区后, 会通过图形API(比如: Dx12的ExecuteCommandLists), 将命令缓冲区提交
到GPU的命令队列
a.命令队列是GPU内部的"任务排队区", 按"先提交先执行"的顺序处理
b.此时CPU的工作就暂时结束了, 可以去处理下一轮的游戏逻辑, 不用等GPU渲染完
csharp
复制代码
4).GPU主动"取任务 + 执行渲染"
GPU内部有一个"命令处理器(Command Processor)", 会持续轮询命令队列
a.一旦发现队列里有新的命令缓冲区, 就将它取出来
b.按指令顺序执行: 绑定shader -> 绑定显存数据 -> 执行DrawCall -> 逐像素渲染
c.整个过程完全由GPU硬件独立完成, 无需CPU干预
csharp
复制代码
Unity中的直观例子:
脚本中调用Graphics.DrawMesh(mesh, matrix, material, layer), 本质就是让CPU往命令缓冲区里添加了一条"绘制这个"
"Mesh"的指令, 最终提交给GPU执行
2.CPU 怎么知道 GPU 渲染完毕
csharp
复制代码
CPU和GPU是"异步并行工作"的(CPU处理下一帧逻时, GPU还在渲染上一帧), CPU要获知GPU渲染完成,靠的是硬件同步原语,核
心有两种方式:
1).同步等待(阻塞CPU) - 低性能, 仅调试用
这是最直接但最影响性能的方式, 原理是"CPU主动等GPU发完成新号"
a.CPU提交命令缓冲区时, 会创建一个"围栏(Fence)", 将这个围栏和命令缓冲区绑定
b.围栏有两种状态: 未触发(GPU未完成)/已触发(GPU完成)
c.CPU调用WaitForFence()函数, 主动等待围栏状态变为"已触发" - 此时CPU会完全阻塞, 不处理任何任务,直到GPU渲染完毕
d.GPU执行命令缓冲区的所有指令后, 会自动把绑定的围栏标记为"已触发", CPU收到信号后才继续工作
csharp
复制代码
Unity中的对应操作
调用Graphics.WaitForPresent()或AsyncGPUReadback.WaitForCompletion()本质就是触发同步等待, 游戏运行时绝对要避免
csharp
复制代码
2).异步通知(不阻塞CPU) - 高性能, 游戏开发主流
这是最优解, 原理是"GPU完成后主动给CPU发回调信号, CPU无需等待, 可继续处理其他任务"
a.CPU提交命令缓冲区时, 注册一个回调函数, 并绑定一个"信号量(Semaphore)"
b.GPU执行完渲染指令后, 会触发信号量, 并通过图形驱动通知CPU"任务完成"
c.CPU收到信号后, 在空闲时执行回调函数(比如: 处理渲染结果, 读取RenderTexture像素, 更新UI显示)
d.整个过程CPU完全不阻塞, 始终在处理游戏逻辑, 只有收到信号后才花少量时间执行回调
csharp
复制代码
Unity中的核心应用
a.AsyncGPUReadback: 读取显存中RenderTexture的像素数据时, 用AsyncGPUReadback.Request(texture, (request)=>{
if(request.hasError) return; var data = request.GetData<Color>(); }) ------ 这个Lambda表达式就是异步回调函数
GPU读取完成后才会执行
b.URP/HDRP的后处理回调: 比如在渲染完成后执行高斯模糊, 本质也是GPU触发的异步回调
3.GPU 的显存数据是什么时机上传的
csharp
复制代码
显存数据(纹理, 顶点, shader等)的上传时机, 核心原则是"静态资源一次性上传, 动态资源每帧按需上传", 完全由CPU主动
发起, 分两种场景:
1).静态资源(长期复用) - 加载时一次性上传, 常驻显存
静态资源表示"游戏运行中很少变化的资源", 比如场景模型, UI纹理, Shader程序,上传时机资源加载完成后, 第一次使用前
a.CPU从硬盘加载资源(比如AssetBundle加载纹理/模型), 先把压缩数据放到内存
b.CPU调用图形API(比如Dx12 CreateCommittedResources), 向GPU申请一块显存空间
c.CPU通过PCle总线, 把内存中的静态资源数据一次性拷贝到显存(压缩格式, 比如ETC2/ASTC, 无需解压)
d.资源上传完成后, 会在显存中常驻, 直到游戏退出或主动释放(比如Texture2D.Destroy)
e.后续每帧渲染时, CPU只需在命令缓冲区中"绑定显存地址", 无需重复上传
csharp
复制代码
Texture2d.uploadedToGPU - 这个属性为true时, 说明该纹理已经上传到显存, 内存中只保留一份轻量级数据, 原始数据会
被Unity自动释放(节省内存)
2).动态资源(每帧变化) - 每帧提交DrawCall前, 实时上传
动态资源指"每帧都会变化的资源", 比如粒子系统的顶点数据, 骨骼动画的蒙皮顶点, 动态生成的RenderTexture, 上传时机
是"每帧CPU准备渲染指令时, 提交DrawCall前"
a.每帧游戏逻辑阶段, CPU计算动态资源的最新数据(比如粒子的新位置, 骨骼的新姿态), 更新内存中的顶点缓冲区
b.CPU调用"更新显存"指令(比如Dx12的UpdateSubresource), 将内存中更新后的动态数据拷贝到显存的"动态区域"
- 这里的拷贝是"增量拷贝(只传变化的部分, 不是全量)", 减少PCIe总线压力
c.数据上传完成后, CPU才会把"绑定该动态资源"的指令写入命令缓冲区, 提交给GPU
d.GPU渲染时, 直接从显存的动态区域读取最新数据
csharp
复制代码
延迟上传(懒加载) - Unity的默认优化
Unity对静态资源默认采用"延迟上传"策略
a.资源加载后, 不会立刻上传到显存, 而是等"第一次被渲染时"才触发上传
b.比如你加载了一个场景纹理, 但前10帧都没有用到它, Unity不会浪费显存和PCIe带宽区上传, 直到第11帧它被绑定到材质
上, 才会由CPU发起上传
csharp
复制代码
CPU-GPU协作 + 数据上传的一帧流程
a.CPU游戏逻辑阶段: 计算 AI、物理碰撞、更新模型位置, 生成动态顶点数据
b.CPU数据上传阶段
- 静态资源: 若未上传则延迟上传
- 动态资源: 增量拷贝到显存动态区域
c.CPU指令打包阶段: 构建命令缓冲区, 写入"绑定资源 + 执行 DrawCall"指令
d.CPU提交阶段: 把命令缓冲区提交到GPU命令队列, 注册异步回调
e.GPU执行阶段: 从命令队列取指令, 执行渲染, 输出画面到屏幕
f.同步阶段: GPU渲染完成, 触发信号量, CPU执行回调(比如读取渲染结果)
g.回到步骤1, 开始下一帧