Первый срез модели игрока, чтобы не блокировать 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:
parent
4ad0405ed3
commit
8a2eb4ef60
4 changed files with 315 additions and 0 deletions
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Габариты пропов уходят в коллизию игрока: без этого он ходит по
|
||||
// карте, но проходит сквозь всё, что лежит на ней.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
122
docs/DIARY.md
Normal 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` + картовые тесты.
|
||||
Loading…
Add table
Reference in a new issue