From 81296d8fca7233206113a1dcb9dfb4917302db22 Mon Sep 17 00:00:00 2001 From: Dubhghlas McLaughlin <103212704+mcdubhghlas@users.noreply.github.com> Date: Tue, 15 Sep 2026 08:51:07 -0500 Subject: [PATCH 1/6] Optimize 2D physics threading for small workloads while avoiding redundant transforms. --- doc/classes/PhysicsServer2D.xml | 3 ++ doc/classes/ProjectSettings.xml | 4 ++ modules/godot_physics_2d/godot_shape_2d.h | 2 +- modules/godot_physics_2d/godot_space_2d.cpp | 6 +++ modules/godot_physics_2d/godot_space_2d.h | 2 + modules/godot_physics_2d/godot_step_2d.cpp | 44 ++++++++++++++++++--- servers/physics_2d/physics_server_2d.cpp | 2 + servers/physics_2d/physics_server_2d.h | 1 + 8 files changed, 58 insertions(+), 6 deletions(-) diff --git a/doc/classes/PhysicsServer2D.xml b/doc/classes/PhysicsServer2D.xml index 86bd29ea259..e0c5104e311 100644 --- a/doc/classes/PhysicsServer2D.xml +++ b/doc/classes/PhysicsServer2D.xml @@ -1024,6 +1024,9 @@ Constant to set/get the number of solver iterations for all contacts and constraints. The greater the number of iterations, the more accurate the collisions will be. However, a greater number of iterations requires more CPU power, which can decrease performance. The default value of this parameter is [member ProjectSettings.physics/2d/solver/solver_iterations]. + + Constant to set/get the minimum number of constraints a physics step must have before constraint setup and eligible island solving are distributed across the [WorkerThreadPool]. Below this threshold the work is done on the calling thread instead, avoiding thread dispatch and synchronization overhead that would otherwise outweigh the benefit for light workloads. Island solving additionally requires more than one island, since each island is a single unit of parallel work (so reaching the threshold does not by itself thread solving). A value of [code]0[/code] threads as aggressively as possible, that is, whenever there is actual parallel work to distribute. The default value of this parameter is [member ProjectSettings.physics/2d/solver/min_constraints_for_threading]. + This is the constant for creating world boundary shapes. A world boundary shape is an [i]infinite[/i] line with an origin point, and a normal. Thus, it can be used for front/behind checks. diff --git a/doc/classes/ProjectSettings.xml b/doc/classes/ProjectSettings.xml index c83ae5b2288..fd0342d034d 100644 --- a/doc/classes/ProjectSettings.xml +++ b/doc/classes/ProjectSettings.xml @@ -2505,6 +2505,10 @@ Default solver bias for all physics contacts. Defines how much bodies react to enforce contact separation. See [constant PhysicsServer2D.SPACE_PARAM_CONTACT_DEFAULT_BIAS]. Individual shapes can have a specific bias value (see [member Shape2D.custom_solver_bias]). + + Minimum number of constraints a 2D physics step must have before constraint setup and eligible island solving are distributed across the [WorkerThreadPool]. Below this threshold the work runs on the calling thread instead, avoiding thread dispatch and synchronization overhead that would otherwise outweigh the benefit for light workloads (such as scenes with many fast, mostly-separated bodies). Island solving additionally requires more than one island, since each island is a single unit of parallel work, so reaching the threshold does not by itself thread solving. A value of [code]0[/code] threads as aggressively as possible, that is, whenever there is actual parallel work to distribute. Raising it keeps more steps single-threaded; lowering it threads sooner. See [constant PhysicsServer2D.SPACE_PARAM_SOLVER_MIN_CONSTRAINTS_FOR_THREADING]. + [b]Note:[/b] This value is read when a physics space is created, so changing it at runtime does not affect existing physics spaces. Use [method PhysicsServer2D.space_set_param] to change it for an existing space. + Maximum broadphase pairing margin, in pixels. Each object's collision pairs are kept alive within a margin that scales with its size (up to this maximum), so large moving objects (such as big [Area2D] sensors) re-pair less often, improving performance in scenes with many large overlapping shapes. Small objects keep a proportionally tiny margin so dense scenes do not accumulate excess pairs. Set to [code]0[/code] to disable and use the legacy pair-count-based margin. [b]Note:[/b] This value is read when a physics space is created, so changing it at runtime does not affect existing physics spaces. diff --git a/modules/godot_physics_2d/godot_shape_2d.h b/modules/godot_physics_2d/godot_shape_2d.h index 4deeccdb3a3..f0d1b71afab 100644 --- a/modules/godot_physics_2d/godot_shape_2d.h +++ b/modules/godot_physics_2d/godot_shape_2d.h @@ -432,7 +432,7 @@ class GodotConvexPolygonShape2D : public GodotShape2D { Vector2 a = points[p_idx].pos; p_idx++; Vector2 b = points[p_idx == point_count ? 0 : p_idx].pos; - return (p_xform.xform(b) - p_xform.xform(a)).normalized().orthogonal(); + return p_xform.basis_xform(b - a).normalized().orthogonal(); } virtual PhysicsServer2D::ShapeType get_type() const override { return PhysicsServer2D::SHAPE_CONVEX_POLYGON; } diff --git a/modules/godot_physics_2d/godot_space_2d.cpp b/modules/godot_physics_2d/godot_space_2d.cpp index a9461111c2b..0d95a15203f 100644 --- a/modules/godot_physics_2d/godot_space_2d.cpp +++ b/modules/godot_physics_2d/godot_space_2d.cpp @@ -1173,6 +1173,9 @@ void GodotSpace2D::set_param(PhysicsServer2D::SpaceParameter p_param, real_t p_v case PhysicsServer2D::SPACE_PARAM_SOLVER_ITERATIONS: solver_iterations = p_value; break; + case PhysicsServer2D::SPACE_PARAM_SOLVER_MIN_CONSTRAINTS_FOR_THREADING: + solver_min_constraints_for_threading = MAX(0, (int)p_value); + break; } } @@ -1196,6 +1199,8 @@ real_t GodotSpace2D::get_param(PhysicsServer2D::SpaceParameter p_param) const { return constraint_bias; case PhysicsServer2D::SPACE_PARAM_SOLVER_ITERATIONS: return solver_iterations; + case PhysicsServer2D::SPACE_PARAM_SOLVER_MIN_CONSTRAINTS_FOR_THREADING: + return solver_min_constraints_for_threading; } return 0; } @@ -1221,6 +1226,7 @@ GodotSpace2D::GodotSpace2D() { body_angular_velocity_sleep_threshold = GLOBAL_GET("physics/2d/sleep_threshold_angular"); body_time_to_sleep = GLOBAL_GET("physics/2d/time_before_sleep"); solver_iterations = GLOBAL_GET("physics/2d/solver/solver_iterations"); + solver_min_constraints_for_threading = GLOBAL_GET("physics/2d/solver/min_constraints_for_threading"); contact_recycle_radius = GLOBAL_GET("physics/2d/solver/contact_recycle_radius"); contact_max_separation = GLOBAL_GET("physics/2d/solver/contact_max_separation"); contact_max_allowed_penetration = GLOBAL_GET("physics/2d/solver/contact_max_allowed_penetration"); diff --git a/modules/godot_physics_2d/godot_space_2d.h b/modules/godot_physics_2d/godot_space_2d.h index 04b5616af9b..c57ae4c0017 100644 --- a/modules/godot_physics_2d/godot_space_2d.h +++ b/modules/godot_physics_2d/godot_space_2d.h @@ -101,6 +101,7 @@ class GodotSpace2D { GodotArea2D *area = nullptr; int solver_iterations = 0; + int solver_min_constraints_for_threading = 256; real_t contact_recycle_radius = 0.0; real_t contact_max_separation = 0.0; @@ -163,6 +164,7 @@ class GodotSpace2D { const HashSet &get_objects() const; _FORCE_INLINE_ int get_solver_iterations() const { return solver_iterations; } + _FORCE_INLINE_ int get_solver_min_constraints_for_threading() const { return solver_min_constraints_for_threading; } _FORCE_INLINE_ real_t get_contact_recycle_radius() const { return contact_recycle_radius; } _FORCE_INLINE_ real_t get_contact_max_separation() const { return contact_max_separation; } _FORCE_INLINE_ real_t get_contact_max_allowed_penetration() const { return contact_max_allowed_penetration; } diff --git a/modules/godot_physics_2d/godot_step_2d.cpp b/modules/godot_physics_2d/godot_step_2d.cpp index 3fb5e68daf5..ef4d89c8794 100644 --- a/modules/godot_physics_2d/godot_step_2d.cpp +++ b/modules/godot_physics_2d/godot_step_2d.cpp @@ -248,9 +248,26 @@ void GodotStep2D::step(GodotSpace2D *p_space, real_t p_delta) { /* SETUP CONSTRAINTS / PROCESS COLLISIONS */ - uint32_t total_constraint_count = all_constraints.size(); - WorkerThreadPool::GroupID group_task = WorkerThreadPool::get_singleton()->add_template_group_task(this, &GodotStep2D::_setup_constraint, nullptr, total_constraint_count, -1, true, SNAME("Physics2DConstraintSetup")); - WorkerThreadPool::get_singleton()->wait_for_group_task_completion(group_task); + // Below this many constraints, running constraint setup and island solving + // through the WorkerThreadPool costs more than it saves. So, do it on the + // calling thread instead. Tunable per space + // (SPACE_PARAM_SOLVER_MIN_CONSTRAINTS_FOR_THREADING) and via the project + // setting physics/2d/solver/min_constraints_for_threading. + const uint32_t min_constraints_for_threading = p_space->get_solver_min_constraints_for_threading(); + + // Setup distributes one task per constraint, so it can only parallelize with + // more than one constraint. Avoid the thread pool when there is no parallel + // work to distribute. + const uint32_t total_constraint_count = all_constraints.size(); + const bool setup_on_thread_pool = total_constraint_count > 1 && total_constraint_count >= min_constraints_for_threading; + if (setup_on_thread_pool) { + WorkerThreadPool::GroupID group_task = WorkerThreadPool::get_singleton()->add_template_group_task(this, &GodotStep2D::_setup_constraint, nullptr, total_constraint_count, -1, true, SNAME("Physics2DConstraintSetup")); + WorkerThreadPool::get_singleton()->wait_for_group_task_completion(group_task); + } else { + for (uint32_t i = 0; i < total_constraint_count; i++) { + _setup_constraint(i, nullptr); + } + } { //profile profile_endtime = OS::get_singleton()->get_ticks_usec(); @@ -261,16 +278,33 @@ void GodotStep2D::step(GodotSpace2D *p_space, real_t p_delta) { /* PRE-SOLVE CONSTRAINT ISLANDS */ // WARNING: This doesn't run on threads, because it involves thread-unsafe processing. + // Pre-solve also prunes each island to its constraints that actually produced + // contacts, so afterwards we know how much real solving work there is. + uint32_t active_constraint_count = 0; for (uint32_t island_index = 0; island_index < island_count; ++island_index) { _pre_solve_island(constraint_islands[island_index]); + active_constraint_count += constraint_islands[island_index].size(); } /* SOLVE CONSTRAINT ISLANDS */ + // Solving distributes one task per island, so it can only parallelize with + // more than one island: a single big island (however many constraints) is + // one unit of work and would just pay pool overhead. Also gate on the actual + // (post-prune) constraint count, so scenes with many broadphase pairs but few + // real contacts (fast, mostly-separated bodies) don't thread for nothing. + const bool solve_on_thread_pool = island_count > 1 && active_constraint_count >= min_constraints_for_threading; + // WARNING: `_solve_island` modifies the constraint islands for optimization purpose, // their content is not reliable after these calls and shouldn't be used anymore. - group_task = WorkerThreadPool::get_singleton()->add_template_group_task(this, &GodotStep2D::_solve_island, nullptr, island_count, -1, true, SNAME("Physics2DConstraintSolveIslands")); - WorkerThreadPool::get_singleton()->wait_for_group_task_completion(group_task); + if (solve_on_thread_pool) { + WorkerThreadPool::GroupID group_task = WorkerThreadPool::get_singleton()->add_template_group_task(this, &GodotStep2D::_solve_island, nullptr, island_count, -1, true, SNAME("Physics2DConstraintSolveIslands")); + WorkerThreadPool::get_singleton()->wait_for_group_task_completion(group_task); + } else { + for (uint32_t island_index = 0; island_index < island_count; ++island_index) { + _solve_island(island_index, nullptr); + } + } { //profile profile_endtime = OS::get_singleton()->get_ticks_usec(); diff --git a/servers/physics_2d/physics_server_2d.cpp b/servers/physics_2d/physics_server_2d.cpp index a6371903c3c..54c0501313c 100644 --- a/servers/physics_2d/physics_server_2d.cpp +++ b/servers/physics_2d/physics_server_2d.cpp @@ -819,6 +819,7 @@ void PhysicsServer2D::_bind_methods() { BIND_ENUM_CONSTANT(SPACE_PARAM_BODY_TIME_TO_SLEEP); BIND_ENUM_CONSTANT(SPACE_PARAM_CONSTRAINT_DEFAULT_BIAS); BIND_ENUM_CONSTANT(SPACE_PARAM_SOLVER_ITERATIONS); + BIND_ENUM_CONSTANT(SPACE_PARAM_SOLVER_MIN_CONSTRAINTS_FOR_THREADING); BIND_ENUM_CONSTANT(SHAPE_WORLD_BOUNDARY); BIND_ENUM_CONSTANT(SHAPE_SEPARATION_RAY); @@ -920,6 +921,7 @@ PhysicsServer2D::PhysicsServer2D() { GLOBAL_DEF(PropertyInfo(Variant::FLOAT, "physics/2d/sleep_threshold_angular", PROPERTY_HINT_RANGE, "0,90,0.1,radians_as_degrees"), Math::deg_to_rad(8.0)); GLOBAL_DEF(PropertyInfo(Variant::FLOAT, "physics/2d/time_before_sleep", PROPERTY_HINT_RANGE, "0,5,0.01,or_greater,suffix:s"), 0.5); GLOBAL_DEF(PropertyInfo(Variant::INT, "physics/2d/solver/solver_iterations", PROPERTY_HINT_RANGE, "1,32,1,or_greater"), 16); + GLOBAL_DEF(PropertyInfo(Variant::INT, "physics/2d/solver/min_constraints_for_threading", PROPERTY_HINT_RANGE, "0,4096,1,or_greater"), 256); GLOBAL_DEF(PropertyInfo(Variant::FLOAT, "physics/2d/solver/contact_recycle_radius", PROPERTY_HINT_RANGE, "0,10,0.01,or_greater"), 1.0); GLOBAL_DEF(PropertyInfo(Variant::FLOAT, "physics/2d/solver/contact_max_separation", PROPERTY_HINT_RANGE, "0,10,0.01,or_greater"), 1.5); GLOBAL_DEF(PropertyInfo(Variant::FLOAT, "physics/2d/solver/contact_max_allowed_penetration", PROPERTY_HINT_RANGE, "0.01,10,0.01,or_greater"), 0.3); diff --git a/servers/physics_2d/physics_server_2d.h b/servers/physics_2d/physics_server_2d.h index 2794e7fb62b..3763fecf604 100644 --- a/servers/physics_2d/physics_server_2d.h +++ b/servers/physics_2d/physics_server_2d.h @@ -280,6 +280,7 @@ class PhysicsServer2D : public Object { SPACE_PARAM_BODY_TIME_TO_SLEEP, SPACE_PARAM_CONSTRAINT_DEFAULT_BIAS, SPACE_PARAM_SOLVER_ITERATIONS, + SPACE_PARAM_SOLVER_MIN_CONSTRAINTS_FOR_THREADING, }; virtual void space_set_param(RID p_space, SpaceParameter p_param, real_t p_value) = 0; From d12cde93405fa800e89ad60fb3e5ede4eea0a2aa Mon Sep 17 00:00:00 2001 From: Dubhghlas McLaughlin <103212704+mcdubhghlas@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:43:01 -0500 Subject: [PATCH 2/6] Improve 2D physics threading workload estimation. Avoids dispatching large numbers of mostly empty constraint setup tasks in scenes with many broadphase pairs but few actual contacts. --- doc/classes/PhysicsServer2D.xml | 5 ++++- doc/classes/ProjectSettings.xml | 5 ++++- modules/godot_physics_2d/godot_space_2d.h | 13 ++++++++++++ modules/godot_physics_2d/godot_step_2d.cpp | 23 ++++++++++++++++++---- 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/doc/classes/PhysicsServer2D.xml b/doc/classes/PhysicsServer2D.xml index e0c5104e311..e60f0f8ce4b 100644 --- a/doc/classes/PhysicsServer2D.xml +++ b/doc/classes/PhysicsServer2D.xml @@ -1025,7 +1025,10 @@ Constant to set/get the number of solver iterations for all contacts and constraints. The greater the number of iterations, the more accurate the collisions will be. However, a greater number of iterations requires more CPU power, which can decrease performance. The default value of this parameter is [member ProjectSettings.physics/2d/solver/solver_iterations]. - Constant to set/get the minimum number of constraints a physics step must have before constraint setup and eligible island solving are distributed across the [WorkerThreadPool]. Below this threshold the work is done on the calling thread instead, avoiding thread dispatch and synchronization overhead that would otherwise outweigh the benefit for light workloads. Island solving additionally requires more than one island, since each island is a single unit of parallel work (so reaching the threshold does not by itself thread solving). A value of [code]0[/code] threads as aggressively as possible, that is, whenever there is actual parallel work to distribute. The default value of this parameter is [member ProjectSettings.physics/2d/solver/min_constraints_for_threading]. + Constant to set/get the minimum number of constraints a physics step must have before constraint setup and eligible island solving are distributed across the [WorkerThreadPool]. Below this threshold the work is done on the calling thread instead, avoiding thread dispatch and synchronization overhead that would otherwise outweigh the benefit for light workloads. + Constraint setup compares the threshold against an [i]estimate[/i] of useful work: the candidate constraint count scaled by the previous completed step's ratio of active (post-prune) to candidate constraints. This reduces unnecessary threading in scenes with many broadphase pairs but few real contacts (such as fast, mostly-separated bodies). The raw candidate count is used when there is no usable previous-step ratio, including the first step or after a step with no candidate constraints. Island solving instead uses the [i]actual[/i] current post-prune constraint count, and additionally requires more than one island, since each island is a single unit of parallel work (so reaching the threshold does not by itself thread solving). + A value of [code]0[/code] threads as aggressively as possible, that is, whenever there is actual parallel work to distribute. The default value of this parameter is [member ProjectSettings.physics/2d/solver/min_constraints_for_threading]. + [b]Note:[/b] The setup estimate assumes temporal coherence between consecutive physics steps. An abrupt workload change may cause one step to choose a suboptimal scheduling path, and workloads that repeatedly alternate between sparse and dense contacts can defeat the estimate. For persistent workloads, the estimate typically adapts on the following step. This is the constant for creating world boundary shapes. A world boundary shape is an [i]infinite[/i] line with an origin point, and a normal. Thus, it can be used for front/behind checks. diff --git a/doc/classes/ProjectSettings.xml b/doc/classes/ProjectSettings.xml index fd0342d034d..9c0c985424b 100644 --- a/doc/classes/ProjectSettings.xml +++ b/doc/classes/ProjectSettings.xml @@ -2506,7 +2506,10 @@ Individual shapes can have a specific bias value (see [member Shape2D.custom_solver_bias]). - Minimum number of constraints a 2D physics step must have before constraint setup and eligible island solving are distributed across the [WorkerThreadPool]. Below this threshold the work runs on the calling thread instead, avoiding thread dispatch and synchronization overhead that would otherwise outweigh the benefit for light workloads (such as scenes with many fast, mostly-separated bodies). Island solving additionally requires more than one island, since each island is a single unit of parallel work, so reaching the threshold does not by itself thread solving. A value of [code]0[/code] threads as aggressively as possible, that is, whenever there is actual parallel work to distribute. Raising it keeps more steps single-threaded; lowering it threads sooner. See [constant PhysicsServer2D.SPACE_PARAM_SOLVER_MIN_CONSTRAINTS_FOR_THREADING]. + Minimum number of constraints a 2D physics step must have before constraint setup and eligible island solving are distributed across the [WorkerThreadPool]. Below this threshold the work runs on the calling thread instead, avoiding thread dispatch and synchronization overhead that would otherwise outweigh the benefit for light workloads (such as scenes with many fast, mostly-separated bodies). + Constraint setup compares the threshold against an [i]estimate[/i] of useful work: the candidate constraint count scaled by the previous completed step's ratio of active (post-prune) to candidate constraints, which reduces unnecessary threading in scenes with many broadphase pairs but few real contacts. The raw candidate count is used when there is no usable previous-step ratio, including the first step or after a step with no candidate constraints. Island solving instead uses the [i]actual[/i] current post-prune constraint count, and additionally requires more than one island, since each island is a single unit of parallel work, so reaching the threshold does not by itself thread solving. + A value of [code]0[/code] threads as aggressively as possible, that is, whenever there is actual parallel work to distribute. Raising it keeps more steps single-threaded; lowering it threads sooner. See [constant PhysicsServer2D.SPACE_PARAM_SOLVER_MIN_CONSTRAINTS_FOR_THREADING]. + [b]Note:[/b] The setup estimate assumes temporal coherence between consecutive physics steps. An abrupt workload change may cause one step to choose a suboptimal scheduling path, and workloads that repeatedly alternate between sparse and dense contacts can defeat the estimate. For persistent workloads, the estimate typically adapts on the following step. [b]Note:[/b] This value is read when a physics space is created, so changing it at runtime does not affect existing physics spaces. Use [method PhysicsServer2D.space_set_param] to change it for an existing space. diff --git a/modules/godot_physics_2d/godot_space_2d.h b/modules/godot_physics_2d/godot_space_2d.h index c57ae4c0017..13dc951df7b 100644 --- a/modules/godot_physics_2d/godot_space_2d.h +++ b/modules/godot_physics_2d/godot_space_2d.h @@ -103,6 +103,13 @@ class GodotSpace2D { int solver_iterations = 0; int solver_min_constraints_for_threading = 256; + // Previous completed step's constraint counts, used to predict how much + // useful work this step's constraint setup will contain (candidate count is + // a poor proxy when most pairs don't actually collide). A setup count of 0 + // falls back to the static candidate-count gate. + uint32_t solver_prev_setup_constraint_count = 0; + uint32_t solver_prev_active_constraint_count = 0; + real_t contact_recycle_radius = 0.0; real_t contact_max_separation = 0.0; real_t contact_max_allowed_penetration = 0.0; @@ -165,6 +172,12 @@ class GodotSpace2D { _FORCE_INLINE_ int get_solver_iterations() const { return solver_iterations; } _FORCE_INLINE_ int get_solver_min_constraints_for_threading() const { return solver_min_constraints_for_threading; } + _FORCE_INLINE_ uint32_t get_solver_prev_setup_constraint_count() const { return solver_prev_setup_constraint_count; } + _FORCE_INLINE_ uint32_t get_solver_prev_active_constraint_count() const { return solver_prev_active_constraint_count; } + _FORCE_INLINE_ void set_solver_prev_constraint_counts(uint32_t p_setup, uint32_t p_active) { + solver_prev_setup_constraint_count = p_setup; + solver_prev_active_constraint_count = p_active; + } _FORCE_INLINE_ real_t get_contact_recycle_radius() const { return contact_recycle_radius; } _FORCE_INLINE_ real_t get_contact_max_separation() const { return contact_max_separation; } _FORCE_INLINE_ real_t get_contact_max_allowed_penetration() const { return contact_max_allowed_penetration; } diff --git a/modules/godot_physics_2d/godot_step_2d.cpp b/modules/godot_physics_2d/godot_step_2d.cpp index ef4d89c8794..65c23e73065 100644 --- a/modules/godot_physics_2d/godot_step_2d.cpp +++ b/modules/godot_physics_2d/godot_step_2d.cpp @@ -255,11 +255,23 @@ void GodotStep2D::step(GodotSpace2D *p_space, real_t p_delta) { // setting physics/2d/solver/min_constraints_for_threading. const uint32_t min_constraints_for_threading = p_space->get_solver_min_constraints_for_threading(); - // Setup distributes one task per constraint, so it can only parallelize with - // more than one constraint. Avoid the thread pool when there is no parallel - // work to distribute. + // Setup distributes one task per constraint, but candidate count can greatly + // overestimate useful work when most pairs do not produce contacts. Estimate + // the current setup workload from the previous step's active/candidate ratio. + // If no usable history exists, fall back to the static candidate-count gate. + // + // Cross-multiply the ratio comparison to avoid division and truncation. const uint32_t total_constraint_count = all_constraints.size(); - const bool setup_on_thread_pool = total_constraint_count > 1 && total_constraint_count >= min_constraints_for_threading; + bool setup_on_thread_pool = false; + if (total_constraint_count > 1) { + const uint32_t prev_setup = p_space->get_solver_prev_setup_constraint_count(); + if (prev_setup == 0) { + setup_on_thread_pool = total_constraint_count >= min_constraints_for_threading; + } else { + const uint32_t prev_active = p_space->get_solver_prev_active_constraint_count(); + setup_on_thread_pool = (uint64_t)total_constraint_count * prev_active >= (uint64_t)min_constraints_for_threading * prev_setup; + } + } if (setup_on_thread_pool) { WorkerThreadPool::GroupID group_task = WorkerThreadPool::get_singleton()->add_template_group_task(this, &GodotStep2D::_setup_constraint, nullptr, total_constraint_count, -1, true, SNAME("Physics2DConstraintSetup")); WorkerThreadPool::get_singleton()->wait_for_group_task_completion(group_task); @@ -286,6 +298,9 @@ void GodotStep2D::step(GodotSpace2D *p_space, real_t p_delta) { active_constraint_count += constraint_islands[island_index].size(); } + // Record this step's counts so the next step can predict its setup work. + p_space->set_solver_prev_constraint_counts(total_constraint_count, active_constraint_count); + /* SOLVE CONSTRAINT ISLANDS */ // Solving distributes one task per island, so it can only parallelize with From cbc8a70d79e09365ae782aa1b0e348e7510a11ac Mon Sep 17 00:00:00 2001 From: Dubhghlas McLaughlin <103212704+mcdubhghlas@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:35:33 -0500 Subject: [PATCH 3/6] Add prediction modes for 2D constraint setup threading. --- doc/classes/PhysicsServer2D.xml | 18 +++++++++-- doc/classes/ProjectSettings.xml | 8 +++++ modules/godot_physics_2d/godot_space_2d.cpp | 12 +++++++ modules/godot_physics_2d/godot_space_2d.h | 36 ++++++++++++++------- modules/godot_physics_2d/godot_step_2d.cpp | 36 +++++++++++++++------ servers/physics_2d/physics_server_2d.cpp | 8 +++++ servers/physics_2d/physics_server_2d.h | 9 ++++++ 7 files changed, 105 insertions(+), 22 deletions(-) diff --git a/doc/classes/PhysicsServer2D.xml b/doc/classes/PhysicsServer2D.xml index e60f0f8ce4b..96d7ed6cf5d 100644 --- a/doc/classes/PhysicsServer2D.xml +++ b/doc/classes/PhysicsServer2D.xml @@ -1026,9 +1026,23 @@ Constant to set/get the minimum number of constraints a physics step must have before constraint setup and eligible island solving are distributed across the [WorkerThreadPool]. Below this threshold the work is done on the calling thread instead, avoiding thread dispatch and synchronization overhead that would otherwise outweigh the benefit for light workloads. - Constraint setup compares the threshold against an [i]estimate[/i] of useful work: the candidate constraint count scaled by the previous completed step's ratio of active (post-prune) to candidate constraints. This reduces unnecessary threading in scenes with many broadphase pairs but few real contacts (such as fast, mostly-separated bodies). The raw candidate count is used when there is no usable previous-step ratio, including the first step or after a step with no candidate constraints. Island solving instead uses the [i]actual[/i] current post-prune constraint count, and additionally requires more than one island, since each island is a single unit of parallel work (so reaching the threshold does not by itself thread solving). + How constraint setup compares against this threshold depends on [constant SPACE_PARAM_SOLVER_SETUP_THREADING_MODE]: the static mode uses the raw candidate constraint count, while the predicted modes use an estimate of useful work (candidate count scaled by a recent step's ratio of active, post-prune, to candidate constraints), which reduces unnecessary threading in scenes with many broadphase pairs but few real contacts. Island solving always uses the [i]actual[/i] current post-prune constraint count, and additionally requires more than one island, since each island is a single unit of parallel work (so reaching the threshold does not by itself thread solving). A value of [code]0[/code] threads as aggressively as possible, that is, whenever there is actual parallel work to distribute. The default value of this parameter is [member ProjectSettings.physics/2d/solver/min_constraints_for_threading]. - [b]Note:[/b] The setup estimate assumes temporal coherence between consecutive physics steps. An abrupt workload change may cause one step to choose a suboptimal scheduling path, and workloads that repeatedly alternate between sparse and dense contacts can defeat the estimate. For persistent workloads, the estimate typically adapts on the following step. + + + Constant to set/get how the 2D solver decides whether to distribute constraint [i]setup[/i] across the [WorkerThreadPool] (see the [enum SolverSetupThreadingMode] values). Only affects setup scheduling; island solving always uses the actual post-prune work. The default value of this parameter is [member ProjectSettings.physics/2d/solver/setup_threading_mode]. + + + Constant to set/get how many past steps [constant SOLVER_SETUP_THREADING_PREDICTED_BIASED] maxes its work estimate over (the [i]K[/i] in max-over-K). Clamped to [code]1[/code]–[code]8[/code]; [code]1[/code] makes the biased mode behave like [constant SOLVER_SETUP_THREADING_PREDICTED]. Larger values bias further toward threading (they keep threading for more steps after a heavy one). Has no effect in the static or (unbiased) predicted modes. The default value of this parameter is [member ProjectSettings.physics/2d/solver/setup_prediction_window]. + + + Thread constraint setup purely on the raw candidate constraint count (no prediction). Predictable and free of transition artifacts, but threads setup even for scenes with many broadphase pairs that produce few real contacts. + + + Thread constraint setup based on an estimate of useful work: the candidate count scaled by the [i]previous[/i] completed step's active/candidate ratio. Avoids threading light workloads, but assumes temporal coherence — an abrupt workload change costs one suboptimally-scheduled step before the estimate adapts, and workloads that alternate sparse/dense every step defeat it. This is the default. + + + As [constant SOLVER_SETUP_THREADING_PREDICTED], but takes the maximum estimate over the last [i]K[/i] steps (K = [member ProjectSettings.physics/2d/solver/setup_prediction_window], default 2), biasing toward threading. This trades a costly mis-serialized dense step for a cheap redundantly-threaded sparse step across transitions, which removes the alternating-workload regression at the cost of adapting a step or two slower when a workload becomes persistently sparse. This is the constant for creating world boundary shapes. A world boundary shape is an [i]infinite[/i] line with an origin point, and a normal. Thus, it can be used for front/behind checks. diff --git a/doc/classes/ProjectSettings.xml b/doc/classes/ProjectSettings.xml index 9c0c985424b..032745b9380 100644 --- a/doc/classes/ProjectSettings.xml +++ b/doc/classes/ProjectSettings.xml @@ -2516,6 +2516,14 @@ Maximum broadphase pairing margin, in pixels. Each object's collision pairs are kept alive within a margin that scales with its size (up to this maximum), so large moving objects (such as big [Area2D] sensors) re-pair less often, improving performance in scenes with many large overlapping shapes. Small objects keep a proportionally tiny margin so dense scenes do not accumulate excess pairs. Set to [code]0[/code] to disable and use the legacy pair-count-based margin. [b]Note:[/b] This value is read when a physics space is created, so changing it at runtime does not affect existing physics spaces. + + Number of past steps the [constant PhysicsServer2D.SOLVER_SETUP_THREADING_PREDICTED_BIASED] mode maxes its setup-work estimate over (the [i]K[/i] in max-over-K), clamped to 1–8. [code]1[/code] makes biased behave like plain predicted; larger values bias further toward threading. Only used by the biased mode. See [constant PhysicsServer2D.SPACE_PARAM_SOLVER_SETUP_PREDICTION_WINDOW]. + [b]Note:[/b] This value is read when a physics space is created, so changing it at runtime does not affect existing physics spaces. Use [method PhysicsServer2D.space_set_param] to change it for an existing space. + + + How the 2D solver decides whether to distribute constraint setup across the [WorkerThreadPool]: [code]0[/code] static (raw candidate count), [code]1[/code] predicted (default; scale by the previous step's active/candidate ratio), [code]2[/code] predicted biased (max over the last two steps, biased toward threading). See [enum PhysicsServer2D.SolverSetupThreadingMode] for the trade-offs, and [member physics/2d/solver/min_constraints_for_threading] for the threshold itself. Only affects setup; island solving always uses the actual post-prune work. + [b]Note:[/b] This value is read when a physics space is created, so changing it at runtime does not affect existing physics spaces. Use [method PhysicsServer2D.space_set_param] to change it for an existing space. + Number of solver iterations for all contacts and constraints. The greater the number of iterations, the more accurate the collisions will be. However, a greater number of iterations requires more CPU power, which can decrease performance. See [constant PhysicsServer2D.SPACE_PARAM_SOLVER_ITERATIONS]. diff --git a/modules/godot_physics_2d/godot_space_2d.cpp b/modules/godot_physics_2d/godot_space_2d.cpp index 0d95a15203f..6ccad5b00f8 100644 --- a/modules/godot_physics_2d/godot_space_2d.cpp +++ b/modules/godot_physics_2d/godot_space_2d.cpp @@ -1176,6 +1176,12 @@ void GodotSpace2D::set_param(PhysicsServer2D::SpaceParameter p_param, real_t p_v case PhysicsServer2D::SPACE_PARAM_SOLVER_MIN_CONSTRAINTS_FOR_THREADING: solver_min_constraints_for_threading = MAX(0, (int)p_value); break; + case PhysicsServer2D::SPACE_PARAM_SOLVER_SETUP_THREADING_MODE: + solver_setup_threading_mode = CLAMP((int)p_value, 0, PhysicsServer2D::SOLVER_SETUP_THREADING_PREDICTED_BIASED); + break; + case PhysicsServer2D::SPACE_PARAM_SOLVER_SETUP_PREDICTION_WINDOW: + solver_setup_prediction_window = CLAMP((int)p_value, 1, SOLVER_SETUP_HISTORY_MAX); + break; } } @@ -1201,6 +1207,10 @@ real_t GodotSpace2D::get_param(PhysicsServer2D::SpaceParameter p_param) const { return solver_iterations; case PhysicsServer2D::SPACE_PARAM_SOLVER_MIN_CONSTRAINTS_FOR_THREADING: return solver_min_constraints_for_threading; + case PhysicsServer2D::SPACE_PARAM_SOLVER_SETUP_THREADING_MODE: + return solver_setup_threading_mode; + case PhysicsServer2D::SPACE_PARAM_SOLVER_SETUP_PREDICTION_WINDOW: + return solver_setup_prediction_window; } return 0; } @@ -1227,6 +1237,8 @@ GodotSpace2D::GodotSpace2D() { body_time_to_sleep = GLOBAL_GET("physics/2d/time_before_sleep"); solver_iterations = GLOBAL_GET("physics/2d/solver/solver_iterations"); solver_min_constraints_for_threading = GLOBAL_GET("physics/2d/solver/min_constraints_for_threading"); + solver_setup_threading_mode = GLOBAL_GET("physics/2d/solver/setup_threading_mode"); + solver_setup_prediction_window = CLAMP((int)GLOBAL_GET("physics/2d/solver/setup_prediction_window"), 1, SOLVER_SETUP_HISTORY_MAX); contact_recycle_radius = GLOBAL_GET("physics/2d/solver/contact_recycle_radius"); contact_max_separation = GLOBAL_GET("physics/2d/solver/contact_max_separation"); contact_max_allowed_penetration = GLOBAL_GET("physics/2d/solver/contact_max_allowed_penetration"); diff --git a/modules/godot_physics_2d/godot_space_2d.h b/modules/godot_physics_2d/godot_space_2d.h index 13dc951df7b..1768369db38 100644 --- a/modules/godot_physics_2d/godot_space_2d.h +++ b/modules/godot_physics_2d/godot_space_2d.h @@ -100,15 +100,21 @@ class GodotSpace2D { GodotArea2D *area = nullptr; + enum { SOLVER_SETUP_HISTORY_MAX = 8 }; + int solver_iterations = 0; int solver_min_constraints_for_threading = 256; - - // Previous completed step's constraint counts, used to predict how much - // useful work this step's constraint setup will contain (candidate count is - // a poor proxy when most pairs don't actually collide). A setup count of 0 - // falls back to the static candidate-count gate. - uint32_t solver_prev_setup_constraint_count = 0; - uint32_t solver_prev_active_constraint_count = 0; + int solver_setup_threading_mode = PhysicsServer2D::SOLVER_SETUP_THREADING_PREDICTED; + // Number of past steps the biased mode maxes over (K). Clamped to [1, MAX]. + int solver_setup_prediction_window = 2; + + // Ring of the last few completed steps' (setup, active) constraint counts, + // used to predict how much useful work this step's setup will contain + // (candidate count is a poor proxy when most pairs don't actually collide). + // A setup count of 0 means no history for that slot -> static-gate fallback. + uint32_t solver_setup_hist_setup[SOLVER_SETUP_HISTORY_MAX] = {}; + uint32_t solver_setup_hist_active[SOLVER_SETUP_HISTORY_MAX] = {}; + uint32_t solver_setup_hist_head = 0; // next write slot; (head-1) is most recent real_t contact_recycle_radius = 0.0; real_t contact_max_separation = 0.0; @@ -172,11 +178,19 @@ class GodotSpace2D { _FORCE_INLINE_ int get_solver_iterations() const { return solver_iterations; } _FORCE_INLINE_ int get_solver_min_constraints_for_threading() const { return solver_min_constraints_for_threading; } - _FORCE_INLINE_ uint32_t get_solver_prev_setup_constraint_count() const { return solver_prev_setup_constraint_count; } - _FORCE_INLINE_ uint32_t get_solver_prev_active_constraint_count() const { return solver_prev_active_constraint_count; } + _FORCE_INLINE_ int get_solver_setup_threading_mode() const { return solver_setup_threading_mode; } + _FORCE_INLINE_ int get_solver_setup_prediction_window() const { return solver_setup_prediction_window; } + // p_back == 0 is the most recent completed step, 1 the one before it, etc. + _FORCE_INLINE_ uint32_t get_solver_prev_setup_at(uint32_t p_back) const { + return solver_setup_hist_setup[(solver_setup_hist_head + SOLVER_SETUP_HISTORY_MAX - 1 - p_back) % SOLVER_SETUP_HISTORY_MAX]; + } + _FORCE_INLINE_ uint32_t get_solver_prev_active_at(uint32_t p_back) const { + return solver_setup_hist_active[(solver_setup_hist_head + SOLVER_SETUP_HISTORY_MAX - 1 - p_back) % SOLVER_SETUP_HISTORY_MAX]; + } _FORCE_INLINE_ void set_solver_prev_constraint_counts(uint32_t p_setup, uint32_t p_active) { - solver_prev_setup_constraint_count = p_setup; - solver_prev_active_constraint_count = p_active; + solver_setup_hist_setup[solver_setup_hist_head] = p_setup; + solver_setup_hist_active[solver_setup_hist_head] = p_active; + solver_setup_hist_head = (solver_setup_hist_head + 1) % SOLVER_SETUP_HISTORY_MAX; } _FORCE_INLINE_ real_t get_contact_recycle_radius() const { return contact_recycle_radius; } _FORCE_INLINE_ real_t get_contact_max_separation() const { return contact_max_separation; } diff --git a/modules/godot_physics_2d/godot_step_2d.cpp b/modules/godot_physics_2d/godot_step_2d.cpp index 65c23e73065..e90bdab6aa4 100644 --- a/modules/godot_physics_2d/godot_step_2d.cpp +++ b/modules/godot_physics_2d/godot_step_2d.cpp @@ -256,20 +256,38 @@ void GodotStep2D::step(GodotSpace2D *p_space, real_t p_delta) { const uint32_t min_constraints_for_threading = p_space->get_solver_min_constraints_for_threading(); // Setup distributes one task per constraint, but candidate count can greatly - // overestimate useful work when most pairs do not produce contacts. Estimate - // the current setup workload from the previous step's active/candidate ratio. - // If no usable history exists, fall back to the static candidate-count gate. - // - // Cross-multiply the ratio comparison to avoid division and truncation. + // overestimate useful work when most pairs do not produce contacts. Depending + // on the space's mode, gate threading on the raw candidate count (STATIC) or + // on an estimate of useful work: candidate count scaled by a recent step's + // active/candidate ratio (cross-multiplied to avoid division/truncation). + // With no usable history, all modes fall back to the static candidate gate. const uint32_t total_constraint_count = all_constraints.size(); bool setup_on_thread_pool = false; if (total_constraint_count > 1) { - const uint32_t prev_setup = p_space->get_solver_prev_setup_constraint_count(); - if (prev_setup == 0) { + const int mode = p_space->get_solver_setup_threading_mode(); + const uint32_t prev_setup = p_space->get_solver_prev_setup_at(0); + if (mode == PhysicsServer2D::SOLVER_SETUP_THREADING_STATIC || prev_setup == 0) { setup_on_thread_pool = total_constraint_count >= min_constraints_for_threading; } else { - const uint32_t prev_active = p_space->get_solver_prev_active_constraint_count(); - setup_on_thread_pool = (uint64_t)total_constraint_count * prev_active >= (uint64_t)min_constraints_for_threading * prev_setup; + // PREDICTED uses just the previous step; PREDICTED_BIASED takes the + // max estimate over the last K steps, trading a costly false-serial + // (running a suddenly-dense setup serially) for a cheap false-thread + // across transitions, which neutralizes the alternating regression. + uint32_t window = 1; + if (mode == PhysicsServer2D::SOLVER_SETUP_THREADING_PREDICTED_BIASED) { + window = (uint32_t)p_space->get_solver_setup_prediction_window(); + } + for (uint32_t back = 0; back < window; back++) { + const uint32_t hist_setup = p_space->get_solver_prev_setup_at(back); + if (hist_setup == 0) { + continue; // no history in this slot + } + const uint32_t hist_active = p_space->get_solver_prev_active_at(back); + if ((uint64_t)total_constraint_count * hist_active >= (uint64_t)min_constraints_for_threading * hist_setup) { + setup_on_thread_pool = true; + break; + } + } } } if (setup_on_thread_pool) { diff --git a/servers/physics_2d/physics_server_2d.cpp b/servers/physics_2d/physics_server_2d.cpp index 54c0501313c..af4e5a57f31 100644 --- a/servers/physics_2d/physics_server_2d.cpp +++ b/servers/physics_2d/physics_server_2d.cpp @@ -820,6 +820,12 @@ void PhysicsServer2D::_bind_methods() { BIND_ENUM_CONSTANT(SPACE_PARAM_CONSTRAINT_DEFAULT_BIAS); BIND_ENUM_CONSTANT(SPACE_PARAM_SOLVER_ITERATIONS); BIND_ENUM_CONSTANT(SPACE_PARAM_SOLVER_MIN_CONSTRAINTS_FOR_THREADING); + BIND_ENUM_CONSTANT(SPACE_PARAM_SOLVER_SETUP_THREADING_MODE); + BIND_ENUM_CONSTANT(SPACE_PARAM_SOLVER_SETUP_PREDICTION_WINDOW); + + BIND_ENUM_CONSTANT(SOLVER_SETUP_THREADING_STATIC); + BIND_ENUM_CONSTANT(SOLVER_SETUP_THREADING_PREDICTED); + BIND_ENUM_CONSTANT(SOLVER_SETUP_THREADING_PREDICTED_BIASED); BIND_ENUM_CONSTANT(SHAPE_WORLD_BOUNDARY); BIND_ENUM_CONSTANT(SHAPE_SEPARATION_RAY); @@ -922,6 +928,8 @@ PhysicsServer2D::PhysicsServer2D() { GLOBAL_DEF(PropertyInfo(Variant::FLOAT, "physics/2d/time_before_sleep", PROPERTY_HINT_RANGE, "0,5,0.01,or_greater,suffix:s"), 0.5); GLOBAL_DEF(PropertyInfo(Variant::INT, "physics/2d/solver/solver_iterations", PROPERTY_HINT_RANGE, "1,32,1,or_greater"), 16); GLOBAL_DEF(PropertyInfo(Variant::INT, "physics/2d/solver/min_constraints_for_threading", PROPERTY_HINT_RANGE, "0,4096,1,or_greater"), 256); + GLOBAL_DEF(PropertyInfo(Variant::INT, "physics/2d/solver/setup_threading_mode", PROPERTY_HINT_ENUM, "Static,Predicted,Predicted Biased"), PhysicsServer2D::SOLVER_SETUP_THREADING_PREDICTED); + GLOBAL_DEF(PropertyInfo(Variant::INT, "physics/2d/solver/setup_prediction_window", PROPERTY_HINT_RANGE, "1,8,1"), 2); GLOBAL_DEF(PropertyInfo(Variant::FLOAT, "physics/2d/solver/contact_recycle_radius", PROPERTY_HINT_RANGE, "0,10,0.01,or_greater"), 1.0); GLOBAL_DEF(PropertyInfo(Variant::FLOAT, "physics/2d/solver/contact_max_separation", PROPERTY_HINT_RANGE, "0,10,0.01,or_greater"), 1.5); GLOBAL_DEF(PropertyInfo(Variant::FLOAT, "physics/2d/solver/contact_max_allowed_penetration", PROPERTY_HINT_RANGE, "0.01,10,0.01,or_greater"), 0.3); diff --git a/servers/physics_2d/physics_server_2d.h b/servers/physics_2d/physics_server_2d.h index 3763fecf604..89d22a5efd3 100644 --- a/servers/physics_2d/physics_server_2d.h +++ b/servers/physics_2d/physics_server_2d.h @@ -281,6 +281,14 @@ class PhysicsServer2D : public Object { SPACE_PARAM_CONSTRAINT_DEFAULT_BIAS, SPACE_PARAM_SOLVER_ITERATIONS, SPACE_PARAM_SOLVER_MIN_CONSTRAINTS_FOR_THREADING, + SPACE_PARAM_SOLVER_SETUP_THREADING_MODE, + SPACE_PARAM_SOLVER_SETUP_PREDICTION_WINDOW, + }; + + enum SolverSetupThreadingMode { + SOLVER_SETUP_THREADING_STATIC, // gate on raw candidate count, no prediction + SOLVER_SETUP_THREADING_PREDICTED, // scale candidate count by previous step's active/candidate ratio + SOLVER_SETUP_THREADING_PREDICTED_BIASED, // as PREDICTED, but max over the last two steps (biased toward threading) }; virtual void space_set_param(RID p_space, SpaceParameter p_param, real_t p_value) = 0; @@ -859,6 +867,7 @@ class PhysicsServer2DManager : public Object { VARIANT_ENUM_CAST(PhysicsServer2D::ShapeType); VARIANT_ENUM_CAST(PhysicsServer2D::SpaceParameter); +VARIANT_ENUM_CAST(PhysicsServer2D::SolverSetupThreadingMode); VARIANT_ENUM_CAST(PhysicsServer2D::AreaParameter); VARIANT_ENUM_CAST(PhysicsServer2D::AreaSpaceOverrideMode); VARIANT_ENUM_CAST(PhysicsServer2D::BodyMode); From a2060d593bc59ac054a163849c6fd6941354bbae Mon Sep 17 00:00:00 2001 From: Dubhghlas McLaughlin <103212704+mcdubhghlas@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:16:08 -0500 Subject: [PATCH 4/6] reverted to original. --- modules/godot_physics_2d/godot_shape_2d.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/godot_physics_2d/godot_shape_2d.h b/modules/godot_physics_2d/godot_shape_2d.h index f0d1b71afab..4deeccdb3a3 100644 --- a/modules/godot_physics_2d/godot_shape_2d.h +++ b/modules/godot_physics_2d/godot_shape_2d.h @@ -432,7 +432,7 @@ class GodotConvexPolygonShape2D : public GodotShape2D { Vector2 a = points[p_idx].pos; p_idx++; Vector2 b = points[p_idx == point_count ? 0 : p_idx].pos; - return p_xform.basis_xform(b - a).normalized().orthogonal(); + return (p_xform.xform(b) - p_xform.xform(a)).normalized().orthogonal(); } virtual PhysicsServer2D::ShapeType get_type() const override { return PhysicsServer2D::SHAPE_CONVEX_POLYGON; } From 90c16354ce0ca3726df2dade0ccbbc9868c01a75 Mon Sep 17 00:00:00 2001 From: Dubhghlas McLaughlin <103212704+mcdubhghlas@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:31:52 -0500 Subject: [PATCH 5/6] rabbit complaints. --- doc/classes/ProjectSettings.xml | 2 +- modules/godot_physics_2d/godot_step_2d.cpp | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/doc/classes/ProjectSettings.xml b/doc/classes/ProjectSettings.xml index 032745b9380..084c1ae153d 100644 --- a/doc/classes/ProjectSettings.xml +++ b/doc/classes/ProjectSettings.xml @@ -2507,7 +2507,7 @@ Minimum number of constraints a 2D physics step must have before constraint setup and eligible island solving are distributed across the [WorkerThreadPool]. Below this threshold the work runs on the calling thread instead, avoiding thread dispatch and synchronization overhead that would otherwise outweigh the benefit for light workloads (such as scenes with many fast, mostly-separated bodies). - Constraint setup compares the threshold against an [i]estimate[/i] of useful work: the candidate constraint count scaled by the previous completed step's ratio of active (post-prune) to candidate constraints, which reduces unnecessary threading in scenes with many broadphase pairs but few real contacts. The raw candidate count is used when there is no usable previous-step ratio, including the first step or after a step with no candidate constraints. Island solving instead uses the [i]actual[/i] current post-prune constraint count, and additionally requires more than one island, since each island is a single unit of parallel work, so reaching the threshold does not by itself thread solving. + How constraint setup compares against this threshold depends on [member physics/2d/solver/setup_threading_mode]: [b]Static[/b] uses the raw candidate constraint count; [b]Predicted[/b] scales the candidate count by the previous completed step's ratio of active (post-prune) to candidate constraints (falling back to the raw count when there is no usable previous-step ratio, e.g. the first step); [b]Predicted Biased[/b] takes the maximum of that estimate over the last [member physics/2d/solver/setup_prediction_window] steps. The predicted modes reduce unnecessary threading in scenes with many broadphase pairs but few real contacts. Island solving always uses the [i]actual[/i] current post-prune constraint count, and additionally requires more than one non-empty island, since each island is a single unit of parallel work, so reaching the threshold does not by itself thread solving. A value of [code]0[/code] threads as aggressively as possible, that is, whenever there is actual parallel work to distribute. Raising it keeps more steps single-threaded; lowering it threads sooner. See [constant PhysicsServer2D.SPACE_PARAM_SOLVER_MIN_CONSTRAINTS_FOR_THREADING]. [b]Note:[/b] The setup estimate assumes temporal coherence between consecutive physics steps. An abrupt workload change may cause one step to choose a suboptimal scheduling path, and workloads that repeatedly alternate between sparse and dense contacts can defeat the estimate. For persistent workloads, the estimate typically adapts on the following step. [b]Note:[/b] This value is read when a physics space is created, so changing it at runtime does not affect existing physics spaces. Use [method PhysicsServer2D.space_set_param] to change it for an existing space. diff --git a/modules/godot_physics_2d/godot_step_2d.cpp b/modules/godot_physics_2d/godot_step_2d.cpp index e90bdab6aa4..21dd99c6b25 100644 --- a/modules/godot_physics_2d/godot_step_2d.cpp +++ b/modules/godot_physics_2d/godot_step_2d.cpp @@ -311,9 +311,14 @@ void GodotStep2D::step(GodotSpace2D *p_space, real_t p_delta) { // Pre-solve also prunes each island to its constraints that actually produced // contacts, so afterwards we know how much real solving work there is. uint32_t active_constraint_count = 0; + uint32_t active_island_count = 0; // islands still non-empty after pruning for (uint32_t island_index = 0; island_index < island_count; ++island_index) { _pre_solve_island(constraint_islands[island_index]); - active_constraint_count += constraint_islands[island_index].size(); + const uint32_t island_size = constraint_islands[island_index].size(); + active_constraint_count += island_size; + if (island_size > 0) { + ++active_island_count; + } } // Record this step's counts so the next step can predict its setup work. @@ -322,11 +327,12 @@ void GodotStep2D::step(GodotSpace2D *p_space, real_t p_delta) { /* SOLVE CONSTRAINT ISLANDS */ // Solving distributes one task per island, so it can only parallelize with - // more than one island: a single big island (however many constraints) is - // one unit of work and would just pay pool overhead. Also gate on the actual + // more than one *non-empty* island: a single unit of work would just pay pool + // overhead. Pre-solve can prune islands to empty, so gate on the post-prune + // non-empty count, not the raw island_count. Also gate on the actual // (post-prune) constraint count, so scenes with many broadphase pairs but few // real contacts (fast, mostly-separated bodies) don't thread for nothing. - const bool solve_on_thread_pool = island_count > 1 && active_constraint_count >= min_constraints_for_threading; + const bool solve_on_thread_pool = active_island_count > 1 && active_constraint_count >= min_constraints_for_threading; // WARNING: `_solve_island` modifies the constraint islands for optimization purpose, // their content is not reliable after these calls and shouldn't be used anymore. From d8fdea1416a8f0b297c44522921c99020dee72a9 Mon Sep 17 00:00:00 2001 From: Dubhghlas McLaughlin <103212704+mcdubhghlas@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:51:17 -0500 Subject: [PATCH 6/6] Jon was like 'yo i prefer constexpr int' and I agreed with him. --- modules/godot_physics_2d/godot_space_2d.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/godot_physics_2d/godot_space_2d.h b/modules/godot_physics_2d/godot_space_2d.h index 1768369db38..d3387771569 100644 --- a/modules/godot_physics_2d/godot_space_2d.h +++ b/modules/godot_physics_2d/godot_space_2d.h @@ -100,7 +100,7 @@ class GodotSpace2D { GodotArea2D *area = nullptr; - enum { SOLVER_SETUP_HISTORY_MAX = 8 }; + static constexpr int SOLVER_SETUP_HISTORY_MAX = 8; int solver_iterations = 0; int solver_min_constraints_for_threading = 256;