Compare commits

..

8 Commits

Author SHA1 Message Date
chexit 67a2a523b4 P2/P3 issues: network-fed launchers priced into smart routes (#4), stable blast light ids (#15), retargeted smart drones replanned round the defences (#21), sim tick without per-unit clones and linear scans (#26), ground cache swept rarely (#31), Oreshnik pieces keep their sector (#32), drives with acceleration and fire only once aimed (#33), radar monitor range (#19), delayed sound volume (#22), world change clears transient state (#23), score line always shown (#24), replay map without full paths (#25), text fit by bisection (#27), docs (#29), glow occlusion by ray distance (#14), blasts composited nearest first (#16)
build / mod (push) Successful in 2m26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:28:24 +03:00
chexit a806635681 HD lights culled per tile (fork patch 14; #10)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:25:44 +03:00
chexit 0092ec64a9 HD renderer: cached bind groups, shadow casters only (fork patch 13; #12, #13)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:19:53 +03:00
chexit e08fc65ac8 P1 issues: ground queries keep room for unknown land (#1), a destroyed truck frees its job (#2), trucks follow a system that moved (#3), repair cancels cook-offs (#5), a failed save is retried and autosaves are paced (#6, #7), a combined system reloads when either weapon is spent (#20), munitions go down to the land left under a destroyed target (#34); regression tests from the issues
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:18:33 +03:00
chexit 90379f6e17 Fixes from review: replays found by raid number, no fired-count underflow, one raid planned at a time, repair resets waiting and marches, clear removes cook-offs and flares, beams reach their targets, tracer direction never NaN, the fork's previous branch always kept
build / mod (push) Successful in 2m24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:29:38 +03:00
chexit bb35975642 Fixes from review: one missile per target when systems fire in the same tick, moves and ammunition saved, searchlight spot and lighting once the beam is on, retargeting only for smart raids, no free reloads after the last depot falls, fork rebuilt when its commit or patches change
build / mod (push) Successful in 2m24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:25:14 +03:00
chexit f2838a8f08 HD stage 2: sun and moon shadows, the world relit, light shafts, fog and a colour grade (fork patch 12)
build / mod (push) Successful in 2m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:59:51 +03:00
chexit c42d97af08 HD effects preset: mod lights for flames, flares, blasts, wrecks, searchlights; searchlight beams no longer add shader lights (FPS)
build / mod (push) Successful in 2m26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:18:43 +03:00
33 changed files with 4590 additions and 213 deletions
+13 -4
View File
@@ -23,9 +23,10 @@ python3 tools/airdef.py play # запустить (аргументы игр
(`tools/gen_sounds.py`, нужны numpy и ffmpeg), собирает и ставит мод. Сгенерированные файлы лежат в репозитории,
так что для обычной сборки нужен только Rust.
CI: `.gitea/workflows/build.yml` на каждый пуш собирает мод и игру для Linux, а пуш в `main` публикует релиз
`build-N`; `.gitea/workflows/desktop.yml` (запуск вручную с тегом релиза) собирает игру для macOS и Windows на
раннерах с метками `macos-latest` / `windows-latest` и дописывает архивы в релиз.
CI: `.gitea/workflows/build.yml` на каждый пуш собирает только мод (`airdef.hmod`), а пуш в `main` публикует его
релизом `build-N`. Игру-форк собирают вручную: `.gitea/workflows/game.yml` (Linux) и `.gitea/workflows/desktop.yml`
(macOS и Windows, на раннерах с метками `macos-latest` / `windows-latest`); оба запускаются из Actions с тегом
готового релиза и дописывают свои архивы в него.
## Как играть
@@ -97,6 +98,14 @@ CI: `.gitea/workflows/build.yml` на каждый пуш собирает мо
25 с на цель в темноте), игрок — **осветительным пистолетом** или `/airdef flare`. Ракета висит ~30 с и
освещает всё вокруг на ~60–90 м.
**Графика: три пресета** (меню N → «Огонь, ремонт, эффекты…» → «Эффекты», или `/airdef shader off|on|hd`):
выкл — ванильная картинка; обычные — шейдерные огненные шары, ударная волна и подсветка; **HD** — свой рендер
освещения в форке: мир дополнительно рисуется в G-буфер (цвет без света и нормали), и все источники света мода
освещают его попиксельно в HDR — факелы ракет и двигатели, раскалённые боевые блоки, осветительные ракеты, вспышки
выстрелов, трассеры, взрывы (вспышка и догорающий шар), горящие остовы, пятна прожекторов, — затем bloom и мягкий
тонмаппинг (ванильный диапазон не трогается, сжимается только то, что ярче). Источники передаются движку новым
API мода `render.set-lights` (до 512, плавно интерполируются), режим — `render.set-lighting`.
**Сетка J** после сбития ещё 3,5 с держит камеру на сбитой цели, и комплекс в это время не пропадает из сетки.
**Повреждения.** Взрыв рядом с комплексом повреждает его: он хуже поворачивается, хуже видит и попадает,
@@ -122,7 +131,7 @@ CI: `.gitea/workflows/build.yml` на каждый пуш собирает мо
перехватчиков (жёлтые), сбития (x), прилёты, шкала времени с событиями; время идёт само, колесо мыши перематывает;
- **повтор момента** — клик по событию в списке: «призраки» дронов и ракет снова летят в мире, камера идёт за
целью, потом за перехватчиком (или смотрит из пушки, со стороны на прилёт), в момент сбития время замедляется;
- **полный повтор** — весь налёт, камера-режиссёр сама выбирает, за чем следить; колесо — скорость (×0.5–×16);
- **полный повтор** — весь налёт, камера-режиссёр сама выбирает, за чем следить; колесо — перемотка, стрелки — скорость;
- **лучшие моменты** — нарезка до 8 самых интересных событий (перехват баллистики и гиперзвука, поражения
объектов, потерянные комплексы, разделение «Орешника») с титрами и замедлением; колесо листает моменты.
+7 -3
View File
@@ -115,7 +115,8 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
// the pixel sees, much stronger at night.
let night = clamp(0.35 - frame.time.z * 1.8, 0.0, 1.0);
let strength = mix(0.35, 2.2, night);
if wall < 1.0e5 {
// Under the HD lighting (params[0].w) the engine lights the world from the same lights.
if wall < 1.0e5 && frame.params[0].w < 0.5 {
let p = dir * wall;
var light = vec3<f32>(0.0);
for (var i = 0; i < blasts; i++) {
@@ -196,7 +197,10 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
if age < 0.0 || age > 10.0 { continue; }
let p = project(a.xyz);
if p.z <= 0.5 { continue; }
let visible = select(0.25, 1.0, scene_distance(p.xy) > p.z - a.w);
// Ray distance on both sides: `p.z` is the depth along the view axis, which is shorter
// than the distance along the ray towards the screen edge.
let dist = length(a.xyz - frame.camera.xyz);
let visible = select(0.25, 1.0, scene_distance(p.xy) > dist - a.w);
let f = exp(-age * 0.7) * min(a.w / 4.0, 2.5) * visible;
let r = screen_radius(a.xyz, a.w * 2.5) + 20.0;
let dd = length(px - p.xy * frame.resolution.xy) / r;
@@ -210,7 +214,7 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
let b = frame.params[LIGHT_BASE + 2 * j + 1];
let p = project(a.xyz);
if p.z <= 0.3 { continue; }
if scene_distance(p.xy) < p.z - a.w * 1.5 { continue; }
if scene_distance(p.xy) < length(a.xyz - frame.camera.xyz) - a.w * 1.5 { continue; }
let r = screen_radius(a.xyz, a.w) + 3.0;
let dd = length(px - p.xy * frame.resolution.xy) / r;
let flicker = 0.85 + 0.15 * noise3(vec3<f32>(a.xyz * 0.7 + vec3<f32>(t_now * 1.3)));
+185 -35
View File
@@ -7,7 +7,7 @@ use horizon_mod_sdk::{
Mod, Registration, chat, command, game,
hud::{self, Corner},
input::{self, Event},
render::{self, ModelInstance, ParticleSpawn},
render::{self, Lighting, ModelInstance, ParticleSpawn, PointLight},
storage,
ui::{self, ButtonWidget, ItemWidget, ListWidget, Screen, Widget},
};
@@ -56,6 +56,9 @@ struct AirDef {
sim: Sim,
ground: Ground,
dirty: bool,
/// Wall-clock-free pacing of autosaves: the tick before which none is written (at most one a
/// second while guns fire, and a pause after a failed write).
next_save: u64,
hud_shown: bool,
/// The system the monitor shows (K cycles), none: hidden.
monitor: Option<usize>,
@@ -72,6 +75,8 @@ struct AirDef {
far: bool,
/// The unit the move remote picked (its id), waiting for where to go.
moving: Option<u32>,
/// The HD lighting preset (the engine's lights, bloom, tone mapping).
hd: bool,
/// The world whose state is loaded (`game.world-id`), once one is.
world: Option<Option<String>>,
/// The systems' and objects' health as last saved, to save again when it changes.
@@ -80,8 +85,17 @@ struct AirDef {
menu: Option<Page>,
/// Where the player is, for the sounds' delay and far variants.
listener: DVec3,
/// Sounds on their way to the player at the speed of sound: (tick, event, at, pitch).
pending: Vec<(u64, String, DVec3, f32)>,
/// Sounds on their way to the player at the speed of sound.
pending: Vec<PendingSound>,
}
/// A sound heard later: when, what, where, and how loud and high it was.
struct PendingSound {
due: u64,
name: String,
at: DVec3,
volume: f32,
pitch: f32,
}
/// Blocks a tick the sound travels (343 m/s).
@@ -276,12 +290,89 @@ impl AirDef {
self.emit(out);
}
/// The HD lighting's lights: flames, glowing warheads, flares, muzzle flashes, tracers,
/// blasts (a flash, then the fireball's glow dying down), burning wrecks and the
/// searchlights' spots.
fn lights(&self, out: &Out) -> Vec<PointLight> {
let mut lights: Vec<PointLight> = Vec::new();
let mut add = |id: u32, at: DVec3, rgb: (f32, f32, f32), intensity: f32, radius: f32| {
if lights.len() < 500 && intensity > 0.05 {
lights.push(PointLight { id, x: at.x, y: at.y, z: at.z, color: rgb, intensity, radius: radius.clamp(1.0, 256.0) });
}
};
let tick = self.sim.tick;
let flicker = |id: u32| 0.85 + 0.15 * (((tick as u32).wrapping_mul(2654435761) ^ id.wrapping_mul(97)) % 100) as f32 / 100.0;
for m in out.models.iter().filter(|m| m.glow) {
let s = m.scale.max(0.1);
match m.name {
"airdef:flame" => add(m.id, m.at, (1.0, 0.55, 0.2), 45.0 * s * flicker(m.id), 32.0 * s.sqrt()),
"airdef:geran3_flame" => add(m.id, m.at, (1.0, 0.6, 0.25), 25.0 * s * flicker(m.id), 22.0),
"airdef:reentry" => add(m.id, m.at, (1.0, 0.6, 0.32), 60.0 * s, 40.0),
"airdef:flare" => add(m.id, m.at, (1.0, 0.93, 0.78), 420.0 * s, 110.0),
"airdef:flash" => add(m.id, m.at, (1.0, 0.8, 0.45), 30.0, 14.0),
"airdef:tracer" => add(m.id, m.at, (1.0, 0.25, 0.1), 2.5, 6.0),
_ => {}
}
}
for b in &self.sim.blasts {
let age = tick.saturating_sub(b.start) as f32;
let size = b.size;
let glow = (-age / (b.life as f32 * 0.15).max(1.0)).exp();
let flash = (-age * 0.35).exp() * 4.0;
// Its own id range: the models' and the systems' ids are small numbers.
let id = 0x6100_0000 | (b.id & 0x00ff_ffff);
add(id, b.at + DVec3::Y * size as f64 * 0.5, (1.0, 0.58, 0.25), size * size * 18.0 * (glow + flash), size * 14.0);
}
for s in &self.sim.sams {
if !s.alive() && s.burning() {
add(s.id + 15, s.pos + DVec3::Y * 1.5, (1.0, 0.5, 0.18), 80.0 * flicker(s.id), 30.0);
}
// A searchlight's spot where its beam meets the target.
if s.kind == SamKind::Light
&& let Some(t) = s.target.and_then(|t| self.sim.drones.iter().find(|d| d.id == t))
&& self.sim.lit.contains(&t.id)
{
add(s.id + 14, t.pos, (0.95, 0.97, 1.0), 220.0, 30.0);
}
}
lights
}
/// The effects preset: none, the post pass, or the post pass under the HD lighting.
fn set_effects(&mut self, shader: bool, hd: bool) {
self.sim.shader = shader;
if shader {
render::set_post(include_str!("../shaders/effects.wgsl"));
} else {
render::clear_post();
}
self.hd = shader && hd;
self.apply_hd();
self.dirty = true;
}
/// Switches the HD lighting.
fn apply_hd(&self) {
render::set_lighting(Lighting { hd: self.hd, exposure: 1.0, bloom: 0.55 });
if !self.hd {
render::set_lights(&[]);
}
}
/// Hands the simulation's output to the game.
/// The post pass's parameters: blasts and glowing parts (shaders/effects.wgsl).
fn shader_params(&self, out: &Out) -> Vec<f32> {
let mut p = vec![0.0f32; 4];
p[0] = self.sim.tick as f32;
let blasts: Vec<&sim::Blast> = self.sim.blasts.iter().rev().take(48).collect();
p[3] = if self.hd { 1.0 } else { 0.0 };
let mut blasts: Vec<&sim::Blast> = self.sim.blasts.iter().rev().take(48).collect();
// The shader composites the volumes front to back with one shared transmittance, so
// the nearest to the player goes first. This is exact for volumes that do not
// overlap; where two intersect, no order by centre is right along every ray.
if let Some(pl) = player() {
let e = eye(&pl);
blasts.sort_by(|a, b| a.at.distance_squared(e).total_cmp(&b.at.distance_squared(e)));
}
p[1] = blasts.len() as f32;
for b in &blasts {
p.extend([b.at.x as f32, b.at.y as f32, b.at.z as f32, b.size]);
@@ -300,7 +391,6 @@ impl AirDef {
"airdef:tracer" => (1.0, 0.12, 0.05, 0.9),
"airdef:reentry" => (1.0, 0.6, 0.3, 1.4),
"airdef:flare" => (1.0, 0.94, 0.78, 1.2),
"airdef:beam" => (1.0, 0.97, 0.88, 0.15),
_ => continue,
};
// The shader lights about six times the radius round a light and draws a glow of
@@ -318,6 +408,9 @@ impl AirDef {
if self.sim.shader {
render::set_params(&self.shader_params(&out));
}
if self.hd {
render::set_lights(&self.lights(&out));
}
let models: Vec<ModelInstance> = out
.models
.into_iter()
@@ -368,7 +461,7 @@ impl AirDef {
if delay == 0 {
render::play_sound(&name, at.x, at.y, at.z, volume, pitch);
} else if self.pending.len() < 256 {
self.pending.push((now + delay, name, at, pitch));
self.pending.push(PendingSound { due: now + delay, name, at, volume, pitch });
}
} else {
render::play_sound(event, at.x, at.y, at.z, volume, pitch);
@@ -376,9 +469,9 @@ impl AirDef {
}
let mut i = 0;
while i < self.pending.len() {
if self.pending[i].0 <= now {
let (_, name, at, pitch) = self.pending.swap_remove(i);
render::play_sound(&name, at.x, at.y, at.z, 1.0, pitch);
if self.pending[i].due <= now {
let s = self.pending.swap_remove(i);
render::play_sound(&s.name, s.at.x, s.at.y, s.at.z, s.volume, s.pitch);
} else {
i += 1;
}
@@ -415,7 +508,24 @@ impl AirDef {
fn save(&mut self) {
let mut text = String::new();
for s in &self.sim.sams {
text += &format!("sam {} {} {} {} {} {} {} {}\n", s.kind.key(), s.pos.x, s.pos.y, s.pos.z, s.heading, s.roles, s.health, s.cargo);
// Then its ammunition (missiles, bursts, out and waiting for a truck, reload ticks
// left) and where it was driving, if anywhere.
let dest = s.dest.map_or("- - -".to_string(), |d| format!("{} {} {}", d.x, d.y, d.z));
text += &format!(
"sam {} {} {} {} {} {} {} {} {} {} {} {} {dest}\n",
s.kind.key(),
s.pos.x,
s.pos.y,
s.pos.z,
s.heading,
s.roles,
s.health,
s.cargo,
s.missiles,
s.bursts,
u8::from(s.empty),
s.reloading,
);
}
for (i, t) in self.sim.targets.iter().enumerate() {
let h = self.sim.target_health.get(i).copied().unwrap_or(100.0);
@@ -423,13 +533,21 @@ impl AirDef {
}
text += &format!("damage {}\n", self.sim.damage);
text += &format!("far {}\n", self.far);
text += &format!("effects {} {}\n", self.sim.shader, self.hd);
for (kind, roles) in &self.sim.roles {
text += &format!("roles {} {}\n", kind.key(), roles);
}
if let Err(e) = storage::set(&self.state_key(), text.as_bytes()) {
horizon_mod_sdk::warn(&format!("could not save: {e}"));
// Clean only once written: a failed write is tried again after a pause.
match storage::set(&self.state_key(), text.as_bytes()) {
Ok(()) => {
self.dirty = false;
self.next_save = self.sim.tick + 20;
}
Err(e) => {
horizon_mod_sdk::warn(&format!("could not save: {e}"));
self.next_save = self.sim.tick + 200;
}
}
self.dirty = false;
}
fn load(&mut self) {
@@ -458,7 +576,22 @@ impl AirDef {
if let Some(c) = num(8) {
sam.cargo = c as u32;
}
// Older saves have no ammunition: full, as before.
if let (Some(m), Some(b)) = (num(9), num(10)) {
sam.missiles = (m as u32).min(sam.missiles);
sam.bursts = (b as u32).min(sam.bursts);
sam.empty = num(11) == Some(1.0);
sam.reloading = num(12).map_or(0, |r| r as u32);
}
let dest = match (num(13), num(14), num(15)) {
(Some(x), Some(y), Some(z)) => Some(DVec3::new(x, y, z)),
_ => None,
};
self.sim.sams.push(sam);
if let Some(to) = dest {
let i = self.sim.sams.len() - 1;
self.sim.order_move(i, to);
}
}
}
Some(&"target") => {
@@ -469,6 +602,11 @@ impl AirDef {
}
Some(&"damage") => self.sim.damage = w.get(1) != Some(&"false"),
Some(&"far") => self.far = w.get(1) != Some(&"false"),
Some(&"effects") => {
let shader = w.get(1) != Some(&"false");
let hd = w.get(2) == Some(&"true");
self.set_effects(shader, hd);
}
Some(&"roles") => {
if let (Some(kind), Some(r)) = (w.get(1).and_then(|k| SamKind::from_key(k)), w.get(2).and_then(|v| v.parse().ok())) {
self.sim.roles.insert(kind, r);
@@ -524,11 +662,18 @@ impl AirDef {
let shader = self.sim.shader;
let seed = game::get_clock().map_or(0x5eed, |c| c.game_time.wrapping_mul(2654435761) ^ 0x9e37);
self.sim = Sim::new(seed);
self.next_save = 0;
self.sim.shader = shader;
self.ground = Ground::default();
self.monitor = None;
self.grid = false;
self.grid_ids.clear();
// The last world's transient state: its sounds were due on its own
// clock, its picked unit may have another meaning in this one, and
// its models would stay until the next full model list.
self.pending.clear();
self.moving = None;
render::set_models(&[]);
self.far = true;
self.world = Some(world);
self.load();
@@ -563,7 +708,7 @@ impl AirDef {
}
_ => replay::Mode::Map,
};
self.replay = Some(replay::Player::new(n - 1, mode, &self.sim));
self.replay = Some(replay::Player::new(rec.number, mode, &self.sim));
self.grid = false;
input::claim_wheel(true);
Ok(())
@@ -699,7 +844,8 @@ impl AirDef {
}));
let broken = self.sim.sams.iter().filter(|s| s.health < 1.0).count();
widgets.push(Widget::Button(ButtonWidget { id: BTN_REPAIR, label: format!("Ремонт всех (повреждено: {broken})") }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_SHADER, label: format!("Эффекты: {}", onoff(self.sim.shader)) }));
let fx = if !self.sim.shader { "выкл" } else if self.hd { "HD (свет, bloom)" } else { "обычные" };
widgets.push(Widget::Button(ButtonWidget { id: BTN_SHADER, label: format!("Эффекты: {fx}") }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_DAMAGE, label: format!("Разрушения: {}", onoff(self.sim.damage)) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_FAR, label: format!("Дальний ландшафт: {}", onoff(self.far)) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Назад".into() }));
@@ -764,7 +910,7 @@ impl AirDef {
}
}
const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|stop] | raid | repair | import | ew on|off | flare | move <номер> | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | shader on|off | hold on|off | roles [комплекс классы] | stats";
const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|stop] | raid | repair | import | ew on|off | flare | move <номер> | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats";
impl Mod for AirDef {
fn init() -> Result<(Self, Registration), String> {
@@ -773,6 +919,7 @@ impl Mod for AirDef {
sim: Sim::new(seed),
ground: Ground::default(),
dirty: false,
next_save: 0,
hud_shown: false,
monitor: None,
monitor_shown: false,
@@ -782,6 +929,7 @@ impl Mod for AirDef {
replay: None,
far: true,
moving: None,
hd: false,
world: None,
health_mark: 0,
menu: None,
@@ -834,7 +982,7 @@ impl Mod for AirDef {
_ => replay::Control::Pause,
};
if let Some(r) = &mut self.replay
&& let Some(rec) = self.sim.replays.get(r.rec)
&& let Some(rec) = self.sim.replay(r.rec)
{
r.control(c, rec);
}
@@ -851,7 +999,7 @@ impl Mod for AirDef {
}
Event::Wheel(w) => {
if let Some(r) = &mut self.replay
&& let Some(rec) = self.sim.replays.get(r.rec)
&& let Some(rec) = self.sim.replay(r.rec)
{
r.wheel(w, rec);
}
@@ -913,19 +1061,25 @@ impl Mod for AirDef {
}
}
}
// Damage is saved too: a destroyed system stays destroyed.
// Damage is saved too: a destroyed system stays destroyed. So are where the units stand
// (or drive to: not every step of the way), their ammunition and the depots' stock.
let mark = self
.sim
.sams
.iter()
.map(|s| (s.health * 1000.0) as u64)
.map(|s| {
let at = s.dest.unwrap_or(s.pos);
let place = (at.x * 10.0) as i64 as u64 ^ ((at.z * 10.0) as i64 as u64).rotate_left(21) ^ (if s.dest.is_some() { 0 } else { (s.heading * 100.0) as i64 as u64 }).rotate_left(42);
let ammo = u64::from(s.missiles) | u64::from(s.bursts) << 16 | u64::from(s.cargo) << 32 | u64::from(s.empty) << 48 | u64::from(s.reloading > 0) << 49;
((s.health * 1000.0) as u64).wrapping_mul(1_000_003) ^ place ^ ammo.rotate_left(7)
})
.chain(self.sim.target_health.iter().map(|h| (h * 10.0) as u64))
.fold(self.sim.sams.len() as u64, |a, h| a.wrapping_mul(31).wrapping_add(h));
if mark != self.health_mark {
self.health_mark = mark;
self.dirty = true;
}
if self.dirty {
if self.dirty && self.sim.tick >= self.next_save {
self.save();
}
}
@@ -1032,12 +1186,13 @@ impl Mod for AirDef {
return;
}
(Page::More, BTN_SHADER) => {
self.sim.shader = !self.sim.shader;
if self.sim.shader {
render::set_post(include_str!("../shaders/effects.wgsl"));
} else {
render::clear_post();
}
// Off → effects → HD → off.
let (shader, hd) = match (self.sim.shader, self.hd) {
(false, _) => (true, false),
(true, false) => (true, true),
(true, true) => (false, false),
};
self.set_effects(shader, hd);
}
(Page::More, BTN_FAR) => {
self.far = !self.far;
@@ -1224,14 +1379,9 @@ impl Mod for AirDef {
self.sim.hold = *v == "on";
Some(if self.sim.hold { "ПВО: огонь запрещён".into() } else { "ПВО: огонь разрешён".into() })
}
["shader", v @ ("on" | "off")] => {
self.sim.shader = *v == "on";
if self.sim.shader {
render::set_post(include_str!("../shaders/effects.wgsl"));
} else {
render::clear_post();
}
Some(format!("Шейдерные эффекты: {}", if self.sim.shader { "вкл" } else { "выкл" }))
["shader", v @ ("on" | "off" | "hd")] => {
self.set_effects(*v != "off", *v == "hd");
Some(format!("Эффекты: {}", match *v { "off" => "выкл", "hd" => "HD", _ => "обычные" }))
}
["stats"] => Some(format!(
"В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}",
+24 -7
View File
@@ -25,11 +25,22 @@ fn fit(t: String, w: f32) -> String {
if render::text_width(&t) <= w {
return t;
}
let mut cut: String = t;
while !cut.is_empty() && render::text_width(&format!("{cut}…")) > w {
cut.pop();
// The width grows with the prefix: bisect over its char starts, so a long
// label costs a few host calls rather than one per dropped char.
let starts: Vec<usize> = t.char_indices().map(|(i, _)| i).collect();
let (mut lo, mut hi) = (0, starts.len());
while lo < hi {
let mid = (lo + hi) / 2;
if render::text_width(&format!("{}…", &t[..starts[mid]])) <= w {
lo = mid + 1;
} else {
hi = mid;
}
}
format!("{cut}…")
if lo == 0 {
return "…".into();
}
format!("{}…", &t[..starts[lo - 1]])
}
fn argb(rgb: u32, a: u32) -> u32 {
@@ -101,7 +112,13 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
s.push(quad(p(a0, r - 0.4), p(a0, r + 0.4), p(a1, r + 0.4), p(a1, r - 0.4), 0x9040FF60));
}
}
let reach = sam.kind.range();
// A system that only fires (no radar) has no reach: a radar's sensor
// range stands in, and nothing is divided by zero.
let reach = match sam.kind.range() {
r if r > 0.0 => r,
_ => spec.sensor,
};
let scale = if reach > 0.0 { rr as f64 / (reach * 1.25) } else { 0.0 };
let sweep = (tick as f32 * 0.12 + index as f32) % std::f32::consts::TAU;
for k in 0..6 {
let a1 = sweep - k as f32 * 0.09;
@@ -115,11 +132,11 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
for d in sim.drones.iter().filter(|d| d.alive() && sam.seen.contains(&d.id)) {
let rel = d.pos - sam.pos;
let flat = (rel.x * rel.x + rel.z * rel.z).sqrt();
if flat > reach * 1.25 {
if reach <= 0.0 || flat > reach * 1.25 {
continue;
}
seen += 1;
let k = (rr as f64 / (reach * 1.25)) as f32;
let k = scale as f32;
let (bx, by) = (rx + rel.x as f32 * k, ry + rel.z as f32 * k);
let allowed = sam.roles & d.kind.spec().class.bit() != 0;
let color = if Some(d.id) == target.map(|t| t.id) {
+15 -4
View File
@@ -10,6 +10,8 @@ const CYAN: u32 = 0x9FE0FF;
const GREY: u32 = 0xB0B0B0;
const YELLOW: u32 = 0xFFE070;
const GREEN: u32 = 0x80FF90;
/// The most lines the summary holds (the HUD's room).
const MAX_LINES: usize = 16;
/// Metres (a block is a metre) as a short distance.
pub fn distance(m: f64) -> String {
@@ -77,7 +79,15 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec<Line> {
if !sim.missiles.is_empty() {
lines.push(line(format!("Перехватчиков в полёте: {}", sim.missiles.len()), YELLOW));
}
for sam in sim.sams.iter().take(8) {
// The score is the last line and always shown: the unit list gets what
// room is left, and cuts its own tail.
let room = (MAX_LINES - 1).saturating_sub(lines.len());
let total = sim.sams.len();
let mut shown = total.min(8);
if shown + usize::from(total > 8) > room {
shown = room.saturating_sub(1);
}
for sam in sim.sams.iter().take(shown) {
let mut text = if sam.kind.armed() {
format!("{} [{}]", sam.status(), crate::sim::roles_text(sam.roles))
} else {
@@ -91,15 +101,16 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec<Line> {
}
lines.push(line(text, CYAN));
}
if sim.sams.len() > 8 {
lines.push(line(format!("…и ещё {} комплексов", sim.sams.len() - 8), CYAN));
if total > shown && room > 0 {
lines.push(line(format!("…и ещё {} комплексов", total - shown), CYAN));
}
// Room for the score whatever came before.
lines.truncate(MAX_LINES - 1);
let real = sim.launched.saturating_sub(0);
let rate = if real > 0 { sim.kills as f64 * 100.0 / real as f64 } else { 0.0 };
lines.push(line(
format!("Сбито {} из {} ({rate:.0}%) · прорвалось {}", sim.kills, sim.launched, sim.hits),
GREY,
));
lines.truncate(16);
lines
}
+68 -40
View File
@@ -5,7 +5,8 @@
//! behind the drone, then behind the interceptor or from the side — slowing
//! time round the moment itself.
use crate::panel::distance;
use crate::sim::record::{EventKind, Recording, What};
use crate::sim::record::{EventKind, Recording, STEP, What};
use std::cell::Cell;
use crate::sim::{Class, DroneKind, Out, Sim};
use glam::DVec3;
use horizon_mod_sdk::render::{CameraView, FillShape, Point, QuadShape, Rect, Shape, TextShape, ViewShape};
@@ -31,8 +32,8 @@ pub struct Clip {
}
pub struct Player {
/// Which of the sim's recordings.
pub rec: usize,
/// Which raid's recording (its number: the list drops the oldest as new ones come).
pub rec: u32,
pub mode: Mode,
t: f64,
speed: f64,
@@ -55,6 +56,8 @@ pub struct Player {
/// Ticks the controls stay on screen.
help: u32,
pub done: bool,
/// The map's area, worked out on the first map frame.
area: Cell<Option<[f64; 4]>>,
}
/// What the replay's keys do.
@@ -188,7 +191,7 @@ pub fn reel(rec: &Recording) -> Vec<Clip> {
}
impl Player {
pub fn new(rec: usize, mode: Mode, sim: &Sim) -> Self {
pub fn new(rec: u32, mode: Mode, sim: &Sim) -> Self {
let start = match &mode {
Mode::Clips { list, .. } => list.first().map_or(0.0, |c| c.from),
_ => 0.0,
@@ -201,6 +204,7 @@ impl Player {
};
let _ = sim;
Player {
area: Cell::new(None),
rec,
mode,
t: start,
@@ -291,7 +295,7 @@ impl Player {
/// One tick: time runs, the ghosts fly, the director films. Models and sounds go to `out`.
pub fn tick(&mut self, sim: &Sim, listener: DVec3, out: &mut Out) {
let Some(rec) = sim.replays.get(self.rec) else {
let Some(rec) = sim.replay(self.rec) else {
self.done = true;
return;
};
@@ -491,7 +495,7 @@ impl Player {
/// The replay's screen: the film with its titles, or the map.
pub fn hud(&self, sim: &Sim, (gw, gh): (f32, f32)) -> Vec<Shape> {
let Some(rec) = sim.replays.get(self.rec) else { return Vec::new() };
let Some(rec) = sim.replay(self.rec) else { return Vec::new() };
if matches!(self.mode, Mode::Map) {
return self.map(rec, gw, gh);
}
@@ -581,34 +585,16 @@ impl Player {
fn map(&self, rec: &Recording, gw: f32, gh: f32) -> Vec<Shape> {
let mut s = vec![fill(0.0, 0.0, gw, gh, 0xF0060A08)];
let o = rec.origin;
// The area: everything that flew, but not far beyond the systems.
let mut lo = (f64::MAX, f64::MAX);
let mut hi = (f64::MIN, f64::MIN);
let mut grow = |p: DVec3| {
lo = (lo.0.min(p.x), lo.1.min(p.z));
hi = (hi.0.max(p.x), hi.1.max(p.z));
// The area is the same every tick: worked out once for the recording.
let [l0, l1, h0, h1] = match self.area.get() {
Some(a) => a,
None => {
let a = area(rec, o);
self.area.set(Some(a));
a
}
};
for tr in &rec.tracks {
if let What::Drone(k) = tr.what
&& k.spec().ballistic.is_some()
{
continue;
}
for p in tr.path(o, 20, rec.len as f64) {
grow(p);
}
}
for sam in &rec.sams {
grow(sam.2 - DVec3::splat(60.0));
grow(sam.2 + DVec3::splat(60.0));
}
for p in &rec.objects {
grow(*p);
}
if lo.0 > hi.0 {
lo = (o.x - 200.0, o.z - 200.0);
hi = (o.x + 200.0, o.z + 200.0);
}
let (lo, hi) = ((l0, l1), (h0, h1));
let (top, bottom) = (20.0f32, 34.0f32);
let (aw, ah) = (gw - 16.0, gh - top - bottom);
let k = (aw as f64 / (hi.0 - lo.0).max(1.0)).min(ah as f64 / (hi.1 - lo.1).max(1.0));
@@ -638,7 +624,9 @@ impl Player {
let (x, y) = at(*p);
s.push(fill(x - 3.0, y - 3.0, 6.0, 6.0, 0xFFFFFFFF));
}
// Tracks up to now: a budget of segments shared out.
// Tracks up to now: a budget of segments shared out. The stride is
// worked out from the points there are, so only the segments drawn
// are ever copied.
let n = rec.tracks.len().max(1);
let per = (640 / n).clamp(2, 14);
for tr in &rec.tracks {
@@ -647,16 +635,21 @@ impl Player {
What::Drone(_) => (0x90FF4040, 0xFFFF6060),
What::Missile(..) => (0x90FFD040, 0xFFFFE070),
};
let pts = tr.path(o, 1, self.t);
let upto = ((self.t - tr.first as f64) / STEP as f64).floor();
if upto < 0.0 {
continue;
}
let last = ((tr.last() - tr.first) / STEP) as f64;
let count = upto.min(last) as usize + 1;
if count < 2 {
continue;
}
let pts = tr.path(o, count.div_ceil(per).max(1), self.t);
if pts.len() < 2 {
continue;
}
let every = pts.len().div_ceil(per).max(1);
let mut prev = at(pts[0]);
for (i, p) in pts.iter().enumerate().skip(1) {
if i % every != 0 && i != pts.len() - 1 {
continue;
}
for p in pts.iter().skip(1) {
let q = at(*p);
if inside(prev) && inside(q) {
s.push(line(prev, q, 1.0, color));
@@ -718,6 +711,41 @@ impl Player {
}
}
/// The ground the map shows, as [lo x, lo z, hi x, hi z]: everything that
/// flew (not the ballistic falls), with the systems and objects, or round the
/// origin when nothing did.
fn area(rec: &Recording, o: DVec3) -> [f64; 4] {
let mut lo = (f64::MAX, f64::MAX);
let mut hi = (f64::MIN, f64::MIN);
let mut grow = |p: DVec3| {
lo = (lo.0.min(p.x), lo.1.min(p.z));
hi = (hi.0.max(p.x), hi.1.max(p.z));
};
for tr in &rec.tracks {
if let What::Drone(k) = tr.what
&& k.spec().ballistic.is_some()
{
continue;
}
for p in tr.path(o, 20, rec.len as f64) {
grow(p);
}
}
// The systems, with some room round them.
for sam in &rec.sams {
grow(sam.2 - DVec3::splat(60.0));
grow(sam.2 + DVec3::splat(60.0));
}
for p in &rec.objects {
grow(*p);
}
if lo.0 > hi.0 {
lo = (o.x - 200.0, o.z - 200.0);
hi = (o.x + 200.0, o.z + 200.0);
}
[lo.0, lo.1, hi.0, hi.1]
}
impl Player {
pub fn event_clip(rec: &Recording, i: usize) -> Mode {
Mode::Clips { list: vec![clip(rec, i)], at: 0, reel: false }
+6
View File
@@ -179,12 +179,18 @@ impl Sim {
sam.burn = 0;
sam.state = SamState::Search;
sam.reloading = 0;
sam.empty = false;
// Rebuilt where it stands: a march under way is called off.
sam.dest = None;
sam.march = 0;
sam.missiles = spec.missile.as_ref().map_or(0, |m| m.magazine);
sam.bursts = spec.gun.as_ref().map_or(0, |g| g.bursts);
}
for h in &mut self.target_health {
*h = 100.0;
}
// The wrecks' ammunition still to cook off goes with them.
self.later.clear();
n
}
+67 -3
View File
@@ -145,7 +145,7 @@ impl Sim {
out
}
/// During a raid: drones not yet launched go for what just gave itself away (a jamming station
/// During a smart raid: its drones not yet launched go for what just gave itself away (a jamming station
/// first, then systems that fired), and drones whose system target is gone pick another.
pub(super) fn adapt(&mut self) {
if self.tick % 40 != 0 {
@@ -159,8 +159,10 @@ impl Sim {
return;
}
let pick = |i: usize, list: &[(u32, DVec3)]| list[i % list.len()].1;
// The drones sent at a new target: (waiting in the queue, index), to get a new route.
let mut retargeted: Vec<(bool, usize)> = Vec::new();
let mut n = 0usize;
for (_, d) in self.queue.iter_mut() {
for (qi, (_, d)) in self.queue.iter_mut().enumerate().filter(|(_, (_, d))| d.smart) {
let hunter = matches!(d.kind, DroneKind::Lancet | DroneKind::Kh22 | DroneKind::Zircon);
if !hunter && !(d.kind == DroneKind::Shahed && n % 4 == 0) {
n += 1;
@@ -173,18 +175,80 @@ impl Sim {
let to = if !ew.is_empty() { pick(n, &ew) } else { pick(n, &known) };
d.target = to;
d.route.clear();
retargeted.push((true, qi));
n += 1;
}
// In flight: a target system destroyed — the next known one, or keep going.
for d in self.drones.iter_mut().filter(|d| d.alive()) {
for (di, d) in self.drones.iter_mut().enumerate().filter(|(_, d)| d.alive() && d.smart) {
let aimed_at_wreck = self.sams.iter().any(|s| !s.alive() && s.pos.distance(d.target) < 6.0);
if aimed_at_wreck {
let next = known.iter().min_by(|a, b| a.1.distance(d.pos).total_cmp(&b.1.distance(d.pos)));
if let Some((_, at)) = next {
d.target = *at;
d.route.clear();
retargeted.push((false, di));
}
}
}
self.replan_routes(&retargeted);
}
/// Routes round the known defences for drones that were just sent at a new target, from where they are
/// (or wait). One grid and one set of prices cover them all, and the work is the planner's share for a
/// tick: those left over fly straight, as they did before there was a replan. A Lancet's strike on a
/// unit and the ballistic missiles go straight on purpose, as in the raid's own plan.
fn replan_routes(&mut self, jobs: &[(bool, usize)]) {
let drone = |s: &Sim, (queued, i): (bool, usize)| if queued { s.queue[i].1.clone() } else { s.drones[i].clone() };
let jobs: Vec<(bool, usize)> = jobs
.iter()
.copied()
.filter(|&j| {
let d = drone(self, j);
let spec = d.kind.spec();
d.smart && spec.ballistic.is_none() && !spec.decoy && d.kind != DroneKind::Lancet
})
.collect();
if jobs.is_empty() {
return;
}
// One grid over all their starts and goals, with room round them to go by.
let (mut lo, mut hi) = (DVec3::splat(f64::INFINITY), DVec3::splat(f64::NEG_INFINITY));
for &j in &jobs {
let d = drone(self, j);
for p in [d.pos, d.target] {
lo = lo.min(p);
hi = hi.max(p);
}
}
let span = (hi.x - lo.x).max(hi.z - lo.z) / 2.0 + 450.0;
let cell = raid::CELL.max(2.0 * span / raid::CELLS as f64);
let centre = (lo + hi) / 2.0;
let grid = raid::Grid { min: centre - DVec3::new(span, 0.0, span), cell, size: (2.0 * span / cell) as i32 + 1 };
let mut prices: HashMap<DroneKind, Vec<f32>> = HashMap::new();
let mut routes: HashMap<(DroneKind, (i32, i32), (i32, i32)), Vec<DVec3>> = HashMap::new();
let mut work = 0usize;
for j in jobs {
let d = drone(self, j);
if work >= raid::WORK_PER_TICK {
break;
}
let key = (d.kind, grid.index(d.pos), grid.index(d.target));
let route = match routes.get(&key) {
Some(r) => r.clone(),
None => {
if !prices.contains_key(&d.kind) {
let (p, cost) = self.price_grid(&grid, d.kind);
work += cost;
prices.insert(d.kind, p);
}
let (r, cost) = raid::plan_route(&grid, &prices[&d.kind], d.pos, d.target);
work += cost;
routes.insert(key, r.clone());
r
}
};
let d = if j.0 { &mut self.queue[j.1].1 } else { &mut self.drones[j.1] };
d.route = route;
}
}
}
+10 -5
View File
@@ -103,18 +103,23 @@ impl Sim {
sam.yaw = slew(sam.yaw, want_yaw, 0.12);
sam.pitch = slew(sam.pitch, want_pitch.clamp(0.05, 1.5), 0.08);
sam.target = target.map(|t| t.0);
if let Some((t, _)) = target {
self.lit.push(t);
}
// The beam: segments widening with distance, out to the target.
let (head, rot) = self.sams[i].mount();
let dir = rot * DVec3::NEG_Z;
// The target is lit once the beam is on it, not while it still swings round.
if let Some((t, at)) = target
&& dir.angle_between(at - head) < 0.08
{
self.lit.push(t);
}
let mut along = 1.0;
for k in 0..10u32 {
// Up to 14 segments (the unit's part ids 2..15), each longer than the last: one
// reaches about 550 blocks, past the beam's reach, about 9 blocks wide out there.
for k in 0..14u32 {
if along >= reach {
break;
}
let size = 0.8 + along * 0.025;
let size = 0.8 + along * 0.12;
let len = 3.0 * size;
out.model(id + 2 + k, "airdef:beam", head + dir * (along + len / 2.0), rot, size, true);
along += len;
+14 -4
View File
@@ -12,9 +12,11 @@ const HANDOVER: f64 = 10.0;
const TRUCK_LOAD: u32 = 2;
impl Sim {
/// Whether ammunition is counted: a depot stands (without one, magazines refill as before).
/// Whether ammunition is counted: a depot was set up (without one, magazines refill as
/// before). A destroyed depot still counts: losing the last one leaves the systems short,
/// not supplied from nowhere again.
pub fn logistics(&self) -> bool {
self.sams.iter().any(|s| s.kind == SamKind::Depot && s.alive())
self.sams.iter().any(|s| s.kind == SamKind::Depot)
}
/// Reloads from depots near the systems, and the trucks' rounds.
@@ -41,6 +43,10 @@ impl Sim {
self.reload_now(i, out);
}
}
// A destroyed truck drops its job, for another to take.
for s in self.sams.iter_mut().filter(|s| s.kind == SamKind::Tzm && !s.alive()) {
s.job = None;
}
// The trucks: a job each, when idle.
for t in 0..self.sams.len() {
let truck = &self.sams[t];
@@ -64,13 +70,17 @@ impl Sim {
}
self.sams[t].job = None;
} else if self.sams[j].alive() && (self.sams[j].empty || self.sams[j].kind == SamKind::Depot) {
continue; // still on its way (a new order follows when it arrives)
// Stopped short of it (it drove off meanwhile): on to where it stands now.
let to = self.sams[j].pos;
let side = horizontal(at - to).normalize_or(DVec3::X) * (HANDOVER * 0.6);
self.order_move(t, to + side);
continue;
} else {
self.sams[t].job = None;
}
}
// A new job: the nearest waiting system no other truck is driving to, or a depot to load at.
let taken: Vec<u32> = self.sams.iter().filter(|s| s.kind == SamKind::Tzm).filter_map(|s| s.job).collect();
let taken: Vec<u32> = self.sams.iter().filter(|s| s.kind == SamKind::Tzm && s.alive()).filter_map(|s| s.job).collect();
let next = if self.sams[t].cargo > 0 {
self.sams
.iter()
+178 -73
View File
@@ -18,6 +18,8 @@ mod lights;
mod logistics;
mod orders;
mod units;
#[cfg(test)]
mod regress;
pub use ew::EW_RADIUS;
pub use units::{Family, SamKind};
use units::Muzzle;
@@ -52,6 +54,8 @@ pub struct Particle {
/// A blast the post-processing shader draws as a fireball, flash and shock.
#[derive(Clone, Copy)]
pub struct Blast {
/// Never reused while the simulation lives: the HD light keeps its identity through the blast's life.
pub id: u32,
pub at: DVec3,
/// Fireball radius in blocks.
pub size: f32,
@@ -161,6 +165,33 @@ fn slew(from: f64, to: f64, rate: f64) -> f64 {
from + angle_diff(from, to).clamp(-rate, rate)
}
/// One axis of a drive: speeds up towards `err` (the angle still to turn), brakes in time to stop on it,
/// and returns the turn this tick. Arriving takes the last bit at once, so there is no overshoot or jitter.
fn drive(vel: &mut f64, err: f64, speed: f64, accel: f64) -> f64 {
// The fastest it may go and still stop within what is left.
let fastest = (2.0 * accel * err.abs()).sqrt().min(speed);
let want = fastest.copysign(err);
*vel = (*vel + (want - *vel).clamp(-accel, accel)).clamp(-speed, speed);
if vel.abs() >= err.abs() && vel.signum() == err.signum() {
*vel = 0.0;
return err;
}
*vel
}
/// Moves one system's claim from the drone it had to the one it has, in a count of claims per drone.
fn retarget(counts: &mut HashMap<u32, u32>, from: Option<u32>, to: Option<u32>) {
if from == to {
return;
}
if let Some(n) = from.and_then(|id| counts.get_mut(&id)) {
*n = n.saturating_sub(1);
}
if let Some(id) = to {
*counts.entry(id).or_insert(0) += 1;
}
}
fn angle_diff(from: f64, to: f64) -> f64 {
(to - from + std::f64::consts::PI).rem_euclid(TAU) - std::f64::consts::PI
}
@@ -173,8 +204,13 @@ pub struct Ground {
known: HashMap<(i32, i32), (i32, u64)>,
wanted: Vec<(i32, i32)>,
asked: std::collections::HashSet<(i32, i32)>,
/// The tick of the last full clean-up (a big cache would otherwise be scanned on every answer).
swept: u64,
}
/// Ticks between full clean-ups of a big ground cache.
const SWEEP_EVERY: u64 = 200;
impl Ground {
/// One above the highest solid block, if known; the column is asked for
/// when it isn't (or is old).
@@ -182,7 +218,9 @@ impl Ground {
let key = (p.x.floor() as i32, p.z.floor() as i32);
match self.known.get(&key) {
Some(&(y, when)) => {
if now.saturating_sub(when) > 200 && self.wanted.len() < 256 && self.asked.insert(key) {
// Refreshes of known columns take at most a quarter of the questions: the rest
// stay free for columns never seen, or those would starve.
if now.saturating_sub(when) > 200 && self.wanted.len() < 64 && self.asked.insert(key) {
self.wanted.push(key);
}
(y > -64).then_some(f64::from(y))
@@ -199,8 +237,9 @@ impl Ground {
for (x, z, y) in answers {
self.known.insert((x, z), (y, now));
}
if self.known.len() > 300_000 {
self.known.retain(|_, (_, when)| now - *when < 2000);
if self.known.len() > 300_000 && now.saturating_sub(self.swept) >= SWEEP_EVERY {
self.swept = now;
self.known.retain(|_, (_, when)| now.saturating_sub(*when) < 2000);
}
}
pub fn take_wanted(&mut self) -> Vec<(i32, i32)> {
@@ -657,6 +696,8 @@ pub struct Drone {
pub jammed: bool,
/// The approach sector it came from (the enemy keeps score).
sector: u8,
/// Sent by the smart enemy: it may be turned on systems that give themselves away.
smart: bool,
}
impl Drone {
@@ -930,6 +971,9 @@ pub struct Sam {
pub heading: f64,
yaw: f64,
pitch: f64,
/// How fast the mount turns now (radians a tick); not saved, a loaded system starts at rest.
yaw_vel: f64,
pitch_vel: f64,
pub missiles: u32,
pub bursts: u32,
cooldown: u32,
@@ -983,6 +1027,8 @@ impl Sam {
heading,
yaw: heading,
pitch: spec.rest_pitch,
yaw_vel: 0.0,
pitch_vel: 0.0,
missiles: spec.missile.as_ref().map_or(0, |m| m.magazine),
bursts: spec.gun.as_ref().map_or(0, |g| g.bursts),
cooldown: 40,
@@ -1022,6 +1068,21 @@ impl Sam {
self.health > 0.0
}
/// How well it still works: turning, aiming, its radar's reach.
/// Turns the mount towards `yaw` (and `pitch`, if it elevates) under its drive's limits; a damaged
/// system has the less of both speed and acceleration.
fn aim_towards(&mut self, yaw: f64, pitch: Option<f64>) {
let d = self.kind.spec().drive();
let fit = self.fitness();
self.yaw += drive(&mut self.yaw_vel, angle_diff(self.yaw, yaw), d.yaw.0 * fit, d.yaw.1 * fit);
if let Some(pitch) = pitch {
self.pitch += drive(&mut self.pitch_vel, pitch - self.pitch, d.pitch.0 * fit, d.pitch.1 * fit);
}
}
/// The mount stops where it is (a march, a deployment: the vehicle drives it directly).
pub(super) fn halt_mount(&mut self) {
self.yaw_vel = 0.0;
self.pitch_vel = 0.0;
}
fn fitness(&self) -> f64 {
0.45 + 0.55 * self.health.clamp(0.0, 1.0)
}
@@ -1088,6 +1149,8 @@ pub struct Sim {
fx: Vec<(u32, Fx)>,
/// Blasts for the post-processing shader.
pub blasts: Vec<Blast>,
/// The next blast's id (separate from `next`, so the other units' ids stay as they were).
blast_seq: u32,
next: u32,
pub rng: Rng,
pub tick: u64,
@@ -1122,7 +1185,7 @@ pub struct Sim {
pub night: bool,
/// Illumination flares in the sky, and the drones lit this tick.
pub flares: Vec<lights::Flare>,
lit: Vec<u32>,
pub lit: Vec<u32>,
}
/// Ids: objects get multiples of 16, their parts the low bits.
@@ -1138,6 +1201,7 @@ impl Sim {
targets: Vec::new(),
fx: Vec::new(),
blasts: Vec::new(),
blast_seq: 0,
next: 1,
rng: Rng::new(seed),
tick: 0,
@@ -1230,6 +1294,7 @@ impl Sim {
low: false,
jammed: false,
sector: 0,
smart: false,
});
self.launched += 1;
}
@@ -1269,6 +1334,10 @@ impl Sim {
self.queue.clear();
self.plan = None;
self.wave = None;
// Ammunition still to cook off, flares in the sky.
self.later.clear();
self.flares.clear();
self.lit.clear();
}
/// One game tick.
@@ -1301,8 +1370,8 @@ impl Sim {
let now = self.tick;
let mut boom: Vec<(DVec3, u32, bool)> = Vec::new();
let mut removed: Vec<u32> = Vec::new();
// Oreshnik pieces released this tick: kind, at, velocity, target.
let mut released: Vec<(DroneKind, DVec3, DVec3, DVec3)> = Vec::new();
// Oreshnik pieces released this tick: kind, at, velocity, target, the parent's sector (what they teach the enemy).
let mut released: Vec<(DroneKind, DVec3, DVec3, DVec3, u8)> = Vec::new();
// Warheads that reached their aim (for the replays): drone, kind, where, TNT; and splits.
let mut impacts: Vec<(u32, DroneKind, DVec3, u32)> = Vec::new();
let mut splits: Vec<(u32, DVec3)> = Vec::new();
@@ -1310,6 +1379,15 @@ impl Sim {
d.age += 1;
let spec = d.kind.spec();
let below = ground.at(d.pos, now);
// Near its aim it looks at the land there again: a platform or hilltop blown away
// meanwhile, it goes for what is left below, not for the air where it stood.
if d.flight != Flight::Falling
&& horizontal(d.target - d.pos).length() < 250.0
&& let Some(g) = ground.at(d.target, now)
&& g < d.target.y - 1.5
{
d.target.y = g;
}
if d.flight == Flight::Falling {
d.vel.y -= 0.05;
d.vel *= 0.985;
@@ -1345,7 +1423,7 @@ impl Sim {
let speed = d.vel.length();
let hit = d.pos.distance(d.target) < speed.max(2.0)
|| below.is_some_and(|g| d.pos.y <= g + 0.2)
|| (d.vel.y < 0.0 && d.pos.y < d.target.y - 1.0)
|| (d.vel.y < 0.0 && d.pos.y < d.target.y - 1.0 && horizontal(d.target - d.pos).length() < speed * 2.0)
|| d.age > 4000;
if hit {
boom.push((d.pos, spec.warhead, false));
@@ -1370,7 +1448,7 @@ impl Sim {
let aim = d.target + DVec3::new(a.sin() * r, 0.0, a.cos() * r);
// Straight down at the aim point, at the bus's speed.
let v = (aim - d.pos).normalize_or(DVec3::NEG_Y) * 3.0;
released.push((piece, d.pos + v.normalize_or_zero() * (i as f64 * 0.3), v, aim));
released.push((piece, d.pos + v.normalize_or_zero() * (i as f64 * 0.3), v, aim, d.sector));
}
out.sound("airdef:boom.sonic", d.pos, 1.0, 0.7);
splits.push((d.id, d.pos));
@@ -1502,7 +1580,7 @@ impl Sim {
self.explode(pos, warhead, small, ground, player, out);
}
self.drones.retain(|d| !removed.contains(&d.id));
for (kind, pos, vel, target) in released {
for (kind, pos, vel, target, sector) in released {
let id = self.id();
self.drones.push(Drone {
id,
@@ -1518,7 +1596,8 @@ impl Sim {
route: Vec::new(),
low: false,
jammed: false,
sector: 0,
sector,
smart: false,
});
}
for d in &self.drones {
@@ -1616,7 +1695,8 @@ impl Sim {
}
fn fireball(&mut self, pos: DVec3, size: f64, life: u32) {
self.blasts.push(Blast { at: pos, size: size as f32, start: self.tick, life: life * 9 });
self.blast_seq = self.blast_seq.wrapping_add(1);
self.blasts.push(Blast { id: self.blast_seq, at: pos, size: size as f32, start: self.tick, life: life * 9 });
if self.blasts.len() > 48 {
self.blasts.remove(0);
}
@@ -1680,6 +1760,15 @@ impl Sim {
return;
}
let logistics = self.logistics();
// Drones by id (none come or go while the systems act), and how many systems have each as their
// missile target and as their gun target: counted once, then kept up to date as targets change.
let drone_at: HashMap<u32, usize> = self.drones.iter().enumerate().map(|(k, d)| (d.id, k)).collect();
let mut missile_on: HashMap<u32, u32> = HashMap::new();
let mut guns_on: HashMap<u32, u32> = HashMap::new();
for s in &self.sams {
retarget(&mut missile_on, None, s.target);
retarget(&mut guns_on, None, s.gun_target);
}
let mut launches: Vec<(MissileKind, DVec3, DVec3, u32, u32)> = Vec::new();
let mut gun_hits: Vec<(u32, u32)> = Vec::new();
let mut tracers: Vec<(u32, DVec3, DVec3, u32)> = Vec::new();
@@ -1688,8 +1777,8 @@ impl Sim {
if !self.sams[i].alive() {
let sam = &mut self.sams[i];
sam.state = SamState::Destroyed;
sam.target = None;
sam.gun_target = None;
retarget(&mut missile_on, sam.target.take(), None);
retarget(&mut guns_on, sam.gun_target.take(), None);
continue;
}
let fit = self.sams[i].fitness();
@@ -1715,59 +1804,73 @@ impl Sim {
if !self.sams[i].kind.armed() {
let sam = &mut self.sams[i];
sam.state = SamState::Search;
sam.target = None;
sam.gun_target = None;
// A searchlight's target is its beam's (step_lights): kept for its spot of light.
if sam.kind != SamKind::Light {
retarget(&mut missile_on, sam.target.take(), None);
}
retarget(&mut guns_on, sam.gun_target.take(), None);
continue;
}
let pos = self.sams[i].pos;
let me = self.sams[i].id;
let roles = self.sams[i].roles;
let seen = self.sams[i].seen.clone();
let own = self.sams[i].own.clone();
let allowed = |d: &Drone| d.alive() && roles & d.kind.spec().class.bit() != 0 && seen.contains(&d.id);
// Interceptors a target has or will have: in flight, and other systems' launchers on it.
let claimed = |d: &Drone| {
d.engaged + self.sams.iter().filter(|s| s.id != me && s.target == Some(d.id)).count() as u32
};
// Ballistic missiles get a pair; anything else one.
let needed = |d: &Drone| if d.kind.spec().class == Class::Ballistic { 2 } else { 1 };
if let Some(load) = &spec.missile {
let in_reach = |d: &Drone, k: f64| (load.min..load.max * k).contains(&d.pos.distance(pos));
let keep = self.sams[i].target.filter(|t| {
self.drones.iter().any(|d| d.id == *t && allowed(d) && in_reach(d, 1.1))
let (had_target, had_gun) = (self.sams[i].target, self.sams[i].gun_target);
let (target, gun_target) = {
let (seen, own) = (&self.sams[i].seen, &self.sams[i].own);
let allowed = |d: &Drone| d.alive() && roles & d.kind.spec().class.bit() != 0 && seen.contains(&d.id);
// Interceptors a target has or will have: in flight, and other systems' launchers on it.
let claimed = |d: &Drone| {
let on = missile_on.get(&d.id).copied().unwrap_or(0) - u32::from(had_target == Some(d.id));
d.engaged + on
};
// Ballistic missiles get a pair; anything else one.
let needed = |d: &Drone| if d.kind.spec().class == Class::Ballistic { 2 } else { 1 };
let target = spec.missile.as_ref().map(|load| {
let in_reach = |d: &Drone, k: f64| (load.min..load.max * k).contains(&d.pos.distance(pos));
let keep = had_target.filter(|t| {
drone_at.get(t).is_some_and(|&k| allowed(&self.drones[k]) && in_reach(&self.drones[k], 1.1))
});
keep.or_else(|| {
self.drones
.iter()
.filter(|d| allowed(d) && in_reach(d, 1.0) && claimed(d) < needed(d))
.max_by(|a, b| a.threat(pos).total_cmp(&b.threat(pos)))
.map(|d| d.id)
})
});
let target = keep.or_else(|| {
// Guns: the nearest, spread over the targets the other guns and missiles aren't on.
let gun_target = spec.gun.as_ref().map(|gun| {
let on_it = |d: &Drone| {
(guns_on.get(&d.id).copied().unwrap_or(0) - u32::from(had_gun == Some(d.id))) as f64
};
self.drones
.iter()
.filter(|d| allowed(d) && in_reach(d, 1.0) && claimed(d) < needed(d))
.max_by(|a, b| a.threat(pos).total_cmp(&b.threat(pos)))
.map(|d| d.id)
.filter(|d| allowed(d) && own.contains(&d.id) && d.pos.distance(pos) < gun.track)
.map(|d| {
let busy = 1.0 + 2.0 * on_it(d) + if d.engaged > 0 { 1.5 } else { 0.0 };
(d.pos.distance(pos) * busy, d.id)
})
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, id)| id)
});
(target, gun_target)
};
if let Some(target) = target {
retarget(&mut missile_on, had_target, target);
self.sams[i].target = target;
}
// Guns: the nearest, spread over the targets the other guns and missiles aren't on.
if let Some(gun) = &spec.gun {
let guns_on = |d: &Drone| {
self.sams.iter().filter(|s| s.id != me && s.gun_target == Some(d.id)).count() as f64
};
self.sams[i].gun_target = self
.drones
.iter()
.filter(|d| allowed(d) && own.contains(&d.id) && d.pos.distance(pos) < gun.track)
.map(|d| {
let busy = 1.0 + 2.0 * guns_on(d) + if d.engaged > 0 { 1.5 } else { 0.0 };
(d.pos.distance(pos) * busy, d.id)
})
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, id)| id);
if let Some(gun_target) = gun_target {
retarget(&mut guns_on, had_gun, gun_target);
self.sams[i].gun_target = gun_target;
}
let sam = &mut self.sams[i];
// What the turret or launcher points at: guns first when a target is close.
let aim = sam.gun_target.or(sam.target).and_then(|t| self.drones.iter().find(|d| d.id == t));
let aim = sam.gun_target.or(sam.target).and_then(|t| drone_at.get(&t).map(|&k| &self.drones[k]));
sam.launched = sam.launched.saturating_sub(1);
let Some(aim) = aim else {
// Out of missiles and nothing to shoot: reload now, guns or not.
if spec.missile.is_some() && sam.missiles == 0 && sam.reloading == 0 && !sam.empty {
// Out of missiles or of shells, and nothing to shoot: reload now (both, as a
// combined system's crew would), not only once both are spent.
let spent = (spec.missile.is_some() && sam.missiles == 0) || (spec.gun.is_some() && sam.bursts == 0);
if spent && sam.reloading == 0 && !sam.empty {
if logistics { sam.empty = true } else { sam.reloading = spec.reload }
}
sam.state = if sam.reloading > 0 {
@@ -1777,8 +1880,8 @@ impl Sim {
} else {
SamState::Search
};
sam.yaw = slew(sam.yaw, sam.heading, 0.03);
sam.pitch += (spec.rest_pitch - sam.pitch) * 0.05;
// Back to the rest position, under the drive's limits like any other turn.
sam.aim_towards(sam.heading, Some(spec.rest_pitch));
continue;
};
let (mount, _) = sam.mount();
@@ -1787,21 +1890,21 @@ impl Sim {
let to = lead - mount;
let want_yaw = heading_of(to);
let want_pitch = (to.y / to.length().max(0.01)).asin().clamp(-0.1, 1.45);
let (yaw_rate, pitch_rate) = spec.gun.as_ref().map_or((0.05, 0.05), |g| g.slew);
let (yaw_rate, pitch_rate) = (yaw_rate * fit, pitch_rate * fit);
let cells = matches!(spec.muzzle, Muzzle::Cells);
match spec.family {
// Vertical launch: no aiming (the Tor's turret turns its radar to the target).
Family::Vertical if cells => sam.yaw = slew(sam.yaw, want_yaw, 0.15 * fit),
Family::Vertical if cells => sam.aim_towards(want_yaw, None),
Family::Vertical => {}
Family::Trainable => sam.yaw = slew(sam.yaw, want_yaw, 0.12 * fit),
_ => {
sam.yaw = slew(sam.yaw, want_yaw, yaw_rate);
sam.pitch = slew(sam.pitch, want_pitch, pitch_rate);
}
// The launcher's elevation is fixed (the missile turns after its boost): only the training is aimed.
Family::Trainable => sam.aim_towards(want_yaw, None),
_ => sam.aim_towards(want_yaw, Some(want_pitch)),
}
let aim_error = angle_diff(sam.yaw, want_yaw).abs()
+ if spec.gun.is_some() { (want_pitch - sam.pitch).abs() } else { 0.0 };
// How far the mount still is from the aim, over the axes this family aims.
let yaw_error = angle_diff(sam.yaw, want_yaw).abs();
let aim_error = match spec.family {
Family::Trainable | Family::Vertical => yaw_error,
_ => yaw_error + (want_pitch - sam.pitch).abs(),
};
let vertical = spec.family == Family::Vertical;
sam.state = if sam.reloading > 0 {
SamState::Reload
@@ -1820,7 +1923,7 @@ impl Sim {
};
// Guns.
if let (Some(gun), Some(gt)) = (&spec.gun, sam.gun_target) {
if let Some(t) = self.drones.iter().find(|d| d.id == gt) {
if let Some(t) = drone_at.get(&gt).map(|&k| &self.drones[k]) {
let dist = t.pos.distance(mount);
if aim.id == gt && aim_error < 0.1 && dist < gun.range && sam.gun_cooldown == 0 && sam.reloading == 0 && sam.bursts > 0 {
sam.gun_cooldown = 2;
@@ -1838,7 +1941,7 @@ impl Sim {
};
for b in barrels {
let muzzle = mount + rot * *b * s;
let dir = (lead - muzzle).normalize();
let dir = (lead - muzzle).normalize_or(DVec3::NEG_Z);
let len = muzzle.distance(lead).min(gun.range * 1.2);
// Every round is a red tracer flying out at the shell's speed.
let scatter = DVec3::new(self.rng.s(), self.rng.s(), self.rng.s()) * 0.012;
@@ -1868,19 +1971,24 @@ impl Sim {
let ready = sam.cooldown == 0 && sam.reloading == 0 && sam.missiles > 0;
let aligned = match spec.family {
Family::Trainable => {
let t = self.drones.iter().find(|d| d.id == mt);
// The missile turns after its boost: the launcher needn't be exact.
t.is_some_and(|t| angle_diff(sam.yaw, heading_of(t.pos - mount)).abs() < 0.45)
let t = drone_at.get(&mt).map(|&k| &self.drones[k]);
// The missile turns after its boost, but the launcher must point near the target.
t.is_some_and(|t| angle_diff(sam.yaw, heading_of(t.pos - mount)).abs() < 0.15)
}
Family::Turret => aim.id == mt && aim_error < 0.2,
Family::Turret => aim.id == mt && aim_error < 0.15,
_ => true,
};
if ready && aligned {
let n = load.magazine - sam.missiles;
let (muzzle, dir) = sam.muzzle(n);
launches.push((load.kind, muzzle, dir, mt, sam.id));
// Its missile is on it: the next launch looks for another target.
// Its missile is on it from now, so the systems after this one this tick see
// the target taken; this one's next launch looks for another.
if let Some(&k) = drone_at.get(&mt) {
self.drones[k].engaged += 1;
}
sam.target = None;
retarget(&mut missile_on, Some(mt), None);
sam.launched = 12;
sam.missiles -= 1;
sam.fired += 1;
@@ -1900,9 +2008,6 @@ impl Sim {
let id = self.id();
let v0 = if spec.cold > 0 { 0.7 } else { 0.8 };
self.missiles.push(Missile { id, kind, pos: muzzle, vel: dir * v0, target, sam, age: 0 });
if let Some(d) = self.drones.iter_mut().find(|d| d.id == target) {
d.engaged += 1;
}
if spec.cold > 0 {
// Thrown out by gas: a puff, a thump; the motor lights later.
out.particles("minecraft:cloud", muzzle, 0.6, dir * 0.05, 0.05, 30);
+4
View File
@@ -56,6 +56,10 @@ impl Sim {
continue;
}
let spec = sam.kind.spec();
// The march and the packing drive the mount themselves: no speed carries over into or out of them.
if matches!(sam.state, SamState::Packing | SamState::Moving | SamState::Deploying) {
sam.halt_mount();
}
match sam.state {
SamState::Packing => {
// The launcher comes down for the march.
+53 -14
View File
@@ -98,20 +98,20 @@ pub(super) struct WaveStats {
}
/// The planner's grid: cells this big, at most this many a side.
const CELL: f64 = 16.0;
const CELLS: i32 = 140;
pub(super) const CELL: f64 = 16.0;
pub(super) const CELLS: i32 = 140;
/// The planner's work a tick (cells priced and opened): a raid takes a few ticks to plan, never a long one.
const WORK_PER_TICK: usize = 40_000;
pub(super) const WORK_PER_TICK: usize = 40_000;
/// The planner's grid over a raid: its corner, its cells' size and how many a side.
struct Grid {
min: DVec3,
cell: f64,
size: i32,
pub(super) struct Grid {
pub(super) min: DVec3,
pub(super) cell: f64,
pub(super) size: i32,
}
impl Grid {
fn index(&self, p: DVec3) -> (i32, i32) {
pub(super) fn index(&self, p: DVec3) -> (i32, i32) {
let n = self.size;
((((p.x - self.min.x) / self.cell) as i32).clamp(0, n - 1), (((p.z - self.min.z) / self.cell) as i32).clamp(0, n - 1))
}
@@ -121,7 +121,7 @@ impl Grid {
}
/// The cheapest way over `grid` from `from` to `to` by its prices (A*): points along it, `to` last.
fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> (Vec<DVec3>, usize) {
pub(super) fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> (Vec<DVec3>, usize) {
let mut opened = 0;
let n = grid.size;
let at = |(i, j): (i32, i32)| (i * n + j) as usize;
@@ -195,6 +195,11 @@ impl Sim {
/// (each route found in full, a few a tick) and launched when its plan is ready.
pub fn order_raid(&mut self, order: &RaidOrder, objects: &[DVec3], out: &mut Out) -> u32 {
let Some(&centre) = objects.first() else { return 0 };
// One raid is planned at a time: another order waits for this one's drones to be sent.
if self.plan.is_some() {
out.messages.push("Противник ещё готовит прошлый налёт: подожди пару секунд".into());
return 0;
}
let total: u32 = order.kinds.iter().map(|k| k.1).sum();
if total == 0 {
return 0;
@@ -298,7 +303,8 @@ impl Sim {
// Timing: smart raids arrive at once; others leave at once (a moment apart).
let now = self.tick;
let arrive = plan.done.iter().map(|(_, t, e, g)| t + e - g).fold(0.0, f64::max);
for (d, time, early, gap) in plan.done {
for (mut d, time, early, gap) in plan.done {
d.smart = plan.smart;
let wait = if plan.smart { arrive - (early - gap) - time } else { gap + self.rng.f() * 40.0 };
self.queue.push((now + wait.clamp(0.0, 2400.0) as u64, d));
}
@@ -348,6 +354,7 @@ impl Sim {
low: false,
jammed: false,
sector: 0,
smart: false,
}
}
@@ -394,7 +401,7 @@ impl Sim {
/// The price of each cell of `grid` for this kind flying low: 1, and much more where a
/// system sees it there (its radar's reach for it, the land) and can shoot it.
fn price_grid(&self, grid: &Grid, kind: DroneKind) -> (Vec<f32>, usize) {
pub(super) fn price_grid(&self, grid: &Grid, kind: DroneKind) -> (Vec<f32>, usize) {
let mut cost = 0;
let spec = kind.spec();
let height = spec.clearance.max(6.0);
@@ -419,8 +426,16 @@ impl Sim {
let sp = s.kind.spec();
let (lo, hi) = sp.missile.as_ref().map_or((0.0, 0.0), |m| (m.min, m.max));
let gun = sp.gun.as_ref().map_or(0.0, |g| g.range);
let radar = sight::reach(s, kind, height);
let far = hi.max(gun).min(radar);
// A gun needs its own radar; a missile also flies on the tracks of any radar within data-link range.
let own = sight::reach(s, kind, height);
let net = self
.sams
.iter()
.filter(|r| r.alive() && r.id != s.id && r.pos.distance(s.pos) <= sight::LINK)
.map(|r| sight::reach(r, kind, height))
.fold(0.0, f64::max);
let (hi, gun) = (hi.min(own.max(net)), gun.min(own));
let far = hi.max(gun);
let at = sight::antenna(s);
let horizon = self.horizons.get(&s.id);
// Only the cells round it.
@@ -471,7 +486,8 @@ impl Sim {
let kills = self.kills - w.kills;
let through = self.hits - w.hits;
let launched = self.launched - w.launched;
let fired: u32 = self.sams.iter().map(|s| s.fired).sum::<u32>() - w.fired;
// A system taken away during the raid takes its count along: never below none.
let fired: u32 = self.sams.iter().map(|s| s.fired).sum::<u32>().saturating_sub(w.fired);
let lost = w.sams.saturating_sub(self.sams.iter().filter(|s| s.alive()).count());
let objects = if self.target_health.is_empty() {
String::new()
@@ -537,6 +553,29 @@ mod tests {
eprintln!("4000 ticks in {:?}, kills {} hits {}", t.elapsed(), sim.kills, sim.hits);
assert!(sim.queue.is_empty() && !sim.drones.iter().any(|d| d.alive()), "the raid never ended");
}
#[test]
fn systems_ready_together_send_one_missile_at_a_drone() {
let mut sim = Sim::new(3);
// Vertical launchers of one kind side by side: the drone comes into their reach in the
// same tick, and they fire without turning.
for i in 0..6 {
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Tor, DVec3::new(2.0 * i as f64, 70.0, 0.0), 0.0));
}
let mut ground = Ground::default();
let mut out = Out::default();
for _ in 0..30 {
sim.step(&mut ground, None, &mut out);
}
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, -400.0), DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
let id = sim.drones[0].id;
for _ in 0..1200 {
sim.step(&mut ground, None, &mut out);
let on_it = sim.missiles.iter().filter(|m| m.target == id).count();
assert!(on_it <= 1, "{on_it} missiles on one Shahed");
}
assert!(sim.sams.iter().any(|s| s.fired > 0), "nothing was fired");
}
}
#[cfg(test)]
+7
View File
@@ -142,6 +142,13 @@ impl Recording {
}
}
impl Sim {
/// The recording of raid `number`, while it is kept.
pub fn replay(&self, number: u32) -> Option<&Recording> {
self.replays.iter().find(|r| r.number == number)
}
}
impl Sim {
/// Something worth a replay happened.
pub(super) fn log(&mut self, pos: DVec3, kind: EventKind) {
+224
View File
@@ -0,0 +1,224 @@
//! Regression tests for the issues filed on Gitea (each names its issue).
use super::*;
use super::raid::Grid;
// #1: [bug] Запросы рельефа голодают: часть направлений РЛС никогда не получает высоты
#[test]
fn terrain_queries_must_eventually_cover_all_radar_bearings() {
let mut sim=Sim::new(1);
for i in 0..10 {
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::Radar,DVec3::new(i as f64*5000.,70.,0.),0.));
}
let mut ground=Ground::default();
for now in 1..=2500 {
sim.tick=now;sim.update_sight(&mut ground);
let answers=ground.take_wanted().into_iter().map(|(x,z)|(x,z,200)).collect();
ground.answer(answers,now);
}
let visible:Vec<usize>=sim.horizons.values().map(|h|h.rays.iter().filter(|r|r.last().is_some_and(|v|v.is_finite())).count()).collect();
eprintln!("radar terrain coverage after 125 seconds: {:?} / 48 bearings each",visible);
assert!(visible.iter().all(|n|*n==48),"refreshes of early rays starve unknown terrain in later rays");
}
// #2: [bug] Уничтоженная ТЗМ навсегда резервирует заявку на подвоз боеприпасов
#[test]
fn dead_truck_must_not_reserve_a_waiting_system() {
let mut sim=Sim::new(1);
let depot=sim.id(); sim.sams.push(Sam::new(depot,SamKind::Depot,DVec3::new(500.,70.,0.),0.));
let target=sim.id(); let mut s=Sam::new(target,SamKind::Tor,DVec3::new(0.,70.,0.),0.);s.empty=true;s.missiles=0;sim.sams.push(s);
let dead=sim.id();let mut s=Sam::new(dead,SamKind::Tzm,DVec3::new(100.,70.,0.),0.);s.job=Some(target);s.health=0.;sim.sams.push(s);
let live=sim.id();sim.sams.push(Sam::new(live,SamKind::Tzm,DVec3::new(30.,70.,0.),0.));
sim.tick=3;sim.step_supply(&mut Out::default());
assert_eq!(sim.sams[3].job,Some(target),"a live loaded truck must take the waiting system");
}
// #3: [bug] ТЗМ застревает, если обслуживаемая установка сменила позицию
#[test]
fn truck_must_follow_a_target_that_moved_away() {
let mut sim=Sim::new(1);
let depot=sim.id();sim.sams.push(Sam::new(depot,SamKind::Depot,DVec3::new(500.,70.,0.),0.));
let target=sim.id();let mut s=Sam::new(target,SamKind::Tor,DVec3::new(100.,70.,0.),0.);s.empty=true;s.missiles=0;sim.sams.push(s);
let truck=sim.id();let mut s=Sam::new(truck,SamKind::Tzm,DVec3::new(0.,70.,0.),0.);s.job=Some(target);sim.sams.push(s);
sim.tick=2;sim.step_supply(&mut Out::default());
assert!(sim.sams[2].moving(),"truck at obsolete rendezvous must get a new move order");
}
// #4: [bug] Планировщик умного налёта не учитывает пусковую с внешним целеуказанием
#[test]
fn network_launcher_must_contribute_to_route_risk() {
let mut sim=Sim::new(1);
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::P1sun,DVec3::new(0.,70.,0.),0.));
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::Radar,DVec3::new(0.,70.,0.),0.));
let grid=Grid{min:DVec3::new(-160.,0.,-160.),cell:16.,size:20};
let (prices,_)=sim.price_grid(&grid,DroneKind::Shahed);
assert!(prices.iter().any(|p|*p>1.),"armed launcher with a network radar is invisible to route risk");
}
// #5: [bug] После ремонта комплекс снова уничтожается от собственной отложенной детонации
#[test]
fn repair_must_cancel_pending_magazine_explosions() {
let mut sim=Sim::new(1);
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::Patriot,DVec3::new(0.,70.,0.),0.));
sim.blast_damage(DVec3::new(0.,70.,0.),4,&mut Out::default());
assert!(!sim.later.is_empty());sim.repair();
sim.tick=3; sim.step_later(&mut Ground::default(),None,&mut Out::default());
assert!(sim.sams[0].alive(),"a fully repaired launcher was destroyed again by its own pre-repair cook-off");
}
// #34: drones and ballistic missiles burst where their target's platform used to be.
#[test]
fn munitions_must_go_down_to_the_land_left_under_a_destroyed_target() {
for (kind, from, vel) in [
(DroneKind::Shahed, DVec3::new(0.0, 121.0, 0.0), DVec3::ZERO),
(DroneKind::OreshnikSub, DVec3::new(0.0, 124.0, 0.0), DVec3::ZERO),
(DroneKind::Iskander, DVec3::new(100.0, 121.0, 0.0), DVec3::new(0.0, -4.0, 0.0)),
] {
let mut sim = Sim::new(1);
sim.damage = false;
let mut ground = Ground::default();
// The land under the old platform (at y 120) is at 64 now.
for x in -120..=120 {
for z in -8..=8 {
ground.answer(vec![(x, z, 64)], 0);
}
}
sim.add_drone(kind, from, DVec3::NEG_Y, DVec3::new(0.0, 120.0, 0.0));
if vel != DVec3::ZERO {
sim.drones[0].vel = vel;
sim.drones[0].flight = Flight::Ballistic;
}
let mut out = Out::default();
for _ in 0..400 {
if sim.drones.is_empty() {
break;
}
sim.step(&mut ground, None, &mut out);
}
let blast = sim.blasts.first().map(|b| b.at);
assert!(blast.is_some_and(|b| b.y < 80.0), "{kind:?} burst at {blast:?}, in the air over the land");
}
}
// #15: lights of blasts in one tick at the same whole X must stay apart
#[test]
fn blasts_in_one_tick_have_their_own_ids() {
let mut sim = Sim::new(1);
sim.tick = 100;
sim.fireball(DVec3::new(0.0, 70.0, 100.0), 4.0, 10);
sim.fireball(DVec3::new(0.0, 70.0, 200.0), 4.0, 10);
assert_ne!(sim.blasts[0].id, sim.blasts[1].id);
// A blast going away does not hand its id to the next one.
let gone = sim.blasts.remove(0).id;
sim.fireball(DVec3::new(0.0, 70.0, 300.0), 4.0, 10);
assert!(sim.blasts.iter().all(|b| b.id != gone));
}
// #21: a smart drone sent at a new target gets a route again, not a straight line
#[test]
fn retargeted_smart_drone_gets_a_route_round_the_defences() {
let mut sim = Sim::new(1);
// A system in the way, between the drone and the unit it is sent at.
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(0.0, 70.0, 0.0), 0.0));
let goal = sim.id();
sim.sams.push(Sam::new(goal, SamKind::Ew, DVec3::new(0.0, 70.0, 1000.0), 0.0));
sim.intel.revealed.push((goal, DVec3::new(0.0, 70.0, 1000.0), 0));
// Its first target, a system that is destroyed by now.
let id = sim.id();
let mut wreck = Sam::new(id, SamKind::Patriot, DVec3::new(3000.0, 70.0, -1000.0), 0.0);
wreck.health = 0.0;
sim.sams.push(wreck);
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 100.0, -1000.0), DVec3::Z, DVec3::new(3000.0, 70.0, -1000.0));
sim.drones[0].pos = DVec3::new(0.0, 100.0, -1000.0);
sim.drones[0].smart = true;
sim.tick = 40;
sim.adapt();
let d = &sim.drones[0];
assert!(d.target.distance(DVec3::new(0.0, 70.0, 1000.0)) < 1.0, "not sent at the unit");
assert!(d.route.len() > 1, "no route after the new target: it flies straight through the defences");
}
// #31: a big cache of fresh columns is not scanned on every answer
#[test]
fn big_ground_cache_is_swept_rarely() {
let mut ground = Ground::default();
ground.known.extend((0..300_001).map(|x| ((x, 0), (64, 2000))));
ground.known.insert((-1, 0), (64, 0));
// The first answer past the threshold sweeps what is stale and keeps what is fresh.
ground.answer(vec![(-2, 0, 64)], 2100);
assert!(!ground.known.contains_key(&(-1, 0)), "a stale column was never swept");
assert!(ground.known.len() > 300_000);
// The next ones wait their pause: a stale column added now survives them.
ground.known.insert((-1, 0), (64, 0));
let swept = ground.swept;
ground.answer(vec![(-3, 0, 64)], 2101);
assert_eq!(ground.swept, swept, "swept again at once");
assert!(ground.known.contains_key(&(-1, 0)));
ground.answer(vec![(-4, 0, 64)], 2100 + SWEEP_EVERY);
assert!(!ground.known.contains_key(&(-1, 0)), "never swept again");
}
// #32: the pieces of an Oreshnik teach the enemy about the sector it came from
#[test]
fn oreshnik_pieces_keep_the_sector() {
let mut sim = Sim::new(1);
sim.add_drone(DroneKind::Oreshnik, DVec3::new(100.0, 300.0, 0.0), DVec3::NEG_Y, DVec3::new(0.0, 70.0, 0.0));
sim.drones[0].vel = DVec3::new(-1.0, -1.0, 0.0);
sim.drones[0].sector = 7;
let mut out = Out::default();
sim.step_drones(&mut Ground::default(), None, &mut out);
let pieces: Vec<_> = sim.drones.iter().filter(|d| d.kind == DroneKind::OreshnikBlock).collect();
assert_eq!(pieces.len(), 6);
assert!(pieces.iter().all(|d| d.sector == 7));
}
// #33: a turret does not fire before it has turned to the target
#[test]
fn turret_waits_for_its_aim() {
let mut sim = Sim::new(1);
let id = sim.id();
let mut s = Sam::new(id, SamKind::Gepard, DVec3::new(0.0, 70.0, 0.0), 0.0);
s.cooldown = 0;
sim.sams.push(s);
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(0.0, 70.0, 0.0), 0.0));
// Straight behind the turret's heading, a slow target well inside the gun's reach.
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, 60.0), DVec3::ZERO, DVec3::new(0.0, 70.0, 900.0));
sim.drones[0].vel = DVec3::ZERO;
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut turned = 0;
for _ in 0..200 {
sim.step(&mut ground, None, &mut out);
let g = &sim.sams[0];
let want = heading_of(sim.drones[0].pos - g.mount().0);
if angle_diff(g.yaw, want).abs() > 0.1 {
assert_eq!(g.bursts, 160, "fired with the turret still turning");
turned += 1;
}
if g.bursts < 160 || sim.drones.is_empty() {
break;
}
}
assert!(turned > 20, "the turret turned too fast to tell ({turned} ticks)");
assert!(sim.sams[0].bursts < 160, "never fired");
}
// #33: the drive speeds up, brakes in time and does not overshoot
#[test]
fn drive_arrives_without_overshoot() {
for err in [std::f64::consts::PI, 1.57, 0.3, 0.02] {
let (mut vel, mut left) = (0.0, err);
let (speed, accel) = (0.088, 0.011);
let mut ticks = 0;
while left > 0.0 {
let step = drive(&mut vel, left, speed, accel);
assert!(step.abs() <= speed + 1e-9 && step >= 0.0, "step {step} at {left} left");
left -= step;
ticks += 1;
assert!(ticks < 400, "never arrived for {err}");
}
assert!(left.abs() < 1e-9 && vel == 0.0);
}
}
+9 -6
View File
@@ -17,7 +17,7 @@ const NEAR: f64 = 24.0;
pub(super) struct Horizon {
/// Per ray, per sample out: the steepest rise of the land so far (tan of its elevation).
rays: Vec<Vec<f32>>,
pub(super) rays: Vec<Vec<f32>>,
}
impl Horizon {
@@ -90,7 +90,8 @@ impl Sim {
self.horizons.insert(sam.id, h);
}
}
self.horizons.retain(|id, _| self.sams.iter().any(|s| s.id == *id && s.alive()));
let alive: std::collections::HashSet<u32> = self.sams.iter().filter(|s| s.alive()).map(|s| s.id).collect();
self.horizons.retain(|id, _| alive.contains(id));
// Each drone's height over the land under it (or over the systems' ground, unknown).
let heights: Vec<Option<f64>> =
self.drones.iter().map(|d| ground.at(d.pos, now).map(|g| d.pos.y - g)).collect();
@@ -120,18 +121,20 @@ impl Sim {
// The network: every unit gets the tracks of the radars within data-link range.
let tracks: Vec<(DVec3, Vec<u32>)> =
self.sams.iter().filter(|s| !s.own.is_empty()).map(|s| (s.pos, s.own.clone())).collect();
// Merged in the radars' order, each track once: a set marks what is in already.
let mut have: std::collections::HashSet<u32> = std::collections::HashSet::new();
for sam in &mut self.sams {
sam.seen.clone_from(&sam.own);
if !sam.alive() || sam.moving() || !sam.kind.armed() {
sam.seen = sam.own.clone();
continue;
}
let mut seen = sam.own.clone();
have.clear();
have.extend(sam.own.iter().copied());
for (at, ids) in &tracks {
if at.distance(sam.pos) <= LINK {
seen.extend(ids.iter().copied().filter(|id| !seen.contains(id)).collect::<Vec<_>>());
sam.seen.extend(ids.iter().copied().filter(|id| have.insert(*id)));
}
}
sam.seen = seen;
}
}
+27
View File
@@ -99,6 +99,19 @@ pub(super) struct GunSpec {
pub slew: (f64, f64),
}
/// How fast a weapon's mount turns: per axis the top speed (radians a tick) and the acceleration, which
/// is also the braking (radians a tick²). Game settings, not the real machines' figures.
#[derive(Clone, Copy)]
pub(super) struct Drive {
pub yaw: (f64, f64),
pub pitch: (f64, f64),
}
/// The drives are this share of the speeds they had before they had a speed of their own.
const DRIVE_SCALE: f64 = 0.4;
/// Ticks a drive takes to reach its top speed (and to stop from it).
const DRIVE_RAMP: f64 = 8.0;
pub struct SamSpec {
pub name: &'static str,
pub key: &'static str,
@@ -122,6 +135,20 @@ pub struct SamSpec {
pub mast: f64,
}
impl SamSpec {
/// The mount's drive: the guns' own slew rates, the old rates of the launchers, scaled down.
pub(super) fn drive(&self) -> Drive {
let (yaw, pitch) = match (&self.gun, self.family) {
(Some(g), _) => g.slew,
(None, Family::Trainable) => (0.12, 0.0),
(None, Family::Vertical) => (0.15, 0.0),
(None, _) => (0.05, 0.05),
};
let axis = |speed: f64| (speed * DRIVE_SCALE, speed * DRIVE_SCALE / DRIVE_RAMP);
Drive { yaw: axis(yaw), pitch: axis(pitch) }
}
}
const NO_PARTS: Parts = Parts { body: "", launcher: None, pack: None, dish: None, fixed: &[], spinner: None };
/// The battery radar trailer behind a vertical launcher.
const BATTERY_RADAR: &[(&str, f64, f64)] = &[("airdef:radar_trailer", 0.0, 4.2)];
+4 -2
View File
@@ -55,7 +55,7 @@ cd ~/Downloads/airdef-macos-arm64
```powershell
rustup target add wasm32-unknown-unknown
git clone ssh://git@gitea.maoniao.team:2222/chexit/airdef.git
git clone https://gitea.maoniao.team/chexit/airdef.git
cd airdef
py tools\airdef.py all # форк Horizon + сборка игры + мод + установка мода
py tools\airdef.py play # запуск; мир: py tools\airdef.py play --world "Имя мира"
@@ -69,12 +69,14 @@ py tools\airdef.py play # запуск; мир: py tools\airdef.py play
```sh
rustup target add wasm32-unknown-unknown
git clone ssh://git@gitea.maoniao.team:2222/chexit/airdef.git
git clone https://gitea.maoniao.team/chexit/airdef.git
cd airdef
python3 tools/airdef.py all
python3 tools/airdef.py play # или: python3 tools/airdef.py play --world "Имя мира"
```
Клон по HTTPS не требует ключа. Если у тебя есть SSH-ключ в Gitea, можно и так: `git clone ssh://git@gitea.maoniao.team:2222/chexit/airdef.git`.
Шаги по отдельности: `fork` (скачать Horizon и наложить патчи `horizon-patches/`), `game` (собрать игру),
`mod` (собрать `airdef.hmod`), `install` (положить мод в папку игры и включить), `play`.
@@ -1,7 +1,7 @@
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 12:37:45 +0300
Subject: [PATCH 01/10] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 01/14] Mod API (AirDef fork): world models, client particles,
sounds, ground heights
render.set-models draws block models with an interpolated pose per id;
@@ -1,7 +1,7 @@
From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 12:39:45 +0300
Subject: [PATCH 02/10] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 02/14] cargo-horizon-mod: report particles and sounds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
@@ -1,7 +1,7 @@
From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 13:33:10 +0300
Subject: [PATCH 03/10] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 03/14] Mod API (AirDef fork): full-screen post pass for mods
render.set-post compiles a mod's WGSL after a prelude (camera matrices,
time, 256 vec4 of params, the world's colour and depth) and runs it after
@@ -1,7 +1,7 @@
From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 22:09:30 +0300
Subject: [PATCH 04/10] Mod models: draw far models as scaled impostors past
Subject: [PATCH 04/14] Mod models: draw far models as scaled impostors past
the far plane, cull at 4096
---
@@ -1,7 +1,7 @@
From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 22:31:00 +0300
Subject: [PATCH 05/10] Mod cameras: render.set-views draws the world from
Subject: [PATCH 05/14] Mod cameras: render.set-views draws the world from
other points into pictures that HUD view shapes show (API level 9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 23:08:21 +0300
Subject: [PATCH 06/10] Mods: more fuel per tick and per event (AirDef plans
Subject: [PATCH 06/14] Mods: more fuel per tick and per event (AirDef plans
raids)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 23:25:29 +0300
Subject: [PATCH 07/10] Mod cameras up to 1920x1080 (full-screen replays)
Subject: [PATCH 07/14] Mod cameras up to 1920x1080 (full-screen replays)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
@@ -1,7 +1,7 @@
From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 11:15:11 +0300
Subject: [PATCH 08/10] Mod cameras: distant terrain tiles, fog measured from
Subject: [PATCH 08/14] Mod cameras: distant terrain tiles, fog measured from
the camera, no impostors while one is shown
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 11:25:45 +0300
Subject: [PATCH 09/10] Mods: game.world-id, so mods keep state per world (API
Subject: [PATCH 09/14] Mods: game.world-id, so mods keep state per world (API
level 9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 12:01:08 +0300
Subject: [PATCH 10/10] cargo-horizon-mod: trial runs behind feature full, so a
Subject: [PATCH 10/14] cargo-horizon-mod: trial runs behind feature full, so a
packing-only tool skips wasmtime
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,439 @@
From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 16:19:53 +0300
Subject: [PATCH 13/14] HD: bind groups cached until a resize or a new source,
shadow passes draw only the entities that cast shadows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx.rs | 39 ++++-
crates/horizon-client/src/gfx/hd.rs | 247 +++++++++++++++++-----------
2 files changed, 188 insertions(+), 98 deletions(-)
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index e351763fd..f6bbe038c 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -459,6 +459,8 @@ pub struct Renderer {
entity_buffer: wgpu::Buffer,
entity_capacity: u64,
entity_batch: EntityBatch,
+ /// AirDef fork: the runs of entity quads (first, end) that cast shadows this frame.
+ shadow_casters: Vec<(u32, u32)>,
group: wgpu::BindGroup,
globals: wgpu::Buffer,
lightmap: wgpu::Texture,
@@ -2744,6 +2746,7 @@ impl Renderer {
entity_buffer,
entity_capacity,
entity_batch: EntityBatch::default(),
+ shadow_casters: Vec::new(),
group,
globals,
lightmap,
@@ -4980,6 +4983,30 @@ impl Renderer {
}
// The sun's (or moon's) shadow cascades: the world's globals swapped for the light's.
if shadow_strength > 0.0 {
+ // The quads the shadow shader would not discard (overlays 4, 9, 10 and 40:
+ // glowing mod geometry), as runs, so those are not vertex-processed twice.
+ self.shadow_casters.clear();
+ let mut start = None;
+ let quads = self.entity_batch.combined.as_chunks::<4>().0;
+ for (i, quad) in quads.iter().take(entity_quads as usize).enumerate() {
+ let caster = !matches!(quad[0].layer, 4 | 9 | 10 | 40);
+ match (caster, start) {
+ (true, None) => start = Some(i as u32),
+ (false, Some(first)) => {
+ self.shadow_casters.push((first, i as u32));
+ start = None;
+ }
+ _ => {}
+ }
+ }
+ if let Some(first) = start {
+ self.shadow_casters.push((first, entity_quads));
+ }
+ // Many tiny runs cost more in draw calls than they save: draw everything.
+ if self.shadow_casters.len() > 64 {
+ self.shadow_casters.clear();
+ self.shadow_casters.push((0, entity_quads));
+ }
self.queue
.write_buffer(&hd.main_globals, 0, bytemuck::bytes_of(&globals));
for (c, cascade) in hd_cascades.iter().enumerate() {
@@ -5021,11 +5048,13 @@ impl Renderer {
let instance = hd_shadow_first + k as u32;
pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1);
}
- if entity_quads > 0 {
+ if !self.shadow_casters.is_empty() {
pass.set_pipeline(&self.shadow_entity);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
- pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
+ for &(first, end) in &self.shadow_casters {
+ pass.draw_indexed(first * 6..end * 6, 0, 0..1);
+ }
}
}
encoder.copy_buffer_to_buffer(&hd.main_globals, 0, &self.globals, 0, globals_size);
@@ -5586,7 +5615,7 @@ impl Renderer {
);
} else if let Some((color, depth)) = &scaled
&& hd_on
- && let Some(hd) = &self.hd
+ && let Some(hd) = &mut self.hd
{
// AirDef fork: lit by the mods' lights into the HD target, the mod's post pass on
// that, then bloom and the tone curve onto the screen.
@@ -5607,9 +5636,9 @@ impl Renderer {
self.mod_params.1,
);
post.run(&self.device, &mut encoder, (&hd.lit, depth), &hd.fx);
- hd.finish(&self.device, &mut encoder, &hd.fx, &view_target);
+ hd.finish(&self.device, &mut encoder, hd::HdSource::Fx, &view_target);
}
- _ => hd.finish(&self.device, &mut encoder, &hd.lit, &view_target),
+ _ => hd.finish(&self.device, &mut encoder, hd::HdSource::Lit, &view_target),
}
} else if let Some((color, depth)) = &scaled
&& mod_post
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
index c36c1795d..fe2634f2c 100644
--- a/crates/horizon-client/src/gfx/hd.rs
+++ b/crates/horizon-client/src/gfx/hd.rs
@@ -359,6 +359,21 @@ fn target(
Target { view }
}
+/// The outside views and sampler the lighting group reads.
+type LightKey = (
+ wgpu::TextureView,
+ wgpu::TextureView,
+ wgpu::TextureView,
+ wgpu::Sampler,
+);
+
+/// What bloom and the tone curve read: the lit world, or the post pass's output.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum HdSource {
+ Lit,
+ Fx,
+}
+
pub struct Hd {
size: [u32; 2],
pub albedo: wgpu::TextureView,
@@ -377,6 +392,15 @@ pub struct Hd {
pub lit: wgpu::TextureView,
pub fx: wgpu::TextureView,
bloom: Vec<Target>,
+ /// Bind groups, rebuilt only when their views change: `resize` drops all of them, the
+ /// lighting and the source-reading ones are keyed by the views they read.
+ light_group: Option<(LightKey, wgpu::BindGroup)>,
+ prefilter_group: Option<(wgpu::TextureView, wgpu::BindGroup)>,
+ tone_group: Option<(wgpu::TextureView, wgpu::BindGroup)>,
+ /// Reading bloom level k - 1 (for the down pass into level k, index k - 1) and level k (for
+ /// the up pass onto level k - 1, index k - 1).
+ down_groups: Vec<wgpu::BindGroup>,
+ up_groups: Vec<wgpu::BindGroup>,
frame: wgpu::Buffer,
lights: wgpu::Buffer,
sampler: wgpu::Sampler,
@@ -656,6 +680,11 @@ impl Hd {
lit: target(device, "HD lit", [1, 1], HDR).view,
fx: target(device, "HD post", [1, 1], HDR).view,
bloom: Vec::new(),
+ light_group: None,
+ prefilter_group: None,
+ tone_group: None,
+ down_groups: Vec::new(),
+ up_groups: Vec::new(),
frame,
lights,
sampler,
@@ -690,6 +719,16 @@ impl Hd {
target(device, "HD bloom", s, HDR)
})
.collect();
+ // Every group names a view that was just replaced.
+ self.light_group = None;
+ self.prefilter_group = None;
+ self.tone_group = None;
+ self.down_groups = (1..self.bloom.len())
+ .map(|k| self.bloom_group(device, &self.bloom[k - 1].view))
+ .collect();
+ self.up_groups = (1..self.bloom.len())
+ .map(|k| self.bloom_group(device, &self.bloom[k].view))
+ .collect();
}
/// This frame's camera, sun, fog, lights and settings.
@@ -829,73 +868,85 @@ impl Hd {
/// Lights the world (its colour and depth) into `lit`.
pub fn light(
- &self,
+ &mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
world: (&wgpu::TextureView, &wgpu::TextureView),
lightmap: (&wgpu::TextureView, &wgpu::Sampler),
) {
- let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
- label: Some("HD lighting"),
- layout: &self.light_layout,
- entries: &[
- wgpu::BindGroupEntry {
- binding: 0,
- resource: self.frame.as_entire_binding(),
- },
- wgpu::BindGroupEntry {
- binding: 1,
- resource: wgpu::BindingResource::TextureView(world.0),
- },
- wgpu::BindGroupEntry {
- binding: 2,
- resource: wgpu::BindingResource::TextureView(world.1),
- },
- wgpu::BindGroupEntry {
- binding: 3,
- resource: wgpu::BindingResource::TextureView(&self.albedo),
- },
- wgpu::BindGroupEntry {
- binding: 4,
- resource: wgpu::BindingResource::TextureView(&self.normal),
- },
- wgpu::BindGroupEntry {
- binding: 5,
- resource: wgpu::BindingResource::TextureView(&self.depth),
- },
- wgpu::BindGroupEntry {
- binding: 6,
- resource: self.lights.as_entire_binding(),
- },
- wgpu::BindGroupEntry {
- binding: 7,
- resource: wgpu::BindingResource::TextureView(&self.levels),
- },
- wgpu::BindGroupEntry {
- binding: 8,
- resource: wgpu::BindingResource::TextureView(&self.shadow_array),
- },
- wgpu::BindGroupEntry {
- binding: 9,
- resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
- },
- wgpu::BindGroupEntry {
- binding: 10,
- resource: wgpu::BindingResource::TextureView(lightmap.0),
- },
- wgpu::BindGroupEntry {
- binding: 11,
- resource: wgpu::BindingResource::Sampler(lightmap.1),
- },
- ],
- });
+ let key = (
+ world.0.clone(),
+ world.1.clone(),
+ lightmap.0.clone(),
+ lightmap.1.clone(),
+ );
+ if self.light_group.as_ref().is_none_or(|(k, _)| *k != key) {
+ let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("HD lighting"),
+ layout: &self.light_layout,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: self.frame.as_entire_binding(),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::TextureView(world.0),
+ },
+ wgpu::BindGroupEntry {
+ binding: 2,
+ resource: wgpu::BindingResource::TextureView(world.1),
+ },
+ wgpu::BindGroupEntry {
+ binding: 3,
+ resource: wgpu::BindingResource::TextureView(&self.albedo),
+ },
+ wgpu::BindGroupEntry {
+ binding: 4,
+ resource: wgpu::BindingResource::TextureView(&self.normal),
+ },
+ wgpu::BindGroupEntry {
+ binding: 5,
+ resource: wgpu::BindingResource::TextureView(&self.depth),
+ },
+ wgpu::BindGroupEntry {
+ binding: 6,
+ resource: self.lights.as_entire_binding(),
+ },
+ wgpu::BindGroupEntry {
+ binding: 7,
+ resource: wgpu::BindingResource::TextureView(&self.levels),
+ },
+ wgpu::BindGroupEntry {
+ binding: 8,
+ resource: wgpu::BindingResource::TextureView(&self.shadow_array),
+ },
+ wgpu::BindGroupEntry {
+ binding: 9,
+ resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
+ },
+ wgpu::BindGroupEntry {
+ binding: 10,
+ resource: wgpu::BindingResource::TextureView(lightmap.0),
+ },
+ wgpu::BindGroupEntry {
+ binding: 11,
+ resource: wgpu::BindingResource::Sampler(lightmap.1),
+ },
+ ],
+ });
+ self.light_group = Some((key, group));
+ }
+ let Some((_, group)) = &self.light_group else {
+ return;
+ };
Self::draw(
encoder,
"HD lighting",
&self.lit,
false,
&self.light_pipeline,
- &group,
+ group,
);
}
@@ -922,12 +973,53 @@ impl Hd {
/// Bloom from `source` (the lit world or the post pass's output), then the tone curve onto `screen`.
pub fn finish(
- &self,
+ &mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
- source: &wgpu::TextureView,
+ source: HdSource,
screen: &wgpu::TextureView,
) {
+ let source = match source {
+ HdSource::Lit => self.lit.clone(),
+ HdSource::Fx => self.fx.clone(),
+ };
+ if self
+ .prefilter_group
+ .as_ref()
+ .is_none_or(|(v, _)| *v != source)
+ {
+ let group = self.bloom_group(device, &source);
+ self.prefilter_group = Some((source.clone(), group));
+ }
+ if self.tone_group.as_ref().is_none_or(|(v, _)| *v != source) {
+ let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("HD tone mapping"),
+ layout: &self.tone_layout,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: self.frame.as_entire_binding(),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::TextureView(&source),
+ },
+ wgpu::BindGroupEntry {
+ binding: 2,
+ resource: wgpu::BindingResource::TextureView(&self.bloom[0].view),
+ },
+ wgpu::BindGroupEntry {
+ binding: 3,
+ resource: wgpu::BindingResource::Sampler(&self.sampler),
+ },
+ ],
+ });
+ self.tone_group = Some((source, group));
+ }
+ let (Some((_, prefilter)), Some((_, tone))) = (&self.prefilter_group, &self.tone_group)
+ else {
+ return;
+ };
// Down: the prefilter into the first level, then each level from the one above.
Self::draw(
encoder,
@@ -935,61 +1027,30 @@ impl Hd {
&self.bloom[0].view,
false,
&self.prefilter,
- &self.bloom_group(device, source),
+ prefilter,
);
for k in 1..self.bloom.len() {
- let g = self.bloom_group(device, &self.bloom[k - 1].view);
Self::draw(
encoder,
"HD bloom down",
&self.bloom[k].view,
false,
&self.down,
- &g,
+ &self.down_groups[k - 1],
);
}
// Up: each level added onto the bigger one.
for k in (1..self.bloom.len()).rev() {
- let g = self.bloom_group(device, &self.bloom[k].view);
Self::draw(
encoder,
"HD bloom up",
&self.bloom[k - 1].view,
true,
&self.up,
- &g,
+ &self.up_groups[k - 1],
);
}
- let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
- label: Some("HD tone mapping"),
- layout: &self.tone_layout,
- entries: &[
- wgpu::BindGroupEntry {
- binding: 0,
- resource: self.frame.as_entire_binding(),
- },
- wgpu::BindGroupEntry {
- binding: 1,
- resource: wgpu::BindingResource::TextureView(source),
- },
- wgpu::BindGroupEntry {
- binding: 2,
- resource: wgpu::BindingResource::TextureView(&self.bloom[0].view),
- },
- wgpu::BindGroupEntry {
- binding: 3,
- resource: wgpu::BindingResource::Sampler(&self.sampler),
- },
- ],
- });
- Self::draw(
- encoder,
- "HD tone mapping",
- screen,
- false,
- &self.tone,
- &group,
- );
+ Self::draw(encoder, "HD tone mapping", screen, false, &self.tone, tone);
}
}
--
2.54.0 (Apple Git-157)
@@ -0,0 +1,362 @@
From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 16:25:44 +0300
Subject: [PATCH 14/14] HD: lights culled per 16x16 tile (surface and halo
lists built on the CPU), a naga test for the lighting shaders
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx/hd.rs | 242 +++++++++++++++++++++++++++-
1 file changed, 236 insertions(+), 6 deletions(-)
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
index fe2634f2c..2776219fc 100644
--- a/crates/horizon-client/src/gfx/hd.rs
+++ b/crates/horizon-client/src/gfx/hd.rs
@@ -16,6 +16,12 @@ pub const LIGHT_LEVELS: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
pub const HDR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// The most lights drawn a frame.
pub const MAX_LIGHTS: usize = 512;
+/// The side (pixels) of a tile of the screen with its own short list of lights.
+const TILE: u32 = 16;
+/// The indices all the tile lists may hold together; a tile past it loops over every light.
+const TILE_INDICES: usize = 1 << 19;
+/// A list length that means "every light": the list overflowed.
+const EVERY_LIGHT: u32 = u32::MAX;
/// Bloom's mip levels.
const BLOOM_LEVELS: usize = 6;
@@ -55,7 +61,7 @@ struct HdFrame {
fog: [f32; 4],
/// x: sky light kept in the shade; y: blocks' light; z: the air's density (rays, halos).
ambient: [f32; 4],
- /// x: shadows on; y, z: the cascades' half sizes.
+ /// x: shadows on; y, z: the cascades' half sizes; w: the light tiles per row.
shadow: [f32; 4],
}
@@ -123,6 +129,10 @@ struct Light {
@group(0) @binding(9) var shadow_sampler: sampler_comparison;
@group(0) @binding(10) var lightmap: texture_2d<f32>;
@group(0) @binding(11) var linear: sampler;
+// Per tile of 16x16 pixels: where its surface list and its halo list start in this array and
+// how long they are (4 words), then the lists of light indices. A length of 0xffffffff means
+// every light.
+@group(0) @binding(12) var<storage, read> tiles: array<u32>;
// Interleaved gradient noise: a different offset each pixel and frame, for the ray march.
fn ign(px: vec2<f32>) -> f32 {
@@ -181,6 +191,13 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
let sun = frame.sun.xyz;
let sun_on = frame.sun.w;
let count = u32(frame.params.x);
+ let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u;
+ let surface_start = tiles[tile];
+ let surface_all = tiles[tile + 1u] == 0xffffffffu;
+ let surface_count = select(tiles[tile + 1u], count, surface_all);
+ let halo_start = tiles[tile + 2u];
+ let halo_all = tiles[tile + 3u] == 0xffffffffu;
+ let halo_count = select(tiles[tile + 3u], count, halo_all);
var c = scene;
let surface = d > 0.0 && abs(d - g) <= max(d, g) * 0.002;
if surface {
@@ -199,8 +216,8 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
var light = max(base * frame.ambient.x, block * frame.ambient.y) + direct;
// The mods' lights.
- for (var i = 0u; i < count; i++) {
- let l = lights[i];
+ for (var k = 0u; k < surface_count; k++) {
+ let l = lights[select(tiles[surface_start + k], k, surface_all)];
let to = l.pos.xyz - p;
let dist = length(to);
if dist >= l.pos.w { continue; }
@@ -233,8 +250,8 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
let phase = (1.0 - gk * gk) / pow(1.0 + gk * gk - 2.0 * gk * cos_t, 1.5) * 0.08;
c += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density));
}
- for (var i = 0u; i < count; i++) {
- let l = lights[i];
+ for (var k = 0u; k < halo_count; k++) {
+ let l = lights[select(tiles[halo_start + k], k, halo_all)];
// Nearest point of the view ray to the light, and the glow along the ray in front of it.
let t0 = dot(l.pos.xyz, view);
let hh = length(l.pos.xyz - view * t0) + 0.5;
@@ -403,6 +420,11 @@ pub struct Hd {
up_groups: Vec<wgpu::BindGroup>,
frame: wgpu::Buffer,
lights: wgpu::Buffer,
+ /// The tiles' light lists (see `tile_lists`), sized for the screen, and the tiles per row.
+ tiles: wgpu::Buffer,
+ tiles_x: u32,
+ tile_scratch: Vec<Vec<u16>>,
+ tile_words: Vec<u32>,
sampler: wgpu::Sampler,
light_layout: wgpu::BindGroupLayout,
light_pipeline: wgpu::RenderPipeline,
@@ -414,6 +436,137 @@ pub struct Hd {
tone: wgpu::RenderPipeline,
}
+/// The tiles' header (4 words each) and index lists for a screen of `size`.
+fn tiles_buffer(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Buffer {
+ let tiles = (size[0].div_ceil(TILE).max(1) * size[1].div_ceil(TILE).max(1)) as usize;
+ device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("HD light tiles"),
+ size: ((tiles * 4 + TILE_INDICES) * std::mem::size_of::<u32>()) as u64,
+ usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ })
+}
+
+/// Where a camera-relative sphere can show on the screen.
+enum Cover {
+ /// Nowhere: it is wholly behind the camera.
+ Nothing,
+ /// Anywhere: it crosses the camera's plane, or holds the camera.
+ Everywhere,
+ /// Inside this pixel rectangle (min x, min y, max x, max y).
+ Rect([f32; 4]),
+}
+
+/// The screen cover of the sphere at `c` of radius `r`: the projection of the 8 corners of the
+/// box round it. A box is convex and so is its projection while every corner is in front of the
+/// camera, so the rectangle holds the whole sphere. With some corners behind, the projection
+/// is not bounded and the cover is the whole screen; with all of them behind, no pixel ray
+/// (they all point forward) can meet the sphere.
+fn cover(view_proj: &Mat4, c: glam::Vec3, r: f32, size: [u32; 2]) -> Cover {
+ if !c.is_finite() || !r.is_finite() {
+ return Cover::Everywhere;
+ }
+ // A little more than the shader's radius, against rounding.
+ let r = r * 1.01 + 0.01;
+ let (mut lo, mut hi) = (glam::Vec2::splat(f32::MAX), glam::Vec2::splat(f32::MIN));
+ let mut front = 0;
+ for corner in 0..8 {
+ let o = glam::Vec3::new(
+ if corner & 1 == 0 { -r } else { r },
+ if corner & 2 == 0 { -r } else { r },
+ if corner & 4 == 0 { -r } else { r },
+ );
+ let h = *view_proj * (c + o).extend(1.0);
+ if h.w > 0.0 {
+ front += 1;
+ let ndc = glam::Vec2::new(h.x, h.y) / h.w;
+ let px = glam::Vec2::new(
+ (ndc.x * 0.5 + 0.5) * size[0] as f32,
+ (0.5 - ndc.y * 0.5) * size[1] as f32,
+ );
+ lo = lo.min(px);
+ hi = hi.max(px);
+ }
+ }
+ match front {
+ 0 => Cover::Nothing,
+ 8 => Cover::Rect([lo.x - 2.0, lo.y - 2.0, hi.x + 2.0, hi.y + 2.0]),
+ _ => Cover::Everywhere,
+ }
+}
+
+/// Fills `words` with each 16x16 tile's two lists of the lights that can reach it: header
+/// (surface start, surface length, halo start, halo length) per tile, then the indices; a
+/// length of `EVERY_LIGHT` stands for all of `lights` (a list past the buffer's room). The
+/// surface list holds the lights whose sphere can touch what the tile sees. The halo list
+/// holds the lights whose halo can reach a ray of the tile: the shader adds a halo for the
+/// view *line* passing within half the radius of the light, so a ray pointing away from a light
+/// behind it is lit too, and the mirrored sphere is covered as well. Lights stay in order, so
+/// the sums are the ones of the full loop.
+fn tile_lists(
+ view_proj: &Mat4,
+ lights: &[HdLight],
+ size: [u32; 2],
+ scratch: &mut Vec<Vec<u16>>,
+ words: &mut Vec<u32>,
+) {
+ let tx = size[0].div_ceil(TILE).max(1) as usize;
+ let ty = size[1].div_ceil(TILE).max(1) as usize;
+ let tiles = tx * ty;
+ scratch.resize_with(tiles * 2, Vec::new);
+ scratch.truncate(tiles * 2);
+ scratch.iter_mut().for_each(Vec::clear);
+ let mut add = |list: usize, area: Cover, light: u16| match area {
+ Cover::Nothing => {}
+ Cover::Everywhere => {
+ for t in 0..tiles {
+ scratch[t * 2 + list].push(light);
+ }
+ }
+ Cover::Rect([x0, y0, x1, y1]) => {
+ let tile = TILE as f32;
+ if x1 < 0.0 || y1 < 0.0 || x0 >= size[0] as f32 || y0 >= size[1] as f32 {
+ return;
+ }
+ let from = |v: f32, n: usize| ((v / tile).floor().max(0.0) as usize).min(n - 1);
+ for y in from(y0, ty)..=from(y1, ty) {
+ for x in from(x0, tx)..=from(x1, tx) {
+ scratch[(y * tx + x) * 2 + list].push(light);
+ }
+ }
+ }
+ };
+ for (i, l) in lights.iter().enumerate() {
+ let c = glam::Vec3::new(l.pos[0], l.pos[1], l.pos[2]);
+ add(0, cover(view_proj, c, l.pos[3], size), i as u16);
+ // The shader's halo reach: the line within `radius / 2 - 0.5` of the light.
+ let reach = l.pos[3] * 0.5 - 0.5;
+ if reach > 0.0 || !reach.is_finite() {
+ add(1, cover(view_proj, c, reach, size), i as u16);
+ add(1, cover(view_proj, -c, reach, size), i as u16);
+ }
+ }
+ words.clear();
+ words.resize(tiles * 4, 0);
+ let mut room = TILE_INDICES;
+ for t in 0..tiles {
+ for list in 0..2 {
+ let ids = &mut scratch[t * 2 + list];
+ // Both the sphere and its mirror can name a light: once is enough.
+ ids.dedup();
+ let at = t * 4 + list * 2;
+ if ids.len() > room {
+ words[at + 1] = EVERY_LIGHT;
+ } else {
+ room -= ids.len();
+ words[at] = words.len() as u32;
+ words[at + 1] = ids.len() as u32;
+ words.extend(ids.iter().map(|&i| u32::from(i)));
+ }
+ }
+ }
+}
+
fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(label),
@@ -527,6 +680,11 @@ impl Hd {
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
FLOAT,
SAMPLER,
+ wgpu::BindingType::Buffer {
+ ty: wgpu::BufferBindingType::Storage { read_only: true },
+ has_dynamic_offset: false,
+ min_binding_size: None,
+ },
]),
});
let bloom_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -687,6 +845,10 @@ impl Hd {
up_groups: Vec::new(),
frame,
lights,
+ tiles: tiles_buffer(device, [1, 1]),
+ tiles_x: 1,
+ tile_scratch: Vec::new(),
+ tile_words: Vec::new(),
sampler,
light_layout,
light_pipeline,
@@ -713,6 +875,8 @@ impl Hd {
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
self.lit = target(device, "HD lit", size, HDR).view;
self.fx = target(device, "HD post", size, HDR).view;
+ self.tiles = tiles_buffer(device, size);
+ self.tiles_x = size[0].div_ceil(TILE).max(1);
self.bloom = (0..BLOOM_LEVELS)
.map(|k| {
let s = [(size[0] >> (k + 1)).max(1), (size[1] >> (k + 1)).max(1)];
@@ -743,6 +907,15 @@ impl Hd {
if n > 0 {
queue.write_buffer(&self.lights, 0, bytemuck::cast_slice(&lights[..n]));
}
+ // Written every frame, empty too: the shader reads the counts, not `params.x`.
+ tile_lists(
+ &scene.view_proj,
+ &lights[..n],
+ self.size,
+ &mut self.tile_scratch,
+ &mut self.tile_words,
+ );
+ queue.write_buffer(&self.tiles, 0, bytemuck::cast_slice(&self.tile_words));
self.frames = self.frames.wrapping_add(1);
// The sun by day, the moon (opposite) by night, faded out round the horizon.
let (dir, strength, color) = light_of(scene);
@@ -773,7 +946,7 @@ impl Hd {
if strength > 0.0 { 1.0 } else { 0.0 },
CASCADES[0],
CASCADES[1],
- 0.0,
+ self.tiles_x as f32,
],
};
queue.write_buffer(&self.frame, 0, bytemuck::bytes_of(&frame));
@@ -933,6 +1106,10 @@ impl Hd {
binding: 11,
resource: wgpu::BindingResource::Sampler(lightmap.1),
},
+ wgpu::BindGroupEntry {
+ binding: 12,
+ resource: self.tiles.as_entire_binding(),
+ },
],
});
self.light_group = Some((key, group));
@@ -1102,3 +1279,56 @@ pub fn cascades(camera: glam::DVec3, light: glam::Vec3) -> [Mat4; 2] {
(projection * view).as_mat4()
})
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn lighting_shader_parses() {
+ for (name, body) in [("light", LIGHT), ("bloom", BLOOM)] {
+ let source = format!("{COMMON}{body}");
+ let module = naga::front::wgsl::parse_str(&source).unwrap_or_else(|e| {
+ panic!("{name}: {}", e.emit_to_string(&source));
+ });
+ naga::valid::Validator::new(
+ naga::valid::ValidationFlags::all(),
+ naga::valid::Capabilities::all(),
+ )
+ .validate(&module)
+ .unwrap_or_else(|e| panic!("{name}: {e:?}"));
+ }
+ }
+
+ #[test]
+ fn tile_lists_keep_lights_that_reach_a_tile() {
+ let size = [320, 160];
+ let proj = Mat4::perspective_rh(1.0, 2.0, 0.05, 1000.0);
+ let light = |x: f32, y: f32, z: f32, r: f32| HdLight {
+ pos: [x, y, z, r],
+ color: [1.0; 4],
+ };
+ let lights = [
+ // Dead ahead and small; far to the side; behind the camera; holding the camera.
+ light(0.0, 0.0, -20.0, 4.0),
+ light(30.0, 0.0, -5.0, 4.0),
+ light(0.0, 0.0, 10.0, 8.0),
+ light(1.0, 0.0, 0.0, 6.0),
+ ];
+ let (mut scratch, mut words) = (Vec::new(), Vec::new());
+ tile_lists(&proj, &lights, size, &mut scratch, &mut words);
+ let list = |tile: usize, which: usize| -> Vec<u32> {
+ let (start, len) = (words[tile * 4 + which * 2], words[tile * 4 + which * 2 + 1]);
+ words[start as usize..(start + len) as usize].to_vec()
+ };
+ // The centre tile sees the first light and the one at the camera, in both lists.
+ let centre = 5 * 20 + 10;
+ assert_eq!(list(centre, 0), vec![0, 3]);
+ assert!(list(centre, 1).contains(&0) && list(centre, 1).contains(&3));
+ // A far corner sees only the one at the camera.
+ assert_eq!(list(0, 0), vec![3]);
+ // The light behind the camera shows in the halo list where the view points away.
+ assert!(list(centre, 1).contains(&2));
+ assert!(!list(0, 0).contains(&2));
+ }
+}
--
2.54.0 (Apple Git-157)
+28 -3
View File
@@ -12,6 +12,7 @@
Minecraft 26.3), AIRDEF_GAME (готовый horizon-client вместо собранного).
"""
import glob
import hashlib
import os
import platform
import shutil
@@ -51,21 +52,45 @@ def cargo() -> str:
return found
def fork_stamp(commit: str, patches) -> str:
"""Что должно стоять в horizon/: коммит апстрима и содержимое патчей."""
h = hashlib.sha256(commit.encode())
for p in patches:
h.update(p.name.encode())
h.update(p.read_bytes())
return h.hexdigest()
def fork():
"""horizon/ = апстрим на коммите HORIZON_COMMIT + патчи horizon-patches/."""
"""horizon/ = апстрим на коммите HORIZON_COMMIT + патчи horizon-patches/.
Ветка airdef пересобирается, когда коммит или патчи изменились (после обновления репозитория);
прежняя остаётся в ветке airdef-prev."""
commit = (ROOT / "HORIZON_COMMIT").read_text().strip()
patches = sorted((ROOT / "horizon-patches").glob("*.patch"))
stamp = fork_stamp(commit, patches)
stamp_file = FORK / ".git" / "airdef-stamp"
if not (FORK / ".git").exists():
run(["git", "clone", "--filter=blob:none", UPSTREAM, FORK])
branch = subprocess.run(["git", "-C", FORK, "branch", "--show-current"], capture_output=True, text=True).stdout.strip()
if branch == "airdef":
print("horizon/: ветка airdef уже есть")
if branch == "airdef" and stamp_file.exists() and stamp_file.read_text().strip() == stamp:
print("horizon/: ветка airdef на нужных коммите и патчах")
return
dirty = subprocess.run(["git", "-C", FORK, "status", "--porcelain", "--untracked-files=no"],
capture_output=True, text=True).stdout.strip()
if dirty:
sys.exit("horizon/: есть незакоммиченные изменения, а движок надо обновить. Закоммить или убери их и запусти снова.")
exists = subprocess.run(["git", "-C", FORK, "rev-parse", "--verify", "-q", "refs/heads/airdef"],
capture_output=True).returncode == 0
if exists:
print("horizon/: коммит или патчи изменились, ветка airdef пересобирается (прежняя: airdef-prev)")
run(["git", "-C", FORK, "branch", "-f", "airdef-prev", "airdef"])
run(["git", "-C", FORK, "fetch", "--quiet", "origin", commit])
run(["git", "-C", FORK, "checkout", "-B", "airdef", commit])
if patches:
run(["git", "-C", FORK, "-c", "user.name=airdef", "-c", "user.email=airdef@localhost",
"am", "--3way", *patches])
stamp_file.write_text(stamp + "\n")
def game():