52 static constexpr std::size_t
kNoBall =
static_cast<std::size_t
>(-1);
278 std::lock_guard<std::mutex> lock(state_mutex_);
284 std::function<void(std::size_t robot_index,
const RobotState& robot)>;
292 std::lock_guard<std::mutex> lock(state_mutex_);
293 robots_.push_back(robot);
294 const std::size_t robot_index = robots_.size() - 1;
295 if (robot_added_callback_) {
296 robot_added_callback_(robot_index, robots_[robot_index]);
306 robot_added_callback_ = callback;
319 std::lock_guard<std::mutex> lock(state_mutex_);
323 balls_.push_back(entity);
325 return balls_.size() - 1;
334 std::lock_guard<std::mutex> lock(state_mutex_);
335 projectiles_.push_back(projectile);
336 return projectiles_.size() - 1;
345 std::lock_guard<std::mutex> lock(state_mutex_);
346 goals_.push_back(goal_zone);
347 return goals_.back();
356 std::lock_guard<std::mutex> lock(state_mutex_);
357 for (
auto& existing : gamepiece_types_) {
358 if (existing.type == info.
type) {
363 gamepiece_types_.push_back(info);
364 return gamepiece_types_.back();
369 std::lock_guard<std::mutex> lock(state_mutex_);
370 gamepiece_types_.clear();
381 std::lock_guard<std::mutex> lock(state_mutex_);
382 field_elements_.push_back(boundary);
383 return field_elements_.back();
388 std::vector<RobotState>&
robots() {
return robots_; }
391 const std::vector<RobotState>&
robots()
const {
return robots_; }
395 std::vector<BallEntity>&
balls() {
return balls_; }
398 const std::vector<BallEntity>&
balls()
const {
return balls_; }
404 const std::vector<BallEntity>&
gamepieces()
const {
return balls_; }
428 if (ball_index >= ball_types_.size()) {
431 return ball_types_[ball_index];
437 if (ball_index >= ball_types_.size()) {
438 return std::string();
440 return std::string(gamePieceTypeName(ball_types_[ball_index]));
450 std::lock_guard<std::mutex> lock(state_mutex_);
451 if (ball_index >= balls_.size() || ball_index >= ball_types_.size()) {
454 ball_types_[ball_index] = type;
464 bool setBallType(std::size_t ball_index,
const std::string& type_name) {
466 if (!tryParseGamePieceType(type_name, parsed_type)) {
478 std::lock_guard<std::mutex> lock(state_mutex_);
479 if (ball_index >= balls_.size() || ball_index >= ball_types_.size()) {
483 balls_.erase(balls_.begin() +
static_cast<std::ptrdiff_t
>(ball_index));
484 ball_types_.erase(ball_types_.begin() +
485 static_cast<std::ptrdiff_t
>(ball_index));
491 std::vector<ProjectileEntity>&
projectiles() {
return projectiles_; }
499 std::vector<GoalZone>&
goals() {
return goals_; }
501 const std::vector<GoalZone>&
goals()
const {
return goals_; }
506 return field_elements_;
511 return field_elements_;
517 std::lock_guard<std::mutex> lock(state_mutex_);
518 return balls_.size();
524 std::lock_guard<std::mutex> lock(state_mutex_);
525 std::size_t count = 0;
526 for (
const auto& projectile : projectiles_) {
527 if (projectile.active) {
537 std::lock_guard<std::mutex> lock(state_mutex_);
538 std::size_t count = 0;
539 for (
const auto& ball : balls_) {
540 if (ball.scored_in_net) {
558 bool spawn_on_ground_after_touch =
true,
559 const std::function<
void()>& hit_target_callback = {}) {
560 std::lock_guard<std::mutex> lock(state_mutex_);
561 if (robot_index >= robots_.size()) {
565 const RobotState& robot = robots_[robot_index];
566 const double launch_speed =
572 const double cos_pitch = std::cos(command.
pitch_rad);
573 const Vector3 direction_world(std::cos(yaw_world) * cos_pitch,
574 std::sin(yaw_world) * cos_pitch,
577 ProjectileEntity projectile{};
579 projectile.position_m =
581 projectile.velocity_mps =
582 robot.velocity_mps + direction_world * launch_speed;
584 projectile.gravity_mps2 =
585 std::max(0.0, -fallbackBallConfig().gravity_mps2.z);
586 projectile.spawn_on_ground_after_touch = spawn_on_ground_after_touch;
587 projectile.hit_target_callback = hit_target_callback;
589 projectiles_.push_back(projectile);
590 return projectiles_.size() - 1;
602 std::lock_guard<std::mutex> lock(state_mutex_);
604 const int substeps = std::max(1, simulation_substeps_);
605 const double dt_substep_s = dt_s /
static_cast<double>(substeps);
607 for (
int i = 0; i < substeps; ++i) {
608 stepSingle(dt_substep_s);
618 simulation_substeps_ = std::max(1, simulation_substeps);
635 void stepSingle(
double dt_s) {
640 integrateRobots(dt_s);
641 resolveRobotRobotImpedance();
642 updateProjectiles(dt_s);
644 for (std::size_t i = 0; i < balls_.size(); ++i) {
645 BallEntity& ball = balls_[i];
647 const Vector3 pre_step_position = ball.sim.state().position_m;
648 const bool skip_integration =
650 if (!skip_integration) {
654 auto state = ball.sim.state();
655 const double spin_decay =
656 std::max(0.0, 1.0 - field_.free_ball_spin_decay_per_s * dt_s);
657 state.spin_radps *= spin_decay;
658 ball.sim.setState(state);
660 resolveContinuousCollision(ball, pre_step_position, dt_s);
661 resolveRobotBallContacts(ball);
662 resolveFieldElements(ball, dt_s);
663 resolveFieldBounds(ball);
666 resolveBallBallContacts(dt_s);
678 void updateProjectiles(
double dt_s) {
679 const double floor_z = fallbackBallConfig().ground_height_m;
681 for (
auto& projectile : projectiles_) {
682 if (!projectile.active) {
686 projectile.age_s += dt_s;
687 projectile.velocity_mps.z -= projectile.gravity_mps2 * dt_s;
688 projectile.position_m += projectile.velocity_mps * dt_s;
690 if (checkProjectileGoalHit(projectile)) {
691 projectile.hit_target =
true;
692 projectile.active =
false;
693 if (projectile.hit_target_callback) {
694 projectile.hit_target_callback();
699 if (projectile.position_m.z <= floor_z) {
700 if (projectile.spawn_on_ground_after_touch) {
701 const GamePieceInfo* info = findGamePieceInfo(projectile.type);
702 const auto& physics_cfg =
703 (info !=
nullptr) ? info->physics_config : fallbackBallConfig();
704 const auto& physics_props = (info !=
nullptr)
705 ? info->ball_properties
706 : fallbackBallProperties();
708 BallPhysicsSim3D::BallState state{};
709 state.position_m = Vector3(
710 projectile.position_m.x, projectile.position_m.y,
711 std::max(physics_props.radius_m, physics_cfg.ground_height_m));
712 state.velocity_mps = projectile.velocity_mps;
713 state.spin_radps = projectile.spin_radps;
714 addBall(state, physics_cfg, physics_props);
715 if (!ball_types_.empty()) {
716 ball_types_.back() = projectile.type;
719 projectile.active =
false;
723 if (isProjectileOutOfField(projectile)) {
724 projectile.active =
false;
730 for (
const auto& goal : goals_) {
731 if (!goal.accept_any_type && goal.accepted_type != projectile.type) {
734 if (goal.require_positive_vertical_velocity &&
735 projectile.velocity_mps.z <= 0.0) {
738 if (goal.custom_velocity_validator &&
739 !goal.custom_velocity_validator(projectile.velocity_mps)) {
744 const Vector3 delta = projectile.position_m - goal.center_m;
745 if (std::abs(delta.x) <= goal.half_extents_m.x &&
746 std::abs(delta.y) <= goal.half_extents_m.y &&
747 std::abs(delta.z) <= goal.half_extents_m.z) {
751 if ((projectile.position_m - goal.center_m).norm() <= goal.radius_m) {
760 const double tolerance_m = 2.0;
761 return projectile.position_m.x < field_.min_corner_m.x - tolerance_m ||
762 projectile.position_m.x > field_.max_corner_m.x + tolerance_m ||
763 projectile.position_m.y < field_.min_corner_m.y - tolerance_m ||
764 projectile.position_m.y > field_.max_corner_m.y + tolerance_m;
768 for (
const auto& info : gamepiece_types_) {
769 if (info.type == type) {
793 static bool tryParseGamePieceType(
const std::string& type_name,
795 if (type_name ==
"Ball") {
799 if (type_name ==
"Custom1") {
803 if (type_name ==
"Custom2") {
807 if (type_name ==
"Custom3") {
811 if (type_name ==
"Custom4") {
818 static BallPhysicsSim3D::BallProperties fallbackBallProperties() {
819 BallPhysicsSim3D::BallProperties properties{};
820 properties.mass_kg = 0.24;
821 properties.radius_m = 0.09;
822 properties.drag_coefficient = 0.50;
823 properties.reference_area_m2 =
824 3.14159265358979323846 * properties.radius_m * properties.radius_m;
825 properties.restitution = 0.48;
829 static BallPhysicsSim3D::Config fallbackBallConfig() {
830 BallPhysicsSim3D::Config config{};
831 config.gravity_mps2 = Vector3(0.0, 0.0, -9.81);
832 config.air_density_kgpm3 = 1.225;
833 config.magnus_coefficient = 1.0e-4;
834 config.ground_height_m = 0.0;
835 config.rolling_friction_per_s = 1.5;
836 config.min_bounce_speed_mps = 0.06;
846 static Vector3 rotateYaw(
const Vector3& local,
double yaw_rad) {
847 const double c = std::cos(yaw_rad);
848 const double s = std::sin(yaw_rad);
849 return Vector3(local.x * c - local.y * s, local.x * s + local.y * c,
858 static Vector3 boundaryNormalWorld(
const EnvironmentalBoundary& boundary) {
859 return boundary.orientation.rotate(
Vector3::unitZ()).normalized();
869 static void applyContactImpulse(Vector3& velocity,
const Vector3& normal,
870 double restitution,
double friction) {
871 const double vn = velocity.dot(normal);
876 velocity -= normal * ((1.0 + std::clamp(restitution, 0.0, 1.0)) * vn);
877 const Vector3 tangential = velocity - normal * velocity.dot(normal);
878 velocity -= tangential * std::clamp(friction, 0.0, 1.0);
885 static double velocityScaledRestitution(
886 double base_restitution,
double impact_speed_mps,
887 double reference_speed_mps,
double speed_exponent,
889 const double base = std::clamp(base_restitution, 0.0, 1.0);
890 if (!std::isfinite(impact_speed_mps) || impact_speed_mps <= 0.0) {
894 const double reference_speed = std::max(
896 (std::isfinite(reference_speed_mps) && reference_speed_mps >= 0.0)
897 ? reference_speed_mps
899 const double exponent =
900 (std::isfinite(speed_exponent) && speed_exponent >= 0.0)
903 const double clamped_min_scale = std::clamp(
904 std::isfinite(min_scale) ? min_scale : 0.55, 0.0, 1.0);
906 if (impact_speed_mps <= reference_speed || exponent <= 0.0) {
910 const double speed_scale =
911 std::pow(reference_speed / impact_speed_mps, exponent);
912 const double restitution_scale =
913 std::clamp(speed_scale, clamped_min_scale, 1.0);
914 return base * restitution_scale;
919 ball.sleeping =
false;
920 ball.sleep_frame_count = 0;
924 void updateSleepStates() {
925 if (!field_.sleeping_enabled) {
926 for (
auto& ball : balls_) {
932 const double velocity_threshold = std::max(0.0, field_.sleep_velocity_threshold_mps);
933 const double spin_threshold = std::max(0.0, field_.sleep_spin_threshold_radps);
934 const int frame_threshold = std::max(1, field_.sleep_frame_threshold);
936 for (
auto& ball : balls_) {
937 auto state = ball.sim.state();
938 if (state.held || ball.scored_in_net) {
943 const double floor_z =
944 ball.sim.config().ground_height_m + ball.sim.ballProperties().radius_m;
945 const bool near_ground = state.position_m.z <= floor_z + 0.01;
946 const bool sleep_candidate = near_ground &&
947 state.velocity_mps.norm() <= velocity_threshold &&
948 state.spin_radps.norm() <= spin_threshold;
950 if (!sleep_candidate) {
955 ball.sleep_frame_count += 1;
956 if (ball.sleep_frame_count < frame_threshold) {
960 ball.sleeping =
true;
963 ball.sim.setState(state);
973 void resolveContinuousCollision(
BallEntity& ball,
974 const Vector3& previous_position,
976 if (!field_.ccd_enabled || dt_s <= 0.0 || ball.sim.state().held) {
980 auto state = ball.sim.state();
981 const double speed_mps =
982 (state.position_m - previous_position).norm() / std::max(1e-9, dt_s);
983 if (speed_mps < std::max(0.0, field_.ccd_speed_threshold_mps)) {
987 const double radius = ball.sim.ballProperties().radius_m;
988 resolveSweptFieldBounds(state, previous_position, radius);
990 for (
const auto& boundary : field_elements_) {
991 if (!boundary.is_active) {
995 switch (boundary.type) {
998 resolveSweptPlaneBoundary(state, previous_position, boundary, radius);
1001 resolveSweptBoxBoundary(state, previous_position, boundary, radius);
1008 ball.sim.setState(state);
1012 void resolveSweptFieldBounds(BallPhysicsSim3D::BallState& state,
1013 const Vector3& previous_position,
1014 double radius)
const {
1015 const double min_x = field_.min_corner_m.x + radius;
1016 const double max_x = field_.max_corner_m.x - radius;
1017 const double min_y = field_.min_corner_m.y + radius;
1018 const double max_y = field_.max_corner_m.y - radius;
1020 if (previous_position.x >= min_x && state.position_m.x < min_x) {
1021 state.position_m.x = min_x;
1022 state.velocity_mps.x = std::abs(state.velocity_mps.x) *
1023 scaledWallRestitution(
1024 field_.wall_restitution,
1025 std::abs(state.velocity_mps.x));
1026 state.velocity_mps.y *= (1.0 - field_.wall_friction);
1028 if (previous_position.x <= max_x && state.position_m.x > max_x) {
1029 state.position_m.x = max_x;
1030 state.velocity_mps.x = -std::abs(state.velocity_mps.x) *
1031 scaledWallRestitution(
1032 field_.wall_restitution,
1033 std::abs(state.velocity_mps.x));
1034 state.velocity_mps.y *= (1.0 - field_.wall_friction);
1036 if (previous_position.y >= min_y && state.position_m.y < min_y) {
1037 state.position_m.y = min_y;
1038 state.velocity_mps.y = std::abs(state.velocity_mps.y) *
1039 scaledWallRestitution(
1040 field_.wall_restitution,
1041 std::abs(state.velocity_mps.y));
1042 state.velocity_mps.x *= (1.0 - field_.wall_friction);
1044 if (previous_position.y <= max_y && state.position_m.y > max_y) {
1045 state.position_m.y = max_y;
1046 state.velocity_mps.y = -std::abs(state.velocity_mps.y) *
1047 scaledWallRestitution(
1048 field_.wall_restitution,
1049 std::abs(state.velocity_mps.y));
1050 state.velocity_mps.x *= (1.0 - field_.wall_friction);
1055 void resolveSweptPlaneBoundary(BallPhysicsSim3D::BallState& state,
1056 const Vector3& previous_position,
1057 const EnvironmentalBoundary& boundary,
1058 double ball_radius)
const {
1059 Vector3 normal = boundaryNormalWorld(boundary);
1060 if (normal.isZero()) {
1064 const double prev_distance =
1065 (previous_position - boundary.position_m).dot(normal);
1066 const double curr_distance =
1067 (state.position_m - boundary.position_m).dot(normal);
1069 if (!(prev_distance >= ball_radius && curr_distance < ball_radius)) {
1073 state.position_m += normal * (ball_radius - curr_distance);
1074 const double impact_speed = std::max(0.0, -state.velocity_mps.dot(normal));
1075 applyContactImpulse(state.velocity_mps, normal,
1076 scaledWallRestitution(boundary.restitution,
1078 boundary.friction_coefficient);
1082 void resolveSweptBoxBoundary(BallPhysicsSim3D::BallState& state,
1083 const Vector3& previous_position,
1084 const EnvironmentalBoundary& boundary,
1085 double ball_radius)
const {
1086 const Quaternion inverse = boundary.orientation.inverse();
1087 const Vector3 prev_local =
1088 inverse.rotate(previous_position - boundary.position_m);
1089 const Vector3 curr_local =
1090 inverse.rotate(state.position_m - boundary.position_m);
1091 const Vector3 delta_local = curr_local - prev_local;
1093 const Vector3 expanded_min(-boundary.half_extents_m.x - ball_radius,
1094 -boundary.half_extents_m.y - ball_radius,
1095 -boundary.half_extents_m.z - ball_radius);
1096 const Vector3 expanded_max(boundary.half_extents_m.x + ball_radius,
1097 boundary.half_extents_m.y + ball_radius,
1098 boundary.half_extents_m.z + ball_radius);
1100 auto inside_expanded = [&](
const Vector3& p) {
1101 return p.x >= expanded_min.x && p.x <= expanded_max.x &&
1102 p.y >= expanded_min.y && p.y <= expanded_max.y &&
1103 p.z >= expanded_min.z && p.z <= expanded_max.z;
1106 if (inside_expanded(curr_local)) {
1110 double t_enter = 0.0;
1111 double t_exit = 1.0;
1114 auto process_axis = [&](
double p0,
double d,
double min_b,
double max_b,
1115 const Vector3& min_normal,
1116 const Vector3& max_normal) ->
bool {
1117 if (std::abs(d) < 1e-9) {
1118 return p0 >= min_b && p0 <= max_b;
1121 const double inv_d = 1.0 / d;
1122 double t0 = (min_b - p0) * inv_d;
1123 double t1 = (max_b - p0) * inv_d;
1124 Vector3 candidate_normal = min_normal;
1127 candidate_normal = max_normal;
1132 local_normal = candidate_normal;
1134 t_exit = std::min(t_exit, t1);
1135 return t_enter <= t_exit;
1138 if (!process_axis(prev_local.x, delta_local.x, expanded_min.x, expanded_max.x,
1139 Vector3(-1.0, 0.0, 0.0), Vector3(1.0, 0.0, 0.0))) {
1142 if (!process_axis(prev_local.y, delta_local.y, expanded_min.y, expanded_max.y,
1143 Vector3(0.0, -1.0, 0.0), Vector3(0.0, 1.0, 0.0))) {
1146 if (!process_axis(prev_local.z, delta_local.z, expanded_min.z, expanded_max.z,
1147 Vector3(0.0, 0.0, -1.0), Vector3(0.0, 0.0, 1.0))) {
1151 if (t_enter < 0.0 || t_enter > 1.0 || local_normal.isZero()) {
1155 const Vector3 hit_local = prev_local + delta_local * t_enter;
1156 const Vector3 corrected_local = hit_local + local_normal * 1e-4;
1157 const Vector3 world_normal =
1158 boundary.orientation.rotate(local_normal).normalized();
1160 state.position_m = boundary.position_m + boundary.orientation.rotate(corrected_local);
1161 const double impact_speed = std::max(0.0, -state.velocity_mps.dot(world_normal));
1162 applyContactImpulse(state.velocity_mps, world_normal,
1163 scaledWallRestitution(boundary.restitution,
1165 boundary.friction_coefficient);
1169 double scaledWallRestitution(
double base_restitution,
1170 double impact_speed_mps)
const {
1171 return velocityScaledRestitution(
1172 base_restitution, impact_speed_mps,
1173 field_.wall_restitution_reference_speed_mps,
1174 field_.wall_restitution_speed_exponent,
1175 field_.wall_restitution_min_scale);
1179 double scaledRobotBallRestitution(
double impact_speed_mps)
const {
1180 return velocityScaledRestitution(
1181 field_.robot_ball_contact_restitution, impact_speed_mps,
1182 field_.robot_ball_restitution_reference_speed_mps,
1183 field_.robot_ball_restitution_speed_exponent,
1184 field_.robot_ball_restitution_min_scale);
1189 void integrateRobots(
double dt_s) {
1190 for (
auto& robot : robots_) {
1191 robot.position_m += robot.velocity_mps * dt_s;
1194 if (robot.ball_consumption_rate > 0.0 && robot.ball_level > 0.0) {
1195 robot.ball_level -= robot.ball_consumption_rate * dt_s;
1196 if (robot.ball_level < 0.0) robot.ball_level = 0.0;
1197 if (robot.ball_level > 1.0) robot.ball_level = 1.0;
1200 const double min_x = field_.min_corner_m.x + robot.radius_m;
1201 const double max_x = field_.max_corner_m.x - robot.radius_m;
1202 const double min_y = field_.min_corner_m.y + robot.radius_m;
1203 const double max_y = field_.max_corner_m.y - robot.radius_m;
1205 if (robot.position_m.x < min_x) {
1206 robot.position_m.x = min_x;
1207 robot.velocity_mps.x = 0.0;
1209 if (robot.position_m.x > max_x) {
1210 robot.position_m.x = max_x;
1211 robot.velocity_mps.x = 0.0;
1213 if (robot.position_m.y < min_y) {
1214 robot.position_m.y = min_y;
1215 robot.velocity_mps.y = 0.0;
1217 if (robot.position_m.y > max_y) {
1218 robot.position_m.y = max_y;
1219 robot.velocity_mps.y = 0.0;
1225 void resolveRobotRobotImpedance() {
1226 for (std::size_t i = 0; i < robots_.size(); ++i) {
1227 for (std::size_t j = i + 1; j < robots_.size(); ++j) {
1233 const double distance = delta.norm();
1234 const double minimum_distance = a.radius_m + b.radius_m;
1235 if (distance >= minimum_distance) {
1239 Vector3 normal = distance > 1e-9 ? delta / distance :
Vector3::unitX();
1240 const double overlap = minimum_distance - distance;
1241 a.position_m -= normal * (0.5 * overlap);
1242 b.position_m += normal * (0.5 * overlap);
1244 const double relative_speed =
1245 (a.velocity_mps - b.velocity_mps).dot(normal);
1246 if (relative_speed > 0.0) {
1250 const double impulse = -0.7 * relative_speed;
1251 a.velocity_mps += normal * impulse;
1252 b.velocity_mps -= normal * impulse;
1264 void resolveRobotBallContacts(
BallEntity& ball) {
1265 std::lock_guard<std::recursive_mutex> lock(state_mutex_);
1266 auto state = ball.sim.state();
1268 for (
const auto& robot : robots_) {
1269 Vector3 robot_to_ball = state.position_m - robot.position_m;
1270 robot_to_ball.z = 0.0;
1271 const double distance = robot_to_ball.norm();
1272 const double ball_radius = ball.sim.ballProperties().radius_m;
1273 const double contact_distance = robot.radius_m + ball_radius;
1274 if (distance >= contact_distance) {
1280 const Vector3 normal =
1282 const double penetration = contact_distance - distance;
1284 normal * std::max(0.0, penetration - field_.baumgarte_slop_m);
1286 Vector3 relative_velocity = state.velocity_mps - robot.velocity_mps;
1287 const double normal_impact_speed = -relative_velocity.dot(normal);
1288 applyContactImpulse(relative_velocity, normal,
1289 scaledRobotBallRestitution(normal_impact_speed),
1290 field_.robot_ball_contact_friction);
1291 state.velocity_mps = robot.velocity_mps + relative_velocity;
1295 ball.sim.setState(state);
1302 void resolveBallBallContacts(
double dt_s) {
1303 struct BallBallContact {
1304 std::size_t a_index{};
1305 std::size_t b_index{};
1307 double penetration{0.0};
1308 double inv_mass_a{0.0};
1309 double inv_mass_b{0.0};
1312 double radius_a{0.0};
1313 double radius_b{0.0};
1314 double restitution{0.0};
1315 double friction{0.0};
1316 double normal_impulse_accum{0.0};
1317 double tangent_impulse_accum{0.0};
1320 std::vector<BallBallContact> contacts;
1321 contacts.reserve(balls_.size());
1323 for (std::size_t i = 0; i < balls_.size(); ++i) {
1324 const auto& state_a = balls_[i].sim.state();
1325 if (state_a.held || balls_[i].scored_in_net) {
1329 const double radius_a = balls_[i].sim.ballProperties().radius_m;
1330 const double mass_a =
1331 std::max(1e-9, balls_[i].sim.ballProperties().mass_kg);
1333 for (std::size_t j = i + 1; j < balls_.size(); ++j) {
1334 const auto& state_b = balls_[j].sim.state();
1335 if (state_b.held || balls_[j].scored_in_net) {
1339 const double radius_b = balls_[j].sim.ballProperties().radius_m;
1340 const double mass_b =
1341 std::max(1e-9, balls_[j].sim.ballProperties().mass_kg);
1343 const Vector3 delta = state_b.position_m - state_a.position_m;
1344 const double distance = delta.norm();
1345 const double target_distance = radius_a + radius_b;
1346 if (distance >= target_distance) {
1350 BallBallContact contact{};
1351 contact.a_index = i;
1352 contact.b_index = j;
1353 contact.normal = distance > 1e-9 ? delta / distance :
Vector3::unitX();
1354 contact.penetration = target_distance - distance;
1355 contact.inv_mass_a = 1.0 / mass_a;
1356 contact.inv_mass_b = 1.0 / mass_b;
1357 contact.mass_a = mass_a;
1358 contact.mass_b = mass_b;
1359 contact.radius_a = radius_a;
1360 contact.radius_b = radius_b;
1361 const double approach_speed =
1362 std::max(0.0, (state_a.velocity_mps - state_b.velocity_mps).dot(contact.normal));
1363 contact.restitution = velocityScaledRestitution(
1364 field_.ball_ball_contact_restitution, approach_speed,
1365 field_.ball_ball_restitution_reference_speed_mps,
1366 field_.ball_ball_restitution_speed_exponent,
1367 field_.ball_ball_restitution_min_scale);
1368 contact.friction = std::clamp(field_.ball_ball_contact_friction, 0.0, 1.0);
1369 contacts.push_back(contact);
1370 wakeBall(balls_[i]);
1371 wakeBall(balls_[j]);
1375 if (contacts.empty()) {
1379 const int solver_iterations = std::max(1, field_.solver_iterations);
1380 const double inv_dt = 1.0 / std::max(1e-9, dt_s);
1382 for (
int iteration = 0; iteration < solver_iterations; ++iteration) {
1383 for (
auto& contact : contacts) {
1384 auto state_a = balls_[contact.a_index].sim.state();
1385 auto state_b = balls_[contact.b_index].sim.state();
1387 Vector3 relative_velocity = state_b.velocity_mps - state_a.velocity_mps;
1388 const double normal_speed = relative_velocity.dot(contact.normal);
1389 const double position_bias =
1390 std::max(0.0, contact.penetration - field_.baumgarte_slop_m) *
1391 std::clamp(field_.baumgarte_beta, 0.0, 1.0) * inv_dt;
1393 if (normal_speed < 0.0 || position_bias > 0.0) {
1394 const double inv_mass_sum = contact.inv_mass_a + contact.inv_mass_b;
1395 if (inv_mass_sum <= 1e-9) {
1399 const double desired_normal_impulse =
1400 -((1.0 + contact.restitution) * normal_speed + position_bias) /
1402 const double old_normal_accum = contact.normal_impulse_accum;
1403 contact.normal_impulse_accum = std::max(
1404 0.0, contact.normal_impulse_accum + desired_normal_impulse);
1405 const double applied_normal_impulse =
1406 contact.normal_impulse_accum - old_normal_accum;
1407 const Vector3 normal_impulse_vec = contact.normal * applied_normal_impulse;
1409 state_a.velocity_mps -= normal_impulse_vec * contact.inv_mass_a;
1410 state_b.velocity_mps += normal_impulse_vec * contact.inv_mass_b;
1412 relative_velocity = state_b.velocity_mps - state_a.velocity_mps;
1413 const Vector3 tangential_velocity =
1414 relative_velocity - contact.normal *
1415 relative_velocity.dot(contact.normal);
1416 const double tangential_speed = tangential_velocity.norm();
1417 if (tangential_speed > 1e-9 && contact.friction > 0.0) {
1418 const Vector3 tangent = tangential_velocity / tangential_speed;
1419 const double desired_tangent_impulse =
1420 -tangential_speed / inv_mass_sum;
1421 const double max_friction_impulse =
1422 contact.friction * contact.normal_impulse_accum;
1423 const double old_tangent_accum = contact.tangent_impulse_accum;
1424 contact.tangent_impulse_accum = std::clamp(
1425 contact.tangent_impulse_accum + desired_tangent_impulse,
1426 -max_friction_impulse, max_friction_impulse);
1427 const double applied_tangent_impulse =
1428 contact.tangent_impulse_accum - old_tangent_accum;
1429 const Vector3 friction_impulse = tangent * applied_tangent_impulse;
1431 state_a.velocity_mps += friction_impulse * contact.inv_mass_a;
1432 state_b.velocity_mps -= friction_impulse * contact.inv_mass_b;
1434 const double spin_transfer_gain =
1435 std::max(0.0, field_.ball_ball_spin_transfer_gain);
1436 if (spin_transfer_gain > 0.0) {
1437 const double inertia_a =
1438 std::max(1e-9, 0.4 * contact.mass_a * contact.radius_a * contact.radius_a);
1439 const double inertia_b =
1440 std::max(1e-9, 0.4 * contact.mass_b * contact.radius_b * contact.radius_b);
1441 state_a.spin_radps +=
1442 contact.normal.cross(friction_impulse) *
1443 (spin_transfer_gain / inertia_a) * contact.radius_a;
1444 state_b.spin_radps +=
1445 (-contact.normal).cross(-friction_impulse) *
1446 (spin_transfer_gain / inertia_b) * contact.radius_b;
1450 balls_[contact.a_index].sim.setState(state_a);
1451 balls_[contact.b_index].sim.setState(state_b);
1458 void resolveFieldBounds(
BallEntity& ball)
const {
1459 auto state = ball.sim.state();
1460 const double radius = ball.sim.ballProperties().radius_m;
1462 const double min_x = field_.min_corner_m.x + radius;
1463 const double max_x = field_.max_corner_m.x - radius;
1464 const double min_y = field_.min_corner_m.y + radius;
1465 const double max_y = field_.max_corner_m.y - radius;
1467 if (state.position_m.x < min_x) {
1468 state.position_m.x = min_x + field_.baumgarte_slop_m;
1469 state.velocity_mps.x = std::abs(state.velocity_mps.x) *
1470 scaledWallRestitution(
1471 field_.wall_restitution,
1472 std::abs(state.velocity_mps.x));
1473 state.velocity_mps.y *= (1.0 - field_.wall_friction);
1475 if (state.position_m.x > max_x) {
1476 state.position_m.x = max_x - field_.baumgarte_slop_m;
1477 state.velocity_mps.x = -std::abs(state.velocity_mps.x) *
1478 scaledWallRestitution(
1479 field_.wall_restitution,
1480 std::abs(state.velocity_mps.x));
1481 state.velocity_mps.y *= (1.0 - field_.wall_friction);
1483 if (state.position_m.y < min_y) {
1484 state.position_m.y = min_y + field_.baumgarte_slop_m;
1485 state.velocity_mps.y = std::abs(state.velocity_mps.y) *
1486 scaledWallRestitution(
1487 field_.wall_restitution,
1488 std::abs(state.velocity_mps.y));
1489 state.velocity_mps.x *= (1.0 - field_.wall_friction);
1491 if (state.position_m.y > max_y) {
1492 state.position_m.y = max_y - field_.baumgarte_slop_m;
1493 state.velocity_mps.y = -std::abs(state.velocity_mps.y) *
1494 scaledWallRestitution(
1495 field_.wall_restitution,
1496 std::abs(state.velocity_mps.y));
1497 state.velocity_mps.x *= (1.0 - field_.wall_friction);
1502 ball.sim.setState(state);
1510 void resolveFieldElements(
BallEntity& ball,
double dt_s) {
1511 const int solver_iterations = std::max(1, field_.solver_iterations);
1512 for (
int iteration = 0; iteration < solver_iterations; ++iteration) {
1513 auto state = ball.sim.state();
1514 const double radius = ball.sim.ballProperties().radius_m;
1517 if (field_.scoring_element_snapping_enabled) {
1518 for (
const auto& goal : goals_) {
1519 Vector3 displacement = goal.center_m - state.position_m;
1520 double distance = displacement.norm();
1521 if (distance < field_.snap_distance_m) {
1523 state.position_m = goal.center_m;
1524 state.velocity_mps = Vector3(0.0, 0.0, 0.0);
1525 state.spin_radps = Vector3(0.0, 0.0, 0.0);
1526 ball.sim.setState(state);
1532 for (
const auto& boundary : field_elements_) {
1533 if (!boundary.is_active) {
1537 if (field_.net_boundary_user_id >= 0 &&
1538 boundary.user_id == field_.net_boundary_user_id &&
1540 if (isInsideBoxBoundary(state.position_m, boundary,
1541 radius * field_.net_entry_slack_scale)) {
1542 ball.scored_in_net =
true;
1543 state.velocity_mps.x *= field_.net_velocity_decay;
1544 state.velocity_mps.y *= field_.net_velocity_decay;
1545 state.velocity_mps.z = std::min(state.velocity_mps.z, 0.0);
1546 state.spin_radps *= field_.net_spin_decay;
1547 state.velocity_mps +=
1548 Vector3(0.0, 0.0, -field_.net_downward_bias_mps2 * dt_s);
1553 switch (boundary.type) {
1556 resolvePlaneBoundary(state, boundary, radius);
1559 resolveBoxBoundary(state, boundary, radius);
1562 resolveCylinderBoundary(state, boundary, radius);
1569 ball.sim.setState(state);
1575 static bool isInsideBoxBoundary(
const Vector3& world_point,
1576 const EnvironmentalBoundary& boundary,
1578 const Quaternion inverse = boundary.orientation.inverse();
1579 const Vector3 local = inverse.rotate(world_point - boundary.position_m);
1580 return std::abs(local.x) <= boundary.half_extents_m.x + slack &&
1581 std::abs(local.y) <= boundary.half_extents_m.y + slack &&
1582 std::abs(local.z) <= boundary.half_extents_m.z + slack;
1586 void resolvePlaneBoundary(BallPhysicsSim3D::BallState& state,
1587 const EnvironmentalBoundary& boundary,
1588 double ball_radius)
const {
1589 Vector3 normal = boundaryNormalWorld(boundary);
1590 if (normal.isZero()) {
1594 const double signed_distance =
1595 (state.position_m - boundary.position_m).dot(normal);
1596 if (signed_distance >= ball_radius) {
1600 state.position_m += normal *
1601 std::max(0.0, ball_radius - signed_distance - field_.baumgarte_slop_m);
1602 const double impact_speed = std::max(0.0, -state.velocity_mps.dot(normal));
1603 applyContactImpulse(state.velocity_mps, normal,
1604 scaledWallRestitution(boundary.restitution,
1606 boundary.friction_coefficient);
1612 void resolveBoxBoundary(BallPhysicsSim3D::BallState& state,
1613 const EnvironmentalBoundary& boundary,
1614 double ball_radius)
const {
1615 const Quaternion inverse = boundary.orientation.inverse();
1616 const Vector3 local =
1617 inverse.rotate(state.position_m - boundary.position_m);
1619 const Vector3 closest(std::clamp(local.x, -boundary.half_extents_m.x,
1620 boundary.half_extents_m.x),
1621 std::clamp(local.y, -boundary.half_extents_m.y,
1622 boundary.half_extents_m.y),
1623 std::clamp(local.z, -boundary.half_extents_m.z,
1624 boundary.half_extents_m.z));
1626 Vector3 delta = local - closest;
1627 double distance = delta.norm();
1628 Vector3 local_normal =
1631 if (distance >= ball_radius) {
1635 if (distance <= 1e-9) {
1636 const double pen_x = boundary.half_extents_m.x - std::abs(local.x);
1637 const double pen_y = boundary.half_extents_m.y - std::abs(local.y);
1638 const double pen_z = boundary.half_extents_m.z - std::abs(local.z);
1640 if (pen_x <= pen_y && pen_x <= pen_z) {
1641 local_normal = Vector3(local.x >= 0.0 ? 1.0 : -1.0, 0.0, 0.0);
1643 }
else if (pen_y <= pen_z) {
1644 local_normal = Vector3(0.0, local.y >= 0.0 ? 1.0 : -1.0, 0.0);
1647 local_normal = Vector3(0.0, 0.0, local.z >= 0.0 ? 1.0 : -1.0);
1652 const Vector3 world_normal =
1653 boundary.orientation.rotate(local_normal).normalized();
1654 state.position_m += world_normal *
1655 std::max(0.0, ball_radius - distance - field_.baumgarte_slop_m);
1656 const double impact_speed = std::max(0.0, -state.velocity_mps.dot(world_normal));
1657 applyContactImpulse(state.velocity_mps, world_normal,
1658 scaledWallRestitution(boundary.restitution,
1660 boundary.friction_coefficient);
1666 void resolveCylinderBoundary(BallPhysicsSim3D::BallState& state,
1667 const EnvironmentalBoundary& boundary,
1668 double ball_radius)
const {
1669 const Quaternion inverse = boundary.orientation.inverse();
1670 const Vector3 local =
1671 inverse.rotate(state.position_m - boundary.position_m);
1673 const double half_height = std::max(0.0, boundary.half_extents_m.z);
1674 if (std::abs(local.z) > half_height + ball_radius) {
1678 const Vector3 radial(local.x, local.y, 0.0);
1679 const double radial_distance = radial.norm();
1680 const double contact_distance =
1681 std::max(0.0, boundary.radius_m) + ball_radius;
1682 if (radial_distance >= contact_distance) {
1686 const Vector3 radial_normal_local =
1687 radial_distance > 1e-9 ? radial / radial_distance :
Vector3::unitX();
1688 const Vector3 radial_normal_world =
1689 boundary.orientation.rotate(radial_normal_local).normalized();
1690 state.position_m += radial_normal_world *
1691 std::max(0.0, contact_distance - radial_distance - field_.baumgarte_slop_m);
1692 const double impact_speed =
1693 std::max(0.0, -state.velocity_mps.dot(radial_normal_world));
1694 applyContactImpulse(state.velocity_mps, radial_normal_world,
1695 scaledWallRestitution(boundary.restitution,
1697 boundary.friction_coefficient);
1702 std::vector<RobotState> robots_{};
1703 std::vector<BallEntity> balls_{};
1704 std::vector<GamePieceType> ball_types_{};
1705 std::vector<ProjectileEntity> projectiles_{};
1706 std::vector<GoalZone> goals_{};
1707 std::vector<GamePieceInfo> gamepiece_types_{};
1708 std::vector<EnvironmentalBoundary> field_elements_{};
1710 int simulation_substeps_{4};
1711 mutable std::recursive_mutex state_mutex_{};