WebGPU 入门:在浏览器中计算数组与绘制三角形
WebGPU 让网页既能用图形处理器(GPU)绘图,也能执行通用的并行计算,例如批量处理图像像素或更新模拟中的粒子。JavaScript 负责准备数据和提交任务,GPU 上的小程序用 WGSL(WebGPU Shading Language,WebGPU 着色语言)编写。这个小程序通常称为“着色器”,但计算着色器不需要颜色、图像或画布。MDN 的 WebGPU 概览区分了计算管线与渲染管线的用途。
本文先把 [1, 2, 3, 4, 5] 交给 GPU,读回 [2, 4, 6, 8, 10],再用独立页面绘制一个三角形。你需要能看懂基本 JavaScript 数组和 async/await,并准备浏览器、文本编辑器和本地静态服务器。无需打包器、npm 依赖、账号、CUDA 安装或云 API。
想先看到结果,可以从“在本地运行数组计算”开始,再回头理解资源模型。两个示例都是可独立保存的 HTML 文件,不需要修改网站组件。
什么工作适合交给 GPU
中央处理器(CPU)擅长处理低延迟的控制逻辑、复杂分支和前后依赖较强的任务。CPU 也有多核和向量运算能力,不能简单理解为“CPU 串行、GPU 并行”。GPU 的优势场景是让大量执行单元对许多元素进行相似操作;能否充分利用它,取决于数据规模、访存方式和算法的依赖关系。
例如,数组每个元素乘二,彼此没有依赖,可以同时计算。但创建设备、编译着色器、建立管线、上传数据、安排执行和读回结果都需要工作。对五个数做一次乘法,普通 JavaScript 往往是更合理的选择。本例用于看清正确的数据流,不用于证明加速。
Chrome 的 WebGPU 介绍说明了它与现代原生 GPU API 的关系。选择浏览器 API 还是原生计算生态,首先取决于程序要在哪里运行,而不是未经测量的性能排名。
先检查实际能力,再看浏览器版本
WebGPU 需要安全上下文。通常使用 HTTPS;本地开发时,http://localhost:8000 或 http://127.0.0.1:8000 也属于可被信任的本地来源。普通 HTTP 局域网地址不等于 localhost。本文统一使用本地服务器,不以双击 file:// 文件作为操作路径。
按顺序检查:
window.isSecureContext是否为真。- 是否有
navigator.gpu。 requestAdapter()是否返回非空 adapter。adapter.requestDevice()是否成功完成。- 创建的
device.features和device.limits是否满足实际任务。
Adapter 可以理解为浏览器选出的 GPU 能力入口;返回 null 表示没有获得可用入口,不能仅凭这个值断定“驱动坏了”。浏览器配置、运行环境、设备与驱动、所需能力以及资源状态都可能影响可用性。设备创建失败也必须捕获,不能把“属性存在”当作初始化成功。MDN 的初始化说明给出了这条检查路径。
官方发布记录能告诉我们什么
以下是截至 2026-09-12 查阅资料时可引用的发布节点,不是覆盖所有设备的当前支持表:
- Chrome 概览记录了 Chrome 113 在使用 Vulkan 的 ChromeOS、使用 Direct3D 12 的 Windows 和 macOS 上的首批支持;Android 的节点是 Chrome 121、Android 12 及以上、Qualcomm 或 ARM GPU。
- 同一概览列出了 Firefox 141 在 Windows 上的支持,以及 Safari 26 的发布节点。这些信息不能外推为所有 Firefox 平台或所有 Apple 系统、设备均可用。
- 概览中 Linux “即将支持”的表述较旧。Chrome 144 的官方更新已经描述了从 Intel Gen12 及更新 GPU 开始的谨慎 Linux 推出过程,并说明 WebGPU 使用 Vulkan。这是逐步推出的记录,不代表全部 Linux 显卡都能使用。
不必根据这份历史列表猜测自己的机器,也不要默认开启实验标志或降低安全设置。以上运行时检查才决定当前页面能否使用 WebGPU;面向用户的产品还应在目标浏览器、操作系统和设备上测试。
可选功能与限制
adapter.features 和 adapter.limits 表示可申请的能力;device.features 和 device.limits 表示创建出的设备实际暴露的能力。可选功能必须先检查,再放进 requiredFeatures;确有需要时才通过 requiredLimits 请求支持的限制值。请求无法满足的能力会使设备创建失败,具体契约见 requestDevice()。
不要一次申请所有功能和最大限制。本例不要求任何可选功能。扩大数据量时,再按需要检查缓冲区大小、storage binding 大小、工作组与派发数量等限制。也不要把所有限制理解成“越大越好”:最大容量与偏移对齐要求的比较语义不同。
读代码前,认识这些对象
可以把本例的顺序记成:分配 → 上传 → 绑定 → 记录计算 → 结束 pass → 记录复制 → 提交 → 等待读回 → 释放。JavaScript 不会为每个数组元素调用一次回调;一次派发会在 GPU 上启动许多 WGSL 执行实例。对象间的详细契约见 WebGPU API。
在本地运行数组计算
在一个新的学习目录中,把下面的完整内容保存为 index.html。保留 .html 扩展名,不要保存成 .html.txt。示例中的状态和错误消息保留英文,便于与 API 文档对照。
它把输入上传到 storage buffer,在原处乘二,再复制到专门用于 CPU 读取的 staging buffer。这里的 staging 是中转区域,不是另一次计算。失败时页面会明确显示 CPU 回退结果,不会把它冒充 GPU 输出。
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>WebGPU: double an array</title>
<h1>WebGPU compute</h1>
<pre id="output" role="status">Starting…</pre>
<script type="module">
const output = document.querySelector('#output');
const input = new Float32Array([1, 2, 3, 4, 5]);
const WORKGROUP_SIZE = 64;
async function main() {
let device;
let storage;
let staging;
let scopeOpen = false;
let asyncFailure = null;
try {
if (!window.isSecureContext) {
throw new Error('Use HTTPS or a localhost static server.');
}
if (!navigator.gpu) {
throw new Error('WebGPU is unavailable in this browser/context.');
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error('No usable WebGPU adapter was returned.');
}
device = await adapter.requestDevice();
device.lost.then(info => {
if (info.reason !== 'destroyed') {
asyncFailure = new Error(`GPU device lost: ${info.message}`);
output.textContent = asyncFailure.message;
}
});
device.addEventListener('uncapturederror', event => {
asyncFailure = new Error(event.error.message);
output.textContent = `GPU error: ${event.error.message}`;
});
device.pushErrorScope('validation');
scopeOpen = true;
storage = device.createBuffer({
label: 'In-place float data',
size: input.byteLength,
usage: GPUBufferUsage.STORAGE |
GPUBufferUsage.COPY_DST |
GPUBufferUsage.COPY_SRC,
});
staging = device.createBuffer({
label: 'CPU readback',
size: input.byteLength,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
device.queue.writeBuffer(storage, 0, input);
const module = device.createShaderModule({
label: 'Double each float',
code: `
@group(0) @binding(0)
var<storage, read_write> values: array<f32>;
@compute @workgroup_size(${WORKGROUP_SIZE})
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
let i = id.x;
if (i >= arrayLength(&values)) {
return;
}
values[i] = values[i] * 2.0;
}
`,
});
const diagnostics = await module.getCompilationInfo();
const shaderErrors = diagnostics.messages.filter(m => m.type === 'error');
if (shaderErrors.length) {
throw new Error(shaderErrors.map(m =>
`${m.lineNum}:${m.linePos} ${m.message}`).join('\n'));
}
const pipeline = await device.createComputePipelineAsync({
layout: 'auto',
compute: {module, entryPoint: 'main'},
});
const bindings = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [{binding: 0, resource: {buffer: storage}}],
});
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindings);
pass.dispatchWorkgroups(Math.ceil(input.length / WORKGROUP_SIZE));
pass.end();
encoder.copyBufferToBuffer(storage, 0, staging, 0, input.byteLength);
device.queue.submit([encoder.finish()]);
const scopeResult = device.popErrorScope();
scopeOpen = false;
const validationError = await scopeResult;
if (validationError) throw new Error(validationError.message);
await staging.mapAsync(GPUMapMode.READ);
// Copy before unmap: the mapped ArrayBuffer is detached by unmap().
const result = new Float32Array(staging.getMappedRange().slice(0));
staging.unmap();
if (asyncFailure) throw asyncFailure;
const correct = result.every((value, i) => value === input[i] * 2);
if (!correct) throw new Error('Result verification failed.');
output.textContent =
`Input: ${JSON.stringify(Array.from(input))}\n` +
`GPU: ${JSON.stringify(Array.from(result))}\nVerified.`;
} catch (error) {
// Explicit educational fallback, never presented as a GPU result.
const cpu = input.map(value => value * 2);
output.textContent =
`WebGPU did not complete: ${error.message ?? String(error)}\n` +
`CPU fallback: ${JSON.stringify(Array.from(cpu))}`;
} finally {
if (scopeOpen && device) {
try { await device.popErrorScope(); } catch { /* Original error shown. */ }
}
if (staging?.mapState === 'mapped') staging.unmap();
staging?.destroy();
storage?.destroy();
// This is a one-shot demo. A real app normally keeps its device alive.
device?.destroy();
}
}
main();
</script>
</html>
在这个目录打开终端。如果已经安装 Python 3,运行:
python3 -m http.server 8000 --bind 127.0.0.1
Windows 上已有的 Python 安装也可能使用 py -3 -m http.server 8000 --bind 127.0.0.1。已有其他静态服务器也可以使用,无需为本例安装新依赖。只绑定回环地址,避免无意中把目录开放给局域网;目录里只放本次练习文件。
打开 http://127.0.0.1:8000/,正常完成后页面应显示:
Input: [1,2,3,4,5]
GPU: [2,4,6,8,10]
Verified.
如果端口已被占用,把命令和访问地址里的 8000 一起换成 8001。若看到目录列表,检查文件名和启动服务器的目录。结束练习后在终端按 Ctrl+C 停止服务器。
若页面显示 WebGPU did not complete,紧接着的消息说明失败发生在哪里;CPU fallback 只说明普通 JavaScript 得到了结果。先检查地址、安全上下文、浏览器和设备初始化,不要删掉错误处理来“修复”示例。
为什么要这样创建和读取缓冲区
Float32Array 每个元素占 4 字节,五个元素共 20 字节。WGSL 的 array<f32> 在这里使用同样的连续布局。JavaScript 中的 binding 0 对应 WGSL 的 @group(0) @binding(0)。layout: 'auto' 方便单管线教学,但不要假定从一个自动布局创建的绑定组可直接用于其他无关管线。
Storage buffer 的三种用途各有作用:STORAGE 允许着色器访问;COPY_DST 允许 queue.writeBuffer() 上传;COPY_SRC 允许把结果复制出去。读回缓冲区只用 COPY_DST | MAP_READ。根据 WebGPU 缓冲区规则,不能把 MAP_READ 和 STORAGE 直接拼到同一个缓冲区上。
mapAsync()完成后,CPU 才能通过映射范围访问数据。本例中偏移量为 0、大小为 20 字节,满足映射偏移是 8 的倍数、大小是 4 的倍数的要求。先复制映射内容再 unmap(),是因为解除映射会使原来的映射视图失效。
submit() 返回不表示 GPU 已算完。这里等 mapAsync() 就足以等待读回,不必额外在它前面调用 queue.onSubmittedWorkDone()。正常路径读完才释放缓冲区;失败路径终止本次任务并释放资源。这个一次性页面最后销毁设备,实际应用通常复用一个设备,而不是每处理一批数据就重新创建。
工作组大小不是派发数量
@workgroup_size(64) 表示每个工作组在 X 方向有 64 个执行实例。dispatchWorkgroups(1) 派发一个这样的组,不是只处理一个元素。五个元素会产生 0–63 的索引,所以 5–63 必须在越界检查处返回。
处理 1,000 个元素时,Math.ceil(1000 / 64) 得到 16 个工作组,共 1,024 个执行实例,最后 24 个返回。64 是便于说明的选择,不是所有硬件上的最优值。检查 maxComputeWorkgroupSizeX、maxComputeInvocationsPerWorkgroup 和 maxComputeWorkgroupsPerDimension,再决定更大任务如何派发。
每个实例只写自己的元素,因此这里没有同步屏障。保留非空输入;若改成通用函数,应在 CPU 上处理空数组,并在分配前检查字节大小和绑定限制。这个例子里的小整数乘二可以用精确相等验证;一般浮点计算需要根据问题设置误差容限。
一个直接的练习是把运算改成 values[i] = values[i] + 3.0。同时把 JavaScript 验证和 CPU 回退里的乘二改成加三,预期结果是 [4,5,6,7,8]。只改着色器会让正确性检查失败,这正是参考结果应发挥的作用。
再画一个三角形
计算管线只处理数据;渲染管线还要把结果写到图像目标。下面的 triangle.html 是独立完整页面,保存在同一目录后访问 http://127.0.0.1:8000/triangle.html。它绘制一次,并在窗口大小改变时重画,没有连续动画,也不把每帧像素读回 CPU。
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>WebGPU triangle</title>
<style>
canvas { display: block; width: min(90vw, 640px); height: 360px; }
</style>
<h1>WebGPU triangle</h1>
<pre id="status" role="status">Starting…</pre>
<canvas id="canvas" aria-label="A colored triangle on a dark background">
This example requires canvas support.
</canvas>
<script type="module">
const status = document.querySelector('#status');
const canvas = document.querySelector('#canvas');
let device;
let context;
let frame = 0;
let stopped = false;
let listening = false;
let scopeOpen = false;
function stop() {
stopped = true;
cancelAnimationFrame(frame);
if (listening) window.removeEventListener('resize', scheduleDraw);
context?.unconfigure();
device?.destroy();
}
function fail(error) {
status.textContent = `Cannot render with WebGPU: ${error.message ?? error}`;
stop();
}
let draw = () => {};
function scheduleDraw() {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
if (!stopped) {
try { draw(); } catch (error) { fail(error); }
}
});
}
window.addEventListener('pagehide', stop, {once: true});
async function main() {
try {
if (!window.isSecureContext || !navigator.gpu) {
throw new Error('Use a supporting browser over HTTPS or localhost.');
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) throw new Error('No usable GPU adapter.');
device = await adapter.requestDevice();
if (stopped) { device.destroy(); return; }
device.lost.then(info => {
if (info.reason !== 'destroyed') fail(new Error(`Device lost: ${info.message}`));
});
device.addEventListener('uncapturederror', event => fail(event.error));
context = canvas.getContext('webgpu');
if (!context) throw new Error('No WebGPU canvas context.');
const format = navigator.gpu.getPreferredCanvasFormat();
device.pushErrorScope('validation');
scopeOpen = true;
context.configure({device, format, alphaMode: 'opaque'});
const module = device.createShaderModule({code: `
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) color: vec3<f32>,
}
@vertex
fn vs(@builtin(vertex_index) index: u32) -> VertexOutput {
let positions = array<vec2<f32>, 3>(
vec2<f32>(0.0, 0.7),
vec2<f32>(-0.7, -0.7),
vec2<f32>(0.7, -0.7)
);
let colors = array<vec3<f32>, 3>(
vec3<f32>(1.0, 0.2, 0.2),
vec3<f32>(0.2, 1.0, 0.2),
vec3<f32>(0.2, 0.4, 1.0)
);
var result: VertexOutput;
result.position = vec4<f32>(positions[index], 0.0, 1.0);
result.color = colors[index];
return result;
}
@fragment
fn fs(input: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(input.color, 1.0);
}
`});
const pipeline = await device.createRenderPipelineAsync({
layout: 'auto',
vertex: {module, entryPoint: 'vs'},
fragment: {module, entryPoint: 'fs', targets: [{format}]},
primitive: {topology: 'triangle-list'},
});
const scopeResult = device.popErrorScope();
scopeOpen = false;
const error = await scopeResult;
if (error) throw new Error(error.message);
if (stopped) return;
draw = () => {
const ratio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const limit = device.limits.maxTextureDimension2D;
const desiredWidth = Math.max(1, Math.round(rect.width * ratio));
const desiredHeight = Math.max(1, Math.round(rect.height * ratio));
const scale = Math.min(1, limit / desiredWidth, limit / desiredHeight);
const width = Math.max(1, Math.floor(desiredWidth * scale));
const height = Math.max(1, Math.floor(desiredHeight * scale));
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
clearValue: {r: 0.03, g: 0.04, b: 0.08, a: 1},
loadOp: 'clear',
storeOp: 'store',
}],
});
pass.setPipeline(pipeline);
pass.draw(3);
pass.end();
device.queue.submit([encoder.finish()]);
};
window.addEventListener('resize', scheduleDraw);
listening = true;
draw();
status.textContent = 'Triangle submitted. Resize the window to redraw.';
} catch (error) {
fail(error);
} finally {
if (scopeOpen && device) {
try { await device.popErrorScope(); } catch { /* Failure shown above. */ }
}
}
}
main();
</script>
</html>
预期画面是深色背景上的彩色三角形。Triangle submitted 表示命令已提交,并不单凭这条消息证明屏幕呈现成功。没有可用 GPU 时页面显示明确错误;这个渲染示例没有另行实现 WebGL 回退。
顶点、片元和画布分别做什么
顶点着色器 vs 用 vertex_index 生成三个顶点的位置和颜色。位置使用裁剪空间坐标;这里 w = 1,X 和 Y 的 -1 到 1 对应目标区域的两端。为了让几何过程清楚,这个小例子把三个点写在着色器中,不需要顶点缓冲区或绑定组。
光栅化会为三角形覆盖的区域生成片元,并插值顶点颜色;片元着色器 fs 返回该位置的颜色。片元是渲染过程中的候选贡献,不能在所有情况下都等同于最终屏幕上的一个像素。draw(3) 让顶点阶段处理三个顶点,triangle-list 把它们组成三角形。
getPreferredCanvasFormat()返回当前系统推荐的画布格式。管线里的 targets: [{format}] 要与画布一致,不要固定使用某台桌面机器上的格式。每次绘制都重新取得 getCurrentTexture(),不要把一次取得的呈现纹理永久缓存。WebGPU 画布规范说明了配置与呈现纹理的生命周期。
CSS 宽高决定页面布局,canvas.width 和 canvas.height 决定背后实际绘制的像素数。代码按 devicePixelRatio 放大像素尺寸,再按 maxTextureDimension2D 等比例限制。loadOp: 'clear' 配合 clearValue 清背景;storeOp: 'store' 保留结果用于呈现。
这个固定页面只监听窗口 resize。嵌入复杂布局时,还需处理元素自身尺寸与像素比例的变化。本例在 pagehide 时清理设备;若页面通过浏览器前进后退缓存恢复,应重新初始化,练习时也可直接重新加载。不要直接把这段一次性页面生命周期当作完整组件框架。
数据布局:JavaScript 数组必须与 WGSL 对齐
第一个例子的标量数组没有额外填充,但三维坐标常会遇到布局问题。WGSL 对齐与大小规范规定如下 storage 元素布局,单位均为字节:
“大小”是值本身占用的空间;“对齐”限制起始地址;“步长”是相邻数组元素起点之间的距离。数组步长按元素对齐要求向上补齐,所以 array<vec3<f32>> 中每个点应在主机侧占四个浮点槽位,而不是三个:
const points = new Float32Array([
1, 2, 3, 0, // x, y, z, padding
4, 5, 6, 0,
]);
第四个槽位是填充,不是 WGSL vec3 的第四个分量。包含两个 vec3f 成员的结构体,成员偏移为 0 和 16,总大小为 32;更多例子见 WGSL 数组布局说明。Uniform 地址空间还有额外规则,不能把 storage 打包方式直接照搬过去。
结构成员对齐、数组步长、buffer binding 偏移对齐、映射范围对齐是不同约束。数据值奇怪或验证失败时,先按字节检查布局和实际绑定范围,不要只看数组元素数量。数值程序还要留意 WGSL f32 与 JavaScript 默认 Number 的精度差异。
扩大任务时,先处理同步与错误
工作组内的屏障只能同步该组参与的执行实例,不能让所有工作组在某处集合。需要跨组通信的算法通常拆成多次有顺序的派发。若加了 workgroupBarrier(),不要未经分析就保留“部分实例提前返回、其他实例到达屏障”的控制流;WGSL 对屏障所在控制流有一致性要求。相关规则见 WGSL 一致性分析。
在 GPU 仍需要使用某个缓冲区时,不要把同一个缓冲区保持为映射状态。反复运行时复用管线和资源,把中间数据留在 GPU,CPU 确实需要时才读回。多个中转缓冲区可以用于更复杂的重叠调度,但不是入门例子正确运行的前提。
错误作用域 pushErrorScope() / popErrorScope() 捕获所选类别的异步 GPU 错误;popErrorScope() 返回首个匹配错误或 null。示例只捕获 validation,不代表捕获了内存不足和内部错误等所有类别;uncapturederror 监听器处理未被作用域捕获的错误。普通 JavaScript try/catch 本身不能取代这些机制,详见 MDN 错误处理说明。
device.lost 即使在刚创建设备后也可能完成。设备恢复不能复用旧设备的缓冲区、纹理、绑定组或管线。主动调用 device.destroy() 也会触发丢失通知,因此代码跳过 reason === 'destroyed',避免把正常清理当成恢复请求。
不用的自有缓冲区和纹理应及时销毁,映射应解除。管线和绑定组没有通用的 .destroy() 方法,释放引用即可。画布当前呈现纹理由浏览器管理,不应把它当作自行分配、永久持有的纹理。
怎样判断是否真的更快
先写出正确的 CPU 参考实现,再选择真实输入规模。测量至少分清两个问题:首次打开页面多久得到结果,以及复用设备、管线和缓冲区后,一批工作多久完成。
- 把设备初始化与着色器、管线创建成本单独记录,不与热运行混为一谈。
- 端到端比较使用同样的计时范围,包括需要的上传、计算、复制、等待、读回和结果处理。
- 只在
queue.submit()前后计时,测到的主要是提交过程。第一个例子若要测“JavaScript 何时拿到结果”,应计时到await mapAsync()及结果复制完成。 - 重复测量并报告波动、数据规模、浏览器、操作系统和 GPU 条件,不凭一次观察宣布普遍加速。
- 把正确性验证放在适当位置,数值任务说明浮点误差容限;不要为了得到更好看的时间省略必要的数据传输。
这些建议来自上述提交与读回过程,不构成任何速度承诺。可选的 timestamp-query 可用于更细的 GPU 时间分析,但要另行检查和请求支持,也不能代替端到端时间。计算后直接渲染、不读回 CPU,是另一种有价值的工作负载,应与“每次结果都返回 JavaScript”的任务分别测量。
适合的应用与继续练习
图像滤镜、大批粒子更新、规则数值数组、部分模拟内核和浏览器端机器学习推理,都值得评估 WebGPU:它们可能有足够多的独立工作来分摊调度和传输成本。这是选题方向,不是已测得的加速结论。机器学习还要核对所用框架、算子和目标浏览器;科学计算要先确认精度、算法依赖、内存容量和结果验证要求。
少量数组、DOM 操作、UI 状态、强串行逻辑和不断往返的小结果通常更适合普通 JavaScript 或 CPU worker。浏览器提供的能力、内存或客户设备覆盖面不够时,再考虑原生程序或服务器。WebGPU 不提供直接操作 DOM 的能力,也不会绕过浏览器沙箱。
Site Lab 提供了可对照的现有实验:棱镜色散使用 p5.js 批量 WebGL 绘制,ASCII 地球展示了 Three.js 场景如何变成字符,Rössler 吸引子连接数值积分与轨迹呈现。它们是学习对照,不是已经转换为 WebGPU 的实现。
可先把数组例子扩展为一批彼此独立的粒子更新,再尝试让渲染阶段直接使用计算结果,避免每帧读回。比较 Rössler 模型时要保留时间步之间的依赖:一条轨迹的下一步需要上一步结果,不能把所有时间步简单独立派发;多条不同初始条件的轨迹才可能提供另一层并行性。更多开发与项目工具见工具与工作流。