Небо гасится за пределами карты: и 2D-скайбокс, и 3D-скайбокс

Жалоба: «если игрок выходит за карту, то скайбокс и 3D-скайбокс не должны
рендериться (иначе не понятны границы карты)».

Причина видна в шейдере 3D-скайбокса:
    world = camera.pos.xyz + (in.pos - sky_camera) * scale
Геометрия неба размещается относительно камеры, поэтому небо всегда заливает
весь фон — пределы карты на нём не видно.

Замер на gm_construct:
  габариты геометрии  (-5640, -4576, -960) .. (2168, 6464, 2976)
  габариты коллизии   (-15360, -15360, -1024) .. (15872, 15360, 15359)
  sky_camera          (-1428, 1645, 10991), масштаб 1/16
  2D-скайбокса нет, только 3D
Габариты коллизии не годятся — включают и 3D-небо, и далёкие террасы,
поэтому основание — габариты геометрии карты.

- world::inside_bounds — одна реализация предиката, её используют и движок,
  и рендерер (рендерер копирует габариты при загрузке).
- WorldRenderer::update_sky_visibility пересчитывает видимость каждый кадр:
  игрок уходит за край во время игры. Проверка по всем трём осям сразу,
  под картой небо тоже исчезает.
- SKY_BOUNDS_MARGIN = 128: габариты считаются по вершинам, а камера стоит
  на высоте глаз над опорой, так что стоящий на краю игрок формально за ними.
- Гасятся оба прохода неба: иначе за краем остался бы виден 3D-неб, а это
  ровно то, что мешало увидеть границу.

Тесты: sky_is_visible_only_inside_the_map (юнит) и real_map_sky_bounds
(замер на настоящей карте).
This commit is contained in:
Niko Marmeladkov 2026-10-14 07:10:47 +03:00
parent 512f3d0394
commit b96b06ccdb
4 changed files with 208 additions and 3 deletions

View file

@ -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,
};

View file

@ -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");

View file

@ -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<usize>,
sky: Option<SkyResources>,
sky_mesh: Option<SkyMeshResources>,
/// Границы проходимой части карты — за ними небо не рисуется.
bounds_min: Vec3,
bounds_max: Vec3,
/// Рисовать ли небо в этом кадре.
sky_visible: bool,
text: TextResources,
/// Источники света карты — нужны каждый кадр для отбора по близости.
lights: Vec<engine::world::Light>,
@ -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<usize>) {
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);

View file

@ -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-неб, а это ровно то, что мешало увидеть
границу.