Compare commits

...

4 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
30 changed files with 1500 additions and 169 deletions
+5 -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 с тегом
готового релиза и дописывают свои архивы в него.
## Как играть
@@ -130,7 +131,7 @@ API мода `render.set-lights` (до 512, плавно интерполиру
перехватчиков (жёлтые), сбития (x), прилёты, шкала времени с событиями; время идёт само, колесо мыши перематывает;
- **повтор момента** — клик по событию в списке: «призраки» дронов и ракет снова летят в мире, камера идёт за
целью, потом за перехватчиком (или смотрит из пушки, со стороны на прилёт), в момент сбития время замедляется;
- **полный повтор** — весь налёт, камера-режиссёр сама выбирает, за чем следить; колесо — скорость (×0.5–×16);
- **полный повтор** — весь налёт, камера-режиссёр сама выбирает, за чем следить; колесо — перемотка, стрелки — скорость;
- **лучшие моменты** — нарезка до 8 самых интересных событий (перехват баллистики и гиперзвука, поражения
объектов, потерянные комплексы, разделение «Орешника») с титрами и замедлением; колесо листает моменты.
+5 -2
View File
@@ -197,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;
@@ -211,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)));
+47 -12
View File
@@ -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>,
@@ -82,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).
@@ -307,7 +319,8 @@ impl AirDef {
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;
let id = 0x6100_0000 ^ (b.start as u32).wrapping_mul(31) ^ (b.at.x as i32 as u32).wrapping_mul(7);
// 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 {
@@ -352,7 +365,14 @@ impl AirDef {
let mut p = vec![0.0f32; 4];
p[0] = self.sim.tick as f32;
p[3] = if self.hd { 1.0 } else { 0.0 };
let blasts: Vec<&sim::Blast> = self.sim.blasts.iter().rev().take(48).collect();
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]);
@@ -441,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);
@@ -449,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;
}
@@ -517,10 +537,17 @@ impl AirDef {
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) {
@@ -635,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();
@@ -885,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,
@@ -1044,7 +1079,7 @@ impl Mod for AirDef {
self.health_mark = mark;
self.dirty = true;
}
if self.dirty {
if self.dirty && self.sim.tick >= self.next_save {
self.save();
}
}
+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
}
+63 -35
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};
@@ -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.
@@ -201,6 +204,7 @@ impl Player {
};
let _ = sim;
Player {
area: Cell::new(None),
rec,
mode,
t: start,
@@ -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 }
+2
View File
@@ -189,6 +189,8 @@ impl Sim {
for h in &mut self.target_health {
*h = 100.0;
}
// The wrecks' ammunition still to cook off goes with them.
self.later.clear();
n
}
+66 -2
View File
@@ -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().filter(|(_, d)| d.smart) {
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() && d.smart) {
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 -2
View File
@@ -43,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];
@@ -66,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()
+162 -69
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)> {
@@ -932,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,
@@ -985,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,
@@ -1024,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)
}
@@ -1090,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,
@@ -1140,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,
@@ -1308,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();
@@ -1317,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;
@@ -1352,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));
@@ -1377,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));
@@ -1509,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,
@@ -1525,7 +1596,7 @@ impl Sim {
route: Vec::new(),
low: false,
jammed: false,
sector: 0,
sector,
smart: false,
});
}
@@ -1624,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);
}
@@ -1688,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();
@@ -1696,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();
@@ -1725,60 +1806,71 @@ impl Sim {
sam.state = SamState::Search;
// A searchlight's target is its beam's (step_lights): kept for its spot of light.
if sam.kind != SamKind::Light {
sam.target = None;
retarget(&mut missile_on, sam.target.take(), None);
}
sam.gun_target = 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 {
@@ -1788,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();
@@ -1798,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
@@ -1831,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;
@@ -1879,11 +1971,11 @@ 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 {
@@ -1892,10 +1984,11 @@ impl Sim {
launches.push((load.kind, muzzle, dir, mt, sam.id));
// 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(d) = self.drones.iter_mut().find(|d| d.id == mt) {
d.engaged += 1;
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;
+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.
+20 -12
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;
@@ -401,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);
@@ -426,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.
+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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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>
@@ -1,7 +1,7 @@
From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 13:18:43 +0300
Subject: [PATCH 11/12] HD lighting: G-buffer pass, per-pixel mod point lights
Subject: [PATCH 11/14] HD lighting: G-buffer pass, per-pixel mod point lights
in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API
level 10)
@@ -1,7 +1,7 @@
From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 13:59:43 +0300
Subject: [PATCH 12/12] HD lighting stage 2: sun and moon shadow cascades, the
Subject: [PATCH 12/14] HD lighting stage 2: sun and moon shadow cascades, the
world relit (sun by face angle and shadow, sky and block light through the
lightmap), light shafts and halos, fog over it again, a light colour grade
@@ -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)