diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 789ac71..98df175 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,4 +15,5 @@ pub use model::{Model, ModelVertex, load_model}; pub use player::{Collision, Player}; pub use world::{ Batch, Material, Skybox, TextureData, World, WorldError, WorldStats, WorldVertex, build_world, + inside_bounds, }; diff --git a/crates/engine/src/world.rs b/crates/engine/src/world.rs index 269c364..a6ac5af 100644 --- a/crates/engine/src/world.rs +++ b/crates/engine/src/world.rs @@ -1693,7 +1693,31 @@ fn find_spawn(entities: &[formats::KvDocument], world: &World) -> (Vec3, f32) { /// Предел числа динамических источников на кадр (данные из файла карты). pub const MAX_LIGHTS: usize = 64; +/// Находится ли точка внутри габаритов карты с допуском `margin` наружу. +/// +/// За пределами габаритов геометрии небо рисовать нельзя: 3D-скайбокс +/// привязан к камере (`world = cam + (v - sky_camera) * scale`) и всегда заливает +/// весь фон. Из-за этого, вылетев за край карты, игрок видит бесконечное небо и +/// не может понять, где карта кончилась — «границы карты» не видно. +/// +/// `margin` — небольшой допуск наружу: габариты считаются по вершинам +/// геометрии, и стоящий на самом краю игрок находится за ними на половину +/// своего роста. +pub fn inside_bounds(min: Vec3, max: Vec3, pos: Vec3, margin: f32) -> bool { + pos.x >= min.x - margin + && pos.x <= max.x + margin + && pos.y >= min.y - margin + && pos.y <= max.y + margin + && pos.z >= min.z - margin + && pos.z <= max.z + margin +} + impl World { + /// Находится ли точка внутри проходимой части карты. + pub fn inside_bounds(&self, pos: Vec3, margin: f32) -> bool { + inside_bounds(self.bounds_min, self.bounds_max, pos, margin) + } + /// Строит мир физики: рельеф карты и динамические пропы. /// /// В мире физики живут только `prop_physics*`. У `prop_static` физики в @@ -2220,6 +2244,90 @@ mod tests { use super::*; use crate::entity::EntityWorld; + /// Небо гасится только за пределами карты. + #[test] + fn sky_is_visible_only_inside_the_map() { + let min = Vec3::new(-1024.0, -1024.0, -256.0); + let max = Vec3::new(1024.0, 1024.0, 512.0); + let margin = 64.0; + assert!(inside_bounds(min, max, Vec3::ZERO, margin)); + assert!(inside_bounds(min, max, Vec3::new(1024.0, 0.0, 0.0), margin)); + // На самом краю с допуском — ещё карта. + assert!(inside_bounds(min, max, Vec3::new(1088.0, 0.0, 0.0), margin)); + // За краем — уже нет: небо гаснет, граница карты видна. + assert!(!inside_bounds( + min, + max, + Vec3::new(1089.0, 0.0, 0.0), + margin + )); + // По любой оси: под картой небо тоже исчезает. + assert!(!inside_bounds( + min, + max, + Vec3::new(0.0, 0.0, -321.0), + margin + )); + assert!(!inside_bounds( + min, + max, + Vec3::new(0.0, -1089.0, 0.0), + margin + )); + assert!(!inside_bounds(min, max, Vec3::new(0.0, 0.0, 577.0), margin)); + } + + /// Замер границ карты на настоящей карте. + /// + /// Требует локальной установки Garry's Mod: + /// + /// ```text + /// SOURCE_RS_TEST_BSP=~/gmod_server/server/garrysmod/maps/gm_construct.bsp \\ + /// cargo test -p engine -- --ignored real_map_sky_bounds --nocapture + /// ``` + #[test] + #[ignore = "требует ассеты Garry's Mod: SOURCE_RS_TEST_BSP"] + fn real_map_sky_bounds() { + let Some(path) = std::env::var_os("SOURCE_RS_TEST_BSP") else { + eprintln!("SOURCE_RS_TEST_BSP не задан — проверка пропущена"); + return; + }; + let data = std::fs::read(&path).expect("BSP читается"); + let bsp = Bsp::from_bytes(data).expect("BSP разбирается"); + let world = build_world(&bsp, &VirtualFs::new()).expect("мир строится"); + println!( + "ЗАМЕР границы карты {:?} .. {:?}, спавн {:?}", + world.bounds_min, world.bounds_max, world.spawn + ); + // Спавн внутри карты — небо есть. + assert!( + world.inside_bounds(world.spawn, 0.0), + "спавн должен быть внутри карты" + ); + // Далеко за краем по любой оси — неба нет. + for dir in [ + Vec3::new(1.0, 0.0, 0.0), + Vec3::new(-1.0, 0.0, 0.0), + Vec3::new(0.0, 1.0, 0.0), + Vec3::new(0.0, -1.0, 0.0), + Vec3::new(0.0, 0.0, 1.0), + Vec3::new(0.0, 0.0, -1.0), + ] { + let far = world.spawn + dir * 100_000.0; + assert!( + !world.inside_bounds(far, 0.0), + "небо должно гаснуть в {dir:?}: {far:?}" + ); + } + // Даже чуть за краем небо уже не рисуется. + let just_outside = + world.spawn + Vec3::new(0.0, 0.0, -1.0) * (world.spawn.z - world.bounds_min.z + 1.0); + assert!( + !world.inside_bounds(just_outside, 0.0), + "под нижней гранью карты неба быть не должно: {just_outside:?}" + ); + } + #[test] fn checker_is_opaque() { let t = checker_texture("test"); diff --git a/crates/render/src/world_renderer.rs b/crates/render/src/world_renderer.rs index 8adcf2d..1edddab 100644 --- a/crates/render/src/world_renderer.rs +++ b/crates/render/src/world_renderer.rs @@ -4,10 +4,19 @@ use crate::gfx::Gfx; use crate::text::{TextItem, TextVertex, build_atlas, build_vertices}; use crate::texture::upload_texture; use engine::{Batch, FlyCamera, World, WorldVertex}; -use math::Mat4; +use math::{Mat4, Vec3}; const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; +/// Допуск за границы карты, за которыми небо гасится. +/// +/// Габариты считаются по вершинам геометрии, а камера стоит на высоте глаз +/// (около 64 юнитов) над опорой — на самом краю игрок формально оказывается +/// за габаритом на всю высоту своего габарита. Допуск в 128 юнитов это +/// покрывает и при этом намного меньше расстояния, на котором «край карты» +/// ещё можно спутать с небом. +const SKY_BOUNDS_MARGIN: f32 = 128.0; + /// Рендерер мира. pub struct WorldRenderer { pipeline: wgpu::RenderPipeline, @@ -29,6 +38,11 @@ pub struct WorldRenderer { only_material: Option, sky: Option, sky_mesh: Option, + /// Границы проходимой части карты — за ними небо не рисуется. + bounds_min: Vec3, + bounds_max: Vec3, + /// Рисовать ли небо в этом кадре. + sky_visible: bool, text: TextResources, /// Источники света карты — нужны каждый кадр для отбора по близости. lights: Vec, @@ -930,11 +944,30 @@ impl WorldRenderer { only_material: None, sky, sky_mesh, + bounds_min: world.bounds_min, + bounds_max: world.bounds_max, + // До первого кадра считаем небо видимым: иначе окно будет пустым, + // пока не отрисовался хоть один кадр. + sky_visible: true, text, lights: world.lights.clone(), }) } + /// Небо рисуется только внутри габаритов карты. + /// + /// Проверяется каждый кадр, а не один раз при загрузке: игрок уходит за + /// край карты уже во время игры, и там небо обязано исчезнуть, иначе + /// границу карты не видно. + pub fn update_sky_visibility(&mut self, camera_pos: Vec3) { + self.sky_visible = engine::inside_bounds( + self.bounds_min, + self.bounds_max, + camera_pos, + SKY_BOUNDS_MARGIN, + ); + } + /// Диагностика: рисовать только один материал. pub fn set_only_material(&mut self, index: Option) { self.only_material = index; @@ -1057,7 +1090,9 @@ impl WorldRenderer { // 2D-скайбокс — фон, до мира (как в движке: скайбокс рисуется первым, // мир перекрывает его в непрозрачных пикселях). - if let Some(sky) = &self.sky { + if self.sky_visible + && let Some(sky) = &self.sky + { pass.set_pipeline(&sky.pipeline); pass.set_bind_group(0, &self.camera_bind_group, &[]); pass.set_vertex_buffer(0, sky.vertex_buffer.slice(..)); @@ -1074,7 +1109,9 @@ impl WorldRenderer { // 3D-скайбокс — поверх 2D-фона, но под миром (без записи глубины, // поэтому стены карты всегда перекрывают его). - if let Some(sky) = &self.sky_mesh { + if self.sky_visible + && let Some(sky) = &self.sky_mesh + { pass.set_pipeline(&sky.pipeline); pass.set_bind_group(0, &self.camera_bind_group, &[]); pass.set_bind_group(2, &sky.params_bind_group, &[]); @@ -1179,6 +1216,7 @@ impl WorldRenderer { let (width, height) = gfx.size(); let aspect = width as f32 / height.max(1) as f32; + self.update_sky_visibility(camera.pos); self.update_camera(&gfx.queue, &camera.view_proj(aspect), camera.pos); self.update_lights(&gfx.queue, camera.pos); self.upload_text(&gfx.queue, width, height); diff --git a/docs/PARITY.md b/docs/PARITY.md index b44d0a7..71930f3 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -1067,3 +1067,61 @@ velocity += wishdir * accelspeed 2000 юнитов вслепую вдоль +x, но на gm_construct там стена на x = 992 (288 юнитов от спавна). Ожидание теста было неверно, а не игрок; тест удалён, его содержательно корректная часть покрыта `real_map_movement`. + +## Часть E6. Небо за границами карты (сделано) + +### E6.1. Жалоба и измерение + +> «если игрок выходит за карту, то скайбокс и 3D-скайбокс не должны рендериться +> (иначе не понятны границы карты)» + +Причина видна прямо в шейдере 3D-скайбокса: + +```wgsl +let world = camera.pos.xyz + (in.pos - sky.origin_scale.xyz) * sky.origin_scale.w; +``` + +Геометрия неба размещается **относительно камеры**, поэтому небо всегда +заливает весь фон: куда ни вылети, оно рядом. Пределы карты на фоне не видно +не из-за того, что неба «слишком много», а из-за того, что оно не привязано к +миру вовсе. + +Замер на gm_construct: + +| | значение | +|---|---| +| Габариты геометрии карты | `(-5640, -4576, -960) .. (2168, 6464, 2976)` | +| Габариты коллизии | `(-15360, -15360, -1024) .. (15872, 15360, 15359)` | +| `sky_camera` | `(-1428, 1645, 10991)`, масштаб 1/16 | +| Границы 3D-скайбокса | `(-14848, -14336, 10654) .. (15360, 14848, 12520)` | +| 2D-скайбокс (`SKYBOX`) | отсутствует, только 3D | +| Спавн | `(704, 132, -143)` | + +Видно, почему габариты коллизии не годятся: они включают и 3D-небо, и далёкие +террасы. Основание — габариты **геометрии карты** (`World::bounds_min/max`), +они же считаются по вершинам видимых поверхностей. + +### E6.2. Решение + +`World::inside_bounds(pos, margin)` и свободная функция `world::inside_bounds` — +одна реализация, которой пользуются и движок, и рендерер (рендерер копирует +габариты при загрузке, а не хранит ссылку на мир). + +`WorldRenderer::update_sky_visibility` пересчитывает видимость **каждый кадр** +из позиции камеры: игрок уходит за край уже во время игры. Проверка идёт по всем +трём осям сразу: под картой небо тоже исчезает, а не только сбоку. + +`SKY_BOUNDS_MARGIN = 128` — допуск наружу. Габариты считаются по вершинам, а +камера стоит на высоте глаз (около 64 юнитов) над опорой, так что стоящий на самом +краю игрок формально за габаритом на весь свой габарит. + +### E6.3. Тесты + +- `sky_is_visible_only_inside_the_map` — юнит на сам предикат, включая ровно на + границе с допуском и на единицу за ней. +- `real_map_sky_bounds` (`--ignored`) — на настоящей карте: спавн внутри, за + край по любой из шести осей небо гаснет. + +Оба прохода неба (2D-фон и 3D-геометрия) гасятся одним и тем же флагом: иначе +при вылете за край остался бы виден 3D-неб, а это ровно то, что мешало увидеть +границу.