WebGPU Points(WebGPU 点渲染)

原文作者:webgpufundamentals Contributors
原文链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-points.html


核心优化要点(提前提炼)

  1. 用实例化四边形替代原生 point-list
    WebGPU 原生点只支持 1×1 像素,通过 stepMode: 'instance' 将每个点扩展为 6 顶点四边形,可实现任意大小、纹理、旋转,并保持跨平台兼容。

  2. 对点位置乘 clipPos.w 实现固定屏幕尺寸
    在 3D 点渲染中,将点偏移量乘以裁剪空间 w 分量,抵消 GPU 的透视除法,使点无论距离相机远近都保持恒定的屏幕像素大小。

  3. 紧凑顶点布局 + 实例化绘制减少 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.wW 分量裁剪空间齐次坐标的第四分量,用于固定屏幕尺寸技巧
fragment shader片元着色器决定像素颜色
vertex shader顶点着色器处理顶点变换
render pipeline渲染管线封装着色器、混合、拓扑等状态
blending混合透明度/混合模式配置
sampler采样器定义纹理过滤方式(如 linear)
texture coordinates纹理坐标用于采样纹理的 UV 坐标
mipmap多级纹理本文章未涉及,但注意不要与 WebGL 术语混淆
rgba8unormRGBA8 无符号归一化纹理格式
offscreen canvas离屏 Canvas用于生成纹理图案
premultipliedAlpha预乘 Alpha处理透明纹理时的重要概念

以上术语均按照 WebGPU/WGSL 语义审校,避免与 GLSL/WebGL 旧术语混淆。例如 WebGPU 中“uniform buffer”对应 GLSL 中的 uniform block,但底层语义一致;两者均属于全局只读数据。