本文首發於我的部落格,請規範轉載
本系列文章涉及的所有程式碼均已開源,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
#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
,這裡要注意
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
vec2
vec2
vec2
vec2
vec2
vec2
);
var
vec2
vec2
vec2
vec2
vec2
vec2
);
[[stage(vertex)]]
fn main([[builtin(vertex_index)]] VertexIndex : u32) -> VertexOutput {
var output: VertexOutput;
output。position = vec4
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
描述符需要一個
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
[[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
這裡將一個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
let windowSize: vec2
let groupOffset: vec2
let baseIndex: vec2
let baseUV: vec2
var weightsSum: f32 = 0。;
var res: vec4
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
if (any(iuv < vec2
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 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和工作流
本來這裡想順便說說資源和工作流部分的,但篇幅已經太長了,就放在下一個章節講吧。