Физган: дефолты GMod и захват за точку меша
Some checks are pending
CI / checks (push) Waiting to run
Some checks are pending
CI / checks (push) Waiting to run
- пружина GMod: v = clamp(error / physgun_timeToArrive, physgun_maxSpeed), линейная зависимость вместо прежнего разгона; - дальность 4096 (physgun_maxrange), масса 500, maxAngular 5000; - трассировка луча до коллайдера, local_offset и удержание за точку на меше; - поворот колесом вокруг точки захвата с компенсацией omega × r; - тесты закона скорости, луча и локальной точки; docs/DIARY.
This commit is contained in:
parent
3b715dcd65
commit
ed63cb68b8
4 changed files with 427 additions and 102 deletions
|
|
@ -1072,11 +1072,12 @@ pub fn map(assets: Option<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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);
|
||||
|
|
|
|||
|
|
@ -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<Vec3> {
|
||||
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<GrabTarget> {
|
||||
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<Grab> {
|
||||
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<Grab> {
|
||||
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<FreezeToggle> {
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RayHit> {
|
||||
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` рисует меш по углам из кватерниона тела, а рендер вращает
|
||||
|
|
|
|||
|
|
@ -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 — локальный индекс внутри
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue