WebGPU Points(WebGPU 点渲染)
原文作者:webgpufundamentals Contributors
原文链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-points.html
核心优化要点(提前提炼)
-
用实例化四边形替代原生
point-list
WebGPU 原生点只支持 1×1 像素,通过stepMode: 'instance'将每个点扩展为 6 顶点四边形,可实现任意大小、纹理、旋转,并保持跨平台兼容。 -
对点位置乘
clipPos.w实现固定屏幕尺寸
在 3D 点渲染中,将点偏移量乘以裁剪空间w分量,抵消 GPU 的透视除法,使点无论距离相机远近都保持恒定的屏幕像素大小。 -
紧凑顶点布局 + 实例化绘制减少 CPU/GPU 开销
使用单个顶点缓冲存储位置、大小、旋转等属性,并通过draw(6, kNumPoints)一次绘制调用渲染所有点,避免逐个点的缓冲切换和多次 draw call。
1. 原生点渲染:point-list
WebGPU 支持绘制点。只需在渲染管线中将图元拓扑(primitive topology)设置为 'point-list'。
先从简单的顶点/片元着色器开始。位置使用裁剪空间坐标,片元着色器硬编码黄色:
struct Vertex {
@location(0) position: vec2f,
};
struct VSOutput {
@builtin(position) position: vec4f,
};
@vertex fn vs(vert: Vertex) -> VSOutput {
var vsOut: VSOutput;
vsOut.position = vec4f(vert.position, 0, 1);
return vsOut;
}
@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
return vec4f(1, 1, 0, 1); // 黄色
}
创建管线时设置拓扑为 'point-list':
const pipeline = device.createRenderPipeline({
label: '1 pixel points',
layout: 'auto',
vertex: {
module,
buffers: [
{
arrayStride: 2 * 4, // 2 个 float,各 4 字节
attributes: [
{shaderLocation: 0, offset: 0, format: 'float32x2'}, // position
],
},
],
},
fragment: {
module,
targets: [{ format: presentationFormat }],
},
+ primitive: {
+ topology: 'point-list',
+ },
});
填充 100 个随机裁剪空间点:
const rand = (min, max) => min + Math.random() * (max - min);
const kNumPoints = 100;
const vertexData = new Float32Array(kNumPoints * 2);
for (let i = 0; i < kNumPoints; ++i) {
const offset = i * 2;
vertexData[offset + 0] = rand(-1, 1);
vertexData[offset + 1] = rand(-1, 1);
}
const vertexBuffer = device.createBuffer({
label: 'vertex buffer vertices',
size: vertexData.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(vertexBuffer, 0, vertexData);
绘制:
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(kNumPoints);
pass.end();
得到 100 个 1 像素的黄点。但缺点很明显:所有点都只有 1 像素大小。WebGPU 原生只支持 1×1 点,若要更大尺寸,需要自己构建四边形并使用实例化。
2. 可调大小的点:实例化四边形
在顶点着色器中添加四边形顶点数组和尺寸属性,同时增加一个 uniform 传入目标纹理分辨率:
struct Vertex {
@location(0) position: vec2f,
+ @location(1) size: f32,
};
+ struct Uniforms {
+ resolution: vec2f,
+ };
struct VSOutput {
@builtin(position) position: vec4f,
};
+ @group(0) @binding(0) `var<uniform>` uni: Uniforms;
@vertex fn vs(
vert: Vertex,
+ @builtin(vertex_index) vNdx: u32,
) -> VSOutput {
+ let points = array(
+ vec2f(-1, -1),
+ vec2f( 1, -1),
+ vec2f(-1, 1),
+ vec2f(-1, 1),
+ vec2f( 1, -1),
+ vec2f( 1, 1),
+ );
var vsOut: VSOutput;
+ let pos = points[vNdx];
- vsOut.position = vec4f(vert.position, 0, 1);
+ vsOut.position = vec4f(vert.position + pos * vert.size / uni.resolution, 0, 1);
return vsOut;
}
@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
return vec4f(1, 1, 0, 1); // 黄色
}
JavaScript 侧添加逐实例属性(size),设置 stepMode: 'instance',并移除 point-list 拓扑:
const pipeline = device.createRenderPipeline({
label: 'sizeable points',
layout: 'auto',
vertex: {
module,
buffers: [
{
- arrayStride: 2 * 4, // 2 个 float,各 4 字节
+ arrayStride: (2 + 1) * 4, // 3 个 float,各 4 字节
+ stepMode: 'instance',
attributes: [
{shaderLocation: 0, offset: 0, format: 'float32x2'}, // position
+ {shaderLocation: 1, offset: 8, format: 'float32'}, // size
],
},
],
},
fragment: {
module,
targets: [{ format: presentationFormat }],
},
- primitive: {
- topology: 'point-list',
- },
});
顶点数据增加随机尺寸:
const kNumPoints = 100;
- const vertexData = new Float32Array(kNumPoints * 2);
+ const vertexData = new Float32Array(kNumPoints * 3);
for (let i = 0; i < kNumPoints; ++i) {
- const offset = i * 2;
+ const offset = i * 3;
vertexData[offset + 0] = rand(-1, 1);
vertexData[offset + 1] = rand(-1, 1);
+ vertexData[offset + 2] = rand(1, 32);
}
创建 uniform buffer 以传入分辨率:
const uniformValues = new Float32Array(2);
const uniformBuffer = device.createBuffer({
size: uniformValues.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const kResolutionOffset = 0;
const resolutionValue = uniformValues.subarray(
kResolutionOffset, kResolutionOffset + 2);
绑定组:
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: uniformBuffer },
],
});
渲染时更新分辨率并绘制实例:
// 从 canvas context 获取当前纹理
const canvasTexture = context.getCurrentTexture();
renderPassDescriptor.colorAttachments[0].view =
canvasTexture.createView();
+ // 更新 uniform buffer 中的分辨率
+ resolutionValue.set([canvasTexture.width, canvasTexture.height]);
+ device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
+ pass.setBindGroup(0, bindGroup);
- pass.draw(kNumPoints);
+ pass.draw(6, kNumPoints);
pass.end();
现在点可任意调大小。更重要的是,方案不依赖原生点支持,完全可移植。
3. 纹理化点
从顶点着色器传纹理坐标到片元着色器:
struct VSOutput {
@builtin(position) position: vec4f,
+ @location(0) texcoord: vec2f,
};
@vertex fn vs(
vert: Vertex,
@builtin(vertex_index) vNdx: u32,
) -> VSOutput {
let points = array(
vec2f(-1, -1),
vec2f( 1, -1),
vec2f(-1, 1),
vec2f(-1, 1),
vec2f( 1, -1),
vec2f( 1, 1),
);
var vsOut: VSOutput;
let pos = points[vNdx];
vsOut.position = vec4f(vert.position + pos * vert.size / uni.resolution, 0, 1);
+ vsOut.texcoord = pos * 0.5 + 0.5;
return vsOut;
}
片元着色器使用采样器和纹理:
+ @group(0) @binding(1) var s: sampler;
+ @group(0) @binding(2) var t: `texture_2d<f32>`;
@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
- return vec4f(1, 1, 0, 1); // 黄色
+ return textureSample(t, s, vsOut.texcoord);
}
用离屏 canvas 创建纹理(如前面导入纹理文章所述):
const ctx = new OffscreenCanvas(32, 32).getContext('2d');
ctx.font = '27px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('🥑', 16, 16);
const texture = device.createTexture({
size: [32, 32],
format: 'rgba8unorm',
usage: GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.RENDER_ATTACHMENT,
});
device.queue.copyExternalImageToTexture(
{ source: ctx.canvas, flipY: true },
{ texture, premultipliedAlpha: true },
[32, 32],
);
创建采样器并加入绑定组:
const sampler = device.createSampler({
minFilter: 'linear',
magFilter: 'linear',
});
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: uniformBuffer },
+ { binding: 1, resource: sampler },
+ { binding: 2, resource: texture },
],
});
开启混合实现透明度:
fragment: {
module,
- targets: [{ format: presentationFormat }],
+ targets: [
+ {
+ format: presentationFormat,
+ blend: {
+ color: {
+ srcFactor: 'one',
+ dstFactor: 'one-minus-src-alpha',
+ operation: 'add',
+ },
+ alpha: {
+ srcFactor: 'one',
+ dstFactor: 'one-minus-src-alpha',
+ operation: 'add',
+ },
+ },
+ },
+ ],
},
于是得到带纹理的点。
4. 逐点旋转
添加旋转属性,使用矩阵数学(cos/sin 构造 2x2 旋转矩阵):
struct Vertex {
@location(0) position: vec2f,
@location(1) size: f32,
+ @location(2) rotation: f32,
};
@vertex fn vs(
vert: Vertex,
@builtin(vertex_index) vNdx: u32,
) -> VSOutput {
let points = array(
vec2f(-1, -1),
vec2f( 1, -1),
vec2f(-1, 1),
vec2f(-1, 1),
vec2f( 1, -1),
vec2f( 1, 1),
);
var vsOut: VSOutput;
let pos = points[vNdx];
+ let c = cos(vert.rotation);
+ let s = sin(vert.rotation);
+ let rot = mat2x2f(
+ c, s,
+ -s, c,
+ );
- vsOut.position = vec4f(vert.position + pos * vert.size / uni.resolution, 0, 1);
+ vsOut.position = vec4f(vert.position + rot * pos * vert.size / uni.resolution, 0, 1);
vsOut.texcoord = pos * 0.5 + 0.5;
return vsOut;
}
管线添加旋转属性:
arrayStride: (2 + 1 + 1) * 4, // 4 个 float,各 4 字节
stepMode: 'instance',
attributes: [
{shaderLocation: 0, offset: 0, format: 'float32x2'}, // position
{shaderLocation: 1, offset: 8, format: 'float32'}, // size
+ {shaderLocation: 2, offset: 12, format: 'float32'}, // rotation
],
顶点数据:
const vertexData = new Float32Array(kNumPoints * 4);
for (let i = 0; i < kNumPoints; ++i) {
const offset = i * 4;
vertexData[offset + 0] = rand(-1, 1);
vertexData[offset + 1] = rand(-1, 1);
vertexData[offset + 2] = rand(10, 64);
+ vertexData[offset + 3] = rand(0, Math.PI * 2);
}
换一个指向性 emoji 纹理:
- ctx.fillText('🥑', 16, 16);
+ ctx.fillText('👉', 16, 16);
5. 3D 点
原理与 2D 类似,先完成标准 3D 顶点变换,再在裁剪空间添加四边形偏移。
先生成斐波那契球面顶点:
function createFibonacciSphereVertices({ numSamples, radius }) {
const vertices = [];
const increment = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < numSamples; ++i) {
const offset = 2 / numSamples;
const y = ((i * offset) - 1) + (offset / 2);
const r = Math.sqrt(1 - Math.pow(y, 2));
const phi = (i % numSamples) * increment;
const x = Math.cos(phi) * r;
const z = Math.sin(phi) * r;
vertices.push(x * radius, y * radius, z * radius);
}
return new Float32Array(vertices);
}
3D 点着色器(初始仍使用原生 point-list,仅 1px):
struct Vertex {
@location(0) position: vec4f,
};
struct Uniforms {
matrix: mat4x4f,
};
struct VSOutput {
@builtin(position) position: vec4f,
};
@group(0) @binding(0) `var<uniform>` uni: Uniforms;
@vertex fn vs(vert: Vertex) -> VSOutput {
var vsOut: VSOutput;
let clipPos = uni.matrix * vert.position;
vsOut.position = clipPos;
return vsOut;
}
@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
return vec4f(1, 0.5, 0.2, 1); // 橙色
}
管线与缓冲设置类似于 2D 版本,但顶点格式为 float32x3,并设置 topology: 'point-list'。渲染循环使用 requestAnimationFrame,每帧更新 MVP 矩阵。
初始 1px 3D 点视觉效果很差,因此应用与 2D 相同的四边形扩展:
struct Uniforms {
matrix: mat4x4f,
+ resolution: vec2f,
+ size: f32,
};
@vertex fn vs(
vert: Vertex,
+ @builtin(vertex_index) vNdx: u32,
) -> VSOutput {
+ let points = array(
+ vec2f(-1, -1),
+ vec2f( 1, -1),
+ vec2f(-1, 1),
+ vec2f(-1, 1),
+ vec2f( 1, -1),
+ vec2f( 1, 1),
+ );
var vsOut: VSOutput;
+ let pos = points[vNdx];
let clipPos = uni.matrix * vert.position;
+ let pointPos = vec4f(pos * uni.size / uni.resolution, 0, 0);
- vsOut.position = clipPos;
+ vsOut.position = clipPos + pointPos;
return vsOut;
}
在 JS 中,uniform buffer 扩大以包含矩阵(16 floats)、分辨率(2 floats)和尺寸(1 float,额外 padding)。顶点缓冲设置 stepMode: 'instance'(因为每个点是一个实例)。最后用 pass.draw(6, kNumPoints) 替代 pass.draw(kNumPoints)。
这样得到 3D 点,且点会随距离相机的距离自然缩放(透视效果)。
6. 固定屏幕尺寸的 3D 点
若希望点在屏幕上保持固定像素大小,需要抵消透视除法。GPU 会将顶点着色器输出的位置除以 w,远物变小。若在计算点偏移时乘上 clipPos.w,则在除法后大小不变:
var vsOut: VSOutput;
let pos = points[vNdx];
let clipPos = uni.matrix * vert.position;
- let pointPos = vec4f(pos * uni.size / uni.resolution, 0, 0);
+ let pointPos = vec4f(pos * uni.size / uni.resolution * clipPos.w, 0, 0);
vsOut.position = clipPos + pointPos;
return vsOut;
现在点无论远近都保持相同屏幕尺寸。
7. 为什么 WebGPU 原生只支持 1×1 像素点?
WebGPU 构建于 Vulkan、Metal、DirectX 等原生 GPU API 之上。这些 API 对点渲染的支持并不一致:
- 某些 API 对点的最大尺寸有设备相关限制;
- 某些 API 在点中心位于裁剪空间之外时不渲染点,而另一些 API 仍会渲染;
- 在某些 API 中,上述行为甚至取决于驱动实现。
因此,WebGPU 为了可移植性,最终决定只支持 1×1 像素的点。幸运的是,如上所示,自己实现更大的点非常容易。这些方案跨设备可移植,没有尺寸限制,并且裁剪行为一致——无论点的中心是否在裁剪空间外,都会正确绘制位于裁剪空间内的部分。
更重要的是,自定义方案更灵活。例如,旋转点并非原生 API 支持的功能。通过自己实现,我们可以轻松添加更多特性,扩展性更强。
术语检查与关键对照
| 英文术语 | 推荐中文翻译 | 备注 |
|---|---|---|
| primitive topology | 图元拓扑 | WebGPU 中 primitive.topology 的值,常见 point-list、triangle-list |
| point-list | 点列表 | 渲染点时的图元拓扑模式 |
| vertex buffer | 顶点缓冲 | 存放顶点属性数据 |
| arrayStride | 数组步长 | 相邻顶点(或实例)之间的字节数 |
| stepMode: 'instance' | 步进模式:实例 | 逐实例推进顶点属性,核心优化点之一 |
| instancing | 实例化 | 一次绘制调用渲染多个实例 |
| uniform buffer | 统一缓冲 | 存储全局只读数据,对 GPU 高效 |
| bind group | 绑定组 | 组织和绑定资源到着色器 |
| clip space | 裁剪空间 | 顶点着色器输出的坐标空间,GPU 随后执行透视除法 |
| perspective divide | 透视除法 | GPU 自动将位置除以 w,产生近大远小效果 |
| W component / clipPos.w | W 分量 | 裁剪空间齐次坐标的第四分量,用于固定屏幕尺寸技巧 |
| fragment shader | 片元着色器 | 决定像素颜色 |
| vertex shader | 顶点着色器 | 处理顶点变换 |
| render pipeline | 渲染管线 | 封装着色器、混合、拓扑等状态 |
| blending | 混合 | 透明度/混合模式配置 |
| sampler | 采样器 | 定义纹理过滤方式(如 linear) |
| texture coordinates | 纹理坐标 | 用于采样纹理的 UV 坐标 |
| mipmap | 多级纹理 | 本文章未涉及,但注意不要与 WebGL 术语混淆 |
| rgba8unorm | RGBA8 无符号归一化 | 纹理格式 |
| offscreen canvas | 离屏 Canvas | 用于生成纹理图案 |
| premultipliedAlpha | 预乘 Alpha | 处理透明纹理时的重要概念 |
以上术语均按照 WebGPU/WGSL 语义审校,避免与 GLSL/WebGL 旧术语混淆。例如 WebGPU 中“uniform buffer”对应 GLSL 中的 uniform block,但底层语义一致;两者均属于全局只读数据。
