本文首發於我的部落格,請規範轉載

本系列文章涉及的所有程式碼均已開源,Github倉庫在這裡:dtysky/webgpu-renderer。

WebGPU作為Web平臺的下一代圖形API標準(當然不僅僅是圖形),提供了類似於DX12、Metal、Vulkan對GPU深入控制的能力,在經過了數個版本的迭代後,其在當下時點(2021年9月)終於基本穩定,可見WebGPU標準,其使用的著色器語言

wgsl

也基本穩定,詳見WebGPU Shading Language。

WTF,就在我寫這篇文章這一週(2021年9月第二週)標準又有更新,寫完發現原來的程式碼跑不起來了……待我馬上修一修。

在本文中,筆者將論述如何用WebGPU來實現一個簡單的渲染器,並且目前只支援靜態物體渲染,沒有動畫物理等等元件,這個渲染器的架構脫胎於筆者過去研究和實現過的幾個渲染引擎,以求

儘量簡單

,而非

大而全

,所以實現中可能有粗糙之處,但作為如何使用WebGPU的例子而言是絕對足夠的。

概述

在設計一個渲染引擎,或者說渲染器的時候,我們最關心的問題毫無疑問是“渲染”,無論如何設計,首要考慮的都是“渲染是如何被驅動的”。一般來講,目前通用的渲染驅動方案大致都是:

一幀開始 -> 組織提取渲染資料 -> 提交資料更改到GPU -> 設定渲染目標並清屏 -> 提交繪製指令 -> 一幀結束

對應於比較詳細的工程概念,幀的排程可以視作是Web中的

requestAnimationFrame

,以每秒30、60或者120幀驅動;組織提取渲染資料可以分為兩部分,一部分是渲染所需資源的管理,另一部分是“剔除”,即透過某種手段在整個場景中篩選出真正需要渲染的物體;而提交必要資料到GPU,則是指渲染過程中可能有一些資料會被更新,比如模型資料、Uniform等等,這時候就需要把這些資料更新上傳到GPU;然後是設定渲染目標為畫布或者主屏,並進行顏色、深度和模板緩衝區的清除;最後就是提交繪製指令,即進行實際的繪製。

為了實現以上整個驅動的過程,我們首先需要有一個用於渲染的環境,這個環境用於管理Canvas、Context以及一些全域性的變數;其次是需要一些資源,這些資源管理著所有渲染所需要的資料;之後還需要一個場景結構和容器,來將這些渲染資料組裝管理起來,方便剔除和繪製,與此同時還需要相機來提供觀察場景的視角,需要燈光來讓場景更加真實;最終還需要一種資料格式用於儲存模型資料,還需要對應的載入器來它轉換為我們需要的資源資料。

以下內容需要參考文首提到的專案原始碼同步觀看。

HObject

在真正開始講述渲染部分的原理之前,要大概說一下整個工程的基本架構。這裡我採用的是一個比較簡單的繼承式OO,對於工程中的每個類,都擁有統一的基類,並遵循統一的模式。這個基類叫做

HObject

(至於為何是

H

Object,那當然是因為青年H的原因咯):

export default class HObject {

public static CLASS_NAME: string = ‘HObject’;

public isHObject: boolean = true;

public name: string;

get id(): number;

get hash(): string;

}

每個繼承自

HObject

的類都需要提供

static CLASS_NAME

isXXXX

,並會透過

CLASS_NAME

自動生成

id

hash

,還有可選的

name

方便Debug。

而有了

isXXXX

這種標示,還可以避免使用

instanceof

而是使用謂詞判斷型別,比如:

export default class RenderTexture extends HObject {

public static IS(value: any): value is RenderTexture {

return !!value。isRenderTexture;

}

public static CLASS_NAME: string = ‘RenderTexture’;

public isRenderTexture: boolean = true;

}

便可以透過

RenderTexture。IS(obj)

來判斷是否為

obj

是否為

RenderTexture

,無論是執行時還是編譯期。

渲染環境

渲染環境在引擎中的實現是

core/renderEnv

,其實現比較簡單,主要是透過Canvas初始化整個WebGPU的Context,同時管理全域性Uniform和全域性宏,Uniform和宏後面資源的死後會說到,這裡先主要關注最重要的

init

方法實現:

public async init(canvas: HTMLCanvasElement) {

if (!navigator。gpu) {

throw new Error(‘WebGPU is not supported!’);

}

const adapter = await navigator。gpu。requestAdapter();

if (!adapter) {

throw new Error(‘Require adapter failed!’);

}

this。_device = await adapter。requestDevice();

if (!this。_device) {

throw new Error(‘Require device failed!’);

}

this。_canvas = canvas;

this。_ctx = (canvas。getContext(‘webgpu’) as any || canvas。getContext(‘gpupresent’)) as GPUCanvasContext;

this。_ctx。configure({

device: this。_device,

format: this。_swapChainFormat,

});

}

這裡可以看出WebGPU的Context的初始化方式,我們首先要檢查

navigator

上是否有

gpu

例項,以及

gpu。requestAdapter

是否能夠返回值,然後再看是否能夠用

adapter。requestDevice

分配到裝置,這個裝置可以認為是圖形硬體的一個抽象,其可以把實現和底層硬體隔離,有了這個中間層,便可以做各種相容和適配。

有了

device

,還需要一個Context,這可以透過傳入的

canvas

使用

getContext(‘webgpu’)

獲取,獲取了Context後,便可以使用

ctx。configure

來配置SwapChain,注意這裡使用到的

format

一般預設為

‘bgra8unorm’

場景和容器

根據一開始的論述,讀者可能認為接下來就要講資源的實現,接著才是場景和容器,但這種論述其實是反直覺的。對於瞭解一個渲染引擎的設計而言,自頂向下才是最優解,下面我會先從場景的組織講起,然後是渲染的驅動,再到渲染和計算單元,最後才是各個資源的細節。

Node

雖然是自頂向下,但Node,即節點還是要放在最開始說的。由於本文不是3D渲染的科普貼,就不再去說MVP矩陣這種入門知識了。簡單來講,Node就是描述3D場景中某個物體位置資訊的基礎,其擁有平移

pos

、旋轉

quat

、縮放

scale

三個屬性,並擁有父級

parent

和子級

children

來進行級聯,級聯後即為樹狀結構的

節點樹

,這也就是

場景

的基礎:

節點樹將會在每一幀驅動的時候透過深度優先搜尋

dfs

來從根節點遍歷每個節點,呼叫

updateMatrix

進行自頂向下的世界矩陣

worldMat

更新。成熟的渲染引擎都會在節點樹重新整理這一步做很多最佳化,比如髒檢查,但本專案沒有考慮這些,每幀全量重新整理。

節點是非常重要的單元,其也是相機、燈光、渲染單元的基類。

Scene

有了Node,場景Scene就自然而然構成了。所有的渲染引擎基本都有場景的概念,只不過功能可能不同。而本引擎為了方便,直接把渲染驅動的能力給了它。所以本引擎的Scene主要有兩個功能——管理場景,以及管理繪製流程。

對於場景的管理主要在於節點樹,節點樹管理的實現很簡單,場景內部持有了一個

rootNode

,直接將自己新建的節點作為

rootNode

的子級即可將其加入場景。但場景管理不限於此,在每幀從

rootNode

自頂向下重新整理整棵節點樹的時候,我們還可以收集一些渲染必要的資訊,比如

當幀可供剔除的渲染單元列表meshes

當幀需要的燈光列表lights

this。_rootNode。updateSubTree(node => {

if (Mesh。IS(node)) {

this。_meshes。push(node);

} else if (Light。IS(node)) {

this。_lights。push(node);

}

});

關於這二者的時候馬上就會講到。

那麼現在有了場景,我們如何驅動渲染呢,渲染當然需要一些資源,但也需要流程將它們組織起來。在文章的一開始已經提到過一個渲染流程應該是如何的,那麼其實直接將它們翻譯成介面給Scene即可:

1。

startFrame(deltaTime: number)

一幀開始,執行節點樹重新整理、收集資訊,並設定全域性的Uniform,比如

u_lightInfos

u_gameTime

等。然後是最重要的:建立

GPUCommandEncoder

this。_command = renderEnv。device。createCommandEncoder();

GPUCommandEncoder

顧名思義,是WebGPU中用於指令編碼的管理器。一般每一幀都會重新建立一個,並在一幀結束時完成它的生命週期。這也是這一代圖形API的標準設計,但其實類似思想早就存在於一些成熟的遊戲引擎中,比如UE。

2。

setRenderTarget(target: RenderTexture | null)

這個介面用於設定渲染目標,如果是

RenderTexture

資源,那麼接下來執行的所有繪製指令都會會知道這個RT上,如果是null則會會知道主屏的緩衝區。

3。

cullCamera(camera: Camera): Mesh[]

使用某個相機的

camera。cull

進行剔除,返回一個Mesh列表,並對列表按照z進行排序。

注意,在標準流程中,透明物體和非透明物體需要分類反著排序,透明物體由遠及近畫,非透明物體由近及遠。並且往往還會加上

renderQueue

來加上一個可控的排序維度。

4。

renderCamera(camera: Camera, meshes: Mesh[], clear: boolean = true)

使用某個相機進行渲染一批Mesh,直接代理到

camera。render

方法。

5。

renderImages(meshes: ImageMesh[], clear: boolean = true)

渲染一批特殊的渲染單元

ImageMesh

,專用於處理影象效果。

6。

computeUnits(units: ComputeUnit[])

對一批計算單元進行計算,專用於處理計算著色器。

7。

endFrame()

結束一幀的繪製,主要是將當幀的

commandEncoder

送入

GPUQueue

並提交:

renderEnv。device。queue。submit([this。_command。finish()]);

如此,

this。_command

便結束了其生命週期,不能再被使用。

Camera

相機是觀察整個場景的眼睛,其繼承自

Node

,對渲染的主要貢獻有以下幾點:

1。剔除:

剔除分為很多種,相機的剔除是物體級別的可見性剔除,其利用相機位置和投影引數構造的視錐體和物體自身求交(出於效能考慮,往往使用包圍盒或者包圍球)來判定物體是否在可見範圍內,並算出相機到物體的距離,來作為後續排序的依據。剔除的實現為

camera。cull

,但由於其不是教程重點,並且對於目前場景也沒什麼意義,所以

沒有實現

。不過實現也很簡單,讀者可以自行查閱相關資料。

2。提供VP矩陣:

相機還需要為渲染過程提供VP矩陣,即檢視矩陣

ViewMatrix

和投影矩陣

ProjectionMatrix

。前者和相機結點的

WorldMatrix

相關,後者則和相機的投影模式(透視或者正交)以及引數有關。這些引數將在

updateMatrix

方法更新了

WorldMatrix

後計算,計算後會立即設定到全域性Uniform中。

當然理論上這種做法在場景中存在多個相機的時候會出問題,實際上應該有個專門的

preRender

之類的流程來處理,但為了方便這裡就這麼做吧。

3。天空盒:

除了計算VP矩陣外,在

updateMatrix

中還會計算一個

skyVP

,這個是用於天空盒渲染的。天空盒是一種特殊的Mesh,其不會隨著相機的移動變換相對位置(只會相對旋轉)。本引擎對天空盒的實現很簡單,固定使用內建的幾何體

Geometry

,為相機增加了

skyboxMat

這個材質屬性來定製繪製過程,並提供了內建的天空盒材質。

4。渲染:

相機最重要的功能就是進行渲染實際的渲染驅動了,其

render(cmd: GPUCommandEncoder, rt: RenderTexture, meshes: Mesh[], clear: boolean)

方法就用於此。這個方法的實現並不複雜,直接貼程式碼:

const [r, g, b, a] = this。clearColor;

const {x, y, w, h} = this。viewport;

const {width, height, colorViews, depthStencilView} = rt;

const renderPassDescriptor: GPURenderPassDescriptor = {

colorAttachments: colorViews。map(view => ({

view,

loadValue: clear ? { r, g, b, a } : ‘load’ as GPULoadOp,

storeOp: this。colorOp

})),

depthStencilAttachment: depthStencilView && {

view: depthStencilView,

depthLoadValue: this。clearDepth,

stencilLoadValue: this。clearStencil,

depthStoreOp: this。depthOp,

stencilStoreOp: this。stencilOp

}

};

const pass = cmd。beginRenderPass(renderPassDescriptor);

pass。setViewport(x * width, y * height, w * width, h * height, 0, 1);

pass。setBindGroup(0, renderEnv。bindingGroup);

for (const mesh of meshes) {

mesh。render(pass, rt);

}

if (this。drawSkybox && this。_skyboxMesh) {

this。_skyboxMesh。render(pass, rt);

}

pass。endPass();

其中比較重要的是

RenderPass

的概念,在WebGPU中這可以認為是

一次渲染的流程

,建立其使用的

GPURenderPassDescriptor

物件是這個流程要

繪製的目標和操作

的描述符。可以看到其中主要是設定了顏色和深度/模板緩衝的

view

,關於這個將會在後面的

RenderTexture

中詳細說到,這裡就認為其就是畫布即可;而

loadValue

storeOp

是決定如何清屏的,在這裡這些引數都可以交由開發者決定,一般來講是

顏色值

store

操作。

在建立了一個RenderPass後,就可以設定

viewport

bindGroup

,前者和OpenGL中的視口概念等同,後者可以認為就是UniformBlock,這個在後面會詳細講到,這裡設定的是第0個UniformBlock,即

全域性UB

。設定好後便是呼叫每個Mesh的

render

方法逐個渲染,最終渲染天空盒。渲染完畢後呼叫

pass。endPass

來結束這個RenderPass。

事實上關於

ImageMesh

ComputeUnit

的繪製處理也類似,後面會詳細講到。

Mesh

網格

Mesh

是用於渲染的基本單元,其構造為

new Mesh(geometry, material)

。其將儲存著圖元資料的幾何體

Geometry

和決定了渲染方式的材質

Material

對應組裝起來,加之每個物件各有的UniformBlock,在相機繪製的過程中,被呼叫進行渲染:

public render(pass: GPURenderPassEncoder, rt: RenderTexture) {

const {_geometry, _material} = this;

if (_material。version !== this。_matVersion || !this。_pipelines[rt。pipelineHash]) {

this。_createPipeline(rt);

this。_matVersion = _material。version;

}

this。setUniform(‘u_world’, this。_worldMat);

this。_bindingGroup = this。_ubTemplate。getBindingGroup(this。_uniformBlock, this。_bindingGroup);

_geometry。vertexes。forEach((vertex, index) => {

pass。setVertexBuffer(index, vertex);

});

pass。setIndexBuffer(_geometry。indexes, _geometry。indexFormat);

pass。setBindGroup(1, _material。bindingGroup);

pass。setBindGroup(2, this。_bindingGroup);

pass。setPipeline(this。_pipelines[rt。pipelineHash]);

pass。drawIndexed(_geometry。count, 1, 0, 0, 0);

}

渲染的流程並不複雜,先不管下面章節會說到的資源的具體實現,從宏觀的角度來看,這裡主要的工作是設定頂點緩衝

vertexBuffer

,設定索引緩衝

indexBuffer

,分別設定物體(index=1)和材質(index=2)級別的Uniform,最後設定一個叫做

pipeline

的引數,全部設定完後呼叫

drawIndexed

來繪製這一批圖元。

當然,這也支援GPU例項化,但不在本引擎的討論範圍內。

ImageMesh

影象渲染單元

ImageMesh

一般用於影象處理,其構造為

new ImageMesh(material)

。其內建了繪製一張影象的圖元資料,直接用傳入的Material進行繪製。所以其繪製流程也相對簡單,不需要MVP矩陣,所以並不需要相機,也不需要結點,不需要深度緩衝,其他和

camera。render

幾乎一致。

還要注意的是ImageMesh最終的繪製並不是用

drawIndexed

方法而是用

pass。draw(6, 1, 0, 0)

方法,因為其並不需要頂點資料,這個在著色器章節會詳細論述。

ComputeUnit

WebGPU相對於WebGL最重大的進化之一就是支援

計算著色器

,計算單元

ComputeUnit

就用於支援它。和渲染單元不同,其專門用於使用

Compute Shader

進行計算,相比於渲染單元,其不需要頂點、渲染狀態等等資料,所有資料都將視為用於計算的緩衝,所以其構造為

new ComputeUnit(effect, groups, values, marcos)

,效果

effect

以及

values

marcos

引數也是構造Material的引數,所以ComputeUnit合併了一部分Material的功能。與此同時還加上了

groups

(型別為

{x: number, y?: number, z?: number}

),這裡先不談,讓我們看看整個一個單元列表的計算是如何實現的:

// 在Scene。ts中

public computeUnits(units: ComputeUnit[]) {

const pass = this。_command。beginComputePass();

pass。setBindGroup(0, renderEnv。bindingGroup);

for (const unit of units) {

unit。compute(pass);

}

pass。endPass();

}

// 在ComputeUnit。ts中

public compute(pass: GPUComputePassEncoder) {

const {_material, _groups} = this;

if (_material。version !== this。_matVersion) {

this。_createPipeline();

this。_matVersion = _material。version;

}

pass。setPipeline(this。_pipeline);

pass。setBindGroup(1, _material。bindingGroup);

pass。dispatch(_groups。x, _groups。y, _groups。z);

}

可以看到,和渲染流程的

RenderPass

不同,這裡建立了一個

ComputePass

用於這批計算,並且同樣需要設定

BindingGroup0

(可以認為是全域性UniformBlock)。之後針對每個計算單元,都設定了

pipeline

、單元級別的UniformBlock,最後執行

dispatch

,後面的引數涉及到執行緒組的概念,和計算著色器也有關,這個後面會講到。在計算完所有單元后,執行

endPass

來結束這個流程。

Light

除了相機和渲染單元之外,對於一個最簡單的渲染器,燈光也是不可或缺的。本引擎的燈光設計比較簡單,全部集中在

Light

這一個類的實現中。燈光主要屬性有型別

type

、顏色

color

以及分型別的各種引數,型別一般有:

export enum ELightType {

INVALID = 0,

Area,

Directional,

Point,

Spot

}

每種型別的光源都有不同引數,對於本引擎針對的路徑追蹤而言,比較重要的是面光源的引數:模式(矩形

Rect

或圓盤

Disc

)和尺寸

寬高或半徑

。和相機一樣,燈光本身也繼承自結點,所以在更新矩陣的時候也要計算自己的燈光矩陣

LightMatrix

,並更新需要送往全域性UniformBlock的資訊:

public updateMatrix() {

super。updateMatrix();

this。_ubInfo。set(this。_color, 4);

this。_ubInfo。set(this。_worldMat, 8);

this。_ubInfo。set(mat4。invert(new Float32Array(16), this。_worldMat), 24);

}

在實際渲染中,

renderEnv。startFrame

中全域性UB的

u_lightInfos

就是從這個

ubInfo

中獲取資訊更新的,目前一個物體一次繪製最多支援四個燈光。

資源

有了宏觀的渲染管線和容器,就只剩具體的資源來填充它們了。在接下來的章節,我將論述我們熟悉的一些渲染資源抽象如何在WebGPU中實現。

Shader

首先必須要說的是著色器

Shader

,因為後續的所有資源最終都會在Shader中被應用,並且它們的部分設計和Shader也有著十分緊密的聯絡。著色器是什麼我不再贅述,相信寫過OpenGL或WebGL的讀者都寫過

頂點著色器

片段著色器

,在WebGPU中它們同樣存在,而比起WebGL,WebGPU還實現了

計算著色器

WebGPU使用的著色器語言在一次又一次的變更後,終於定論為WGSL。其語法和

glsl

hlsl

都有較大差異,以本人的觀點看比較像融合了metal和rust的很多部分,這一點也體現在編譯階段——WGSL的型別系統十分嚴格。

本文並非是一個WGPU或者WGSL的詳細教程(否則至少得寫一本書),下面就分別以三個/三種著色器的簡單示例,來論述一下本文需要用到的一些WGSL的基本知識。

頂點著色器

//model。vert。wgsl

struct VertexOutput {

[[builtin(position)]] Position: vec4

[[location(0)]] texcoord_0: vec2

[[location(1)]] normal: vec3

[[location(2)]] tangent: vec4

[[location(3)]] color_0: vec3

[[location(4)]] texcoord_1: vec2

};

[[stage(vertex)]]

fn main(attrs: Attrs) -> VertexOutput {

var output: VertexOutput;

output。Position = global。u_vp * mesh。u_world * vec4(attrs。position, 1。);

#if defined(USE_TEXCOORD_0)

output。texcoord_0 = attrs。texcoord_0;

#endif

#if defined(USE_NORMAL)

output。normal = attrs。normal;

#endif

#if defined(USE_TANGENT)

output。tangent = attrs。tangent;

#endif

#if defined(USE_COLOR_0)

output。color_0 = attrs。color_0;

#endif

#if defined(USE_TEXCOORD_1)

output。texcoord_1 = attrs。texcoord_1;

#endif

return output;

}

這是一個典型的頂點著色器,首先從其入口

main

看起,

[[stage(vertex)]]

表明這是一個頂點著色器的入口,

attrs: Attrs

表明其輸入是一個型別為

Attrs

的struct,

-> VertexOutput

則說明頂點著色器要傳輸給下一步插值的資料型別為

VertexOutput

Attrs

在這裡沒有寫,這是因為其相對動態,我將其生成整合到了整個渲染過程的設計中,這個在下面的Geometry章節會講到。而

VertexOutput

的定義就在程式碼頂部,其是一個struct,裡面的內容都是形如

[[位置]] 名字: 型別

的形式,唯一不同的是

[[builtin(position)]] Position

,因為位置資訊是重心座標插值的重要依據,所以需要特殊指明。

接下來

main

的函式體中,主要實現了頂點資料的計算和輸出,其中包括大家熟悉的MVP變換

output。Position = global。u_vp * mesh。u_world * vec4(attrs。position, 1。);

,這裡要注意

var output: VertexOutput

的定義中使用的是

var

,在WGSL中使用

var

let

區分變數是動態還是靜態,靜態變數不可變並且必須初始化,動態的可變。在結尾

return output

表明返回計算結果到下一個階段。

注意這裡出現了

#if defined(USE_TEXCOORD_0)

這樣的寫法,而我們也在前面提到過

,但實際上WGSL目前並沒有實現宏、或者在這裡的功能層面為

預處理器

,詳見Issue[wgsl Consider a preprocessor。這裡其實是我自己透過正則實現的一個簡易預處理器,除此之外我還是用webpack的loader機制實現了一個簡單的

require

方法,來完成shader檔案的分隔複用,這裡不再贅述。

這裡還有必要單獨提一下

ImageMesh

使用的頂點著色器,在上面的章節中提到了其繪製並不依賴於頂點資料,其實是利用WGSL的模組作用域變數(MODULE SCOPE VARIABLE)實現的:

struct VertexOutput {

[[builtin(position)]] position: vec4

[[location(0)]] uv: vec2

};

var pos: array, 6> = array, 6>(

vec2(-1。0, -1。0),

vec2(1。0, -1。0),

vec2(-1。0, 1。0),

vec2(-1。0, 1。0),

vec2(1。0, -1。0),

vec2(1。0, 1。0)

);

var uv: array, 6> = array, 6>(

vec2(0。0, 1。0),

vec2(1。0, 1。0),

vec2(0。0, 0。0),

vec2(0。0, 0。0),

vec2(1。0, 1。0),

vec2(1。0, 0。0)

);

[[stage(vertex)]]

fn main([[builtin(vertex_index)]] VertexIndex : u32) -> VertexOutput {

var output: VertexOutput;

output。position = vec4(pos[VertexIndex], 0。0, 1。0);

output。uv = uv[VertexIndex];

#if defined(FLIP)

output。uv。y = 1。 - output。uv。y;

#endif

return output;

}

這裡透過

var

定義了兩個模組作用域的變數陣列

pos

uv

,可以被著色器函式索引內容,而此處函式入參

[[builtin(vertex_index)]] VertexIndex : u32

是當前正在繪製的頂點索引,用這個索引和兩個陣列即可完成輸出。

模組作用域變數不止有

private

一個域,還有

workgroup

等,這裡不再贅述。

片段著色器

struct VertexOutput {

[[builtin(position)]] Position: vec4

[[location(0)]] texcoord_0: vec2

[[location(1)]] normal: vec3

[[location(2)]] tangent: vec4

[[location(3)]] color_0: vec3

[[location(4)]] texcoord_1: vec2

};

[[stage(fragment)]]

fn main(vo: VertexOutput) -> [[location(0)]] vec4 {

return material。u_baseColorFactor * textureSample(u_baseColorTexture, u_sampler, vo。texcoord_0);

}

main

函式上方的

[[stage(fragment)]]

表明這是一個片段著色器。這個著色器很簡答也很典型,其將頂點著色器的輸出

VertexOutput

重心插值後的結果作為輸入,最終輸出一個

[[location(0)]] vec4

的結果,這個結果就是最終這個片元顏色。

有了結構,再看看函式體中做了什麼。這裡從一個叫

material

的變數中取出了

u_baseColorFactor

,並用

textureSample

方法和UV

vo。texcoord_0

,對名為

u_baseColorTexture

的貼圖進行了取樣,並且還用上了一個叫做

u_sampler

的變數。紋理取樣我們熟悉,但這個

materia

u_baseColorTexture

u_sampler

又是什麼呢?這就要涉及到WGSL的另一點:

BindingGroup了

BindingGroup

這不是本文第一次出現

BindingGroup

這個詞,在前面我們已經提到過無數次,並將其和

UniformBlock

相提並論,並在繪製流程中呼叫了

pass。setBindGroup

方法。那麼這東西到底是什麼呢?回想一下我們在OpenGL或者WebGL中如何在渲染過程中更新uniform的資訊,一般是呼叫類似

gl。uniform4vf

這種藉口,來將從shader反射資訊拿到的location作為key,把值更新上去,如果是紋理則還需要線繫結紋理等等操作。就算是加上了快取,每幀用於更新uniform的時間也是可觀的,所以新一代圖形API為了解決這個問題,就給出了BindingGroup或者類似的方案。

BindingGroup本質上可以看做是一個uniform的集合,和其他資源一樣,在WebGPU最後建立一個BindingGroup也需要一個描述符:

device。createBindGroup(descriptor: {layout: GPUBindGroupLayout; entries: Iterable}): GPUBindGroup;

描述符需要一個

layout

和一個

entries

,前者給出了

結構

,後者給出了實際的

。以一個簡單的構造為例:

const visibility = GPUShaderStage。VERTEX | GPUShaderStage。FRAGMENT;

const bindingGroup = renderEnv。device。createBindGroup({

layout: device。createBindGroupLayout({entries: [

{

binding: 0, visibility,

buffer: {type: ‘uniform’ as GPUBufferBindingType},

},

{

binding: 1, visibility,

texture: {

sampleType: ‘rgba8unorm’,

viewDimension: ‘2d’

},

},

{

binding: 2, visibility,

sampler: {type: ‘filtering’}

},

{

binding: 3,

visibility: GPUShaderStage。COMPUTE,

buffer: {type: ‘storage’}

},

]}),

entries: [

{

binding: 0,

resource: {buffer: gpuBuffer}

},

{

binding: 1,

resource: textureView

},

{

binding: 2,

resource: sampler

},

{

binding: 3,

resource: {buffer}

}

});

這裡構建的BindingGroup涵蓋了幾種值,分別是Uniform、Texture、Sampler和Storage,可見每種方式的描述定義都不一樣,但它們都有共同之處——需要制定

binding

(可以認為是這個Group下的子地址)和

visibility

(在哪種著色器可見)。而這裡構建的BindingGroup最終會設定到Pass中指定地址給Shader使用,在Shader中我們同樣需要定義它們的結構:

[[block]] struct UniformsMaterial {

[[align(16)]] u_baseColorFactor: vec4

};

[[group(2), binding(0)]] var material: UniformsMaterial;

[[group(2), binding(1)]] var u_baseColorTextures: texture_2d

[[group(2), binding(2)]] var u_sampler: sampler;

struct Debug {

origin: vec4

dir: vec4

};

[[block]] struct DebugInfo {

rays: array

};[[group(2), binding(3)]] var u_debugInfo: DebugInfo;

這裡將一個BindingGroup分為了幾個部分,所有的向量、矩陣等uniform型別的資料被打包在

UniformsMaterial

這個struct中,texture、sampler和storage型別的資料則需要分離出來,並且都有各自的定義形式,這個就是為何我們上面要透過

materia。u_baseColorFactor

去取uniform資料,而直接用

u_texture

來取紋理資料。

還需要注意的是各種字首,

group(2)

表明這是繫結的第2個BindingGroup,這和前面說過的全域性、單元、材質的UniformBlock是繫結在不同級別的一致。事實上前面也給出過其他兩個級別的例子,在頂點著色器中使用到的

global。u_vp * mesh。u_world

就是在全域性0和單元1級別的。而後面的

binding(x)

則需要和ts中的宣告一致。同時還要注意的是uniform型別的struct的

align(16)

,這是為了強制每一項16位元組對齊,同時可能使用的還是

stride

這樣的修飾。

由於BindingGroup的建立和Shader描述耦合十分高,其中還有位元組對齊之類的問題,為了方便使用、減少冗餘程式碼,在引擎中我將BindingGroup的構造、Shader對應的struct的定義生成、Uniform管理都深度融合了,將在下面的UBTemplate論述。

計算著色器

計算著色器和前兩種都不同,其不屬於渲染管線的一部分,利用的是GPU的通用計算能力,所以也沒有頂點輸入、畫素輸出之類的要求,下面是一個比較糙的對影象卷積濾波的例子:

let c_radius: i32 = ${RADIUS};

let c_windowSize: i32 = ${WINDOW_SIZE};

[[stage(compute), workgroup_size(c_windowSize, c_windowSize, 1)]]

fn main(

[[builtin(workgroup_id)]] workGroupID : vec3

[[builtin(local_invocation_id)]] localInvocationID : vec3

) {

let size: vec2 = textureDimensions(u_input, 0);

let windowSize: vec2 = vec2(c_windowSize, c_windowSize);

let groupOffset: vec2 = vec2(workGroupID。xy) * windowSize;

let baseIndex: vec2 = groupOffset + vec2(localInvocationID。xy);

let baseUV: vec2 = vec2(baseIndex) / vec2(size);

var weightsSum: f32 = 0。;

var res: vec4 = vec4(0。, 0。, 0。, 1。);

for (var r: i32 = -c_radius; r <= c_radius; r = r + 1) {

for (var c: i32 = -c_radius; c <= c_radius; c = c + 1) {

let iuv: vec2 = baseIndex + vec2(r, c);

if (any(iuv < vec2(0)) || any(iuv >= size)) {

continue;

}

let weightIndex: i32 = (r + c_radius) * c_windowSize + (c + c_radius);

let weight: f32 = material。u_kernel[weightIndex / 4][weightIndex % 4];

weightsSum = weightsSum + weight;

res = res + weight * textureLoad(u_input, iuv, 0);

}

}

res = res / f32(weightsSum);

textureStore(u_output, baseIndex, res);

}

main

函式的修飾

stage(compute)

表明這是一個計算著色器,除此之外後面還有

workgroup_size(c_windowSize, c_windowSize, 1)

,讀者應該注意到了這裡有三個維度的引數,還記得我們前面在論述

ComputeUint

時提到的

groupSize

以及

pass。dispatch(x, y, z)

的三個引數嗎?它們毫無疑問是有關聯的,這就是

執行緒組

的概念。

這裡不再贅述卷積濾波的原理,只需要知道它每次要對一個N X N視窗內的畫素做處理,所以這裡定義了一個視窗大小

windowSize

、也就定義了一個二維的(第三個維度為1)、大小為

size = windowSize X windowSize

的執行緒組,這表明一個執行緒組有這麼大數量的執行緒,如果我們要處理紋理資料、同時每個執行緒處理一個畫素,那這麼一個執行緒組可以處理

size

個數量的畫素,那麼如果我們要處理一張

width x height

大小的圖片,就應當將

groupSize

設定

(width / windowSize, height / windowSize, 1)

不同平臺上允許的最大執行緒數量有限制,但一般都應當是

16 x 16

以上。

除此之外,可以看到計算著色器確實沒有頂點畫素之類的概念,但它有輸入

workgroup_id

local_invocation_id

,這其實就是

執行緒組的偏移

執行緒組內執行緒的偏移

,透過它們我們就可以得到真正的執行緒偏移,也可以作為紋理取樣的依據。能夠取樣紋理,就可以進行計算,最後將計算的結果透過

textureStore

寫回輸出紋理即可,輸出紋理是一個

write

型別的

storageTexture

著色器中的

${WINDOW_SIZE}

這種也屬於我自己實現的簡陋的宏的一部分。

Geometry

幾何體

Geometry

本質上就是頂點資料和索引資料的集合。在OpenGL中,我們往往會自己組織一個結構來描述頂點資料的儲存構造,在WebGPU中,API標準即將這個結構定死了,其為

GPUVertexBufferLayout[]

。這個結構的描述加上

vertexBuffer

indexBuffer

即構成了整個渲染單元的幾何資訊:

constructor(

protected _vertexes: {

layout: {

attributes: (GPUVertexAttribute & {name: string})[],

arrayStride: number

},

data: TTypedArray,

usage?: number

}[],

protected _indexData: Uint16Array | Uint32Array,

public count: number,

protected _boundingBox?: IBoundingBox

) {

this。_iBuffer = createGPUBuffer(_indexData, GPUBufferUsage。INDEX);

this。_vBuffers = new Array(_vertexes。length);

this。_vLayouts = new Array(_vertexes。length);

this。_indexFormat = _indexData instanceof Uint16Array ? ‘uint16’ : ‘uint32’;

this。_vInfo = {};

this。_marcos = {};

this。_attributesDef = ‘struct Attrs {\n’;

_vertexes。forEach(({layout, data, usage}, index) => {

const vBuffer = createGPUBuffer(data, GPUBufferUsage。VERTEX | (usage | 0));

layout。attributes。forEach((attr) => {

this。_marcos[`USE_${attr。name。toUpperCase()}`] = true;

this。_attributesDef += ` [[location(${attr。shaderLocation})]] ${attr。name}: ${this。_convertFormat(attr。format)};\n`;

this。_vInfo[attr。name。toLowerCase()] = {

data, index,

offset: attr。offset / 4, stride: layout。arrayStride / 4, length: this。_getLength(attr。format)

};

});

this。_vBuffers[index] = vBuffer;

this。_vLayouts[index] = layout;

this。_vertexCount = data。byteLength / layout。arrayStride;

});

this。_attributesDef += ‘};\n\n’;

}

這是Geometry的構造方法,可以看到其主要是將多個

vertexBuffer

以及對應的

layout

、一個

indexBuffer

、頂點個數

count

處理,最終生成WebGPU需要的

VertexLayout

GPUBuffer

,同時還生成了其他必要的資料。VertexLayout這是描述無須贅述,這裡主要需要注意

GPUBuffer

和所謂

其他資料

的生成。

GPUBuffer

首先是GPUBuffer,其在WebGPU中很常見,除了紋理之外所有在CPU和GPU傳輸的資料都是GPUBuffer。在歷經數次迭代後,我們可以用一段簡短的程式碼來建立它:

export function createGPUBuffer(array: TTypedArray, usage: GPUBufferUsageFlags) {

const size = array。byteLength + (4 - array。byteLength % 4);

const buffer = renderEnv。device。createBuffer({

size,

usage: usage | GPUBufferUsage。COPY_DST,

mappedAtCreation: true

});

const view = new (array。constructor as {new(buffer: ArrayBuffer): TTypedArray})(buffer。getMappedRange(0, size));

view。set(array, 0);

buffer。unmap();

return buffer;

}

這段程式碼透過傳入的

TypedArray

建立一個同尺寸(但需要位元組對齊)的GPUBuffer,並將其資料的值在初始化的時候複製給它。

宏和Shader

在建立Geometry的過程中還會生成別的重要資料:

一張宏的表

_marcos

,所有用到的頂點屬性都會以

USE_XXX

的形式存在,然後作用在Shader中,比如上面頂點著色器示例那樣。

頂點相關的Shader型別定義資料

_attributesDef

,自動組裝出需要的拼接到Shader頭部的字串。

Texture

前面提到了BindingGroup的各種型別,其中有一大類就是紋理

Texture

,而紋理在引擎實現中又可以分為2D紋理和Cube紋理。

2D紋理

紋理在WebGPU中的建立很簡單:

this。_gpuTexture = renderEnv。device。createTexture({

label: this。hash,

size: {width: this。_width, height: this。_height, depthOrArrayLayers: this。_arrayCount},

format: _format || ‘rgba8unorm’,

usage: GPUTextureUsage。TEXTURE_BINDING | GPUTextureUsage。COPY_DST | GPUTextureUsage。RENDER_ATTACHMENT

});

if (isTextureSourceArray(_src)) {

_src。forEach((src, index) => this。_load(src, index));

this。_gpuTextureView = this。_gpuTexture。createView({dimension: ‘2d-array’, arrayLayerCount: this。_arrayCount});

} else {

this。_load(_src);

this。_gpuTextureView = this。_gpuTexture。createView();

}

首先我們需要用

device。createTexture

來建立一個紋理,這裡需要注意的引數是

size

中的

depthOrArrayLayers

,其在型別為

2d-array

的時候是陣列的數量。建立了紋理後用

_load

方法來載入並上傳紋理資料,最終呼叫

createView

方法建立

view

並快取。而對於

_load

的實現,則根據來源是

Buffer

或影象有不同的做法:

_loadImg(img: ImageBitmap, layer: number) {

renderEnv。device。queue。copyExternalImageToTexture(

{source: img},

{texture: this。_gpuTexture, origin: this。_isArray ? {x: 0, y: 0, z: layer} : undefined},

{width: this。_width, height: this。_height, depthOrArrayLayers: 1}

);

}

_loadBuffer(buffer: ArrayBuffer, layer: number) {

renderEnv。device。queue。writeTexture(

{texture: this。_gpuTexture, origin: this。_isArray ? {x: 0, y: 0, z: layer} : undefined},

buffer as ArrayBuffer,

{bytesPerRow: this。_width * 4},

{width: this。_width, height: this。_height, depthOrArrayLayers: 1}

);

}

透過影象提供紋理資料,主要使用

device。queue。copyExternalImageToTexture

方法,但要求傳入的是一個

ImageBitMap

,這個可以使用以下程式碼生成:

const img = document。createElement(‘img’);

img。src = src;

await img。decode();

const bitmap = await createImageBitmap(img);

而使用Buffer的話,則直接用

device。queue。writeTexture

即可。

Cube紋理

Cube紋理和2D紋理大差不差,區別在於其初始化的時候

depthOrArrayLayers

為6,並且需要在初始化的時候提交六張紋理,並且在

origin

引數中的

z

為1~6。

RenderTexture

說完Texture,順便可以直接說說可渲染紋理

RenderTexture

,顧名思義,這是一種可以用於用於繪製或寫入資料的紋理。和通常的RenderTexture的設計一樣,為了和MRT(多渲染目標)技術相容,本引擎的RenderTexture也設計為多個

colorTexture

和一個

depthTexture

的形式,來看看它的構造引數:

export interface IRenderTextureOptions {

width: number;

height: number;

forCompute?: boolean;

colors: {

name?: string,

format?: GPUTextureFormat

}[];

depthStencil?: {

format?: GPUTextureFormat;

needStencil?: boolean;

};

}

具體的實現不貼了,就是根據

colors

depthStencil

中的引數去建立不同的

GPUTexture

,然後使用每個

color

name

建表索引,之後建立

view

快取。這裡有個特別的引數

forCompute

(是否用於計算著色器),主要決定了建立

GPUTexture

時的

usage

,如果是則需要開啟

GPUTextureUsage。STORAGE_BINDING

建立好的RenderTexture可以和Texture直接一樣直接用於渲染來源資料,但其最重要的功能是用於繪製,關於如何繪製,已經在前面的Camera章節說過了,將其按照順序設定到

GPURenderPassDescriptor

即可。

UBTemplate

UBTemplate

,可以認為是前面提了無數次的

UniformBlock

的模板,當然到這裡讀者應該察覺到了——這個UniformBlock遠不止管理了Uniform,也管理了紋理、取樣器、SSBO等等,只不過出於習慣這麼稱呼。而這也可以認為是整個引擎最複雜的一部分,因為它不僅涉及到了這些資料的建立和更新,還涉及到了Shader相關定義的生成。而在WebGPU和WGSL規範中,尤其是Uniform部分的規範又非常繁瑣,比如各種位元組對齊(align、stride),在方便使用的前提下,UBTemplate需要自動抹平這些複雜性,對外暴露足夠簡單的構造和介面,當然這是有代價的——會造成部分的記憶體浪費。

將UBTemplate所做的全部工作列出來實在會篇幅過長,而且必要性不大,這裡就說一些核心的地方,剩下的讀者自己去看程式碼即可。首先讓我們看一下它的構造引數:

constructor(

protected _uniformDesc: IUniformsDescriptor,

protected _groupId: EUBGroup,

protected _visibility?: number,

);

export enum EUBGroup {

Global = 0,

Material = 1,

Mesh = 2

}

export type TUniformValue = TUniformTypedArray | Texture | CubeTexture | GPUSamplerDescriptor | RenderTexture;

export interface IUniformsDescriptor {

uniforms: {

name: string,

type: ‘number’ | ‘vec2’ | ‘vec3’ | ‘vec4’ | ‘mat2x2’ | ‘mat3x3’ | ‘mat4x4’,

format?: ‘f32’ | ‘u32’ | ‘i32’,

size?: number,

customType?: {name: string, code: string, len: number},

defaultValue: TUniformTypedArray

}[],

textures?: {

name: string,

format?: GPUTextureSampleType,

defaultValue: Texture | CubeTexture,

storageAccess?: GPUStorageTextureAccess,

storageFormat?: GPUTextureFormat

}[],

samplers?: {

name: string,

defaultValue: GPUSamplerDescriptor

}[],

storages?: {

name: string,

type: ‘number’ | ‘vec2’ | ‘vec3’ | ‘vec4’,

format?: ‘f32’ | ‘u32’ | ‘i32’,

customStruct?: {name: string, code: string},

writable?: boolean,

defaultValue: TUniformTypedArray,

gpuValue?: GPUBuffer

}[]

}

可見主要是

_uniformDesc

_groupId

,後者很簡單,決定UB將用於哪個級別,這決定生成的Shader定義中Uniform部分的是

global

mesh

還是

material

。而前者就相對複雜了,其主要是四個部分:

uniforms:Uniform部分,這一部分的資料一般都是細粒度的向量、矩陣等,支援陣列,在實際生成最後,會將他們都打包成一個大的Buffer,這個Buffer是

嚴格16位元組對齊的

,這是什麼意思呢?比如一個

Vector3陣列

,將會被生成為

[[align(16)]] ${name}: [[stride(16)]] array, 4>;

。也就是說雖然這個資料只佔用

4 x 3 x 4

個位元組的空間即可描述,但這裡強制使其佔用

4 x 4 x 4

的空間,在每個

vec3

元素後都填了一位。在使用

setUniform

設定值的時候也會自動按照這個對齊規則來設定。

textures:紋理部分,和前面給出的建立

BindingGroup

時使用基本一致,但注意這裡有引數

storageAccess

storageFormat

,用於生成

storageTexture

的定義,一般用於給CS提供可寫入的RenderTexture。

samplers:取樣器部分,和前面給出的建立

BindingGroup

時使用完全一致。

storages:SSBO部分,這是一種可以在CPU和GPU共享的特殊Buffer,其可以儲存相對大量的資料,並可以在GPU的CS中寫入和讀取、也可以在CPU中寫入和讀取。在本專案中我一般用於除錯CS。

uniforms和storage都提供了

customStruct

customType

引數來讓使用者自定義結構,而非預設生成,提供了自由度。

每次建立UBTemplate的時候,實際上都只是生成了

BindingGroup

中的

layout

部分和CPU端的預設值等,於此同時還生成了Shader中對應的Uniform相關的定義字串。而在後續實際渲染中,我們將使用

createUniformBlock

方法實際建立UB時,返回的是:

export interface IUniformBlock {

isBufferDirty: boolean;

isDirty: boolean;

layout: GPUBindGroupLayout;

entries: GPUBindGroupEntry[];

cpuBuffer: Uint32Array;

gpuBuffer: GPUBuffer;

values: {

[name: string]: {

value: TUniformValue,

gpuValue: GPUBuffer | GPUSampler | GPUTextureView

}

};

}

後續便可以使用

setUniform(ub: IUniformBlock, name: string, value: TUniformValue, rtSubNameOrGPUBuffer?: string | GPUBuffer);

方法和

getUniform(ub: IUniformBlock, name: string)

給用到UBTemplate的物件提供修改和獲取的支援。還記得前面說到的幾個UniformBlock嗎?其實就是用UBTemplate生成的,其中

renderEnv

管理了全域性的UniformBlock,

Mesh

/

ImageMesh

/

ComputeUint

管理了單元級別的UniformBlock,而即將說到的Material管理了材質級別的UniformBlock,它們被設定到不同的地址,共同完成渲染。

在最終,也就是後續會提到的建立BindingGroup的那一步,使用的是

getBindingGroup

方法,在上面說到的幾類物件上都有

bindingGroup

訪問器代理到這裡:

public getBindingGroup(ub: IUniformBlock, preGroup: GPUBindGroup) {

if (ub。isBufferDirty) {

renderEnv。device。queue。writeBuffer(

ub。gpuBuffer as GPUBuffer,

0,

ub。cpuBuffer

);

ub。isBufferDirty = false;

}

if (ub。isDirty) {

preGroup = renderEnv。device。createBindGroup({

layout: ub。layout,

entries: ub。entries

});

ub。isDirty = false;

}

return preGroup;

}

可見這裡主要做了兩件事,第一件事是檢查髒位更新Uniform部分的資料,第二則是檢查並更新

group

快取返回。

Effect

效果

Effect

可以認為是對Shader、UniformBlock、渲染狀態和宏的一個管理器,也可以認為是一個模板,以供後面的Material例項化,其構造引數為:

export interface IRenderStates {

cullMode?: GPUCullMode;

primitiveType?: GPUPrimitiveTopology;

blendColor?: GPUBlendComponent;

blendAlpha?: GPUBlendComponent;

depthCompare?: GPUCompareFunction;

}

export interface IEffectOptionsRender {

vs: string;

fs: string;

uniformDesc: IUniformsDescriptor;

marcos?: {[key: string]: number | boolean};

renderState?: IRenderStates;

}

export interface IEffectOptionsCompute {

cs: string;

uniformDesc: IUniformsDescriptor;

marcos?: {[key: string]: number | boolean};

}

export type TEffectOptions = IEffectOptionsRender | IEffectOptionsCompute;

除去

vs/fs

(用於渲染)和

cs

(用於計算)的區分,通用的部分是:

UBTemplate構造引數

uniformDesc

:指定這個Effect提供的預設UniformBlock結構來建立UBTemplate。

宏物件

marcos

:Effect能夠支援的宏特性列表,後續生成Shader的時候會使用。

渲染狀態

renderState

:Effect提供的預設渲染狀態,這裡只定義了幾個我用到過的,實際上還有不少。

Effect的功能並不多,其最重要的是對外暴露了

createDefaultUniformBlock

getShader

兩個方法,以供最後的渲染使用。前者將會在Material中用到,後者則會在最後的Pipeline中用到。

Material

材質

Material

可以看做是例項化後的Effect,其構造如下:

constructor(

protected _effect: Effect,

values?: {[name: string]: TUniformValue},

marcos?: {[key: string]: number | boolean},

renderStates?: IRenderStates

) {

super();

this。_uniformBlock = _effect。createDefaultUniformBlock();

if (values) {

Object。keys(values)。forEach(name => this。setUniform(name, values[name]));

}

this。_marcos = marcos || {};

this。_renderStates = renderStates || {};

}

可見其實很簡單,就是利用擁有的Effect構建了一個材質級別的UniformBlock並設定初始值,然後提供了宏

marcos

和渲染狀態

renderState

,後續也可以修改和獲取這些宏和渲染狀態。需要注意的是,Material還提供了

version

(number)型別,來記錄版本,以便於後續Pipeline的更新。

Pipeline

講了這麼多,基本所有的要素都極其了,終於到了整個流程的最後一步——管線

Pipeline

。Pipeline的建立是分別實現在

Mesh

ImageMesh

ComputeUint

中的,也就是前面在論述這三者時提到的

_createPipeline

方法,如果讀者還記得,其實這裡就利用了上面說到的

material。version

來判斷版本做快取。這是因為和BindingGroup一樣,建立Pipeline的開銷並不低。

在Mesh中,建立的實現為:

const {device} = renderEnv;

const {_geometry, _material, _ubTemplate} = this;

this。_bindingGroup = this。_ubTemplate。getBindingGroup(this。_uniformBlock, this。_bindingGroup);

const marcos = Object。assign({}, _geometry。marcos, _material。marcos);

const {vs, fs} = _material。effect。getShader(marcos, _geometry。attributesDef, renderEnv。shaderPrefix, _ubTemplate。shaderPrefix);

this。_pipelines[rt。pipelineHash] = device。createRenderPipeline({

layout: device。createPipelineLayout({bindGroupLayouts: [

renderEnv。uniformLayout,

_material。effect。uniformLayout,

_ubTemplate。uniformLayout

]}),

vertex: {

module: vs,

entryPoint: “main”,

buffers: _geometry。vertexLayouts

},

fragment: {

module: fs,

targets: rt。colorFormats。map(format => ({

format,

blend: _material。blendColor ? {

color: _material。blendColor,

alpha: _material。blendAlpha

} : undefined

})),

entryPoint: “main”

},

primitive: {

topology: _material。primitiveType,

cullMode: _material。cullMode

},

depthStencil: rt。depthStencilFormat && {

format: rt。depthStencilFormat,

depthWriteEnabled: true,

depthCompare: _material。depthCompare

}

});

可見這裡將之前所有部分基本都串起來了,首先合併兩個級別的宏,透過他們和Geometry的頂點資訊Shader定義、三個級別的UniformBlock的Shader定義來生成最終的

vs

fs

,然後使用

device。createRenderPipeline

方法將這一切都組裝起來,生成最終的Pipeline。

ImageMesh

和上面基本一致,只不過省去了不需要的

vertex

部分,而

ComputeUint

由於沒有頂點、片元,也沒有渲染狀態和渲染目標,更為簡單:

protected _createPipeline() {

const {device} = renderEnv;

const {_material} = this;

const marcos = Object。assign({}, _material。marcos);

const {cs} = _material。effect。getShader(marcos, ‘’, renderEnv。shaderPrefix, ‘’);

this。_pipeline = device。createComputePipeline({

layout: device。createPipelineLayout({bindGroupLayouts: [

renderEnv。uniformLayout,

_material。effect。uniformLayout

]}),

compute: {

module: cs,

entryPoint: “main”

}

});

}

至此,整個渲染引擎部分就此結束。

glTF和工作流

本來這裡想順便說說資源和工作流部分的,但篇幅已經太長了,就放在下一個章節講吧。