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 项目里的典型取值 | 作用 |
|---|---|---|---|
priority | number | 主相机 0、UI 相机 10 | 渲染顺序,小的先画 |
projection | CameraProjection | ORTHO | 2D 一律正交 |
orthoHeight | number | 540 / 2 | 正交高度(世界单位) |
visibility | number | Layers.Enum.UI_2D 等 | 层级可见掩码 |
clearFlags | ClearFlagBit | 主相机 SOLID_COLOR、UI 相机 DEPTH_ONLY | 每帧清哪部分缓冲 |
targetTexture | RenderTexture | null | null(画到屏幕) | 渲染目标,非空则离屏 |
camera | renderer.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) | 内置实现 | 职责 | 关键成员 |
|---|---|---|---|---|
| Pipeline | RenderPipeline(抽象,继承 Asset) | ForwardPipeline / DeferredPipeline | 持有 flows、UBO、描述符集、命令缓冲 | flows、render(cameras)、initialize |
| Flow | RenderFlow(抽象) | ForwardFlow / MainFlow | 服务一台相机,按序跑 stages | stages、render(camera)、activate |
| Stage | RenderStage(抽象) | 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) 读一遍,一帧的顺序就清楚了。去掉调试与判空,主干是这样的:
emit(PipelineEventType.RENDER_FRAME_BEGIN, cameras);- 对
cameras里的每台相机:emit(RENDER_CAMERA_BEGIN)→ 剔除与灯光裁剪 → 更新全局 UBO 与相机 UBO →for循环依次this._flows[j].render(camera)→emit(RENDER_CAMERA_END); emit(PipelineEventType.RENDER_FRAME_END, cameras);- 结束命令缓冲并
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 体系 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。