这是一个老生常谈的问题:如何驱动渲染。或者也可以表述为:渲染出一帧,在图形学算法之外,需要经过怎样的流程。
当然这句话还暗含了一个概念,就是去渲染东西的图形学算法和渲染流程是分离的,渲染流程主要就是数据准备与发起绘制调用的过程,这是一个和引擎密切相关的概念,所以通常会归为架构层面要做的事情。
渲染出一帧这件事从宏观上看,是在核心循环中 Tick 了渲染模块一下。微观上看,主要关注 Tick 内部的实现方式,这也是不同的渲染引擎主要差异所在。也就是说,如何驱动渲染这件事所关注的对象是渲染模块,引擎或者渲染循环告诉模块说“你去渲染一帧”,渲染模块在接收到这个命令之后,如何去取所需的数据,并整理和组织成多个含有先后顺序的绘制调用。
所以很自然地,要去获取数据,依照提供的数据去渲染出一帧来。这是一种数据驱动的思想,渲染模块不关心数据是如何产生的(什么物理碰撞、布料解算、游戏逻辑产生的 Transform 的变动都不考虑),而是只要一个结果:在这一帧之后,场景是什么样子的,然后根据这个场景去渲染。
因此现代引擎会有渲染数据层(或者类似地概念)
graph TD
%% 分层结构
subgraph L1 [应用层]
A[主循环 Main Loop / Game World]
end
subgraph L2 [渲染数据层]
B1[逻辑数据快照 Scene Snapshot]
B2[外部资源引用 External Resources]
end
subgraph L3 [渲染模块]
C[渲染模块 Render Module]
C --> D1[数据准备 Data Preparation]
C --> D2[渲染调度 Render Scheduling]
C --> D3[命令录制 Command Recording]
C --> D4[提交呈现 Submit & Present]
end
subgraph L4 [GPU层]
E[GPURenderPass]
E --> G[屏幕输出 Screen Output]
end
%% 数据流
A --> B1
A --> B2
B1 --> C
B2 --> C
D1 --> D2
D2 --> D3
D3 --> D4
D4 --> E
%% 样式设置
style L1 fill:#e1f5fe,stroke:#01579b,stroke-width:2px
style L2 fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
style L3 fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px
style L4 fill:#fff3e0,stroke:#e65100,stroke-width:2px
style A fill:#29b6f6,stroke:#01579b,stroke-width:2px
style B1 fill:#ba68c8,stroke:#4a148c,stroke-width:2px
style B2 fill:#ab47bc,stroke:#4a148c,stroke-width:2px
style C fill:#66bb6a,stroke:#1b5e20,stroke-width:2px
style E fill:#ffb74d,stroke:#e65100,stroke-width:2px
style G fill:#ff7043,stroke:#bf360c,stroke-width:2px
这样来看,要设计的架构具有这样的特点:
RenderGraph )然后会发现,哎~很多 WebGPU 的配置,都是 JSON 格式的,例如管道描述符。这已经为实现数据驱动奠定了基础。
在一些经典的 webgpu 教程中,init 部分会获取到HTMLCanvasElement、GPUDevice、GPUAdapter和GPUCanvasContext等必要的东西,但是这些东西究竟应该由谁管理?
一个网页未必只有一个canvas需要渲染,但是一个render在同一时间只能渲染到一个canvas上。Renderer 和 RenderTarget 是一对一的,但是 Engine 明显允许持有多个 Renderer。所以需要 Engine 管理全局 GPU 状态,而不直接绑死 Canvas 或 RenderTarget。但是它可以持有多个 Renderer,每个 Renderer 管理自己的 Canvas、SwapChain / Context 以及相关 GPU 渲染资源。
graph TD
%% 层级划分
subgraph L1 [应用层 Application Layer]
A[主循环 Main Loop / Game World]
end
subgraph L2 [引擎 Engine Layer]
B[Engine]
%% Engine 持有的核心对象
B1[GPUAdapter / GPUDevice]
B2[GPUCanvasContext]
B3[GPUQueue]
B4[资源管理 Resource Manager]
end
subgraph L3 [渲染器 Renderer Layer]
C1[Renderer-Forward]
C2[Renderer-Deferred]
C3[Renderer-PostProcess]
%% 渲染器内部职责
subgraph R [Renderer 内部]
D1[渲染数据准备 Scene Culling / Data Prep]
D2[渲染管线配置 GPURenderPipeline / GPUComputePipeline]
D3[命令录制 GPURenderPassEncoder / GPUComputePassEncoder]
end
end
subgraph L4 [GPU执行层 GPU Execution Layer]
E1[GPUBuffer / GPUTexture]
E2[GPUSampler]
E3[GPUBindGroup]
E4[GPURenderPass / GPUComputePass]
E5[GPUCommandEncoder / GPUCommandBuffer]
E6[GPUQuerySet / GPUPipelineLayout]
E7[提交与呈现 Queue.submit + Present]
end
subgraph L5 [显示层 Display Layer]
F[屏幕输出 Screen Output]
end
%% 数据流
A --> B
B --> C1
B --> C2
B --> C3
D1 --> D2
D2 --> D3
D3 --> E5
E5 --> E4
E4 --> E7
E7 --> F
%% Engine 与 Renderer 资源关系
B1 --> D2
B2 --> E7
B3 --> E7
B4 --> E1
B4 --> E2
B4 --> E3
B4 --> E6
%% 样式设置
style L1 fill:#e1f5fe,stroke:#01579b,stroke-width:2px
style L2 fill:#ede7f6,stroke:#311b92,stroke-width:2px
style L3 fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px
style L4 fill:#fff8e1,stroke:#e65100,stroke-width:2px
style L5 fill:#ffebee,stroke:#b71c1c,stroke-width:2px
style A fill:#29b6f6,stroke:#01579b,stroke-width:2px
style B fill:#7e57c2,stroke:#311b92,stroke-width:2px
style C1 fill:#66bb6a,stroke:#1b5e20,stroke-width:2px
style C2 fill:#43a047,stroke:#1b5e20,stroke-width:2px
style C3 fill:#81c784,stroke:#1b5e20,stroke-width:2px
style E4 fill:#ffb74d,stroke:#e65100,stroke-width:2px
style E5 fill:#ffe082,stroke:#e65100,stroke-width:2px
style F fill:#ef5350,stroke:#b71c1c,stroke-width:2px
我觉得设计成这样的算是常见且合理: