2.1 渲染管线:CPU 提交 vs GPU 执行


文档摘要

2.1 渲染管线:CPU 提交 vs GPU 执行 本节摘要:WebGL 渲染管线是把顶点变成屏幕像素的固定顺序:顶点着色、图元装配、光栅化、片元着色、逐片元测试与混合、写入帧缓冲。顺序不能跳,但真正耗时的位置随数据路径而变。CPU 在 draw 之前完成上传与状态设置,GPU 在 draw 之后(或异步重叠地)执行可编程阶段。把整条管线都当成“GPU 的事”,会把 CPU 侧的绑定风暴误诊成着色器太重。

2.1 渲染管线:CPU 提交 vs GPU 执行

本节摘要:WebGL 渲染管线是把顶点变成屏幕像素的固定顺序:顶点着色、图元装配、光栅化、片元着色、逐片元测试与混合、写入帧缓冲。顺序不能跳,但真正耗时的位置随数据路径而变。CPU 在 draw 之前完成上传与状态设置,GPU 在 draw 之后(或异步重叠地)执行可编程阶段。把整条管线都当成“GPU 的事”,会把 CPU 侧的绑定风暴误诊成着色器太重。

先说结论

阅读完本节,你应当能够:

  1. 按顺序列出 WebGL 管线主要阶段,并标出哪些在 CPU、哪些在 GPU
  2. 说明一次 drawArraysdrawElements 提交的是命令,不是“立刻画完”
  3. 对照固定阶段与可编程阶段:测试混合仍在硬件约定里,着色公式在你的程序里
  4. 用一帧时间线解释为何 CPU 绑定过多时 GPU 会空转
  5. 指出引擎的 render() 内部仍是在走同一条管线

管线是顺序,账单在两端

教科书把管线画成从左到右的箭头:顶点进去,像素出来。这张图没错,但缺了作者:箭头上半段的“准备”是 CPU 在主线程(或 Worker)上做的,下半段的“执行”才是 GPU。浏览器还插了一层:命令被记录进上下文,驱动再把它们送到硬件。你在 JavaScript 里看到的 draw* 返回,只表示命令已提交,不表示像素已呈现。

一个有用的类比是餐厨传票。CPU 是后厨备料:切好顶点、调好酱料(Uniform)、把盘子(纹理)放到窗口。draw 是敲铃。GPU 是炉灶:按菜谱(着色器)炒、装盘、端到大厅(帧缓冲)。备料慢时炉灶空烧;菜谱太复杂时大厅在等。两者都叫“卡顿”,药方相反。

CPU 线程 命令队列 GPU 准备缓冲/纹理 设置程序与状态 draw ────────────────► 记录绘制 ──────────► 顶点着色 requestAnimationFrame 光栅 呈现 片元着色 测试混合

WebGL 把可编程切口只开了两处:顶点着色器、片元着色器。几何着色器、曲面细分、计算着色器不在合同里。所以“高级一点的变形”要么在 CPU 算完当顶点上传,要么在 VS 里用公式扭,要么等 WebGL2 的变换反馈把 GPU 结果写回缓冲。管线短,不是能力差的辱骂,是选型时要承认的边界。

按阶段对照:谁在干活,输入输出是什么

阶段 主要执行者 输入 输出 你能自定义吗
上传与绑定 CPU JS 数组、图片、矩阵 GPU 对象与当前状态 完全是你的代码
顶点着色 GPU Attribute + Uniform gl_Position 与 varying 可编程
图元装配与裁剪 GPU 固定 着色后的顶点 点线三角形 拓扑由绘制模式决定
光栅化 GPU 固定 图元 片元及插值 varying 几乎不能
片元着色 GPU 插值 + Uniform + 纹理 颜色等 可编程
深度模板混合 GPU 固定 片元输出 + 帧缓冲旧值 最终像素 用状态配置,不写公式
呈现 浏览器合成 画布内容 屏幕 受 canvas 尺寸与 CSS 影响

顶点着色器必须写 gl_Position。这是 CPU 无法代劳的切口:即便矩阵在 CPU 乘好了,仍要有一段 VS 把 vec4 写出去。片元着色器必须写出颜色(1.0 写 gl_FragColor,2.0 写自定义 out)。没有这两段,链接失败,管线在提交前就断——注意,断在 CPU 侧的编译链接,而不是 GPU 执行。很多人把链接失败当成“显卡不支持”,其实是字符串没通过驱动编译器。第 2.4 节展开编译与运行的对照。

光栅化是两端之间的翻译官:把三角形变成一堆候选像素。CPU 看不见这些片元,除非你用 GPU 查询或把它们画到纹理再读回。读回是最贵的交接之一,因为它强迫 GPU 停下来把结果送回 CPU。管线教学里常忽略这点,导致有人用 readPixels 做碰撞检测,然后奇怪为什么帧率掉成幻灯片。方向错了:数据一旦交给 GPU,就应尽量留在 GPU。

图:一帧里 CPU 与 GPU 的分界

图:一帧里 CPU 与 GPU 的分界

工程上如何用这条分界看病

诊断卡顿,我先问:主线程时间长,还是 GPU 时间长。浏览器性能面板能把两者分开。主线程长,优先查每帧 bufferData、每帧编译着色器、每帧大量 getUniformLocation、同步 readPixels。GPU 时间长,优先查片元数量、overdraw、纹理格式、循环复杂的 FS。不分端就上“优化”,常见结果是给错误的一端加缓存。

绘制调用次数是两端的交界税。每一次 draw 都要让 CPU 确认状态、让 GPU 可能重启一批工作。一万个物体各 draw 一次,CPU 先死;合成一个大缓冲再 draw 一次,CPU 轻松、GPU 吃大网格。引擎合批就是在付这笔税的对立面。裸写时你要自己决定:状态变化是否值得一次新的 draw。

// 概念:draw 只是敲铃,前面全是备料 gl.useProgram(program); gl.bindVertexArray(vao); gl.uniformMatrix4fv(uMVP, false, mvp); gl.drawElements(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0);

四行里只有最后一行是“让 GPU 按当前状态跑管线”。前三行失败时,GPU 可能仍在用上一份程序或上一份 VAO 画画,画面错得像随机。状态机没有“未初始化则报错”的温情,它只有当前值。这就是为什么管线总览必须强调 CPU 提交:提交错了,GPU 会认真执行错误合同。

⚠️ 常见坑:在 draw 之后立刻 readPixels 取深度做逻辑。这会插入强制同步,把异步管线拧成串行。能留在 GPU 的查询,就不要拉回 CPU。

💡 关键直觉:管线图是顺序说明书,性能图是两端账单。优化对着账单做,而不是对着说明书的每一个方框做。

canvas 尺寸把两端再绑一次。CSS 显示 300 像素宽、backing store 却是 3 倍设备像素时,片元数量按 backing store 计。CPU 以为自己在画小图,GPU 在填九倍像素。对照方法:看 drawingBufferWidth 而不是 CSS 宽度。这不是管线阶段本身,但它决定片元着色被调用多少次。

问题:引擎的 render 是不是另一条管线?

不是。场景图遍历、可见性剔除、按材质排序,都在 CPU。排完序之后仍是 useProgram、绑定、draw。引擎多出来的阶段属于“如何生成更少的 draw”,不属于 GPU 新增的着色阶段。把引擎当魔法管线,会在自定义材质时找不到插入点。插入点仍是那两个着色器,外加你是否允许引擎继续管绑定。

WebGL2 没有把管线改成完全不同的形状,它给现有阶段加了更宽的数据口:实例化让一次 draw 消费多份 per-instance Attribute,MRT 让片元着色一次写多个附件,变换反馈让 VS 输出写回缓冲而不必经过片元。这些是通道加宽,不是阶段重排。选型时仍用本章这张两端图,只是某些箭头变粗。

把一帧画成时间线,而不是画成教科书方框

教科书方框让人以为顶点着色“之后”才光栅。硬件上批次之间可以重叠,命令队列里可能堆着上一帧的片元。对你有用的不是电路图,是可观测的时间线。打开性能面板,看主线程里哪一段在 requestAnimationFrame 回调内:矩阵计算、状态设置、draw 调用次数。再看 GPU 轨道是否在回调结束后仍画很久。若 CPU 轨道很长、GPU 很短,合批;若 CPU 很短、GPU 拖到下一帧,减片元。时间线把 2.1 的分界变成每天能用的尺子。

同步点会把重叠掐死。getError 在有的实现上会冲刷;readPixels 一定冲刷;等纹理完成的某些查询也会。调试期开 getError 循环可以,上线必须关掉。否则你以为在查错,其实在把异步管线拧成串行,还把责任推给“WebGL 就是慢”。慢的是你插入的栅栏。

分辨率再强调一次。CSS 宽 800、devicePixelRatio 3,drawingBuffer 可能是 2400。片元数量按 2400 计。查看器在手机上发烫,先 cap 像素比到 1.5 或 2,再谈 PBR。这不是管线阶段内部的优化,是给管线喂多少片元的阀门。阀门在 CPU 创建上下文与 resize 时拧,不在着色器里拧。

命令提交还有隐藏税:每次改状态可能让驱动验证。验证失败不一定抛 JS 异常,可能是后续 draw 无效。开发时用调试上下文或检查 framebuffer status。生产时靠“默认状态表 + 少切状态”。引擎合批本质是少切。你自己写循环时,按 program 排序再画,比按物体 ID 排序更接近硬件。排序发生在 CPU,收益发生在两端:CPU 少验证,GPU 少停顿。

上下文丢失、多 canvas、以及命令预算

一页多个 canvas 就是多份上下文,内存与编译加倍。能合成一个 canvas 分视口,就不要开三个 WebGL。每个上下文有自己的对象命名空间,不能共享纹理。这是 CPU 侧的资源隔离,看起来像方便,账单像新开一台小 GPU。仪表盘爱开多图,优先用一个上下文加剪刀或离屏区域。

命令预算:移动驱动对每帧状态变更次数敏感。设定一个内部预算,例如每帧不超过若干次 useProgram。超了就合批或降可见物体。预算让 2.1 的交界税可管理。没有预算,draw 数会随关卡膨胀直到某次活动页爆炸。爆炸那天你才会想起合批,而合批需要材质排序,材质排序需要从第一天把物体按 program 分组。分组是 CPU 数据结构,越早越好。

对照问答:提交、呈现、合成

为何 draw 之后画面还不出现?

浏览器按自己的合成节奏把 canvas 送到屏幕。JavaScript 里 draw 返回只表示命令进了队列。下一帧回调到来之前,GPU 可能仍在画。用读像素去“等待画完”会把队列拧成串行,输入也跟着卡。正确姿势是让呈现走浏览器,逻辑走下一帧的时间戳。若必须截图,在需要的那一帧设 preserveDrawingBuffer 并在绘制后读,而不是每帧读。

引擎 render 里为什么还有那么多 CPU?

剔除、排序、矩阵更新、合批判定都在 CPU。GPU 只吃排好的 draw。场景图越深,CPU 越忙。这不是引擎欺骗,是它在付第 2.1 节的提交税,试图让 GPU 少付切换税。关掉自动合批后 CPU 会降、draw 会升,两端哪边先爆取决于物体数。测过再关。

多视口分屏算几条管线?

还是一条 GPU 管线,两次以上的 viewport 加 draw。CPU 要切换视口与相机 Uniform。每个视口的片元数量按该视口像素计。四个小视口加起来可能等于一个全屏,但提交税是四倍清屏与绑定。仪表盘用多视口前先估 CPU。能用离屏画一次再当贴图,有时更划算。

本节速览

  • 顺序固定,执行者分端:VS 到写屏在 GPU;上传、绑定、draw 提交在 CPU。
  • draw 是命令:返回不代表呈现完成,更不代表可以廉价读回。
  • 可编程只有两处:顶点与片元;测试混合仍是可配置的固定阶段。
  • 卡顿先分端:主线程长查上传与绑定;GPU 长查片元与纹理。
  • draw 次数是交界税:合批减的是 CPU 提交,不是改写着色公式。
  • 引擎不换管线:多的是 CPU 侧调度,最终仍敲同一口铃。

下一节对照顶点与图元:CPU 如何决定拓扑,GPU 如何按点线三角形把顶点吃掉。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U