各位观众老爷,晚上好!今儿咱们来聊聊 JavaScript 的新玩具——WebGPU,这玩意儿能让咱的浏览器摇身一变,变成一个 GPU 计算平台。是不是听起来有点儿科幻?别慌,其实也没那么难,今天就带大家伙儿一块儿上手玩玩。
开场白:为啥要用 WebGPU?
话说 JavaScript 这门语言,最初的定位只是在浏览器里搞点儿小动画、验证表单啥的。但随着互联网应用越来越复杂,光靠 CPU 吭哧吭哧地算,那速度简直是蜗牛爬树。这时候,我们就想,能不能让浏览器也能用上 GPU 的强大计算能力呢?
于是乎,WebGPU 就应运而生了。它提供了一个低级的、跨平台的 API,让 JavaScript 能够直接访问 GPU 的硬件加速功能。这意味着啥?这意味着咱们可以用 JavaScript 来做一些以前想都不敢想的事情,比如:
- 高性能的图形渲染: 复杂的 3D 场景、实时光照效果,统统不在话下。
- 并行计算: 图像处理、物理模拟、机器学习,GPU 的并行能力简直是神器。
- 通用计算: 只要是能并行化的任务,都可以交给 GPU 去算,让 CPU 歇歇脚。
第一幕:准备工作——硬件和软件
要玩 WebGPU,首先得确认你的设备支持。
- 硬件要求: 必须得有一块像样的 GPU,现在的独立显卡基本都支持,集成显卡也行,但性能可能会打折扣。
- 软件要求:
- 浏览器: Chrome、Firefox、Safari 的最新版本都支持 WebGPU。
- 操作系统: Windows、macOS、Linux 都可以。
确认硬件软件没问题后,就可以撸起袖子开干了!
第二幕:WebGPU 的基本概念
在深入代码之前,咱们先了解一下 WebGPU 的几个核心概念:
概念 | 解释 |
---|---|
GPUAdapter | 代表一个 GPU 设备。你可以把它想象成你的显卡。 |
GPUDevice | 代表与 GPU 的一个逻辑连接。通过它,你可以创建各种资源,比如纹理、缓冲区、着色器等等。 |
GPUQueue | 用于提交命令的队列。所有的 GPU 操作都是通过命令提交到队列中执行的。 |
GPUBuffer | 代表 GPU 内存中的一块缓冲区。可以用来存储顶点数据、索引数据、uniform 数据等等。 |
GPUTexture | 代表 GPU 内存中的一张纹理。可以用来存储图像数据、颜色数据等等。 |
GPUShaderModule | 包含着色器代码的模块。着色器是用 WGSL (WebGPU Shading Language) 编写的,用于控制 GPU 如何渲染图形或执行计算。 |
GPURenderPipeline | 定义了如何渲染图形。它包含了顶点着色器、片元着色器、以及渲染状态的配置。 |
GPUComputePipeline | 定义了如何执行通用计算。它包含一个计算着色器,以及计算状态的配置。 |
GPUBindGroup | 用于将资源(比如缓冲区、纹理)绑定到着色器。它可以让你在着色器中访问这些资源。 |
GPUCommandEncoder | 用于创建命令。你可以使用它来创建渲染命令、计算命令、以及复制命令。 |
GPUCommandBuffer | 包含一系列命令的缓冲区。你可以将它提交到 GPUQueue 中执行。 |
是不是有点儿晕?没关系,咱们结合代码来理解。
第三幕:第一个 WebGPU 程序—— Hello Compute!
咱们先来写一个最简单的 WebGPU 程序,实现一个向量加法的计算。
async function main() {
// 1. 获取 GPUAdapter
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
console.error("No WebGPU adapter found.");
return;
}
// 2. 获取 GPUDevice
const device = await adapter.requestDevice();
// 3. 创建两个输入缓冲区
const bufferA = device.createBuffer({
size: 16, // 4个float,每个float 4个字节
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
mappedAtCreation: true, // 创建时映射
});
new Float32Array(bufferA.getMappedRange()).set([1, 2, 3, 4]);
bufferA.unmap(); // 解除映射
const bufferB = device.createBuffer({
size: 16,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
mappedAtCreation: true,
});
new Float32Array(bufferB.getMappedRange()).set([5, 6, 7, 8]);
bufferB.unmap();
// 4. 创建一个输出缓冲区
const bufferResult = device.createBuffer({
size: 16,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, // 可以作为存储,也可以复制到别的地方
});
// 5. 创建一个计算着色器模块
const shaderModule = device.createShaderModule({
code: `
@group(0) @binding(0) var<storage, read_write> a: array<f32>;
@group(0) @binding(1) var<storage, read_write> b: array<f32>;
@group(0) @binding(2) var<storage, read_write> result: array<f32>;
@compute @workgroup_size(1)
fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
let i: u32 = global_id.x;
result[i] = a[i] + b[i];
}
`,
});
// 6. 创建一个计算管线
const computePipeline = device.createComputePipeline({
layout: 'auto', // 让 WebGPU 自动推断 layout
compute: {
module: shaderModule,
entryPoint: "main",
},
});
// 7. 创建一个绑定组
const bindGroup = device.createBindGroup({
layout: computePipeline.getBindGroupLayout(0), // 获取 layout
entries: [
{ binding: 0, resource: { buffer: bufferA } },
{ binding: 1, resource: { buffer: bufferB } },
{ binding: 2, resource: { buffer: bufferResult } },
],
});
// 8. 创建一个命令编码器
const commandEncoder = device.createCommandEncoder();
// 9. 创建一个计算通道编码器
const passEncoder = commandEncoder.beginComputePass();
// 10. 设置管线和绑定组
passEncoder.setPipeline(computePipeline);
passEncoder.setBindGroup(0, bindGroup);
// 11. 调度计算着色器
passEncoder.dispatchWorkgroups(4); // 处理 4 个元素
// 12. 结束计算通道
passEncoder.end();
// 13. 将结果从 GPU 复制到 CPU
const readBuffer = device.createBuffer({
size: 16,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
commandEncoder.copyBufferToBuffer(bufferResult, 0, readBuffer, 0, 16);
// 14. 提交命令
const commandBuffer = commandEncoder.finish();
device.queue.submit([commandBuffer]);
// 15. 读取结果
await readBuffer.mapAsync(GPUMapMode.READ);
const resultArray = new Float32Array(readBuffer.getMappedRange());
console.log("Result:", resultArray); // 输出 [6, 8, 10, 12]
readBuffer.unmap();
}
main();
这段代码做了啥?
- 获取 GPU 设备: 首先,我们通过
navigator.gpu.requestAdapter()
和adapter.requestDevice()
获取 GPUAdapter 和 GPUDevice。 - 创建缓冲区: 创建了三个缓冲区:
bufferA
和bufferB
用于存储输入数据,bufferResult
用于存储计算结果。 - 创建着色器模块: 创建了一个计算着色器模块
shaderModule
,其中的 WGSL 代码实现了向量加法。 - 创建计算管线: 创建了一个计算管线
computePipeline
,它定义了如何执行计算着色器。 - 创建绑定组: 创建了一个绑定组
bindGroup
,它将缓冲区绑定到着色器。 - 创建命令编码器: 创建了一个命令编码器
commandEncoder
,用于创建命令。 - 创建计算通道编码器: 创建了一个计算通道编码器
passEncoder
,用于配置计算通道。 - 调度计算着色器: 使用
passEncoder.dispatchWorkgroups(4)
调度计算着色器,让 GPU 执行计算。 - 复制结果: 将计算结果从
bufferResult
复制到readBuffer
,然后读取readBuffer
中的数据。
第四幕:深入 WGSL——WebGPU 的着色器语言
上面的例子中,我们用到了 WGSL (WebGPU Shading Language) 来编写着色器代码。WGSL 是一种专门为 WebGPU 设计的着色器语言,它借鉴了 GLSL 和 SPIR-V 的一些特性,但更加现代化、安全和易于使用。
咱们来仔细看看上面的 WGSL 代码:
@group(0) @binding(0) var<storage, read_write> a: array<f32>;
@group(0) @binding(1) var<storage, read_write> b: array<f32>;
@group(0) @binding(2) var<storage, read_write> result: array<f32>;
@compute @workgroup_size(1)
fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
let i: u32 = global_id.x;
result[i] = a[i] + b[i];
}
@group(0) @binding(0) var<storage, read_write> a: array<f32>;
: 这行代码声明了一个存储缓冲区a
,它是一个浮点数数组。@group(0)
和@binding(0)
指定了它在绑定组中的位置。var<storage, read_write>
表示这是一个可读写的存储缓冲区。@compute @workgroup_size(1)
: 这行代码声明了一个计算着色器函数main
,@compute
表示这是一个计算着色器,@workgroup_size(1)
表示每个工作组包含一个工作项。@builtin(global_invocation_id) global_id: vec3<u32>
: 这行代码声明了一个内置变量global_id
,它是一个三维向量,表示当前工作项的全局 ID。let i: u32 = global_id.x;
: 这行代码将全局 ID 的 x 分量赋值给变量i
,用于索引数组。result[i] = a[i] + b[i];
: 这行代码实现了向量加法,将a[i]
和b[i]
相加,然后赋值给result[i]
。
WGSL 的语法比较简单,但功能非常强大。你可以用它来编写各种复杂的着色器代码,实现各种炫酷的效果。
第五幕:渲染图形—— Hello Triangle!
除了通用计算,WebGPU 还可以用于图形渲染。咱们来写一个简单的程序,渲染一个三角形。
async function main() {
// 1. 获取 GPUAdapter 和 GPUDevice
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
console.error("No WebGPU adapter found.");
return;
}
const device = await adapter.requestDevice();
// 2. 获取 canvas
const canvas = document.getElementById("canvas");
const context = canvas.getContext("webgpu");
// 3. 配置 canvas
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device: device,
format: presentationFormat,
});
// 4. 创建顶点缓冲区
const vertices = new Float32Array([
0.0, 0.5, 0.0, // x, y, z
-0.5, -0.5, 0.0,
0.5, -0.5, 0.0,
]);
const vertexBuffer = device.createBuffer({
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
mappedAtCreation: true,
});
new Float32Array(vertexBuffer.getMappedRange()).set(vertices);
vertexBuffer.unmap();
// 5. 创建着色器模块
const shaderModule = device.createShaderModule({
code: `
@vertex
fn vertexMain(@location(0) pos: vec3<f32>) -> @builtin(position) vec4<f32> {
return vec4<f32>(pos, 1.0);
}
@fragment
fn fragmentMain() -> @location(0) vec4<f32> {
return vec4<f32>(1.0, 0.0, 0.0, 1.0); // 红色
}
`,
});
// 6. 创建渲染管线
const renderPipeline = device.createRenderPipeline({
layout: 'auto',
vertex: {
module: shaderModule,
entryPoint: "vertexMain",
buffers: [{
arrayStride: 12, // 每个顶点 12 个字节 (3 个 float)
attributes: [{
shaderLocation: 0, // 对应着色器中的 @location(0)
offset: 0,
format: GPUVertexFormat.float32x3,
}],
}],
},
fragment: {
module: shaderModule,
entryPoint: "fragmentMain",
targets: [{
format: presentationFormat,
}],
},
primitive: {
topology: "triangle-list", // 使用三角形列表
},
});
// 7. 渲染循环
function render() {
// 8. 获取纹理视图
const textureView = context.getCurrentTexture().createView();
// 9. 创建命令编码器
const commandEncoder = device.createCommandEncoder();
// 10. 创建渲染通道编码器
const renderPassDescriptor = {
colorAttachments: [{
view: textureView,
clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, // 清空画布
loadOp: "clear",
storeOp: "store",
}],
};
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
// 11. 设置管线和顶点缓冲区
passEncoder.setPipeline(renderPipeline);
passEncoder.setVertexBuffer(0, vertexBuffer);
// 12. 绘制三角形
passEncoder.draw(3, 1, 0, 0); // 3 个顶点, 1 个实例
// 13. 结束渲染通道
passEncoder.end();
// 14. 提交命令
const commandBuffer = commandEncoder.finish();
device.queue.submit([commandBuffer]);
requestAnimationFrame(render); // 循环渲染
}
render();
}
main();
这段代码做了啥?
- 获取 GPU 设备和 Canvas: 首先,我们获取 GPUAdapter、GPUDevice 和 Canvas。
- 配置 Canvas: 使用
context.configure()
配置 Canvas,指定设备和纹理格式。 - 创建顶点缓冲区: 创建一个顶点缓冲区
vertexBuffer
,存储三角形的顶点数据。 - 创建着色器模块: 创建一个着色器模块
shaderModule
,包含顶点着色器和片元着色器。 - 创建渲染管线: 创建一个渲染管线
renderPipeline
,它定义了如何渲染三角形。 - 渲染循环: 在
render()
函数中,我们获取纹理视图、创建命令编码器、创建渲染通道编码器、设置管线和顶点缓冲区、绘制三角形、结束渲染通道、提交命令,然后循环渲染。
第六幕:高级技巧——BindGroup 和 Uniform
在实际应用中,我们经常需要将一些数据(比如模型矩阵、颜色、纹理)传递到着色器。这时,就可以使用 BindGroup 和 Uniform。
- BindGroup: 用于将资源(比如缓冲区、纹理)绑定到着色器。
- Uniform: 用于将常量数据传递到着色器。
咱们来修改一下上面的三角形程序,添加一个 Uniform,控制三角形的颜色。
async function main() {
// 前面的代码不变,省略...
// 6. 创建 Uniform 缓冲区
const uniformBuffer = device.createBuffer({
size: 16, // 4 个 float (RGBA)
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
// 初始颜色:绿色
device.queue.writeBuffer(uniformBuffer, 0, new Float32Array([0.0, 1.0, 0.0, 1.0]));
// 7. 创建绑定组布局
const bindGroupLayout = device.createBindGroupLayout({
entries: [{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
buffer: {
type: "uniform",
},
}],
});
// 8. 创建绑定组
const bindGroup = device.createBindGroup({
layout: bindGroupLayout,
entries: [{
binding: 0,
resource: {
buffer: uniformBuffer,
},
}],
});
// 9. 创建渲染管线 (需要修改 layout)
const renderPipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
vertex: {
module: shaderModule,
entryPoint: "vertexMain",
buffers: [{
arrayStride: 12,
attributes: [{
shaderLocation: 0,
offset: 0,
format: GPUVertexFormat.float32x3,
}],
}],
},
fragment: {
module: shaderModule,
entryPoint: "fragmentMain",
targets: [{
format: presentationFormat,
}],
},
primitive: {
topology: "triangle-list",
},
});
// 修改后的着色器代码
const shaderModule = device.createShaderModule({
code: `
@group(0) @binding(0) var<uniform> color: vec4<f32>;
@vertex
fn vertexMain(@location(0) pos: vec3<f32>) -> @builtin(position) vec4<f32> {
return vec4<f32>(pos, 1.0);
}
@fragment
fn fragmentMain() -> @location(0) vec4<f32> {
return color; // 使用 Uniform 变量
}
`,
});
// 10. 渲染循环
function render() {
// 前面的代码不变,省略...
// 11. 设置绑定组
passEncoder.setBindGroup(0, bindGroup);
// 12. 绘制三角形
passEncoder.draw(3, 1, 0, 0);
// 前面的代码不变,省略...
}
render();
}
main();
这段代码做了啥?
- 创建 Uniform 缓冲区: 创建了一个 Uniform 缓冲区
uniformBuffer
,用于存储颜色数据。 - 创建绑定组布局: 创建了一个绑定组布局
bindGroupLayout
,描述了绑定组的结构。 - 创建绑定组: 创建了一个绑定组
bindGroup
,将 Uniform 缓冲区绑定到着色器。 - 修改渲染管线: 修改了渲染管线的布局,使用了绑定组布局。
- 修改着色器代码: 修改了着色器代码,从 Uniform 变量
color
中读取颜色数据。 - 设置绑定组: 在渲染循环中,使用
passEncoder.setBindGroup()
设置绑定组。
现在,三角形的颜色由 Uniform 变量控制,你可以通过修改 uniformBuffer
中的数据来改变三角形的颜色。
第七幕:性能优化——Command Buffer 和 Pipeline Cache
WebGPU 的性能优化也是一个重要的课题。这里介绍两个常用的技巧:
- Command Buffer: 尽可能地将多个命令组合到一个 Command Buffer 中,减少 GPU 的状态切换。
- Pipeline Cache: 缓存 Pipeline 对象,避免重复创建,提高渲染效率。
第八幕:总结与展望
今天咱们简单地聊了聊 WebGPU 的基本概念和用法,包括通用计算、图形渲染、BindGroup、Uniform 等等。WebGPU 是一门非常强大的技术,它可以让咱们在浏览器中实现各种高性能的应用。
当然,WebGPU 的学习曲线还是比较陡峭的,需要掌握一些底层的图形学知识。但只要你肯花时间学习,一定能掌握这门技术,创造出令人惊艳的作品。
WebGPU 的未来一片光明,它将成为 Web 开发的一个重要组成部分。相信在不久的将来,咱们就能看到更多基于 WebGPU 的精彩应用。
第九幕:互动与答疑
好了,今天的讲座就到这里。大家有什么问题可以提出来,咱们一块儿讨论讨论。