跳到主要内容

计算着色器

计算着色器是在 GPU 上执行通用计算、独立于渲染管线的程序。与顶点和片元着色器不同,它不绑定几何体或像素,而是操作任意数据,适合粒子模拟、图像处理、物理计算和程序化内容生成。

注意

计算着色器仅受 WebGPU 支持,使用 WebGL 时不可用。

检查支持情况​

使用前先确认设备支持:

if (device.supportsCompute) {
// Compute shaders are available
}

WGSL 语言扩展​

浏览器提供可选 WGSL 功能时,例如线性工作组/调用索引、子组或半浮点,引擎会设置对应的 device.supports* 和 CAPS_*。完整列表和用法参见 WGSL 语言扩展。

创建计算着色器​

通过 Shader 类和 WGSL cshader 源码创建。使用简化语法声明 uniform、存储缓冲区、纹理和存储纹理,不写 @group/@binding;引擎从源码反射并自动构建绑定组。完整声明语法参见 WGSL 反射。

基本着色器定义​

const shader = new pc.Shader(device, {
name: 'MyComputeShader',
shaderLanguage: pc.SHADERLANGUAGE_WGSL,
cshader: `
// resources declared with the simplified syntax are reflected automatically -
// no computeBindGroupFormat required
uniform count: u32;
var<storage, read_write> data: array<f32>;

@compute @workgroup_size(64, 1, 1)
fn main(@builtin(global_invocation_id) global_id: vec3u) {
let i = global_id.x;
if (i >= uniform.count) { return; }
data[i] = data[i] * 2.0;
}
`
});
备注

若需显式控制绑定组,例如手写 computeBindGroupFormat、显式绑定索引或混合手动与反射资源,参见高级计算着色器。

入口函数​

默认入口名为 main。可通过 computeEntryPoint 指定其他名称,从而让一份源码包含多个入口:

const shaderSource = `
@compute @workgroup_size(64, 1, 1)
fn initParticles(@builtin(global_invocation_id) global_id: vec3u) {
// Initialize particles
}

@compute @workgroup_size(64, 1, 1)
fn updateParticles(@builtin(global_invocation_id) global_id: vec3u) {
// Update particles
}
`;

// Create separate shaders from the same source, each using a different entry point
const initShader = new pc.Shader(device, {
name: 'InitParticles',
shaderLanguage: pc.SHADERLANGUAGE_WGSL,
cshader: shaderSource,
computeEntryPoint: 'initParticles'
});

const updateShader = new pc.Shader(device, {
name: 'UpdateParticles',
shaderLanguage: pc.SHADERLANGUAGE_WGSL,
cshader: shaderSource,
computeEntryPoint: 'updateParticles'
});

创建计算实例​

Compute 类表示计算着色器及其参数的一次可执行实例:

const compute = new pc.Compute(device, shader, 'MyComputeInstance');

设置参数​

使用 setParameter 绑定资源并设置 uniform。资源按名称匹配声明:

// Bind a storage buffer
compute.setParameter('data', storageBuffer);

// Bind a texture
compute.setParameter('inputTexture', texture);

// Set uniform values
compute.setParameter('count', 1024);
compute.setParameter('tint', [1.0, 0.5, 0.0, 1.0]);

计算着色器声明的每个 uniform 和资源,都必须在计算实例上提供值。不使用图形设备作用域上的全局值;调试构建会报告缺值的声明。

创建存储缓冲区​

存储缓冲区保存计算着色器可读写的数据:

const storageBuffer = new pc.StorageBuffer(
device,
bufferSizeInBytes,
pc.BUFFERUSAGE_COPY_SRC | // Enable reading back to CPU
pc.BUFFERUSAGE_COPY_DST // Enable writing from CPU
);

// Write initial data
const data = new Float32Array([...]);
storageBuffer.write(0, data);

// Clear the buffer
storageBuffer.clear();

创建存储纹理​

使用 storage: true 创建存储纹理:

const storageTexture = new pc.Texture(device, {
name: 'StorageTexture',
width: 512,
height: 512,
format: pc.PIXELFORMAT_RGBA8,
mipmaps: false,
minFilter: pc.FILTER_LINEAR,
magFilter: pc.FILTER_LINEAR,
storage: true // Enable as storage texture
});

派发计算着色器​

先设置派发维度,再派发执行:

// Set up dispatch dimensions (number of workgroups in X, Y, Z)
compute.setupDispatch(width, height, 1);

// Dispatch the compute shader
device.computeDispatch([compute], 'MyDispatch');

可在单个计算通道内一起派发多个着色器:

compute1.setupDispatch(64, 64);
compute2.setupDispatch(128, 128);
device.computeDispatch([compute1, compute2], 'BatchedDispatch');
备注

device.computeDispatch 将任务记录到当前帧命令编码器,因此必须在渲染帧内调用,通常位于 app.on('update', ...) 处理函数。直接从帧外 setTimeout 或脱离帧的 Promise 调用并不可靠,可能静默跳过派发。

每个计算实例每帧最多派发一次。同一着色器要在一帧中执行多次,需为每次派发使用独立实例。实例可共享只编译一次的着色器:

const computes = [0.25, 0.5].map((strength) => {
const compute = new pc.Compute(device, shader, `Blur-${strength}`);
compute.setParameter('strength', strength);
compute.setupDispatch(width, height);
return compute;
});
device.computeDispatch(computes, 'Blur');

工作组大小​

总调用次数为 dispatchSize × workgroupSize。例如派发 (width, height)、着色器为 @workgroup_size(1, 1, 1),总调用数就是 width × height。

大型数据集建议更大的工作组,例如一维数据用 64,图像用 8 × 8:

@compute @workgroup_size(64, 1, 1)
fn main(@builtin(global_invocation_id) global_id: vec3u) {
// Process element at global_id.x
}

按线程数派发​

setupDispatchThreads 接收每维线程数,而非工作组数。一个线程对应一次着色器调用,通常处理一个元素或像素。工作组数根据声明的大小推导,声明必须使用整数字面量,如 @workgroup_size(8, 8)。通过 cdefines 注入的值会在读取大小前替换为字面量,参见宏与包含。

// a thread for each element
compute.setupDispatchThreads(numElements);

// a thread for each pixel of an image
compute.setupDispatchThreads(width, height);

派发向上取整到完整工作组,因此着色器应跳过超出请求范围的线程。dispatchThreadsCS 提供相应检查和索引函数:

#include "dispatchThreadsCS"

var<storage, read_write> values: array<f32>;

@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3u) {
if (!dispatchThreadInRange(id)) {
return;
}
let index = dispatchThreadIndex(id);
values[index] = values[index] * 2.0;
}

dispatchThreadsCS 提供:

函数说明
dispatchThreadCount() -> vec3u配置派发时指定的线程数量。
dispatchThreadInRange(globalId: vec3u) -> bool线程是否属于请求的范围。
dispatchThreadIndex(globalId: vec3u) -> u32线程线性索引,x 变化最快。

也可按自身边界(例如输出纹理尺寸)跳过线程。但设备每维工作组数有上限,至少为 65535。一维派发超过上限时会铺成多行,此时必须包含 dispatchThreadsCS 并使用考虑该布局的 dispatchThreadIndex。

间接派发​

间接派发允许一个计算着色器为另一个生成派发参数,无需 CPU 回读即可完全由 GPU 驱动,适用于:

  • 在 GPU 上确定可变工作量。
  • 动态计算分块数的分块处理。
  • GPU 剔除后仅处理可见元素。

预留派发槽位​

设备内置间接派发参数缓冲区,每帧预留槽位:

const slot = device.getIndirectDispatchSlot();

每槽包含三个 32 位无符号整数,表示 x、y、z 工作组数。最大槽位数由 device.maxIndirectDispatchCount 控制,默认 256。

写入派发参数​

在着色器中声明间接缓冲区(自动反射),并将参数写入预留槽位:

struct DispatchIndirectArgs {
x: u32,
y: u32,
z: u32
};

var<storage, read_write> indirectBuffer: array<DispatchIndirectArgs>;
uniform slot: u32; // slot index to write into

@compute @workgroup_size(1)
fn main() {
// Compute workload size dynamically
let workloadSize = calculateWorkload();

// Write dispatch parameters to the slot
indirectBuffer[uniform.slot].x = workloadSize;
indirectBuffer[uniform.slot].y = 1u;
indirectBuffer[uniform.slot].z = 1u;
}

使用间接派发​

通过 setupIndirectDispatch 让第二个计算着色器从缓冲区读取派发参数:

// Reserve a slot for this frame
const slot = device.getIndirectDispatchSlot();

// First pass: compute shader writes dispatch parameters
prepareCompute.setParameter('indirectBuffer', device.indirectDispatchBuffer);
prepareCompute.setParameter('slot', slot);
prepareCompute.setupDispatch(1, 1, 1);
device.computeDispatch([prepareCompute]);

// Second pass: dispatch using parameters from the buffer
processCompute.setupIndirectDispatch(slot);
device.computeDispatch([processCompute]);
备注

使用设备内置间接缓冲区时,槽位只在当前帧有效,因此必须每帧调用 setupIndirectDispatch。

自定义间接缓冲区​

对于帧外复杂调度等高级用途,可提供自定义存储缓冲区:

// Create a custom buffer for indirect dispatch
const customBuffer = new pc.StorageBuffer(device, 3 * 4, pc.BUFFERUSAGE_INDIRECT);

// Use custom buffer for indirect dispatch
compute.setupIndirectDispatch(0, customBuffer);

此时由你管理生命周期与内容,不进行帧有效性检查。

引擎 2.23 及以上会在设备恢复时重建计算资源,但存储缓冲区内容必须重写或重新生成。中断读取及显式清理参见设备丢失与恢复。

将数据回读到 CPU​

从存储缓冲区回读结果:

const resultData = new Float32Array(numElements);
storageBuffer.read(0, undefined, resultData).then((data) => {
// Process the data
console.log('First value:', data[0]);
});

GPU 操作异步执行,因此 read() 返回 Promise。数据在 GPU 完成计算后可用,可能延迟数帧。

对时间敏感的读取,可将第四个参数设为 immediate: true:

storageBuffer.read(0, undefined, resultData, true).then((data) => {
// Data available sooner, but with performance cost
});

默认 immediate: false 将读取延后到下一次事件处理周期,等待 GPU 命令缓冲区自然提交。immediate: true 则立即提交并执行读取。

注意

立即提交会提前提交命令缓冲区,影响性能,仅在必须低延迟回读时使用。

场景深度和颜色贴图​

计算着色器可读取相机渲染的场景深度和颜色贴图,实现屏幕空间效果或分析图像。

在相机组件上请求后,相机会渲染这些贴图:

cameraEntity.camera.requestSceneDepthMap(true);
cameraEntity.camera.requestSceneColorMap(true);

使用 CameraFrame 的相机需配置相应选项:

cameraFrame.rendering.sceneDepthMap = true;
cameraFrame.rendering.sceneColorMap = true;
cameraFrame.update();

将相机贴图附加到计算实例:

compute.setSceneDepthMap(cameraEntity.camera.sceneDepthMapHandle);
compute.setSceneColorMap(cameraEntity.camera.sceneColorMapHandle);

只需附加一次。每次派发使用相机最近一次渲染的贴图,因此在相机渲染前(例如 update 事件)派发时,会使用上一帧贴图。不再使用,或相机销毁之前,应传入 null 解除附加。

通过 sceneDepthCS 和 sceneColorCS 的函数访问贴图:

#include "sceneDepthCS"
#include "sceneColorCS"

var output: texture_storage_2d<rgba16float, write>;

@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) id: vec3u) {
// process the texels of the viewport the camera rendered to
let viewport = sceneDepthViewport();
if (id.x >= viewport.z || id.y >= viewport.w) {
return;
}
let texel = vec2i(viewport.xy + id.xy);

// the depth of the surface in front of the camera, and the scene color converted to linear
let depth = sceneDepthLinear(texel);
let color = sceneColorToLinear(sceneColorLoad(texel, 0).rgb);

// fade the scene to gray with distance
let fogged = mix(color, vec3f(0.5), saturate(depth / 100.0));
textureStore(output, texel, vec4f(fogged, 1.0));
}

sceneDepthCS 提供:

函数说明
sceneDepthSize() -> vec2u深度贴图尺寸。
sceneDepthViewport() -> vec4u相机渲染深度贴图的纹素视口:起始纹素 x、y,以及宽、高。
sceneDepthNearClip() -> f32渲染深度贴图所用的近裁剪平面。
sceneDepthFarClip() -> f32渲染深度贴图所用的远裁剪平面。
sceneDepthLinear(texel: vec2i) -> f32沿相机视线方向、以世界单位表示的表面线性深度。
sceneDepthWorldPosition(texel: vec2i) -> vec3f纹素处表面的世界位置,纹素必须在视口内。

sceneColorCS 提供:

函数说明
sceneColorSize(lod: i32) -> vec2u颜色贴图指定 mip 层级的尺寸。
sceneColorLoad(texel: vec2i, lod: i32) -> vec4f纹素颜色。
sceneColorSample(uv: vec2f, lod: f32) -> vec4f滤波采样颜色;若设备不支持该格式滤波,返回最近纹素颜色。
sceneColorToLinear(color: vec3f) -> vec3f将从颜色贴图读取的颜色转换为线性值。
sceneColorToDisplay(color: vec3f) -> vec3f将从颜色贴图读取的颜色转换为伽马编码值。

无论相机如何存储深度,深度函数都返回相同含义的值。颜色则保留相机存储方式:CameraFrame 为线性值,其他情况为伽马编码。后者会在计算着色器中定义 SCENE_COLORMAP_GAMMA。两个转换函数接收贴图读出的颜色,已位于目标空间时原样返回。

相机仅渲染目标的一部分时(参见 rect),只覆盖贴图中的自身视口。其他区域可能保存同一目标上其他相机的视图。若仅处理当前相机,应像上例一样将调用映射到视口,并按视口尺寸派发,即相机 rect 乘以目标尺寸。

预处理器​

计算着色器支持与顶点和片元着色器相同的预处理器,包括 #define、#ifdef、#if、#include 等。

内置包含代码块​

引擎为计算着色器自动提供以下内置代码块:

包含项说明
halfTypesCShalf、half2 等半精度别名,支持时解析为 f16,否则为 f32,参见半精度类型。
sceneDepthCS访问相机场景深度贴图,参见场景深度和颜色贴图。
sceneColorCS访问相机场景颜色贴图,参见场景深度和颜色贴图。
dispatchThreadsCS检查和索引 setupDispatchThreads 配置的线程,参见按线程数派发。

示例:

#include "halfTypesCS"

@compute @workgroup_size(64, 1, 1)
fn main(@builtin(global_invocation_id) global_id: vec3u) {
// Use half types for calculations
var color: half3 = half3(1.0, 0.5, 0.0);
// ...
}

宏与包含​

通过 cdefines 传入宏,cincludes 提供包含内容:

const shader = new pc.Shader(device, {
name: 'ComputeShader',
shaderLanguage: pc.SHADERLANGUAGE_WGSL,
cshader: `
#include "myHelper"

@compute @workgroup_size({WORKGROUP_SIZE}, 1, 1)
fn main(@builtin(global_invocation_id) global_id: vec3u) {
var<workgroup> sharedData: array<f32, {WORKGROUP_SIZE}>;
// ...
}
`,
cdefines: new Map([
['{WORKGROUP_SIZE}', '64']
]),
cincludes: pc.ShaderChunks.get(device, pc.SHADERLANGUAGE_WGSL)
});

编译前 {WORKGROUP_SIZE} 会被替换为 64。普通宏与注入宏的区别参见预处理器文档。

示例​

以下实时示例展示不同用途:

  • 边缘检测:图像边缘检测处理。
边缘检测
  • 粒子:带碰撞检测的 GPU 粒子模拟。
粒子
  • 直方图:通过原子操作计算图像直方图。
直方图
  • 纹理生成:通过计算着色器生成和修改纹理。
纹理生成
  • 顶点更新:实时修改网格顶点缓冲区。
顶点更新
  • 间接绘制:间接调用的 GPU 驱动渲染。
间接绘制
  • 间接派发:基于深度的分块分类驱动 GPU 计算派发。
间接派发
  • 场景贴图:利用场景深度和颜色贴图,显示扫过场景的扫描脉冲。
场景贴图