前言

  前一篇Godot 物理源码分析把Godot内置的3D物理流程分析了下,接下来再扩展的话就是其在4.4版中集成的Jolt物理引擎了。本来想单开一篇介绍的,但看了下感觉Godot对Jolt也只是简易集成,功能方面与自己的已有的物理功能做了对齐阉割,性能方面接了个适配器又传回自己的通用线程池并行。与其介绍Godot中的Jolt,不如把两者独立开来,分别进行下介绍。

  各物理引擎最为差异化的地方在哪?个人认为不在算法(Impulse, PBD),而是并行化处理。如何构建调整Island,如何设计ThreadPool,这直接决定了效率,且不同平台上可能表现截然相反。以下就将展开这两点进行比较。

构造约束

  先从较为简单的构造约束讲起,毕竟Godot这边的逻辑十分简单。前文介绍过仅分了区域,刚体和软体三处不同逻辑,简单来说区域部分就是逮着一个就扔进去(毕竟不进行计算),刚体是检测与其关联的刚体软体,软体只检测与其关联的刚体。

 v而Jolt的处理相对就复杂些,执行的是8位掩码图分割逻辑,其中7位并行,1位非并行,对应一般的八核CPU。为了提高并行效率,在启用动态分割后Jolt会分割约束数超过分割阈值(默认128)的约束组。之后检查分割位,若未达到合并阈值(默认16)则会合并到非并行组,否则创建新并行组。详细流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool LargeIslandSplitter::SplitIsland()
{
// 1. 获取岛屿数据 → 检查尺寸阈值 → 如果小于 128,则返回“假”值
// 2. 重置对象掩码 → 计算每个分割区域的约束数量
// 3. 对接触点进行分割(掩码着色算法)
// - 获取与该接触点相关的两个对象
// - 在这两个对象的掩码中找到第一个共同且未使用的位
// - 将该位设为“1”并记录分割索引
// 4. 为约束条件分配分段操作(调用针对约束条件的特定逻辑)
// 5. 创建分割结构并合并小的分割段
// - 遍历 8 个分割位
// - 如果分割中的约束条件加上接触点的数量少于 16 个,则合并为非平行分割(第 7 位)
// - 否则,创建一个新的平行分割
// 6. 按分段对数据进行重新排序
// - 按分段将接触点和约束索引进行分组存储
// - 提高缓存命中率
}

求解约束

  然后就是求解阶段,如前文所说,这里不涉及具体求解算法,而是线程池调度。Godot走的是通用线程池,使用高低优先级队列调度,信号量 + 条件变量等待;而Jolt走的物理线程池,基于任务依赖图调度,屏障等待。

Godot通用线程池

  首先是Godot线程池的大致工作过程:初始化,任务提交 ,线程执行 ,协作式等待,清理。具体流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
1. 初始化阶段:
- 创建指定数量的工作线程
- 设置低优先级线程的比例
- 建立线程索引映射关系
2. 任务提交:
- 创建一个任务对象(分配内存)
- 根据优先级将其放入相应的队列中
- 通知空闲线程
3. 线程执行:
- 从队列中获取任务
- 执行任务(脚本/原生函数)
- 处理任务组同步
- 发送完成通知
4. 协作等待:
- 工作线程能够协同处理其他任务
- 有防止死锁的机制
- 支持暂停/恢复机制
5. 清理阶段:
- 逐步退出
- 等待所有任务完成
- 销毁线程池

基本结构

  Godots通用线程池的结构大抵如下,以双队列系统平衡响应性和吞吐量,分页式分配减少内存碎片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// Class structure and main members
class WorkerThreadPool : public Object {
GDCLASS(WorkerThreadPool, Object)

// Use TaskID and GroupID as the task identifiers
typedef int64_t TaskID;
typedef int64_t GroupID;

private:
struct Task; // Task Structure
struct Group; // Task Group Structure
struct ThreadData; // Thread Data

// Core Data Stucture
PagedAllocator<Task, false, TASKS_PAGE_SIZE> task_allocator;
PagedAllocator<Group, false, GROUPS_PAGE_SIZE> group_allocator;

SelfList<Task>::List low_priority_task_queue; // Low-priority queue
SelfList<Task>::List task_queue; // High-priority queue
BinaryMutex task_mutex; // Mutual exclusion lock to protect task queue

TightLocalVector<ThreadData> threads; // Thread data array
HashMap<Thread::ID, int> thread_ids; // Thread IDs to indices Map
HashMap<TaskID, Task *> tasks; // All tasks map
HashMap<GroupID, Group *> groups; // All task groups map
};

// Memory Management for Tasks and Groups
// Use the paging allocator to reduce memory fragmentation.
static const uint32_t TASKS_PAGE_SIZE = 1024;
static const uint32_t GROUPS_PAGE_SIZE = 256;

// Obtain the Task from the distributor instead of creating one.
Task *task = task_allocator.alloc();
// Return after use
task_allocator.free(task);

协作式等待

  比较有意思的地方大概就是协作式等待机制了:工作线程在等待时不会闲置,而是继续处理其他任务,这在处理任务依赖关系时较为有用,能有效预防死锁和提高CPU利用率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void WorkerThreadPool::_wait_collaboratively(ThreadData *p_caller_pool_thread, Task *p_task) {
while (true) {
MutexLock lock(task_mutex);

// Check if the waiting period has ended
if (p_task->completed) break;

// Try to do other tasks
if (task_queue.first()) {
Task *next_task = task_queue.first()->self();
task_queue.remove(task_queue.first());
lock.unlock();
_process_task(next_task);
} else {
// Waiting for a condition variable
p_caller_pool_thread->awaited_task = p_task;
p_caller_pool_thread->cond_var.wait(lock);
p_caller_pool_thread->awaited_task = nullptr;
}
}
}

Jolt 物理线程池

  之后是Jolt的物理线程池,大致过程是:初始化,任务提交 ,线程执行 ,屏障同步,清理。具体流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
1. 初始化阶段:
- 初始化任务空闲列表和屏障空闲列表
- 创建指定数量的工作线程
- 设置线程初始化/退出回调函数
2. 任务创建:
- 从空闲列表中分配一个任务对象
- 设置任务名称、颜色、执行函数和依赖计数
- 如果依赖计数为 0,则立即将任务入队
3. 任务排队:
- 将任务添加到循环队列中
- 使用原子操作管理队列的头部和尾部
- 释放信号量以唤醒工作线程
4. 工作线程循环:
- 等待信号量
- 从队列中获取任务并执行它们
- 在任务执行完成后,减少其后续任务的依赖关系数量
- 如果后续任务的依赖关系数量变为 0,则将其排队等待处理
5. 屏障同步:
- 利用屏障来等待一组任务完成
- 主线程也可以参与任务执行(在屏障处等待)
6. 清理阶段:
- 停止运行的线程
- 等待所有线程完成
- 释放已分配的资源

基本结构

  Jolt的物理线程池的大致结构如下,其采用了环形无锁队列和固定大小的内存分布

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class JobSystemThreadPool final : public JobSystemWithBarrier {
private:
// Task memory pool (fixed size)
FixedSizeFreeList<Job> mJobs;

// Array of worker threads
Array<thread> mThreads;

// Circular Queue (Lock-Free Design)
static constexpr uint32 cQueueLength = 1024;
atomic<Job *> mQueue[cQueueLength];

// The local head pointer of each thread + the global tail pointer
atomic<uint> *mHeads = nullptr; // One head per thread
atomic<uint> mTail = 0; // Global tail

// Semaphores are used for thread wake-up.
Semaphore mSemaphore;

// Exit indicator
atomic<bool> mQuit = false;
};

屏障同步

  Jolt的屏障在个人看来是Godot协作等待的物理特化版,适于物理阶段同步

  屏障的结构如下,读写指针分别对齐到不同缓存行,避免多核间的缓存失效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class BarrierImpl : public Barrier {
private:
static constexpr uint cMaxJobs = 2048; // Maximum number of tasks (a power of 2)
atomic<Job *> mJobs[cMaxJobs]; // Use circular buffer stores tasks

// Read-write pointer (cache alignment in line to avoid false sharing)
alignas(JPH_CACHE_LINE_SIZE) atomic<uint> mJobReadIndex { 0 };
alignas(JPH_CACHE_LINE_SIZE) atomic<uint> mJobWriteIndex { 0 };

// Synchronous Control
atomic<int> mNumToAcquire { 0 }; // The required count of semaphores
Semaphore mSemaphore; // Task Completion Semaphore

atomic<bool> mInUse { false };
};

  添加多任务逻辑如下,屏障支持批量添加任务,减少同步开销:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void BarrierImpl::AddJobs(const JobHandle *inHandles, uint inNumHandles) {
bool release_semaphore = false;

for (const JobHandle *handle = inHandles;
handle < inHandles + inNumHandles; ++handle) {
Job *job = handle->GetPtr();

if (job->SetBarrier(this)) {
mNumToAcquire++;

// Release the semaphore only when the first executable task is completed
if (!release_semaphore && job->CanBeExecuted()) {
release_semaphore = true;
mNumToAcquire++;
}

// Add to buffer
job->AddRef();
uint write_index = mJobWriteIndex++;
while (write_index - mJobReadIndex >= cMaxJobs) {
std::this_thread::sleep_for(std::chrono::microseconds(100));
}
mJobs[write_index & (cMaxJobs - 1)] = job;
}
}

// Notify after the batch processing is completed.
if (release_semaphore) mSemaphore.Release();
}

  核心等待机制如下,同样采用了协作式等待,平衡了效率与正确性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
void BarrierImpl::Wait() {
// Loop until all tasks are completed
while (mNumToAcquire > 0) {
bool has_executed;

do {
has_executed = false;

// A. Clean the completed job
while (mJobReadIndex < mJobWriteIndex) {
atomic<Job *> &job = mJobs[mJobReadIndex & (cMaxJobs - 1)];
Job *job_ptr = job.load();

if (job_ptr == nullptr || !job_ptr->IsDone()) break;

// Release completed tasks
job_ptr->Release();
job = nullptr;
++mJobReadIndex;
}

// B. Search and excute tasks
for (uint index = mJobReadIndex; index < mJobWriteIndex; ++index) {
const atomic<Job *> &job = mJobs[index & (cMaxJobs - 1)];
Job *job_ptr = job.load();

if (job_ptr != nullptr && job_ptr->CanBeExecuted()) {
job_ptr->Execute();
has_executed = true;
break;
}
}

} while (has_executed); // As long as there is a task to be executed, continue.

// C. Wait for semaphore
int num_to_acquire = max(1, mSemaphore.GetValue());

// Batch acquisition of semaphores (to reduce context switching)
mSemaphore.Acquire(num_to_acquire);

// Reduce waiting count
mNumToAcquire -= num_to_acquire;
}

// D. Final Cleanup (Ensure that all tasks have been released)
while (mJobReadIndex < mJobWriteIndex) {
atomic<Job *> &job = mJobs[mJobReadIndex & (cMaxJobs - 1)];
Job *job_ptr = job.load();
job_ptr->Release();
job = nullptr;
++mJobReadIndex;
}
}

后记

  通过对比 Godot 与原版 Jolt 在物理并行化上的实现,可以看出两者在设计目标上的明显差异。Godot 采用的是高度通用的线程池与任务系统,更偏向引擎整体一致性与可维护性;而 Jolt 则围绕物理计算本身,构建了专用的 Job System、依赖图与屏障机制,以最大化物理阶段的并行效率。在当前 Godot 对 Jolt 的集成方式中,Jolt 的任务最终仍被适配回 Godot 的通用线程池,这在功能层面是可行的,但也意味着其原生并行化设计优势并未完全发挥。

  相比之下,Unreal Engine (Chaos) 通过 Task Graph 将物理任务拆解并融入引擎级调度体系,使其在不同硬件平台上具备更强的适应性。这种“以任务而非线程为中心”的设计,在复杂场景下更容易充分利用多核 CPU。不愧是UE,打不了一点,点了点了。