From 0226e830108dcfcb2d62e0fe9ea9ecc78b4150ee Mon Sep 17 00:00:00 2001 From: sergeypdev Date: Mon, 4 Aug 2025 00:35:46 +0400 Subject: [PATCH] A bunch of tweaks - Add max steering lock and limit drift assist to never exceed it - Bring back tyre curve debug - Show relevant wheel debug values - Calculate camber for pacejka --- assets/tyre_lateral.csv | 10 +- assets/tyre_longitudinal.csv | 14 +-- common/emath/math.odin | 4 + game/game.odin | 61 ++++++----- game/physics/debug.odin | 206 +++++++++++++++++------------------ game/physics/pacejka.odin | 6 + game/physics/scene.odin | 8 ++ game/physics/simulation.odin | 22 +++- 8 files changed, 179 insertions(+), 152 deletions(-) diff --git a/assets/tyre_lateral.csv b/assets/tyre_lateral.csv index 10f376e..47c32d7 100644 --- a/assets/tyre_lateral.csv +++ b/assets/tyre_lateral.csv @@ -1,13 +1,13 @@ a -1.1 --200 +1.5 +-20 1700 2000 -10 +0.5 0 0 --0.5 -0 +0 +-1 0 0 0 diff --git a/assets/tyre_longitudinal.csv b/assets/tyre_longitudinal.csv index 6e0999a..988c39e 100644 --- a/assets/tyre_longitudinal.csv +++ b/assets/tyre_longitudinal.csv @@ -1,13 +1,13 @@ b -1.2 +1.5 -200 1400 -20 -1 -0 -0 -0 --2 +-10 +300 +0 +0 +0 +0 0 0 0 diff --git a/common/emath/math.odin b/common/emath/math.odin index b93059f..616217f 100644 --- a/common/emath/math.odin +++ b/common/emath/math.odin @@ -8,3 +8,7 @@ _ :: math exp_smooth :: proc "contextless" (target: $T, pos: T, speed: f32, dt: f32) -> T { return pos + ((target - pos) * (1.0 - math.exp_f32(-speed * dt))) } + +exp_smooth_angle :: proc "contextless" (target: $T, pos: T, speed: f32, dt: f32) -> T { + return pos + (math.angle_diff(pos, target) * (1.0 - math.exp_f32(-speed * dt))) +} diff --git a/game/game.odin b/game/game.odin index f0a4865..22b3856 100644 --- a/game/game.odin +++ b/game/game.odin @@ -543,7 +543,7 @@ update_world :: proc(world: ^World, dt: f32, config: World_Update_Config) { }, }, mass = 1000, - com_shift = physics.Vec3{0, 0.8, -0.5}, + com_shift = physics.Vec3{0, 1, -0.5}, }, ) @@ -611,11 +611,16 @@ update_world :: proc(world: ^World, dt: f32, config: World_Update_Config) { assets.get_curve_1d(&g_mem.assetman, "assets/tyre_lateral.csv"), ) + TURN_ANGLE_AT_HIGH_SPEED :: f32(10) * math.RAD_PER_DEG + TURN_ANGLE_AT_LOW_SPEED :: f32(30) * math.RAD_PER_DEG + wheel_fl := physics.immediate_suspension_constraint( &world.physics_scene, #hash("FL", "fnv32a"), { + name = name.from_string("FL"), turn_wheel = true, + steer_lock = TURN_ANGLE_AT_LOW_SPEED, rel_pos = {-wheel_extent_x_front, wheel_y, wheel_front_z}, rel_dir = {0, -1, 0}, radius = radius, @@ -632,7 +637,9 @@ update_world :: proc(world: ^World, dt: f32, config: World_Update_Config) { &world.physics_scene, #hash("FR", "fnv32a"), { + name = name.from_string("FR"), turn_wheel = true, + steer_lock = TURN_ANGLE_AT_LOW_SPEED, rel_pos = {wheel_extent_x_front, wheel_y, wheel_front_z}, rel_dir = {0, -1, 0}, radius = radius, @@ -649,6 +656,7 @@ update_world :: proc(world: ^World, dt: f32, config: World_Update_Config) { &world.physics_scene, #hash("RL", "fnv32a"), { + name = name.from_string("RL"), rel_pos = {-wheel_extent_x_back, wheel_y, wheel_back_z}, rel_dir = {0, -1, 0}, radius = radius, @@ -665,6 +673,7 @@ update_world :: proc(world: ^World, dt: f32, config: World_Update_Config) { &world.physics_scene, #hash("RR", "fnv32a"), { + name = name.from_string("RR"), rel_pos = {wheel_extent_x_back, wheel_y, wheel_back_z}, rel_dir = {0, -1, 0}, radius = radius, @@ -706,10 +715,7 @@ update_world :: proc(world: ^World, dt: f32, config: World_Update_Config) { front_wheels := turn_wheels back_wheels := drive_wheels - DRIVE_IMPULSE :: 3000 BRAKE_IMPULSE :: 10 - TURN_ANGLE_AT_HIGH_SPEED :: f32(10) * math.RAD_PER_DEG - TURN_ANGLE_AT_LOW_SPEED :: f32(30) * math.RAD_PER_DEG // 68% front, 32% rear BRAKE_BIAS :: f32(0.68) @@ -745,7 +751,10 @@ update_world :: proc(world: ^World, dt: f32, config: World_Update_Config) { } car_body := physics.get_body(sim_state, world.car_handle) - turn_vel_correction := math.smoothstep(f32(90), f32(10), linalg.length(car_body.v) * 3.6) + car_vel_kmh := linalg.length(car_body.v) * 3.6 + LOW_SPEED :: f32(10) + HIGH_SPEED :: f32(90) + turn_vel_correction := 1.0 - math.saturate((car_vel_kmh - 10) / (HIGH_SPEED - LOW_SPEED)) turn_input := rl.GetGamepadAxisMovement(0, .LEFT_X) if abs(turn_input) < GAMEPAD_DEADZONE { @@ -980,9 +989,14 @@ yaw_pitch_to_rotation :: proc "contextless" (yaw, pitch: f32) -> linalg.Matrix3f follow_camera_update :: proc(world: ^World, camera: ^Car_Follow_Camera, dt: f32) { body := physics.get_interpolated_body(&world.physics_scene, SOLVER_CONFIG, camera.body) + body_up := physics.body_local_to_world_vec(body, {0, 1, 0}) - camera.follow_target = body.x + physics.Vec3{0, 4, 0} - camera.target = body.x + physics.Vec3{0, 2.5, 0} + // COM can be a lot lower than the center of visual mesh, offset by that first + car_center := body.x + body_up * 1 + + // When car flips it's noticeable that camera is offset from COM, remove the offset when it sways + camera.follow_target = car_center + physics.Vec3{0, 3, 0} + camera.target = car_center + physics.Vec3{0, 1.5, 0} delta, sense, _ := collect_camera_input() @@ -995,31 +1009,28 @@ follow_camera_update :: proc(world: ^World, camera: ^Car_Follow_Camera, dt: f32) if camera.manual_rotation_time > 0 { camera.manual_rotation_time -= dt - if !camera.has_yaw_pitch { - camera.has_yaw_pitch = true - camera.yaw, camera.pitch = dir_to_yaw_pitch( - linalg.normalize0(camera.follow_target - camera.pos), - ) - } - camera.yaw += delta.x * sense camera.pitch += delta.y * sense camera.pitch = math.clamp(camera.pitch, -math.PI / 2.0 + 0.0001, math.PI / 2.0 - 0.0001) - camera.pos = - camera.follow_target + - yaw_pitch_to_rotation(camera.yaw, camera.pitch) * rl.Vector3{0, 0, 1} * distance } else { - dir := linalg.normalize0(camera.pos - camera.follow_target) - if dir == 0 { - dir = {0, 0, -1} + v_len := linalg.length(body.v) + if v_len >= 0.0001 { + dir := body.v / v_len + + speed := math.smoothstep(f32(0.2), f32(20), v_len) * 6 + // speed *= math.smoothstep(f32(6), f32(20), v_len) * 3 + + target_yaw, target_pitch := dir_to_yaw_pitch(dir) + + camera.yaw = emath.exp_smooth_angle(target_yaw, camera.yaw, speed, dt) + camera.pitch = emath.exp_smooth_angle(target_pitch, camera.pitch, speed, dt) } - - camera.has_yaw_pitch = false - - pos_target := camera.follow_target + dir * distance - camera.pos = pos_target } + + camera.pos = + camera.follow_target + + yaw_pitch_to_rotation(camera.yaw, camera.pitch) * rl.Vector3{0, 0, 1} * distance } follow_camera_to_rl :: proc(camera: Car_Follow_Camera) -> rl.Camera3D { diff --git a/game/physics/debug.odin b/game/physics/debug.odin index 922b040..03dd455 100644 --- a/game/physics/debug.odin +++ b/game/physics/debug.odin @@ -366,133 +366,121 @@ draw_debug_ui :: proc( } } - // active_wheels := []int{0, 1} - // w, h: i32 = 500, 500 + if .ACTIVE in ui.treenode(ctx, "Wheels") { + for i in 0 ..< len(sim_state.suspension_constraints_slice) { + s := get_interpolated_wheel(scene, config, Suspension_Constraint_Handle(i + 1)) + if s.alive { - // window_x: i32 = 0 + if .ACTIVE in ui.treenode(ctx, fmt.tprintf("Wheel {}", name.to_string(s.name))) { + ui.keyval(ctx, "Spring Impulse", s.spring_impulse) + ui.keyval(ctx, "Turn Angle", s.turn_angle * math.DEG_PER_RAD) + ui.keyval(ctx, "Turn Assist", s.turn_assist * math.DEG_PER_RAD) + ui.keyval(ctx, "Total Turn", (s.turn_angle + s.turn_assist) * math.DEG_PER_RAD) + ui.keyval(ctx, "Long Slip", s.slip_ratio) + ui.keyval(ctx, "Lat Slip", s.slip_angle) + ui.keyval(ctx, "Camber", s.camber) - // for i in 0 ..< len(sim_state.suspension_constraints_slice) { - // s := &sim_state.suspension_constraints_slice[i] + NUM_SAMPLES :: 100 - // if s.alive { - // for idx in active_wheels { - // if i == idx { - // if ui.window( - // ctx, - // fmt.tprintf("Wheel %v", i), - // ui.Rect{x = window_x, y = 0, w = w, h = h}, - // ui.Options{}, - // ) { - // NUM_SAMPLES :: 100 + dt := f32(config.timestep) / f32(config.substreps_minus_one + 1) + inv_dt := 1.0 / dt - // dt := f32(config.timestep) / f32(config.substreps_minus_one + 1) - // inv_dt := 1.0 / dt + if .ACTIVE in ui.treenode(ctx, "Longitudinal") { + ui.layout_row(ctx, {-1}, 200) + { + ui.begin_line(ctx, ui.Color{255, 0, 0, 255}) + defer ui.end_line(ctx) - // { - // ui.layout_row(ctx, {-1}, 300) - // { - // ui.begin_line(ctx, ui.Color{255, 0, 0, 255}) - // defer ui.end_line(ctx) + for j in 0 ..< NUM_SAMPLES { + alpha := f32(j) / f32(NUM_SAMPLES - 1) + x := alpha * 200.0 - 100.0 - // for j in 0 ..< NUM_SAMPLES { - // alpha := f32(j) / f32(NUM_SAMPLES - 1) - // x := alpha * 200.0 - 100.0 + long_friction := abs( + pacejka_94_longitudinal( + s.pacejka_long, + x, + max(abs(s.spring_impulse), 0.001) * inv_dt * 0.001, + ), + ) - // long_friction := abs( - // pacejka_94_longitudinal( - // s.pacejka_long, - // x, - // max(abs(s.spring_impulse), 0.001) * inv_dt * 0.001, - // ), - // ) + ui.push_line_point(ctx, ui.Vec2f{alpha, long_friction * -0.5 + 1}) + } - // ui.push_line_point( - // ctx, - // ui.Vec2f{alpha, long_friction * -0.5 + 1}, - // ) - // } + long_friction := abs( + pacejka_94_longitudinal( + s.pacejka_long, + s.slip_ratio, + max(abs(s.spring_impulse), 0.001) * inv_dt * 0.001, + ), + ) - // long_friction := abs( - // pacejka_94_longitudinal( - // s.pacejka_long, - // s.slip_ratio, - // max(abs(s.spring_impulse), 0.001) * inv_dt * 0.001, - // ), - // ) + rect := ui.get_line(ctx).rect - // rect := ui.get_line(ctx).rect + cur_point := + Vec2{(s.slip_ratio + 100.0) / 200.0, long_friction * -0.5 + 1} * + Vec2{f32(rect.w), f32(rect.h)} + + Vec2{f32(rect.x), f32(rect.y)} + ui.draw_rect( + ctx, + ui.rect_from_point_extent( + ui.Vec2{i32(cur_point.x), i32(cur_point.y)}, + 2, + ), + ui.Color{255, 255, 0, 255}, + ) + } + } - // cur_point := - // Vec2 { - // (s.slip_ratio + 100.0) / 200.0, - // long_friction * -0.5 + 1, - // } * - // Vec2{f32(rect.w), f32(rect.h)} + - // Vec2{f32(rect.x), f32(rect.y)} - // ui.draw_rect( - // ctx, - // ui.rect_from_point_extent( - // ui.Vec2{i32(cur_point.x), i32(cur_point.y)}, - // 2, - // ), - // ui.Color{255, 255, 0, 255}, - // ) - // } - // } + if .ACTIVE in ui.treenode(ctx, "Lateral") { + ui.layout_row(ctx, {-1}, 200) + ui.begin_line(ctx, ui.Color{0, 255, 0, 255}) + defer ui.end_line(ctx) - // { - // ui.layout_row(ctx, {-1}, 300) - // ui.begin_line(ctx, ui.Color{0, 255, 0, 255}) - // defer ui.end_line(ctx) + for j in 0 ..< NUM_SAMPLES { + alpha := f32(j) / f32(NUM_SAMPLES - 1) + x := alpha * 180.0 - 90.0 - // for j in 0 ..< NUM_SAMPLES { - // alpha := f32(j) / f32(NUM_SAMPLES - 1) - // x := alpha * 180.0 - 90.0 + lat_friction := abs( + pacejka_94_lateral( + s.pacejka_lat, + x, + max(abs(s.spring_impulse), 0.001) * inv_dt * 0.001, + 0.0, + ), + ) - // lat_friction := abs( - // pacejka_94_lateral( - // s.pacejka_lat, - // x, - // max(abs(s.spring_impulse), 0.001) * inv_dt * 0.001, - // 0.0, - // ), - // ) + ui.push_line_point(ctx, ui.Vec2f{alpha, lat_friction * -0.5 + 1}) + } - // ui.push_line_point(ctx, ui.Vec2f{alpha, lat_friction * -0.5 + 1}) - // } + lat_friction := abs( + pacejka_94_lateral( + s.pacejka_lat, + s.slip_angle, + max(abs(s.spring_impulse), 0.001) * inv_dt * 0.001, + 0.0, + ), + ) - // lat_friction := abs( - // pacejka_94_lateral( - // s.pacejka_lat, - // s.slip_angle, - // max(abs(s.spring_impulse), 0.001) * inv_dt * 0.001, - // 0.0, - // ), - // ) + rect := ui.get_line(ctx).rect - // rect := ui.get_line(ctx).rect - - // cur_point := - // Vec2{(s.slip_angle + 100.0) / 200.0, lat_friction * -0.5 + 1} * - // Vec2{f32(rect.w), f32(rect.h)} + - // Vec2{f32(rect.x), f32(rect.y)} - // ui.draw_rect( - // ctx, - // ui.rect_from_point_extent( - // ui.Vec2{i32(cur_point.x), i32(cur_point.y)}, - // 2, - // ), - // ui.Color{255, 255, 0, 255}, - // ) - // } - - // window_x += w - // } - // } - // } - // } - // } + cur_point := + Vec2{(s.slip_angle + 100.0) / 200.0, lat_friction * -0.5 + 1} * + Vec2{f32(rect.w), f32(rect.h)} + + Vec2{f32(rect.x), f32(rect.y)} + ui.draw_rect( + ctx, + ui.rect_from_point_extent( + ui.Vec2{i32(cur_point.x), i32(cur_point.y)}, + 2, + ), + ui.Color{255, 255, 0, 255}, + ) + } + } + } + } + } } debug_transform_points_local_to_world :: proc(body: Body_Ptr, points: []Vec3) { diff --git a/game/physics/pacejka.odin b/game/physics/pacejka.odin index b7a695d..615cd3e 100644 --- a/game/physics/pacejka.odin +++ b/game/physics/pacejka.odin @@ -55,6 +55,9 @@ pacejka_94_longitudinal :: proc( f_z: f32, s_v: f32 = 0, ) -> f32 { + if f_z < 0.001 { + return 0 + } f_z_sq := f_z * f_z C := b[0] @@ -77,6 +80,9 @@ pacejka_94_lateral :: proc( f_z: f32, camber_angle: f32, ) -> f32 { + if f_z < 0.001 { + return 0 + } camber_angle_sq := camber_angle * camber_angle C := a[0] diff --git a/game/physics/scene.odin b/game/physics/scene.odin index 9d9a648..a57354d 100644 --- a/game/physics/scene.odin +++ b/game/physics/scene.odin @@ -297,7 +297,9 @@ Collision_Shape :: struct { Suspension_Constraint :: struct { alive: bool, + name: name.Name, turn_wheel: bool, + steer_lock: f32, // Pos relative to the body rel_pos: Vec3, // Dir relative to the body @@ -366,6 +368,7 @@ Suspension_Constraint :: struct { // Convenience for debug visualization to avoid recomputing slip_angle: f32, slip_ratio: f32, + camber: f32, // Multipliers for combined friction slip_vec: Vec2, @@ -746,7 +749,10 @@ Body_Config :: struct { // TODO: rename to wheel Suspension_Constraint_Config :: struct { + name: name.Name, turn_wheel: bool, + // Max turn angle either left or right + steer_lock: f32, rel_pos: Vec3, rel_dir: Vec3, body: Body_Handle, @@ -917,7 +923,9 @@ update_suspension_constraint_from_config :: proc( constraint: Suspension_Constraint_Ptr, config: Suspension_Constraint_Config, ) { + constraint.name = config.name constraint.turn_wheel = config.turn_wheel + constraint.steer_lock = config.steer_lock constraint.rel_pos = config.rel_pos constraint.rel_dir = config.rel_dir constraint.body = config.body diff --git a/game/physics/simulation.odin b/game/physics/simulation.odin index efa32bc..f1e109b 100644 --- a/game/physics/simulation.odin +++ b/game/physics/simulation.odin @@ -975,7 +975,7 @@ pgs_solve_contacts :: proc( inv_dt: f32, apply_bias: bool, ) { - bias_rate, mass_coef, impulse_coef := calculate_soft_constraint_params(240 / 8, 1, f64(dt)) + bias_rate, mass_coef, impulse_coef := calculate_soft_constraint_params(30, 8, f64(dt)) if !apply_bias { mass_coef = 1 bias_rate = 0 @@ -1032,8 +1032,7 @@ pgs_solve_contacts :: proc( bias := f32(0.0) - MAX_BAUMGARTE_VELOCITY :: 4.0 - + MAX_BAUMGARTE_VELOCITY :: 1000 if separation > 0 { bias = separation * inv_dt } else if apply_bias { @@ -1332,6 +1331,8 @@ pgs_solve_suspension :: proc( if body.alive { if v.turn_wheel { + v.turn_angle = math.clamp(v.turn_angle, -v.steer_lock, v.steer_lock) + body_forward_vec := body_local_to_world_vec(body, {0, 0, 1}) body_right_vec := body_local_to_world_vec(body, {1, 0, 0}) lateral_to_longitudinal_relation := @@ -1349,6 +1350,11 @@ pgs_solve_suspension :: proc( drift_amount * math.PI * 0.15 + + // Clamp the assist to not go outside steer lock + v.turn_assist = + math.clamp(v.turn_angle + v.turn_assist, -v.steer_lock, v.steer_lock) - + v.turn_angle } @@ -1499,12 +1505,16 @@ pgs_solve_suspension :: proc( // log.debugf("slip_vec: %v", slip_vec) + camber_cos := -lg.dot(wheel_get_right_vec(body, v), v.right) + camber_angle_rad := camber_cos < 0.999999 ? math.acos(camber_cos) : 0 + v.camber = camber_angle_rad * math.DEG_PER_RAD * math.sign(v.rel_pos.x) + long_friction := abs( pacejka_94_longitudinal( v.pacejka_long, slip_ratio, - max(abs(v.spring_impulse), 0.001) * inv_dt * 0.001, + v.spring_impulse * inv_dt * 0.001, ), ) * abs(slip_vec.y) @@ -1513,8 +1523,8 @@ pgs_solve_suspension :: proc( pacejka_94_lateral( v.pacejka_lat, slip_angle, - max(abs(v.spring_impulse), 0.001) * inv_dt * 0.001, - 0.0, + v.spring_impulse * inv_dt * 0.001, + v.camber, ), ) * abs(slip_vec.x)