diff --git a/crates/client/src/map.rs b/crates/client/src/map.rs index 432fd48..4ec886a 100644 --- a/crates/client/src/map.rs +++ b/crates/client/src/map.rs @@ -206,12 +206,33 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { }; let mut player = engine::Player::new(world.borrow().spawn); player.noclip = args.noclip; + if let Some(pos) = args.player_pos.as_deref() { if let Some(position) = parse_vec3(pos) { player.position = position; } } + // Модель игрока (первый срез A20/A23): статический меш на том же рендере + // моделей, что у пропов — скелет и анимации следующим срезом, чтобы не + // блокировать Sandbox. Включается `SOURCE_RS_PLAYER_MODEL`: значение — + // путь к модели, пустое — модель по умолчанию. + let player_model_instance = match std::env::var("SOURCE_RS_PLAYER_MODEL") { + Ok(value) if value.trim().is_empty() => Some("models/player/police.mdl".to_string()), + Ok(value) => Some(value), + Err(_) => None, + } + .and_then(|name| { + let mut budget = usize::MAX; + let mut w = world.borrow_mut(); + let index = + w.spawn_model_instance(&vfs, &name, player.position, math::Vec3::ZERO, &mut budget); + if index.is_none() { + warn!(model = %name, "модель игрока не загрузилась"); + } + index + }); + if let Some(dir) = &args.dump_textures { std::fs::create_dir_all(dir)?; for (index, material) in world.borrow().materials.iter().enumerate() { @@ -416,6 +437,16 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { let mut w = world.borrow_mut(); w.step_physics(dt); w.recover_fallen_props(-8192.0); + // Модель игрока едет за игроком: позиция — ступни, поворот — курс. + if let Some(index) = player_model_instance { + // У моделей Source ось Y смотрит влево, поэтому курс + // разворачиваем: без этого модель стоит лицом против взгляда. + w.move_model_instance( + index, + player.position, + math::Vec3::new(0.0, camera.yaw + 180.0, 0.0), + ); + } } // Габариты пропов уходят в коллизию игрока: без этого он ходит по // карте, но проходит сквозь всё, что лежит на ней. diff --git a/crates/client/src/menu.rs b/crates/client/src/menu.rs index 1e0af08..ff1aa34 100644 --- a/crates/client/src/menu.rs +++ b/crates/client/src/menu.rs @@ -41,6 +41,7 @@ fn empty_world() -> World { materials: Vec::new(), lights: Vec::new(), entities: engine::EntityWorld::new(), + model_instances: Vec::new(), props: Vec::new(), prop_meshes: Vec::new(), physics: engine::physics::PhysicsWorld::new(), diff --git a/crates/engine/src/world.rs b/crates/engine/src/world.rs index 258fda6..74fa89a 100644 --- a/crates/engine/src/world.rs +++ b/crates/engine/src/world.rs @@ -289,6 +289,8 @@ pub struct World { pub ambient: [f32; 3], /// Туман карты (`env_fog_controller` или `worldspawn`), если задан. pub fog: Option, + /// Экземпляры моделей без сущности: модель игрока и вьюмодель. + pub model_instances: Vec, /// Скайбокс (грани в порядке ft, bk, rt, lf, up, dn). pub skybox: Option, /// Треугольники displacement'ов в мировых координатах (для коллизии). @@ -367,6 +369,7 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result { lights: Vec::new(), entities: crate::entity::EntityWorld::new(), props: Vec::new(), + model_instances: Vec::new(), prop_meshes: Vec::new(), physics: crate::physics::PhysicsWorld::new(), home_origins: Vec::new(), @@ -2210,6 +2213,143 @@ impl World { /// /// Возвращает `false`, если индекс или модель неизвестны: вызывающий код /// не должен падать из-за рассинхрона (правило #2 из AGENTS.md). + /// Перезаписывает вершины экземпляра модели по его позе. + /// + /// Общий путь для пропов и экземпляров без сущности: локальные координаты + /// модели лежат в `prop_meshes`, мировые — в общем буфере мира. + fn rewrite_instance( + &mut self, + mesh_index: usize, + first: u32, + count: u32, + origin: Vec3, + angles: Vec3, + ) -> bool { + let (first, count) = (first as usize, count as usize); + let Some(mesh) = self.prop_meshes.get(mesh_index) else { + return false; + }; + if mesh.positions.len() < count || first + count > self.vertices.len() { + return false; + } + let (pitch, yaw, roll) = (angles.x, angles.y, angles.z); + for i in 0..count { + let local = Vec3::new( + mesh.positions[i][0], + mesh.positions[i][1], + mesh.positions[i][2], + ); + let pos = crate::model::rotate_model_vector(local, pitch, yaw, roll) + origin; + let local_n = Vec3::new(mesh.normals[i][0], mesh.normals[i][1], mesh.normals[i][2]); + let normal = crate::model::rotate_model_vector(local_n, pitch, yaw, roll); + let vertex = &mut self.vertices[first + i]; + vertex.pos = [pos.x, pos.y, pos.z]; + vertex.normal = [normal.x, normal.y, normal.z]; + } + true + } + + /// Добавляет экземпляр модели на ходу — модель игрока и вьюмодель. + /// + /// Путь тот же, что у пропов при сборке мира: модель с диска, материалы с + /// дедупликацией по имени, вершины с трансформацией, батчи на материалы. + /// Дальше экземпляр двигается `move_model_instance`. + pub fn spawn_model_instance( + &mut self, + vfs: &VirtualFs, + model_name: &str, + origin: Vec3, + angles: Vec3, + budget: &mut usize, + ) -> Option { + let key = crate::model::normalize_model_name(model_name); + let model = crate::model::load_model(vfs, &key, budget).ok()?; + self.prop_meshes + .push(PropMesh::from_vertices(&model.vertices)); + let mesh = self.prop_meshes.len() - 1; + + let mut material_map = Vec::with_capacity(model.materials.len()); + for material in &model.materials { + let index = if let Some(existing) = + self.materials.iter().position(|m| m.name == material.name) + { + existing + } else { + let index = self.materials.len(); + self.materials.push(Material { + name: material.name.clone(), + texture: material.texture.clone(), + fallback: material.fallback, + water: None, + }); + index + }; + material_map.push(index); + } + + let (pitch, yaw, roll) = (angles.x, angles.y, angles.z); + let first_vertex = self.vertices.len() as u32; + for vertex in &model.vertices { + let pos = crate::model::rotate_model_vector( + Vec3::new(vertex.pos[0], vertex.pos[1], vertex.pos[2]), + pitch, + yaw, + roll, + ) + origin; + let normal = crate::model::rotate_model_vector( + Vec3::new(vertex.normal[0], vertex.normal[1], vertex.normal[2]), + pitch, + yaw, + roll, + ); + self.vertices.push(WorldVertex { + pos: [pos.x, pos.y, pos.z], + normal: [normal.x, normal.y, normal.z], + uv: vertex.uv, + light: [1.0, 1.0, 1.0], + }); + } + let vertex_count = self.vertices.len() as u32 - first_vertex; + for batch in &model.batches { + let first_index = self.indices.len() as u32; + let from = batch.first_index as usize; + let to = from + batch.index_count as usize; + if to > model.indices.len() { + continue; + } + // Индексы модели локальные — смещаем на вершины экземпляра в + // общем буфере мира, как это делает сборка пропов. + self.indices + .extend(model.indices[from..to].iter().map(|i| first_vertex + i)); + let index_count = self.indices.len() as u32 - first_index; + self.batches.push(Batch { + material: material_map.get(batch.material).copied().unwrap_or(0), + first_index, + index_count, + }); + } + self.model_instances.push(ModelInstance { + model: key, + mesh, + origin, + angles, + first_vertex, + vertex_count, + }); + Some(self.model_instances.len() - 1) + } + + /// Двигает экземпляр модели без сущности. + pub fn move_model_instance(&mut self, index: usize, origin: Vec3, angles: Vec3) -> bool { + let Some(instance) = self.model_instances.get(index) else { + return false; + }; + let (mesh, first, count) = (instance.mesh, instance.first_vertex, instance.vertex_count); + self.model_instances[index].origin = origin; + self.model_instances[index].angles = angles; + self.rewrite_instance(mesh, first, count, origin, angles) + } + pub fn move_prop(&mut self, index: usize, origin: Vec3, angles: Vec3) -> bool { let Some(instance) = self.props.get(index) else { return false; @@ -2335,6 +2475,23 @@ impl PropMesh { } } +/// Экземпляр модели без сущности: модель игрока, вьюмодель, эффекты. +/// +/// Отличие от `PropInstance` ровно одно — нет ссылки на сущность: такие +/// экземпляры создаёт клиент (игрок, оружие в руках), а не карта. +#[derive(Debug, Clone, PartialEq)] +pub struct ModelInstance { + /// Нормализованное имя модели. + pub model: String, + /// Индекс модели в `World::prop_meshes`. + pub mesh: usize, + pub origin: Vec3, + pub angles: Vec3, + /// Первый индекс вершины экземпляра в `World::vertices`. + pub first_vertex: u32, + pub vertex_count: u32, +} + /// Экземпляр пропа: какая сущность какую часть геометрии занимает. /// /// Позволяет физике и Lua двигать проп, не пересобирая весь мир, и связывает @@ -3819,6 +3976,7 @@ mod tests { bounds_center: Vec3::ZERO, bounds_half_extents: Vec3::ZERO, }], + model_instances: Vec::new(), props: vec![PropInstance { entity: crate::entity::EntityIndex::INVALID, classname: "prop_static".into(), @@ -3875,6 +4033,7 @@ mod tests { bounds_center: Vec3::ZERO, bounds_half_extents: Vec3::ZERO, }], + model_instances: Vec::new(), props: vec![PropInstance { entity: crate::entity::EntityIndex::INVALID, classname: "prop_physics".into(), @@ -3932,6 +4091,7 @@ mod tests { sky_mesh: None, lights: Vec::new(), entities: crate::entity::EntityWorld::new(), + model_instances: Vec::new(), props: Vec::new(), prop_meshes: Vec::new(), physics: crate::physics::PhysicsWorld::new(), @@ -3983,6 +4143,7 @@ mod tests { sky_mesh: None, lights: Vec::new(), entities: crate::entity::EntityWorld::new(), + model_instances: Vec::new(), props: Vec::new(), prop_meshes: Vec::new(), physics: crate::physics::PhysicsWorld::new(), diff --git a/docs/DIARY.md b/docs/DIARY.md new file mode 100644 index 0000000..8d8db8c --- /dev/null +++ b/docs/DIARY.md @@ -0,0 +1,122 @@ +# Дневник агента: где я и что дальше + +Живой документ. Обновляю по ходу работы, чтобы после автоматического +уплотнения контекста ничего не терялось. Коротко: состояние, замеры, следующий +шаг, грабли. + +Обновлён: 2026-10-16, вечер. + +--- + +## 1. Что просит пользователь (приоритеты) + +**Ускоренный путь до Sandbox-UX** (его слова): «быстрее дойти до того, как смогу +спавнить через Q-меню, иметь норм модель игрока, таскать физганом, Weapon всё +сделать». Поэтому очередь переставлена (docs/PARITY.md A25): + +1. A23 оружие (SWEP из Lua + вьюмодель + аттачменты) — сюда же A20 (модель + игрока: один и тот же рендер моделей). +2. A21 физика взаимодействия: физган (луч → проп → таскание, вращение, + заморозка), без полной гибернации. +3. A24 спавн: сначала консольные команды на данных из Lua (`gm_spawn`, + `gm_give`, режимы `gmod_tool`), потом окно Q. +4. A22 Sandbox целиком: undo/cleanup/duplicator. +5. Визуальная точность (A10 свет на пиксель, A11–A12 вода, A16 трава/декали, + A18 скайбокс) — параллельно, Sandbox не блокирует. + +## 2. Где я сейчас (в работе) + +**A20/A23, первый срез сделан: модель игрока в мире.** Рендер тот же, что у +пропов (статический меш `PropMesh`), скелет и анимации — следующий срез. + +Как включается и как проверять: + +```bash +SOURCE_RS_PLAYER_MODEL= DISPLAY=:99 SDL_VIDEODRIVER=x11 cargo run -q -p client -- \ + map --assets ~/gmod_server/server gm_construct --hidden --frames 4 --noclip \ + --player-pos="-2438 -966 48" --flythrough /tmp/opencode/pl4 --flythrough-frames 4 \ + --route="-2438 -880 75|6 270; -2438 -1180 75|6 90" +``` + +Пустое значение — модель по умолчанию (`models/player/police.mdl`), иначе — +путь к модели. Курс разворачивается на 180° (у моделей Source ось Y влево). + +Движок: `World::spawn_model_instance` и `World::move_model_instance`, +`ModelInstance` (экземпляр без сущности). Грабли: индексы модели **локальные** — +при вставке в общий буфер к ним обязательно прибавляется `first_vertex` +(без этого был мусор в кадре). + +**Следующее:** (а) вьюмодель в первом лице и скрытие своей модели, когда камера +внутри неё; (б) реестр SWEP из Lua; (в) физган; (г) скелет+анимации моделей. + +## 3. Что уже сделано и лежит в `main` + +Последние коммиты (свежие сверху): + +- `4ad0405` очередь: A23 оружие, A24 Q-меню, A25 ускоренный порядок; +- `7a8a750` очередь: A20 модель игрока, A21 полная физика, A22 Sandbox через GLua; +- `257ac07`, `d35efef` **A13 туман сделан** целиком (чтение + шейдер); +- `72bad60` A19: подшаг не гасит скорость о склон (35°: 65 → 147); +- `5bdbdbf`, `e638607` A19: замер по углам + найден `flRedirectCoeff` в движке; +- `3bfd078`, `d88988f` рельеф: треугольники отсекают шаг, нормаль наружу (стены + по рёбрам полигонов ушли); +- `b74824b` склон: догон земли трассировкой (рампы 242–250 вместо 197,8); +- `23c5034` присед: 250/150/520/216,7 по спеке, камера 46 → 28 (была выше + хитбокса); +- `aa11713`, `d220b5a` рампы и стыки: отсечение по последней входной грани, + касание ребром не преграда; +- `86a6cb6`, `5380184` инструменты: слой коллизий, дамп кирпича, обход карты. + +Все 23 сьюта и 28 картовых тестов зелёные. + +## 4. Замеры, которые нельзя потерять + +- **A19 (скорость на склоне)**: замер `probe_slope_angles` (синтетический клин). + Было: 5° 239, 10° 232, 15° 196, 20° 152, 25° 116, 30° 88, 35° **65**, 40° 49, + 44° 39. Стало: 239 / 233 / 226 / 214 / 195 / 175 / **147** / 132 / 107. + Цель — 250 до 44°. Остаток теряется на прямом (скользящем) пути: иногда он + выигрывает выбор у подшага. Инструмент: `SOURCE_RS_TRACE_STEP=1` печатает, + какой путь победил (`step_move`). +- **Механизм движка для скорости на склоне**: `gamemovement.cpp`, `ClipVelocity` + (строка ~3144) — блок `flRedirectCoeff`: `out *= (len + |v·n|·coeff)/len`. + Наивный перенос копит бонус за кадр (15° → 442 юнита/с) — откачен. +- **Туман карты gm_construct**: цвет (0,557 0,643 0,698), start 1024, end + 250000, max_density 1,0, farz −1 (дальний, на ближних дистанциях нейтрален). +- **Габариты/линейки Source**: тело 72/36 (стоя/присед), глаза 64/28 + (`VEC_VIEW`/`VEC_DUCK_VIEW`), `sv_walkable_normal` 0,7 (45,6°), `DIST_EPSILON` + 1/32, `sv_stepsize` 18, воздушный потолок желаемой скорости 30. +- **Коллизия gm_construct**: 2118 кирпичей; 140 за габаритами карты (3D-скайбокс, + часть с несогласованными плоскостями — см. E19.2); 11776 треугольника рельефа, + у всех намотка «гео внутрь» (нормаль разворачиваем в `clip_triangle`). +- **Присед**: обрезка `HandleDuckingSpeedCrop` = 0.33333333 только в приседе и + на земле, желаемая скорость 450 (бег 650), максимум 250 / 520. + +## 5. Инструменты диагностики (не переизобретать) + +- `SOURCE_RS_DEBUG_COLLISION=<радиус>` — слой коллизий: красные рёбра кирпичей, + жёлтые габариты, голубой рельеф, зелёное тело. +- `probe_brush_dump` (`SOURCE_RS_TEST_BSP_AT="x,y,z,радиус"`) — полный дамп + кирпича: содержимое с расшифровкой, владелец-сущность, каждая грань с + текстурой, габарит, попадание в коллизию. +- `probe_ramp_movement` — движение из точки: 8 направлений, рывки, залипания, + кто мешает (кирпич + текстуры). +- `probe_slanted_surface` — лучи по наклонным кирпичам против настоящей формы. +- `probe_slope_angles` — таблица скорости по углам (приёмка A19). +- `probe_phantom_walls_scan`, `probe_collision_contents` — обход карты и состав + коллизии. +- `real_map_*` — 28 картовых тестов, требуют `SOURCE_RS_TEST_BSP`. +- Запуск клиента: `DISPLAY=:99 SDL_VIDEODRIVER=x11 cargo run -q -p client -- + map --assets ~/gmod_server/server gm_construct ...`, для кадров нужен + `--flythrough `. + +## 6. Грабли (уже наступал) + +- `Vec3` без `Index`: писать покомпонентно. +- `%[fx:mean*255]` в ImageMagick без имени канала врёт — считать + `mean.r/g/b`. +- Тесты с картой имеют свой `VirtualFs::new()` (189 материалов) — индексы + материалов не как в рантайме (54). +- Правки кода делать `edit`, не sed/python, кроме больших перестановок блоков — + и после каждой проверять `cargo clippy` и тесты. +- Перед коммитом всегда: `cargo fmt --all && cargo clippy --workspace + --all-targets -- -D warnings && cargo test --workspace` + картовые тесты.