前言
N-Body 模拟(多体模拟)是计算物理中的经典问题,旨在模拟大量粒子在引力相互作用下的运动轨迹。由于每个粒子都会受到场景中所有其他粒子的引力影响,其计算复杂度高达 。
在传统的 CPU 模拟中,随着粒子数量的增加,性能会急剧下降。然而,这正是 GPU 并行计算(General-Purpose computing on GPU, GPGPU)大显身手的领域。本文将基于 Khronos Vulkan Samples 中的代码,详细解读如何利用 Vulkan 的 Compute Shader 和 共享内存(Shared Memory) 优化技术,实现一个高效的 N-Body 粒子系统 。
Vulkan-Samples/samples/api/compute_nbody at main · 13536309143/Vulkan-Samples
系统架构概览
本示例采用 "计算-渲染" 分离 的架构。整个过程分为两个主要阶段:
-
计算阶段 (Compute): 使用 Compute Shader 更新粒子的速度和位置。
-
渲染阶段 (Graphics): 使用 Graphics Pipeline 将粒子渲染为点精灵(Point Sprites)。
为了实现这一架构,我们需要在 Vulkan 中处理好计算队列(Compute Queue)与图形队列(Graphics Queue)之间的同步与资源所有权转移 。
核心数据结构
粒子数据存储在一个 Shader Storage Buffer Object (SSBO) 中。每个粒子包含位置(包含质量)和速度(包含颜色梯度信息)。
GLSL 定义 (particle_calculate.comp)
cpp
struct Particle
{
vec4 pos; // xyz = 位置, w = 质量
vec4 vel; // xyz = 速度, w = 梯度纹理坐标
};
C++ 定义 (compute_nbody.h)
cpp
struct Particle
{
glm::vec4 pos; // xyz = position, w = mass
glm::vec4 vel; // xyz = velocity, w = gradient texture position
};
核心算法:Compute Shader 的两步走的策略
为了清晰地管理物理模拟,示例将计算过程拆分为两个 Pipeline(流水线):
第一步:计算粒子受力 (Calculate Pass)
这是计算量最大的部分。如果不进行优化,每个线程(代表一个粒子)需要从显存(Global Memory)中读取所有其他个粒子的位置来计算引力。这会导致巨大的内存带宽压力。
优化方案:共享内存(Shared Memory)分块技术
代码利用了 Compute Shader 的 shared 变量,这是一种位于 GPU 芯片上的高速缓存,比全局显存快得多。
-
分块加载 (Tiling): 将所有粒子分成若干个块(Tile)。
-
协作加载: 工作组(Work Group)内的线程协作将一个 Tile 的粒子数据加载到共享内存中。
-
高速计算: 线程读取共享内存中的数据计算引力,而不是反复读取全局显存。
代码解析 (particle_calculate.comp):
cpp
// 定义共享内存大小,通过特化常量配置,通常为 1024
layout (constant_id = 1) const int SHARED_DATA_SIZE = 1024;
shared vec4 sharedData[SHARED_DATA_SIZE];
void main()
{
// ... 获取当前粒子位置 ...
vec4 acceleration = vec4(0.0);
// 循环遍历所有粒子块
for (int i = 0; i < ubo.particleCount; i += SHARED_DATA_SIZE)
{
// 1. 协作加载:将当前块的粒子位置加载到共享内存
if (i + gl_LocalInvocationID.x < ubo.particleCount)
{
sharedData[gl_LocalInvocationID.x] = particles[i + gl_LocalInvocationID.x].pos;
}
else
{
sharedData[gl_LocalInvocationID.x] = vec4(0.0);
}
// 2. 内存屏障:确保工作组内所有线程都完成了加载
barrier();
// 3. 计算引力:利用共享内存中的数据
for (int j = 0; j < gl_WorkGroupSize.x; j++)
{
vec4 other = sharedData[j];
vec3 len = other.xyz - position.xyz;
// 引力公式:F = G * m1 * m2 / (r^2 + soften)
acceleration.xyz += GRAVITY * len * other.w / pow(dot(len, len) + SOFTEN, POWER);
}
// 4. 再次屏障:等待所有线程计算完毕,准备加载下一个块
barrier();
}
// 更新速度
particles[index].vel.xyz += ubo.deltaT * TIME_FACTOR * acceleration.xyz;
// ...
}
这一段代码是整个 N-Body 模拟性能优化的灵魂所在。通过 barrier() 和 sharedData,极大地减少了对显存的访问延迟。
第二步:积分更新 (Integrate Pass)
这部分相对简单,使用半隐式欧拉积分(Semi-implicit Euler Integration)更新粒子的位置。
代码解析 (particle_integrate.comp):
cpp
void main()
{
int index = int(gl_GlobalInvocationID);
vec4 position = particles[index].pos;
vec4 velocity = particles[index].vel;
// 更新位置:Pos += Vel * DeltaTime
position += ubo.deltaT * TIME_FACTOR * velocity;
particles[index].pos = position;
}
图形渲染:点精灵 (Point Sprites)
计算完成后,由于粒子位置依然存储在同一个 SSBO 中,图形管线可以直接读取该 Buffer 进行渲染,无需 CPU 回读,实现了完全的 GPU 驻留模拟。
-
Vertex Shader (
particle.vert) : 读取粒子位置。它还做了一个有趣的视觉处理:根据粒子距离相机的远近以及粒子的质量,动态计算gl_PointSize,使大质量或近处的粒子看起来更大 。 -
Fragment Shader (
particle.frag) : 使用gl_PointCoord采样纹理,并根据速度中的梯度信息(inGradientPos)从梯度纹理中采样颜色,实现根据速度变色的酷炫效果 。
Vulkan 主机端实现:同步与屏障
在 C++ 代码中,最复杂的部分是处理计算队列和图形队列之间的同步。在某些硬件上,Compute 和 Graphics 使用不同的队列家族(Queue Family),这需要显式的 Queue Ownership Transfer(队列所有权转移)。
资源屏障 (Pipeline Barriers)
在 ComputeNBody::build_command_buffers (图形命令) 和 ComputeNBody::build_compute_command_buffer (计算命令) 中,你可以看到大量的 vkCmdPipelineBarrier。
场景 1:图形队列获取控制权
在渲染开始前,Graphics Queue 需要等待 Compute Queue 完成写入,并获取 Buffer 的读取权限:
cpp
if (graphics.queue_family_index != compute.queue_family_index)
{
VkBufferMemoryBarrier buffer_barrier = {
// ...
.srcAccessMask = 0, // 初始必须为 0 或由 semaphore 保证
.dstAccessMask = VK_ACCESS_VERTEX_ATTRIBUTE_READ_BIT, // 图形管线需要读取顶点
.srcQueueFamilyIndex = compute.queue_family_index, // 从计算队列来
.dstQueueFamilyIndex = graphics.queue_family_index, // 到图形队列去
// ...
};
// ... 发出屏障命令
}
此逻辑位于 compute_nbody.cpp 的 build_command_buffers 函数中。
场景 2:计算队列获取控制权
在计算开始前,Compute Queue 需要等待图形渲染完成(读取完毕),并获取写入权限:
cpp
if (graphics.queue_family_index != compute.queue_family_index)
{
VkBufferMemoryBarrier buffer_barrier = {
// ...
.srcAccessMask = 0,
.dstAccessMask = VK_ACCESS_SHADER_WRITE_BIT, // 计算着色器需要写入
.srcQueueFamilyIndex = graphics.queue_family_index,
.dstQueueFamilyIndex = compute.queue_family_index,
// ...
};
// ... 发出屏障命令
}
此逻辑位于 compute_nbody.cpp 的 build_compute_command_buffer 函数中。
信号量 (Semaphores)
除了 Buffer 内存屏障,代码还使用了 VkSemaphore 来确保提交(Submit)层面的顺序:
-
Graphics Submit 信号
graphics.semaphore。 -
Compute Submit 等待
graphics.semaphore,完成后信号compute.semaphore。 -
下一帧的 Graphics Submit 等待
compute.semaphore。
这形成了一个闭环的依赖链:Draw -> Compute -> Draw -> ...
总结
这个 N-Body 示例完美展示了现代图形 API 的强大之处:
-
大规模并行计算: 利用 Compute Shader 处理数万个粒子的
物理交互。
-
存储器优化: 通过
shared内存和 Tiling 技术显著降低带宽消耗。 -
异构队列同步: 展示了如何在 Graphics 和 Compute 队列之间安全地转移资源所有权。
通过掌握这些技术,你不仅可以实现粒子系统,还可以将其应用到流体模拟、布料解算等更多高性能计算场景中。

建议:你可以尝试修改 particle_calculate.comp 中的 GRAVITY 或 POWER 常量,或者在 C++ 代码中增加 PARTICLES_PER_ATTRACTOR 的数量,看看性能和视觉效果会发生什么变化。