A20/A23 первый срез: модель игрока в мире
Some checks are pending
CI / checks (push) Waiting to run

Первый срез модели игрока, чтобы не блокировать Sandbox: рендер тот же, что у
пропов (статический меш PropMesh), скелет и анимации следующим срезом.

Движок: ModelInstance (экземпляр модели без сущности) плюс
World::spawn_model_instance (модель с диска, материалы с дедупликацией, вершины
с трансформацией, батчи) и World::move_model_instance; перезапись геометрии
вынесена в общий rewrite_instance, им пользуются и пропы.

Клиент: модель создаётся после разбора --player-pos и едет за игроком (позиция —
ступни, курс — взгляд, развёрнутый на 180°, потому что у моделей Source ось Y
смотрит влево). Включается переменной SOURCE_RS_PLAYER_MODEL: пустое значение —
models/player/police.mdl, иначе путь к модели.

Проверено кадром: модель стоит на площадке в -2438 -966 48, текстуры на месте,
вершин в мире стало 46359 против 43212 без модели. Грабли записаны в дневник:
индексы модели локальные, при вставке в общий буфер к ним прибавляется
first_vertex (без этого кадр превращался в мусор).

Заведён docs/DIARY.md — живой дневник состояния, замеров, инструментов и
грабель, чтобы уплотнение контекста ничего не теряло.
This commit is contained in:
Niko Marmeladkov 2026-10-16 23:44:39 +03:00
parent 4ad0405ed3
commit 8a2eb4ef60
4 changed files with 315 additions and 0 deletions

View file

@ -206,12 +206,33 @@ pub fn map(assets: Option<PathBuf>, 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<PathBuf>, 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),
);
}
}
// Габариты пропов уходят в коллизию игрока: без этого он ходит по
// карте, но проходит сквозь всё, что лежит на ней.

View file

@ -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(),

View file

@ -289,6 +289,8 @@ pub struct World {
pub ambient: [f32; 3],
/// Туман карты (`env_fog_controller` или `worldspawn`), если задан.
pub fog: Option<Fog>,
/// Экземпляры моделей без сущности: модель игрока и вьюмодель.
pub model_instances: Vec<ModelInstance>,
/// Скайбокс (грани в порядке ft, bk, rt, lf, up, dn).
pub skybox: Option<Skybox>,
/// Треугольники displacement'ов в мировых координатах (для коллизии).
@ -367,6 +369,7 @@ pub fn build_world(bsp: &Bsp, vfs: &VirtualFs) -> Result<World, WorldError> {
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<usize> {
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(),

122
docs/DIARY.md Normal file
View file

@ -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 <dir>`.
## 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` + картовые тесты.