Nikolay Lazarev
Integrated Computer Solutions, Inc.
关于群集算法的一般性描述
实施群集算法模拟鱼群的行为。该算法包含四种基本行为:
- 聚集:鱼搜寻定义为聚集半径范围内的同伴。对所有同伴的当前位置进行求和,求和结果除以同伴数量,得到同伴的质量中心,这是鱼努力聚集的点。求和的结果减去鱼的当前位置,得出的矢量结果执行标准化,从而确定鱼的游动方向。
- 分离:鱼搜寻定义为分离半径范围内的同伴。如需计算处于某一鱼群特定分离方向的鱼的游动矢量,对同伴位置和鱼自身位置的差值进行求和。结果除以同伴数量,然后执行标准化并乘以 -1,从而改变鱼的初始方向,使其游向同伴的相对位置。
- 对齐:鱼搜寻定义为对齐半径范围内的同伴。对所有同伴的当前速度进行求和,然后除以同伴数量,对得出的矢量执行标准化。
- 反转:所有的鱼只能在边界明确的特定空间内游动。必须识别出鱼跨越边界的时刻。如果某条鱼碰到边界,它的方向将变成相对矢量(从而确保鱼在指定的空间内游动)。
结合使用关于鱼群中每条鱼的这四种基本行为准则,计算出每条鱼的总位置值、游速和加速度。拟定算法引入了权重系数概念,以增加或降低这三种行为模式(聚集、分离和对齐)所产生的影响。鱼的反转行为中不使用权重系数,因为鱼不许游出指定的边界。因此,反转的优先级最高。而且,为该算法提供了最大速度和加速度。
根据上述算法,计算每条鱼的参数(位置、游速和加速度)。为每一帧计算这些参数。
群集算法的源代码及注释
为了计算鱼群中鱼的状态,使用双缓冲。鱼的状态保存在大小为 N x 2 的阵列中,其中 N 代表鱼的数量,2 代表状态的副本数量。
使用两个嵌套循环实施该算法。在内部嵌套循环中,计算三种行为(聚集、分离和对齐)的方向矢量。在外部嵌套循环中,根据内部嵌套循环的计算结果计算鱼的新状态。这些计算同样以每种行为的权重系数值以及游速和加速度的最大值为基础。
内部循环:在每次循环迭代中,计算每条鱼的新位置值。作为 lambda 函数的参数,引用传递至:
agents | 鱼状态阵列 |
currentStatesIndex | 保存每条鱼当前状态的阵列索引 |
previousStatesIndex | 保存每条鱼之前状态的阵列索引 |
kCoh | for 聚集 行为的权重因子 |
kSep | for 分离 行为的权重因子 |
kAlign | for 对齐 行为的权重因子 |
rCohesion | 为了聚集而搜寻同伴的半径 |
rSeparation | 为了分离而搜寻同伴的半径 |
rAlignment | 为了对齐而搜寻同伴的半径 |
maxAccel | 鱼的最大加速度 |
maxVel | 鱼的最大游速 |
mapSz | 允许鱼游动的区域的边界 |
DeltaTime | 距离上次计算所花费的时间 |
isSingleThread | 指示循环以哪种模式运行的参数 |
ParllelFor 可用于两种模式,取决于 isSingleThread Boolean 变量的状态:
ParallelFor(cnt, [&agents, currentStatesIndex, previousStatesIndex, kCoh, kSep, kAlign, rCohesion, rSeparation, rAlignment, maxAccel, maxVel, mapSz, DeltaTime, isSingleThread](int32 fishNum) {
初始化包含零矢量的方向,分别计算三种行为:
FVector cohesion(FVector::ZeroVector), separation(FVector::ZeroVector), alignment(FVector::ZeroVector);
为每种行为初始化同伴计数器:
int32 cohesionCnt = 0, separationCnt = 0, alignmentCnt = 0;
内部嵌套循环。计算三种行为的方向矢量:
for (int i = 0; i < cnt; i++) {
每条鱼应忽略(不计算)自己:
if (i != fishNum) {
计算当前鱼的位置与阵列中其他鱼的位置之间的距离:
float distance = FVector::Distance(agents[i][previousStatesIndex].position, agents[fishNum][previousStatesIndex].position);
如果距离小于聚集半径:
if (distance < rCohesion) {
将同伴位置添加至聚集矢量:
cohesion += agents[i][previousStatesIndex].position;
增加同伴计数器的值:
cohesionCnt++; }
如果距离小于分离半径:
if (distance < rSeparation) {
将同伴与当前鱼之间位置的差值添加至分离矢量:
separation += agents[i][previousStatesIndex].position - agents[fishNum][previousStatesIndex].position;
增加同伴计数器的值:
separationCnt++; }
如果距离小于对齐半径:
if (distance < rAlignment) {
将同伴的游速添加至对齐矢量:
alignment += agents[i][previousStatesIndex].velocity;
增加同伴计数器的值:
alignmentCnt++; } }
如果发现聚集的同伴:
if (cohesionCnt != 0) {
聚集矢量除以同伴数量,并减去自己的位置:
cohesion /= cohesionCnt; cohesion -= agents[fishNum][previousStatesIndex].position;
聚集矢量执行标准化:
cohesion.Normalize(); }
如果发现分离的同伴:
if (separationCnt != 0) {
分离矢量除以同伴数,并乘以 -1 以改变方向:
separation /= separationCnt; separation *= -1.f;
分离矢量执行标准化:
separation.Normalize(); }
如果发现对齐的同伴:
if (alignmentCnt != 0) {
对齐矢量除以同伴数:
alignment /= alignmentCnt;
对齐矢量执行标准化:
alignment.Normalize(); }
根据每种可能行为的权重系数,确定新的加速矢量,不超过最大加速值:
agents[fishNum][currentStatesIndex].acceleration = (cohesion * kCoh + separation * kSep + alignment * kAlign).GetClampedToMaxSize(maxAccel);
沿 Z 轴限定加速矢量:
agents[fishNum][currentStatesIndex].acceleration.Z = 0;
新游速矢量和自上次计算所花的时间相乘,结果与鱼之前的位置相加:
agents[fishNum][currentStatesIndex].velocity += agents[fishNum][currentStatesIndex].acceleration * DeltaTime;
游速矢量不超过最大值:
agents[fishNum][currentStatesIndex].velocity = agents[fishNum][currentStatesIndex].velocity.GetClampedToMaxSize(maxVel);
新游速矢量和自上次计算以来所花的时间相乘,结果与鱼之前的位置相加:
agents[fishNum][currentStatesIndex].position += agents[fishNum][currentStatesIndex].velocity * DeltaTime;
检查当前鱼是否在指定边界内。如果是,保存计算的游速和位置值。如果鱼沿某条轴游出了边界,将沿该轴的游速矢量值乘以 -1 以改变鱼的游动方向:
agents[fishNum][currentStatesIndex].velocity = checkMapRange(mapSz, agents[fishNum][currentStatesIndex].position, agents[fishNum][currentStatesIndex].velocity); }, isSingleThread);
对每条鱼来说,应用新状态之前,应检测鱼与真实静态物体(比如水下岩石)的碰撞:
for (int i = 0; i < cnt; i++) {
检测鱼与静态物体之间的碰撞:
FHitResult hit(ForceInit); if (collisionDetected(agents[i][previousStatesIndex].position, agents[i][currentStatesIndex].position, hit)) {
检测到碰撞后,撤销之前计算的位置。游速矢量应变成相对方向和计算的方向:
agents[i][currentStatesIndex].position -= agents[i] [currentStatesIndex].velocity * DeltaTime; agents[i][currentStatesIndex].velocity *= -1.0; agents[i][currentStatesIndex].position += agents[i][currentStatesIndex].velocity * DeltaTime; } }
计算所有鱼的新状态后,运用这些更新的状态,所有鱼都将向新的方向游去:
for (int i = 0; i < cnt; i++) { FTransform transform; m_instancedStaticMeshComponent->GetInstanceTransform(agents[i][0]->instanceId, transform);
设置鱼实例的新位置:
transform.SetLocation(agents[i][0]->position);
让鱼的头部转向移动方向:
FVector direction = agents[i][0].velocity; direction.Normalize(); transform.SetRotation(FRotationMatrix::MakeFromX(direction).Rotator().Add(0.f, -90.f, 0.f).Quaternion());
更新实例转换:
m_instancedStaticMeshComponent->UpdateInstanceTransform(agents[i][0].instanceId, transform, false, false); }
重新绘制所有鱼:
m_instancedStaticMeshComponent->ReleasePerInstanceRenderData(); m_instancedStaticMeshComponent->MarkRenderStateDirty();
交换索引的鱼状态:
swapFishStatesIndexes();
算法的复杂程度:如何提高鱼影响工作效率的值
假设参与算法的鱼的数量为 N。为了确定每条鱼的新状态,必须计算其他所有鱼的距离(不算上为了确定三种行为的方向矢量的其他操作)。该算法最初的复杂程度为 O(N2)。例如,1,000 条鱼要求 1,000,000 次操作。
图 1:计算某场景中所有鱼的位置的运算操作。
计算着色器及注释
描述每条鱼的状态的结构:
struct TInfo{ int instanceId; float3 position; float3 velocity; float3 acceleration; };
用于计算两个矢量之间的距离的函数:
float getDistance(float3 v1, float3 v2) { return sqrt((v2[0]-v1[0])*(v2[0]-v1[0]) + (v2[1]-v1[1])*(v2[1]-v1[1]) + (v2[2]-v1[2])*(v2[2]-v1[2])); } RWStructuredBuffer<TInfo> data; [numthreads(1, 128, 1)] void VS_test(uint3 ThreadId :SV_DispatchThreadID) {
鱼的总数:
int fishCount = constants.fishCount;
该变量用 C++ 语言创建和初始化,用于确定每个图形处理单元 (GPU) 线程所计算的鱼数量(默认为 1):
int calculationsPerThread = constants.calculationsPerThread;
用于计算必须在该线程中计算的鱼状态的循环:
for (int iteration = 0; iteration < calculationsPerThread; iteration++) {
线程标识符。对应状态阵列中的鱼索引:
int currentThreadId = calculationsPerThread * ThreadId.y + iteration;
检查当前索引,确保其不超出鱼的总数(如果启动的线程数超过鱼数,可能会发生这种情况):
if (currentThreadId >= fishCount) return;
如欲计算鱼的状态,可使用单个双长度阵列。阵列的第一个 N 要素为待计算的鱼的新状态;第二个 N 要素为之前计算的鱼的旧状态。
当前鱼索引:
int currentId = fishCount + currentThreadId;
鱼的当前状态结构副本:
TInfo currentState = data[currentThreadId + fishCount];
鱼的新状态结构副本:
TInfo newState = data[currentThreadId];
初始化三种行为的方向矢量:
float3 steerCohesion = {0.0f, 0.0f, 0.0f}; float3 steerSeparation = {0.0f, 0.0f, 0.0f}; float3 steerAlignment = {0.0f, 0.0f, 0.0f};
初始化每种行为的同伴计数器:
float steerCohesionCnt = 0.0f; float steerSeparationCnt = 0.0f; float steerAlignmentCnt = 0.0f;
根据每条鱼的当前状态,分别计算三种行为的方向矢量。循环从输入阵列的中间开始,它位于保存旧状态的位置:
for (int i = fishCount; i < 2 * fishCount; i++) {
每条鱼应忽略(不计算)自己:
if (i != currentId) {
计算当前鱼的位置与阵列中其他鱼的位置之间的距离:
float d = getDistance(data[i].position, currentState.position);
如果距离小于聚集半径:
if (d < constants.radiusCohesion) {
将同伴位置添加至聚集矢量:
steerCohesion += data[i].position;
增加聚集同伴的计数器:
steerCohesionCnt++; }
如果距离小于分离半径:
if (d < constants.radiusSeparation) {
同伴与当前鱼的位置差值与分离矢量相加:
steerSeparation += data[i].position - currentState.position;
增加分离同伴计数器:
steerSeparationCnt++; }
如果距离小于对齐半径:
if (d < constants.radiusAlignment) {
将同伴的游速添加至对齐矢量:
steerAlignment += data[i].velocity;
The counter of the number of neighbors for alignment increases:
steerAlignmentCnt++; } } }
如果发现聚集的同伴:
if (steerCohesionCnt != 0) {
聚集矢量除以同伴数量,并减去自己的位置:
steerCohesion = (steerCohesion / steerCohesionCnt - currentState.position);
聚集矢量执行标准化:
steerCohesion = normalize(steerCohesion); }
如果发现分离的同伴:
if (steerSeparationCnt != 0) {
分离矢量除以同伴数,并乘以 -1 以改变方向:
steerSeparation = -1.f * (steerSeparation / steerSeparationCnt);
分离矢量执行标准化:
steerSeparation = normalize(steerSeparation); }
如果发现对齐的同伴:
if (steerAlignmentCnt != 0) {
对齐矢量除以同伴数:
steerAlignment /= steerAlignmentCnt;
对齐矢量执行标准化:
steerAlignment = normalize(steerAlignment); }
根据三种可能行为的权重系数,确定新的加速矢量,不超过最大加速值:
newState.acceleration = (steerCohesion * constants.kCohesion + steerSeparation * constants.kSeparation + steerAlignment * constants.kAlignment); newState.acceleration = clamp(newState.acceleration, -1.0f * constants.maxAcceleration, constants.maxAcceleration);
沿 Z 轴限定加速矢量:
newState.acceleration[2] = 0.0f;
新加速矢量和自上次计算以来所花的时间相乘,结果与之前的游速矢量相加:
游速矢量不超过最大值:
newState.velocity += newState.acceleration * variables.DeltaTime; newState.velocity = clamp(newState.velocity, -1.0f * constants.maxVelocity, constants.maxVelocity);
新游速矢量和自上次计算所花的时间相乘,结果与鱼之前的位置相加:
newState.position += newState.velocity * variables.DeltaTime;
检查当前鱼是否在指定边界内。如果是,保存计算的游速和位置值。如果鱼沿某条轴游出了边界,将沿该轴的游速矢量值乘以 -1 以改变鱼的游动方向:
float3 newVelocity = newState.velocity; if (newState.position[0] > constants.mapRangeX || newState.position[0] < -constants.mapRangeX) { newVelocity[0] *= -1.f; } if (newState.position[1] > constants.mapRangeY || newState.position[1] < -constants.mapRangeY) { newVelocity[1] *= -1.f; } if (newState.position[2] > constants.mapRangeZ || newState.position[2] < -3000.f) { newVelocity[2] *= -1.f; } newState.velocity = newVelocity; data[currentThreadId] = newState; } }
表 1:算法比较。
鱼 | 算法 (FPS) | 运算操作 | ||
---|---|---|---|---|
CPU SINGLE | CPU MULTI | GPU MULTI | ||
100 | 62 | 62 | 62 | 10000 |
500 | 62 | 62 | 62 | 250000 |
1000 | 62 | 62 | 62 | 1000000 |
1500 | 49 | 61 | 62 | 2250000 |
2000 | 28 | 55 | 62 | 4000000 |
2500 | 18 | 42 | 62 | 6250000 |
3000 | 14 | 30 | 62 | 9000000 |
3500 | 10 | 23 | 56 | 12250000 |
4000 | 8 | 20 | 53 | 16000000 |
4500 | 6 | 17 | 50 | 20250000 |
5000 | 5 | 14 | 47 | 25000000 |
5500 | 4 | 12 | 35 | 30250000 |
6000 | 3 | 10 | 31 | 36000000 |
6500 | 2 | 8 | 30 | 42250000 |
7000 | 2 | 7 | 29 | 49000000 |
7500 | 1 | 7 | 27 | 56250000 |
8000 | 1 | 6 | 24 | 64000000 |
8500 | 0 | 5 | 21 | 72250000 |
9000 | 0 | 5 | 20 | 81000000 |
9500 | 0 | 4 | 19 | 90250000 |
10000 | 0 | 3 | 18 | 100000000 |
10500 | 0 | 3 | 17 | 110250000 |
11000 | 0 | 2 | 15 | 121000000 |
11500 | 0 | 2 | 15 | 132250000 |
12000 | 0 | 1 | 14 | 144000000 |
13000 | 0 | 0 | 12 | 169000000 |
14000 | 0 | 0 | 11 | 196000000 |
15000 | 0 | 0 | 10 | 225000000 |
16000 | 0 | 0 | 9 | 256000000 |
17000 | 0 | 0 | 8 | 289000000 |
18000 | 0 | 0 | 3 | 324000000 |
19000 | 0 | 0 | 2 | 361000000 |
20000 | 0 | 0 | 1 | 400000000 |
图 2:算法比较。
笔记本电脑硬件:
CPU – 智能英特尔®酷睿™ i7-3632QM 处理器 2.2 Ghz,睿频加速可达 3.2 GHz
GPU - NVIDIA GeForce* GT 730M
RAM - 8 GB DDR3*