《Cocos 游戏开发高级编程》2.1 渲染管线与自定义管线

第 1 章把引擎拆成分层,这一节往下钻一层:一帧画面在 Cocos Creator 3.8 里由谁驱动。我们从 Camera 组件切入,顺出 RenderPipeline、RenderFlow、RenderStage 的三层结构,用 cc.d.ts 与 cocos-engine 3.8.8 源码逐层核实,再讲 PipelineEventType 可插入点,最后界定自定义管线的边界与代价。

2.1 渲染管线与自定义管线

第 1 章把引擎按 cocos/2d/ cocos/3d/ cocos/rendering/ 分了层,但那只回答了「代码放在哪」。真正决定《方块冲刺》一帧画面长什么样、按什么顺序画出来的,是渲染管线。它是引擎里最不常被业务代码触碰、却决定了所有渲染能力上限的一层。

这一节不写通用图形学——那些在站内 graphics/ 的 45 篇里已经讲透。我们要做的是把 Cocos Creator 3.8 的管线拆开,看清楚它由哪几段组成、每段的接口在 cc.d.ts 里长什么样、以及一个 2D 项目到底需不需要碰它。凡涉及引擎内部实现,都会给出 cocos/cocos-engine 仓库 tag 3.8.8 下的文件路径,供你自行核对。

2.1.1 一帧的驱动链条

先回答一个最基础的问题:每帧是谁去调渲染的。逻辑侧由 director 推进,渲染侧挂在 director.root 上——Root 持有一个 RenderPipeline 实例,cc.d.ts 里 Root.pipeline 的签名是 get pipeline(): RenderPipeline。一帧的渲染入口就是这个管线的 render(cameras)。

RenderPipeline.render 接收的是已经排好序的相机数组,类型是 renderer.scene.Camera[]。注意这里有两个「相机」概念容易混淆:Camera 是挂在节点上的组件,renderer.scene.Camera 是它背后的渲染场景对象,由组件通过 get camera() 暴露。组件负责编辑器里能看到的那些属性(优先级、清屏色、目标贴图),渲染对象负责管线真正读取的窗口、视口与矩阵。

于是链条可以概括成一句:

director 逻辑帧 → Root.pipeline.render(cameras) → 逐个 camera → 逐个 flow → 逐个 stage → 提交命令缓冲

2.1.2 Camera:渲染的入口与排序单位

管线一次只服务一台相机,所以「有几台相机、什么顺序」直接决定了渲染结构。2D 项目通常至少两台:一台主相机画游戏世界,一台 UI 相机画界面。它们靠 Camera.priority 排序,值越小越先渲染。

下面是《方块冲刺》里常用的相机属性速查,字段名全部来自 cc.d.ts 的 Camera 组件:

属性类型2D 项目里的典型取值作用
prioritynumber主相机 0、UI 相机 10渲染顺序,小的先画
projectionCameraProjectionORTHO2D 一律正交
orthoHeightnumber540 / 2正交高度(世界单位)
visibilitynumberLayers.Enum.UI_2D 等层级可见掩码
clearFlagsClearFlagBit主相机 SOLID_COLOR、UI 相机 DEPTH_ONLY每帧清哪部分缓冲
targetTextureRenderTexture | nullnull(画到屏幕)渲染目标,非空则离屏
camerarenderer.scene.Camera只读背后的渲染场景对象

两台相机叠在一起时,UI 相机之所以用 DEPTH_ONLY,是因为主相机已经用 SOLID_COLOR 把颜色缓冲清过一次了;UI 相机若再清一次颜色,会把刚画好的世界抹掉。这个细节卷二做多分辨率适配时踩过,这里从管线顺序上给出解释:清屏标志位是按相机依次生效的,不是每帧一次性生效。

这两台相机在场景文件里长什么样。本段是 Cocos Creator 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。

[
  { "__type__": "cc.SceneAsset", "_name": "main", "scene": { "__id__": 1 } },
  {
    "__type__": "cc.Scene",
    "_name": "main",
    "_children": [ { "__id__": 2 }, { "__id__": 5 } ]
  },
  {
    "__type__": "cc.Node", "_name": "MainCamera",
    "_components": [ { "__id__": 3 } ]
  },
  {
    "__type__": "cc.Camera",
    "_projection": 0,
    "_priority": 0,
    "_clearFlags": 7,
    "_orthoHeight": 270,
    "_visibility": 1073741824,
    "_targetTexture": null
  },
  {
    "__type__": "cc.Node", "_name": "UICamera",
    "_components": [ { "__id__": 6 } ]
  },
  {
    "__type__": "cc.Camera",
    "_projection": 0,
    "_priority": 10,
    "_clearFlags": 6,
    "_orthoHeight": 270,
    "_visibility": 33554432,
    "_targetTexture": null
  }
]

在编辑器里打开这个场景,你会看到什么:层级管理器里 main 下有两个一级节点 MainCamera 与 UICamera,选中任意一个,属性检查器里的 Projection 都是 ortho,Priority 分别是 0 与 10,Clear Flags 一个是 SOLID_COLOR、一个是 DEPTH_ONLY,Ortho Height 都是 270(对应 540 的设计高度)。_visibility 是一串十进制数字,它其实是层级掩码的十进制写法——1073741824 是 1 << 30,33554432 是 1 << 25,分别对应默认层与 UI_2D 层。数值随层级增删会变,不要手改。

(以上工程文件需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。)

一段可以挂在常驻节点上、把场景里所有相机参数打出来的组件:

import { _decorator, Component, Camera, director, renderer } from 'cc';
const { ccclass } = _decorator;

@ccclass('CameraInspector')
export class CameraInspector extends Component {
  start(): void {
    const scene = director.getScene();
    if (!scene) return;
    const cams = scene.getComponentsInChildren(Camera);
    for (const cam of cams) {
      const rc: renderer.scene.Camera = cam.camera;
      const ortho = cam.projection === Camera.ProjectionType.ORTHO;
      console.log(
        `[cam] ${cam.node.name} prio=${cam.priority} ortho=${ortho} ` +
        `vis=0x${cam.visibility.toString(16)} clear=${cam.clearFlags} ` +
        `rt=${cam.targetTexture ? cam.targetTexture.name : 'screen'} ` +
        `win=${rc.window.width}x${rc.window.height}`,
      );
    }
  }
}

Camera.ProjectionType 是组件上的静态引用(cc.d.ts 里写作 static ProjectionType: typeof renderer.scene.CameraProjection),所以 Camera.ProjectionType.ORTHO 与 renderer.scene.CameraProjection.ORTHO 是同一个值,用哪个都能过类型检查。

2.1.3 三层结构:Pipeline / Flow / Stage

管线本身不是一个巨大的函数,而是三层可组合对象:

  • Pipeline:RenderPipeline 的子类,管线的顶层容器。cc.d.ts 里它被声明为 export abstract class RenderPipeline extends Asset,即它本身是一个可序列化的资源。引擎自带两个实现:ForwardPipeline(前向)与 DeferredPipeline(延迟)。2D 项目用的一直是前向。
  • Flow:RenderFlow 的子类,RenderPipeline.flows 返回 RenderFlow[]。前向管线对应 ForwardFlow,延迟管线对应 MainFlow。一个 Flow 代表「针对一台相机要跑完的一串阶段」。
  • Stage:RenderStage 的子类,RenderFlow.stages 返回 RenderStage[]。前向管线里是 ForwardStage,延迟管线里是 GbufferStage 等。Stage 才是真正收集渲染对象、录制并提交命令缓冲的地方。

对照 cocos/cocos-engine tag 3.8.8 的 cocos/rendering/render-pipeline.ts:第 118 行 export abstract class RenderPipeline extends Asset implements IPipelineEvent, PipelineRuntime;initialize(info) 只是把 info.flows 存进 this._flows;activate(swapchain) 里逐条 this._flows[i].activate(this)。也就是说管线的组合方式是数据驱动的——flows 是序列化进来的数组,而非写死的调用。

这解释了为什么自定义管线在 Cocos 里是「换一个 Pipeline 资源」而不是「改一堆引擎代码」:只要新管线提供自己的 flows,引擎的其余部分(相机收集、UBO 更新、命令提交)都还能复用。

三层的对应关系与职责,可以整理成一张表:

层基类(cc.d.ts)内置实现职责关键成员
PipelineRenderPipeline(抽象,继承 Asset)ForwardPipeline / DeferredPipeline持有 flows、UBO、描述符集、命令缓冲flows、render(cameras)、initialize
FlowRenderFlow(抽象)ForwardFlow / MainFlow服务一台相机,按序跑 stagesstages、render(camera)、activate
StageRenderStage(抽象)ForwardStage / GbufferStage收集渲染对象、录制并提交命令render(camera)

越往下越接近 GPU,可组合性也越低:Pipeline 换资源、Flow 调顺序都算轻量操作,而替换一个 Stage 的实现基本等同于重写一段渲染逻辑。真要做深度定制,通常也是新增一个 Stage 插进现有 Flow,而不是从 Stage 往上全部重写。

2.1.4 帧循环里到底发生了什么

把 cocos/rendering/render-pipeline.ts(tag 3.8.8)的 render(cameras) 读一遍,一帧的顺序就清楚了。去掉调试与判空,主干是这样的:

  1. emit(PipelineEventType.RENDER_FRAME_BEGIN, cameras);
  2. 对 cameras 里的每台相机:emit(RENDER_CAMERA_BEGIN) → 剔除与灯光裁剪 → 更新全局 UBO 与相机 UBO → for 循环依次 this._flows[j].render(camera) → emit(RENDER_CAMERA_END);
  3. emit(PipelineEventType.RENDER_FRAME_END, cameras);
  4. 结束命令缓冲并 device.queue.submit(...)。

两个要点。第一,相机是并行的单位:每台相机的 UBO 单独更新,所以两台相机可以有完全不同的视口与矩阵。第二,Flow 在相机内部串行:flows[j].render(camera) 是顺序执行的,想改渲染顺序就得改 flows 数组的顺序,而不是指望并行。

这也解释了 2D 与 3D 混排时的表现:如果你的 2D 精灵和 3D 模型由同一台相机渲染,它们会落进同一个 Flow 的不同 Stage,谁先谁后取决于阶段顺序而非节点顺序。要让 2D 永远盖在 3D 上,最省事的做法仍是分成两台相机。

2.1.5 可插入点:PipelineEventType

不想换整条管线、只想在某个时刻插一段逻辑时,用管线事件。cc.d.ts 里 PipelineEventType 是 cc 的顶层导出(枚举,engine/cc.d.ts 第 23978 行),不是 pipeline 命名空间的成员;四个取值都是字符串常量:RENDER_FRAME_BEGIN、RENDER_FRAME_END、RENDER_CAMERA_BEGIN、RENDER_CAMERA_END。

事件入口也别搞混:director.root 上有两个相邻的属性——pipeline(类型 PipelineRuntime,只管渲染流程,没有 on / off)和 pipelineEvent(类型 IPipelineEvent,才是 on / once / off / emit 的持有者)。订阅管线事件必须走 pipelineEvent,写成 director.root.pipeline.on(...) 会直接被 tsc 报 TS2339。

import { _decorator, Component, director, PipelineEventType } from 'cc';
const { ccclass } = _decorator;

@ccclass('PipelineProbe')
export class PipelineProbe extends Component {
  private frames = 0;
  private camCount = 0;

  onEnable(): void {
    const pe = director.root?.pipelineEvent;
    if (!pe) return;
    pe.on(PipelineEventType.RENDER_FRAME_BEGIN, this.onFrameBegin, this);
    pe.on(PipelineEventType.RENDER_CAMERA_BEGIN, this.onCameraBegin, this);
  }

  onDisable(): void {
    const pe = director.root?.pipelineEvent;
    if (!pe) return;
    pe.off(PipelineEventType.RENDER_FRAME_BEGIN, this.onFrameBegin, this);
    pe.off(PipelineEventType.RENDER_CAMERA_BEGIN, this.onCameraBegin, this);
  }

  private onFrameBegin(): void {
    this.frames += 1;
    this.camCount = 0;
  }

  private onCameraBegin(): void {
    this.camCount += 1;
  }
}

onFrameBegin 里重置、onCameraBegin 里累加,就能数出「这一帧被渲染了几台相机」。这个数在小游戏端很有用:相机数量直接决定 UBO 更新与裁剪的次数,多一台就多一轮固定开销。注意 director.root 类型是 Root | null,所以用了可选链,取 pipelineEvent 后必须判空再订阅。顺带一提,RenderPipeline 自身也 implements IPipelineEvent(engine/cc.d.ts 第 45454 行),所以拿到具体的管线实例同样能 on / off;但从 director.root 这个通用入口进去时,认准 pipelineEvent 即可。

事件插桩的边界也要说清:它能观察一帧的节奏,但不能改变渲染内容。想在某个阶段之后插入自己的绘制,得往 Flow 里加 Stage,那就回到「自定义管线」的范畴了。

2.1.6 自定义管线:能改到哪一层

cc.d.ts 里有一个看起来很像「一键自定义管线」的宏:

import { macro } from 'cc';
// 名字为空则不启用自定义管线;当前仅识别 'Forward' 与 'Deferred'
macro.CUSTOM_PIPELINE_NAME = 'Forward';

但它的注释写得很明确:实验性、仅限 Web 平台、目前只支持 'Forward' 与 'Deferred' 两种。也就是说这个宏在 3.8.8 里是「在两种内置管线之间切换」的开关,不是一个通用的自定义入口。它能解决的问题,是让同一个项目在 Web 端走延迟渲染、在其它端回退前向,而不是让你塞进第三种管线。

真正想换管线行为,走的是继承 RenderPipeline 覆写 render 的路子。render 在 cc.d.ts 里是公开方法 render(cameras: renderer.scene.Camera[]): void,可以整体接管:

import { RenderPipeline, renderer } from 'cc';

// 完全接管相机遍历顺序的自定义管线骨架
export class Flat2DPipeline extends RenderPipeline {
  render(cameras: renderer.scene.Camera[]): void {
    for (let i = 0; i < cameras.length; i++) {
      const cam = cameras[i];
      if (!cam.scene) continue;
      for (let j = 0; j < this.flows.length; j++) {
        this.flows[j].render(cam);
      }
    }
  }
}

这段骨架编译得过,但不代表它能在运行时被引擎自动装配。引擎在启动时按名字创建管线,能识别的是内置的两种;要让一个自定义 RenderPipeline 子类真正生效,需要走 cocos/rendering/custom/ 那套渲染图系统(render-graph.ts、web-pipeline.ts 等,tag 3.8.8),它把管线描述成一张可序列化的图,再由 compiler.ts 编译成执行器。这条路的学习成本远高于普通业务开发。

要理解自定义管线的落点,还得看它在工程里存在哪里。下面是管线资源的序列化形态。本段是 Cocos Creator 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。

{
  "__type__": "cc.ForwardPipeline",
  "_name": "Forward",
  "_tag": 0,
  "_flows": [
    { "__id__": 1 },
    { "__id__": 2 }
  ]
}

在编辑器里打开这样一个管线资源,你会看到什么:属性检查器顶部是 _name 与只读的 _tag,下面是一个 _flows 数组,每一项是一个拖入的 Flow 资源引用;点击某个 Flow,再往下能展开它的 stages。管线、Flow、Stage 在编辑器里都是可拖拽的资产,这也是「数据驱动」这句话的具体形态——你看到的不是代码,是一串资源引用。

(以上工程文件需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。)

2.1.7 工程决策:什么时候才该动管线

把上面的能力收成几条判断:

  • 90% 的 2D 项目永远不该碰管线。 前向管线 + 两台相机(世界 / UI)能覆盖跑酷、卡牌、休闲三消的全部需求。动管线之前,先确认瓶颈真的在渲染顺序上,而不是 Draw Call(见 2.3 渲染批次与合批机制 )或纹理带宽。
  • 值得动管线的信号有三个:需要一条自定义的全屏后处理链(3.3 会讲)、需要在 2D 里插入自定义光照阶段(4.1)、或者需要在多端之间切换前向 / 延迟以适配不同 GPU。
  • 代价是升级风险。自定义管线一旦覆写 render,就绑定了当前版本的内部行为;引擎大版本升级时,RenderPipeline 的受保护成员、PipelineEventType 的取值都可能变化,你得重新对齐。
  • 可观察的替代方案优先。只想统计或埋点,用 PipelineEventType;只想控制 2D 与 3D 的叠放,用相机 priority;只想控制某个子树的绘制,用 2.3 会讲的 UIStaticBatch。这些都是官方支持面内的手段。

一句话总结:管线的三层结构(Pipeline / Flow / Stage)是理解 Cocos 渲染的骨架,但**「能改」不等于「该改」**。把结构看清楚,是为了在出问题时知道往哪一层挖,而不是为了动手改造它。

下一节把镜头从「谁驱动渲染」拉近到「一次绘制用什么数据」——材质、Effect 与 Pass 三者的关系,以及 setProperty 背后到底改了哪块 uniform。

阅读导航:上一节:目录 · 下一节:2.2 材质与 Effect 体系 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画