From ed63cb68b892a0626fdd8abda19945ef038bf5a8 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Sat, 24 Oct 2026 21:21:06 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B8=D0=B7=D0=B3=D0=B0=D0=BD:=20=D0=B4?= =?UTF-8?q?=D0=B5=D1=84=D0=BE=D0=BB=D1=82=D1=8B=20GMod=20=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=85=D0=B2=D0=B0=D1=82=20=D0=B7=D0=B0=20=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D1=83=20=D0=BC=D0=B5=D1=88=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - пружина GMod: v = clamp(error / physgun_timeToArrive, physgun_maxSpeed), линейная зависимость вместо прежнего разгона; - дальность 4096 (physgun_maxrange), масса 500, maxAngular 5000; - трассировка луча до коллайдера, local_offset и удержание за точку на меше; - поворот колесом вокруг точки захвата с компенсацией omega × r; - тесты закона скорости, луча и локальной точки; docs/DIARY. --- crates/client/src/map.rs | 43 +++-- crates/client/src/sandbox.rs | 310 +++++++++++++++++++++++++---------- crates/engine/src/physics.rs | 135 +++++++++++++++ docs/DIARY.md | 41 +++++ 4 files changed, 427 insertions(+), 102 deletions(-) diff --git a/crates/client/src/map.rs b/crates/client/src/map.rs index 6f9ccae..81dba8f 100644 --- a/crates/client/src/map.rs +++ b/crates/client/src/map.rs @@ -1072,11 +1072,12 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { ) }; match found { - Some((body, along)) => { - let created = sandbox::grab(&mut world.borrow_mut(), body, along); + Some(target) => { + let along = target.distance; + let created = sandbox::grab(&mut world.borrow_mut(), target); if let Some(new_grab) = created { log.push_back(format!( - "физган: взял ({} юнитов)", + "физган: взял за точку на меше ({} юнитов)", along as i32 )); grab = Some(new_grab); @@ -1372,7 +1373,8 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { // оси: цель поворота доворачивается, а физика плавно // догоняет её угловой скоростью. Держим угол в // [0, 360), чтобы длинное вращение не теряло точность. - current.target_yaw = (current.target_yaw + y * 15.0).rem_euclid(360.0); + current.target_yaw = (current.target_yaw + y * sandbox::PHYSGUN_WHEEL_STEP) + .rem_euclid(360.0); } } Event::MouseMotion { x, y, .. } if lua_ui_open => { @@ -1703,7 +1705,11 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { if (test_undo || test_physgun || test_redo) && frames_drawn == 5 { let model = std::env::var("SOURCE_RS_SPAWN_MODEL") .unwrap_or_else(|_| "models/props_junk/watermelon01.mdl".to_string()); - let origin = player.eye_position() + camera.forward() * 140.0; + // Смещаем проп вбок: центральный луч попадает не в origin, а ближе + // к краю — так тест проверяет захват за точку меша. + let forward = camera.forward(); + let right = math::Vec3::new(-forward.y, forward.x, 0.0); + let origin = player.eye_position() + forward * 140.0 + right * 4.0; let angles = math::Vec3::new(0.0, camera.yaw, 0.0); info!(model = %model, "Sandbox тест: спавн тестового пропа"); pending_spawns.push(SpawnRequest { @@ -1717,7 +1723,19 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { // Колесо: доворот на 25° — проверка, что поворот идёт // угловой скоростью, а не телепортом позы. current.target_yaw += 25.0; - info!(body = ?current.body, "Sandbox тест: физган держит, поворот +25°"); + let point = current.world_point(&world.borrow()); + let origin = world + .borrow() + .physics + .transform(current.body) + .map(|(position, _)| position); + info!( + body = ?current.body, + ?point, + ?origin, + offset = ?current.local_offset, + "Sandbox тест: физган держит за точку, поворот +25°" + ); } else { warn!("Sandbox тест: физган не захватил проп"); } @@ -1774,7 +1792,7 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { { let origin = player.eye_position(); let dir = camera.forward(); - if let Some((body, along)) = sandbox::find_target( + if let Some(target) = sandbox::find_target( &w, origin, dir, @@ -1782,7 +1800,7 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { sandbox::PHYSGUN_MAX_MASS, false, ) { - grab = sandbox::grab(&mut w, body, along); + grab = sandbox::grab(&mut w, target); } } // Скорость к точке на луче задаётся ДО шага физики: проп едет @@ -2442,11 +2460,10 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { ); } if let Some(current) = &grab { - let target = world - .borrow() - .physics - .transform(current.body) - .map(|(position, _)| position); + // Крест и конец луча — на захваченной точке меша, а не в + // origin пропа: иначе при захвате за угол не видно, что + // именно держим. + let target = current.world_point(&world.borrow()); if let Some(target) = target { let (sin_yaw, cos_yaw) = camera.yaw.to_radians().sin_cos(); let right = math::Vec3::new(sin_yaw, -cos_yaw, 0.0); diff --git a/crates/client/src/sandbox.rs b/crates/client/src/sandbox.rs index a160511..4ab7c4e 100644 --- a/crates/client/src/sandbox.rs +++ b/crates/client/src/sandbox.rs @@ -15,30 +15,44 @@ use engine::world::World; use math::Vec3; use tracing::info; -/// Дальность физгана в юнитах Source (в GMod порядка 250). -pub const PHYSGUN_RANGE: f32 = 250.0; +/// Дальность физгана в юнитах Source: `physgun_maxrange` в GMod — 4096. +/// +/// Источник: список конваров GMod (`ConVars_In_Garrysmod`, +/// `physgun_maxrange 4096`). Прежние 250 были взяты из головы и не совпадали +/// с игрой: с ними нельзя дотянуться до половины двора gm_construct. +pub const PHYSGUN_RANGE: f32 = 4096.0; /// Предел массы, которую берёт физган. +/// +/// У самого физгана GMod отдельного конвара массы нет: тяжёлые пропы просто +/// движутся медленнее. 500 — верхняя граница из физики Source и прежнего +/// поведения клиента; выше неё `prop_physics` в Sandbox практически не +/// встречаются. pub const PHYSGUN_MAX_MASS: f32 = 500.0; +/// `physgun_timeToArrive` (GMod, по умолчанию 0.05 с). +/// +/// Столько времени тело должно тратить на то, чтобы догнать цель при +/// линейной пружине: `v = error / timeToArrive`. Не квадратичный разгон: +/// удвоили расстояние — удвоили скорость, пока не упёрлись в предел. +pub const PHYSGUN_TIME_TO_ARRIVE: f32 = 0.05; + +/// `physgun_maxSpeed` (GMod, по умолчанию 5000 юнитов/с). +pub const PHYSGUN_MAX_SPEED: f32 = 5000.0; + +/// `physgun_maxAngular` (GMod, по умолчанию 5000 градусов/с). +pub const PHYSGUN_MAX_ANGULAR: f32 = 5000.0; + +/// Шаг доворота колесом за один щелчок. +/// +/// В GMod `physgun_wheelspeed` по умолчанию 10 и задаёт чувствительность, а +/// не абсолютный угол; 15° на щелчок — привычный шаг и прежнее поведение +/// клиента. +pub const PHYSGUN_WHEEL_STEP: f32 = 15.0; + /// Минимальное расстояние вдоль луча: проп «в упоре» не хватаем. const GRAB_MIN_ALONG: f32 = 8.0; -/// Допуск по перпендикуляру к лучу: тело считается под прицелом. -const GRAB_TOLERANCE: f32 = 40.0; - -/// Жёсткость удержания: скорость пропорциональна ошибке позиции. -const HOLD_STIFFNESS: f32 = 12.0; - -/// Максимальная скорость удержания, юнитов/с. -const HOLD_MAX_SPEED: f32 = 900.0; - -/// Жёсткость поворота колесом. -const ROTATE_STIFFNESS: f32 = 10.0; - -/// Максимальная угловая скорость удержания, градусов/с. -const ROTATE_MAX_SPEED: f32 = 720.0; - /// Порог, после которого отпускание считается перемещением для undo. const MOVE_EPSILON: f32 = 0.5; @@ -225,13 +239,29 @@ fn apply(world: &mut World, action: &SandboxAction, forward: bool) -> bool { } } +/// Точка на меше, которую игрок выбрал лучом при захвате. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct GrabTarget { + pub body: BodyId, + /// Точка попадания луча в коллайдер тела (мировые координаты). + pub point: Vec3, + /// Расстояние от глаз до точки попадания. + pub distance: f32, +} + /// Удерживаемый физганом проп. pub struct Grab { pub body: BodyId, /// Индекс в `World::props` — для undo и заморозки. pub prop: usize, - /// Расстояние удержания от глаз вдоль луча. + /// Расстояние удержания от глаз вдоль луча (до точки захвата). pub distance: f32, + /// Точка захвата в осях тела. + /// + /// Ключ к поведению GMod: держим не origin пропа, а ту точку меша, куда + /// попал луч. При повороте она едет вместе с телом, поэтому цель для + /// центра тела считается как `target - rotate(angles, local_offset)`. + pub local_offset: Vec3, /// Куда довернуть проп колесом (yaw, градусы). pub target_yaw: f32, /// Поза на момент захвата — для undo перемещения. @@ -240,11 +270,20 @@ pub struct Grab { pub frozen_before: bool, } -/// Находит динамический проп под лучом: ближайший по расстоянию вдоль луча. +impl Grab { + /// Текущая мировая точка, за которую держим тело. + pub fn world_point(&self, world: &World) -> Option { + let (position, angles) = world.physics.transform(self.body)?; + Some(position + engine::physics::rotate_by_angles(angles, self.local_offset)) + } +} + +/// Находит динамический проп под лучом и точку попадания на его коллайдере. /// -/// Статика карты, игрок и слишком тяжёлые тела отсеиваются. Это не трассировка -/// с окклюзией, а та же выборка по оси, что была в клиенте: для физгана она -/// предсказуемее и дешевле. +/// Это именно трассировка, а не выборка по расстоянию до origin: физган +/// должен брать проп за угол или край, куда смотрит игрок. Луч упирается в +/// статику карты, поэтому сквозь стены пропы не хватаются. Слишком тяжёлые +/// тела и замороженные (если `include_frozen == false`) отсеиваются. pub fn find_target( world: &World, origin: Vec3, @@ -252,71 +291,101 @@ pub fn find_target( max_distance: f32, max_mass: f32, include_frozen: bool, -) -> Option<(BodyId, f32)> { - if !direction.is_finite() || direction.length_squared() < 1e-6 { +) -> Option { + let hit = world.physics.raycast(origin, direction, max_distance)?; + if hit.distance < GRAB_MIN_ALONG { return None; } - let dir = direction.normalized(); - let mut best: Option<(f32, BodyId)> = None; - for id in world.physics.body_list() { - if !world.physics.is_dynamic(id) { - continue; - } - if !include_frozen && world.physics.is_frozen(id) { - continue; - } - if world.physics.mass(id).is_none_or(|mass| mass > max_mass) { - continue; - } - // Берём только пропы: у игрока и служебных тел индекса в `props` нет. - if prop_for_body(world, id).is_none() { - continue; - } - let Some((position, _)) = world.physics.transform(id) else { - continue; - }; - let to_body = position - origin; - let along = to_body.dot(dir); - if !(GRAB_MIN_ALONG..=max_distance).contains(&along) { - continue; - } - if (to_body - dir * along).length() > GRAB_TOLERANCE { - continue; - } - if best.is_none_or(|(best_along, _)| along < best_along) { - best = Some((along, id)); - } + let body = hit.body; + if !world.physics.is_dynamic(body) { + // Статика карты и `prop_static`: захватывать нечего. + return None; } - best.map(|(along, id)| (id, along)) + if !include_frozen && world.physics.is_frozen(body) { + return None; + } + if world.physics.mass(body).is_none_or(|mass| mass > max_mass) { + return None; + } + // Берём только пропы: у игрока и служебных тел индекса в `props` нет. + prop_for_body(world, body)?; + Some(GrabTarget { + body, + point: hit.point, + distance: hit.distance, + }) } -/// Захватывает тело: снимает freeze на время удержания и гасит гравитацию. -pub fn grab(world: &mut World, body: BodyId, along: f32) -> Option { - let prop = prop_for_body(world, body)?; - let (origin, angles) = world.physics.transform(body)?; - let frozen_before = world.physics.is_frozen(body); - if frozen_before && !world.physics.set_frozen(body, false) { +/// Захватывает тело за точку `target.point`: снимает freeze на время +/// удержания и гасит гравитацию. +pub fn grab(world: &mut World, target: GrabTarget) -> Option { + let prop = prop_for_body(world, target.body)?; + let (origin, angles) = world.physics.transform(target.body)?; + let local_offset = engine::physics::inverse_rotate_by_angles(angles, target.point - origin); + if !local_offset.is_finite() { return None; } - world.physics.set_gravity_scale(body, 0.0); - world.physics.set_velocity(body, Vec3::ZERO); - world.physics.set_angular_velocity(body, Vec3::ZERO); - info!(?body, prop, along, "физган: захват"); - Some(Grab { - body, + let frozen_before = world.physics.is_frozen(target.body); + if frozen_before && !world.physics.set_frozen(target.body, false) { + return None; + } + world.physics.set_gravity_scale(target.body, 0.0); + world.physics.set_velocity(target.body, Vec3::ZERO); + world.physics.set_angular_velocity(target.body, Vec3::ZERO); + info!( + body = ?target.body, prop, - distance: along.max(GRAB_MIN_ALONG), + along = target.distance, + offset = ?local_offset, + "физган: захват за точку меша" + ); + Some(Grab { + body: target.body, + prop, + distance: target.distance.max(GRAB_MIN_ALONG), + local_offset, target_yaw: angles.y, start: Pose::new(origin, angles), frozen_before, }) } -/// Один кадр удержания: скорость к точке на луче и доворот к `target_yaw`. +/// Скорость линейной пружины физгана: `error / timeToArrive`, с клампом. /// -/// Скорость считается экспоненциальным приближением к цели, поэтому проп не -/// телепортируется и не проскакивает: чем ближе, тем медленнее. Гравитация на -/// время удержания выключена в `grab`. +/// Линейная зависимость принципиальна: в GMod `physgun_timeToArrive` задаёт +/// время «доезда» до цели, поэтому при 20 юнитах ошибки скорость 400, при +/// 50 — 1000, при 100 — 2000 юнитов/с, а дальше упирается в +/// `physgun_maxSpeed` (5000). Никакого квадратичного роста нет. +/// +/// Дополнительное демпфирование не нужно: скорость задаётся напрямую, и у +/// тела нет накопленной инерции, которая проскакивала бы цель. В GMod +/// `physgun_DampingFactor` по умолчанию 0 — то же поведение. +fn spring_velocity(error: Vec3, dt: f32) -> Vec3 { + let dt = dt.clamp(1.0 / 240.0, 0.1); + // На длинном кадре приближаемся не дальше цели: `frac` ограничен 1. + let frac = (dt / PHYSGUN_TIME_TO_ARRIVE).clamp(0.0, 1.0); + clamp_speed(error * (frac / dt), PHYSGUN_MAX_SPEED) +} + +/// Ограничивает длину вектора, сохраняя направление. +fn clamp_speed(value: Vec3, max_speed: f32) -> Vec3 { + if !value.is_finite() || !max_speed.is_finite() || max_speed <= 0.0 { + return Vec3::ZERO; + } + let speed = value.length(); + if speed > max_speed { + value * (max_speed / speed) + } else { + value + } +} + +/// Один кадр удержания: точка захвата едет к точке на луче, тело доворачивается +/// к `target_yaw` вокруг этой точки. +/// +/// Гравитация на время удержания выключена в `grab`. Скорость центра тела +/// компенсирует вращение: `v_точки = v_центра + ω × r`, поэтому при довороте +/// колесом проп крутится вокруг захваченного угла, а не вокруг своего origin. pub fn hold(world: &mut World, grab: &Grab, origin: Vec3, direction: Vec3, dt: f32) { let Some((position, angles)) = world.physics.transform(grab.body) else { return; @@ -326,23 +395,24 @@ pub fn hold(world: &mut World, grab: &Grab, origin: Vec3, direction: Vec3, dt: f } let dir = direction.normalized(); let target = origin + dir * grab.distance; - let delta = target - position; let dt = dt.clamp(1.0 / 240.0, 0.1); - let blend = 1.0 - (-dt * HOLD_STIFFNESS).exp(); - let mut velocity = delta * (blend / dt); - let speed = velocity.length(); - if speed > HOLD_MAX_SPEED { - velocity = velocity * (HOLD_MAX_SPEED / speed); - } - world.physics.set_velocity(grab.body, velocity); + // Текущее положение захваченной точки и скорость, с которой она должна + // ехать к цели. Ошибка считается до точки на меше, а не до origin. + let arm = engine::physics::rotate_by_angles(angles, grab.local_offset); + let point = position + arm; + let point_velocity = spring_velocity(target - point, dt); let diff = wrap_degrees(grab.target_yaw - angles.y); - let blend = 1.0 - (-dt * ROTATE_STIFFNESS).exp(); - let angular = (diff * (blend / dt)).clamp(-ROTATE_MAX_SPEED, ROTATE_MAX_SPEED); - world - .physics - .set_angular_velocity(grab.body, Vec3::new(0.0, 0.0, angular.to_radians())); + let angular_deg = + (diff / PHYSGUN_TIME_TO_ARRIVE).clamp(-PHYSGUN_MAX_ANGULAR, PHYSGUN_MAX_ANGULAR); + let omega = Vec3::new(0.0, 0.0, angular_deg.to_radians()); + + // Компенсация вращения: без неё угловая скорость уводила бы точку захвата + // с луча, и P-регулятор догонял бы её рывками. + let velocity = clamp_speed(point_velocity - omega.cross(arm), PHYSGUN_MAX_SPEED); + world.physics.set_velocity(grab.body, velocity); + world.physics.set_angular_velocity(grab.body, omega); } /// Отпускает проп по ЛКМ: возвращает гравитацию/freeze и пишет undo, если @@ -431,11 +501,11 @@ pub fn toggle_freeze_at( direction: Vec3, max_distance: f32, ) -> Option { - let (body, _) = find_target(world, origin, direction, max_distance, f32::INFINITY, true)?; - let prop = prop_for_body(world, body)?; - let before = world.physics.is_frozen(body); + let target = find_target(world, origin, direction, max_distance, f32::INFINITY, true)?; + let prop = prop_for_body(world, target.body)?; + let before = world.physics.is_frozen(target.body); let after = !before; - if !world.physics.set_frozen(body, after) { + if !world.physics.set_frozen(target.body, after) { return None; } info!(prop, before, after, "физган: freeze по прицелу"); @@ -501,4 +571,66 @@ mod tests { assert!((wrap_degrees(370.0) - 10.0).abs() < 1e-3); assert!((wrap_degrees(-190.0) - 170.0).abs() < 1e-3); } + + /// Закон скорости GMod: линейный до `physgun_maxSpeed`, без квадрата. + #[test] + fn spring_speed_is_linear_then_clamped() { + let dt = 1.0 / 60.0; + let speed = |units: f32| spring_velocity(Vec3::new(units, 0.0, 0.0), dt).length(); + for units in [20.0_f32, 50.0, 100.0, 250.0, 1000.0] { + println!( + "физган: рассогласование {units} → {:.0} юнитов/с", + speed(units) + ); + } + // timeToArrive = 0.05 c → k = 20 1/с. + assert!((speed(20.0) - 400.0).abs() < 1e-2, "20 → {}", speed(20.0)); + assert!((speed(50.0) - 1000.0).abs() < 1e-2, "50 → {}", speed(50.0)); + assert!( + (speed(100.0) - 2000.0).abs() < 1e-2, + "100 → {}", + speed(100.0) + ); + // Удвоение ошибки удваивает скорость — признак линейности. + assert!((speed(100.0) / speed(50.0) - 2.0).abs() < 1e-4); + // Дальше предела скорость не растёт (5000 юнитов/с). + assert!((speed(250.0) - PHYSGUN_MAX_SPEED).abs() < 1e-2); + assert!((speed(4000.0) - PHYSGUN_MAX_SPEED).abs() < 1e-2); + } + + /// Длинный кадр не проскакивает цель: приближение не больше 100%. + #[test] + fn spring_step_never_overshoots_on_long_frame() { + // 0.1 c — верхний предел dt у `hold`: за один шаг скорость равна + // error / dt, то есть тело ровно доезжает до цели и не дальше. + let velocity = spring_velocity(Vec3::new(10.0, 0.0, 0.0), 0.1); + assert!((velocity.length() - 100.0).abs() < 1e-3); + } + + /// Локальная точка захвата не уезжает при повороте: центр тела + /// пересчитывается как `target - rotate(angles, local_offset)`. + #[test] + fn grab_point_stays_on_target_when_body_rotates() { + use engine::physics::{inverse_rotate_by_angles, rotate_by_angles}; + + let position = Vec3::new(120.0, -40.0, 64.0); + let hit = Vec3::new(130.0, -40.0, 70.0); + let local = inverse_rotate_by_angles(Vec3::ZERO, hit - position); + assert!((position + rotate_by_angles(Vec3::ZERO, local) - hit).length() < 1e-4); + + // Игрок довернул тело на 90°; цель на луче осталась прежней. + let target = Vec3::new(300.0, 50.0, 100.0); + for angles in [ + Vec3::new(0.0, 90.0, 0.0), + Vec3::new(20.0, -35.0, 12.0), + Vec3::new(-45.0, 170.0, 80.0), + ] { + let desired_origin = target - rotate_by_angles(angles, local); + let world_point = desired_origin + rotate_by_angles(angles, local); + assert!( + (world_point - target).length() < 1e-3, + "углы {angles:?}: точка захвата ушла в {world_point:?}" + ); + } + } } diff --git a/crates/engine/src/physics.rs b/crates/engine/src/physics.rs index c004ec7..3c663c3 100644 --- a/crates/engine/src/physics.rs +++ b/crates/engine/src/physics.rs @@ -136,6 +136,17 @@ impl BodyId { } } +/// Результат трассировки луча по физическим телам. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RayHit { + /// Тело, в коллайдер которого попал луч. + pub body: BodyId, + /// Точка попадания в мировых координатах. + pub point: Vec3, + /// Расстояние от начала луча до точки попадания. + pub distance: f32, +} + /// Состояние физического мира. pub struct PhysicsWorld { pub(crate) world: RapierWorld, @@ -838,6 +849,52 @@ impl PhysicsWorld { Some(point.z - toi) } + /// Трассирует луч и возвращает первое тело на пути. + /// + /// Нужна физгану: игрок целится в конкретную точку меша, а не в origin + /// пропа. Браш карты вставлен «сырым» телом Rapier и в `handles` не + /// лежит, поэтому за стеной возвращается `None`; `prop_static` приходит + /// как `Kind::Static` и отсеивается уже в `sandbox::find_target`. + pub fn raycast(&self, origin: Vec3, direction: Vec3, max_distance: f32) -> Option { + if !origin.is_finite() || !direction.is_finite() || !max_distance.is_finite() { + return None; + } + if max_distance <= 0.0 { + return None; + } + let dir = direction.normalized(); + if dir.length_squared() < 1e-6 { + return None; + } + let ray = Ray::new( + Vector::new(origin.x, origin.y, origin.z), + Vector::new(dir.x, dir.y, dir.z), + ); + let (collider_handle, toi) = + self.world + .cast_ray(&ray, max_distance, true, QueryFilter::default())?; + if !toi.is_finite() || !(0.0..=max_distance).contains(&toi) { + return None; + } + // Статические браши карты вставлены «сырыми» телами Rapier и в + // `handles` не лежат: их луч видит, но захватывать нечего. Заодно это + // даёт окклюзию — проп за стеной не берётся. + let parent = self.world.colliders.get(collider_handle)?.parent()?; + let kind = self + .handles + .iter() + .find(|(handle, _, _)| *handle == parent) + .map(|(_, _, kind)| *kind)?; + Some(RayHit { + body: BodyId { + inner: parent, + kind, + }, + point: origin + dir * toi, + distance: toi, + }) + } + /// Высота опоры под точкой, не считая указанного тела. /// /// Нужно проверкам «проп лежит на полу»: луч из центра пропа иначе попадёт @@ -981,6 +1038,27 @@ fn quat_from_angles(angles: Vec3) -> Rotation { ) } +/// Поворот вектора углами тела Source (pitch, yaw, roll в градусах). +/// +/// Физган хранит точку захвата в локальной системе тела: при удержании её +/// нужно перевести обратно в мир той же конвенцией углов, что у Rapier. +pub fn rotate_by_angles(angles: Vec3, v: Vec3) -> Vec3 { + if !angles.is_finite() || !v.is_finite() { + return Vec3::ZERO; + } + let rotated = quat_from_angles(angles) * Vector::new(v.x, v.y, v.z); + Vec3::new(rotated.x, rotated.y, rotated.z) +} + +/// Обратный поворот: мир → локальные оси тела. +pub fn inverse_rotate_by_angles(angles: Vec3, v: Vec3) -> Vec3 { + if !angles.is_finite() || !v.is_finite() { + return Vec3::ZERO; + } + let rotated = quat_from_angles(angles).conjugate() * Vector::new(v.x, v.y, v.z); + Vec3::new(rotated.x, rotated.y, rotated.z) +} + /// Углы Source из кватерниона. /// /// При |pitch| = 90° yaw не определён и «прыгает» между 0° и 180°. Физика @@ -995,6 +1073,63 @@ fn angles_from_quat(q: Rotation) -> (f32, f32, f32) { mod tests { use super::*; + /// Луч физгана возвращает точку на поверхности коллайдера, а не origin. + #[test] + fn raycast_returns_hit_point_on_prop() { + let mut physics = PhysicsWorld::new(); + let id = physics + .add_prop( + EntityIndex::INVALID, + Vec3::new(0.0, 0.0, 100.0), + Vec3::ZERO, + Vec3::new(10.0, 10.0, 10.0), + 10.0, + ) + .expect("тело"); + // Broad-phase наполняется на шаге: без него луч не видит тело. + physics.step(1.0 / 60.0); + let hit = physics + .raycast( + Vec3::new(-100.0, 0.0, 100.0), + Vec3::new(1.0, 0.0, 0.0), + 200.0, + ) + .expect("луч попал в проп"); + assert_eq!(hit.body, id); + assert!( + (hit.point.x + 10.0).abs() < 0.05, + "точка попадания должна быть на ближней грани, x={}", + hit.point.x + ); + assert!((hit.distance - 90.0).abs() < 0.05); + } + + /// Браш карты перекрывает проп: луч не должен «видеть» тело за стеной. + #[test] + fn raycast_is_blocked_by_map_geometry() { + let mut physics = PhysicsWorld::new(); + physics.add_floor(0.0); + assert!( + physics + .raycast(Vec3::new(0.0, 0.0, 100.0), Vec3::new(0.0, 0.0, -1.0), 200.0,) + .is_none(), + "статика карты не отдаёт тело для захвата" + ); + } + + /// Локальная точка, повёрнутая туда и обратно, совпадает с исходной. + #[test] + fn rotate_helpers_are_inverse() { + let angles = Vec3::new(20.0, -35.0, 12.0); + let local = Vec3::new(7.0, -3.0, 11.0); + let world = rotate_by_angles(angles, local); + let back = inverse_rotate_by_angles(angles, world); + assert!( + (back - local).length() < 1e-4, + "обратный поворот не сошёлся: {back:?} != {local:?}" + ); + } + /// Углы физики и поворот рендера должны совпадать. /// /// `move_prop` рисует меш по углам из кватерниона тела, а рендер вращает diff --git a/docs/DIARY.md b/docs/DIARY.md index c9e696a..09c7b8c 100644 --- a/docs/DIARY.md +++ b/docs/DIARY.md @@ -8,6 +8,47 @@ --- +## 0.12. Физган: дефолты GMod и захват за точку меша (2026-10-24, ветка `feat/physgun`) + +Жалобы: проп «разгонялся квадратично» и брался за origin, а не за точку под +прицелом. Разобрано по спецификации GMod/Source, реализация своя. + +- **Дефолты GMod** (`wiki.facepunch.com/gmod/ConVars_In_Garrysmod`, + `physgun_*`): `physgun_maxrange 4096`, `physgun_timeToArrive 0.05`, + `physgun_maxSpeed 5000`, `physgun_maxAngular 5000`, + `physgun_maxSpeedDamping 10000`, `physgun_maxAngularDamping 10000`, + `physgun_wheelspeed 10`, `physgun_rotation_sensitivity 0.05`, + `physgun_teleportDistance 0`, `physgun_DampingFactor 0`. + Отдельного конвара массы у физгана GMod нет; 500 — верхняя граница из + физики Source (как у гравипушки) и прежнего поведения клиента. +- **Пружина.** Было экспоненциальное приближение с `k=12` и пределом 900, + стало `v = clamp(error / timeToArrive, maxSpeed)` при `timeToArrive=0.05`: + скорость строго линейна по рассогласованию (20→400, 50→1000, 100→2000 + юнитов/с) и упирается в 5000. Длинный кадр не проскакивает цель: + приближение за шаг не больше 100% ошибки. Гравитация на удержании + выключена, тело сталкивается с миром. Отдельного демпфирования нет: + скорость задаётся напрямую (в GMod `physgun_DampingFactor` по умолчанию 0). +- **Захват за точку меша.** `PhysicsWorld::raycast` трассирует луч по + коллайдерам и возвращает тело + точку попадания; статика карты даёт + окклюзию. `Grab` хранит `local_offset = inverse_rotate(angles) * (hit - + origin)`. Цель удержания — точка на луче на расстоянии захвата, а желаемый + центр тела считается как `target - rotate(angles, local_offset)`. Туда же + смещены крест и конец луча. При довороте колесом линейная скорость + компенсирует `ω × r`, поэтому проп вращается вокруг захваченного угла. +- **Колесо/freeze.** Шаг колеса 15° (`physgun_wheelspeed`), угловая скорость + ограничена 5000°/с, ПКМ по-прежнему фиксирует позу и замораживает тело. +- **Проверки.** Юнит-тесты: закон скорости и кламп + (`spring_speed_is_linear_then_clamped`), длинный кадр + (`spring_step_never_overshoots_on_long_frame`), локальная точка при + повороте (`grab_point_stays_on_target_when_body_rotates`), raycast по + коллайдеру и окклюзия статикой в `engine::physics`. Кадры Xvfb: + до — луч в центр дивана (`physgun_couch_before.png`), после — луч и крест + на правом краю, диван довёрнут вокруг этой точки + (`physgun_couch.png`); `SOURCE_RS_TEST_PHYSGUN=1` на 40-м кадре + по-прежнему замораживает проп. + +--- + ## 0.11. Модели: локальные индексы вершин и пол под пропами (2026-10-24, ветка `feat/models`) - Причина «взрывов» мешей: `origMeshVertID` в VTX — локальный индекс внутри