data-driven

这是一个老生常谈的问题:如何驱动渲染。或者也可以表述为:渲染出一帧,在图形学算法之外,需要经过怎样的流程。

当然这句话还暗含了一个概念,就是去渲染东西的图形学算法和渲染流程是分离的,渲染流程主要就是数据准备与发起绘制调用的过程,这是一个和引擎密切相关的概念,所以通常会归为架构层面要做的事情。

渲染出一帧这件事从宏观上看,是在核心循环中 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

这样来看,要设计的架构具有这样的特点:

然后会发现,哎~很多 WebGPU 的配置,都是 JSON 格式的,例如管道描述符。这已经为实现数据驱动奠定了基础。

Who hold the Resources

在一些经典的 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

Camera

我觉得设计成这样的算是常见且合理: