diff --git a/airdef-mod/examples/sim_run/harness.rs b/airdef-mod/examples/sim_run/harness.rs index d3323c5..41d7de4 100644 --- a/airdef-mod/examples/sim_run/harness.rs +++ b/airdef-mod/examples/sim_run/harness.rs @@ -18,6 +18,13 @@ pub enum Terrain { Hills { base: i32, amp: f64, period: f64 }, } +/// The land as blocks: solid at and below the column's height (what the game would answer `render.sections` with). +impl airdef::sim::world::WorldSource for Terrain { + fn solid(&self, x: i32, y: i32, z: i32) -> Option { + Some(y < self.height(x, z)) + } +} + impl Terrain { pub fn height(self, x: i32, z: i32) -> i32 { match self { @@ -98,6 +105,9 @@ pub struct Report { /// Missiles fired by air defence (sum of the systems' `fired`). pub fired: u32, pub ground_queries: u64, + /// The block cache's counters (issue #43) and the drones that struck the land (issue #42). + pub world: String, + pub crashes: u32, pub ground_batches: u64, pub tick_p50: Duration, pub tick_p95: Duration, @@ -163,6 +173,8 @@ pub fn run(sc: &Scenario) -> Report { let t = Instant::now(); sim.step(&mut ground, None, &mut out); let wanted = ground.take_wanted(); + let sections = ground.world.take_wanted(now); + ground.world.serve(&sc.terrain, sections, now); let dt = t.elapsed(); times.push(dt); if planning { @@ -212,6 +224,8 @@ pub fn run(sc: &Scenario) -> Report { r.hits = sim.hits; r.fired = sim.sams.iter().map(|s| s.fired).sum(); r.raids_ordered = ordered.iter().filter(|o| **o).count(); + r.world = ground.world.text(); + r.crashes = sim.crashes; r.raids_ended = ended.iter().filter(|o| **o).count(); r.all_ended = r.raids_ended == sc.raids.len(); r.tick_total = times.iter().sum(); @@ -253,7 +267,8 @@ impl Report { planning: {pl:.1} ms over {pt} ticks\n\ ground queries: {gq} in {gb} batches\n\ non-finite positions: {nan}\n\ - stuck trucks: {stuck:?} (busy truck-ticks {busy})\n", + stuck trucks: {stuck:?} (busy truck-ticks {busy})\n\ + crashes into the land: {crashes}; {world}\n", name = self.name, seed = self.seed, units = self.units, @@ -276,6 +291,8 @@ impl Report { nan = self.nan_positions, stuck = self.stuck_trucks, busy = self.truck_busy_ticks, + crashes = self.crashes, + world = self.world, ) } pub fn json(&self) -> String { diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index bbe3afc..fff29fb 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -1061,6 +1061,25 @@ impl Mod for AirDef { } let now = self.sim.tick; self.ground.answer(render::ground(), now); + // The blocks themselves (issues #42, #43): what changed, and the sections asked for last tick. + let changes = render::world_changes(); + self.ground.world.changed(changes.epoch, changes.overflow, &changes.sections); + let sections = render::sections() + .into_iter() + .map(|s| sim::world::SectionAnswer { + sec: (s.x, s.y, s.z), + fill: match s.fill { + render::SectionFill::Unknown => sim::world::Fill::Unknown, + render::SectionFill::Empty => sim::world::Fill::Empty, + render::SectionFill::Full => sim::world::Fill::Full, + render::SectionFill::Mixed => match <[u8; 512]>::try_from(s.bits) { + Ok(bits) => sim::world::Fill::Mixed(Box::new(bits)), + Err(_) => sim::world::Fill::Unknown, + }, + }, + }) + .collect(); + self.ground.world.answer(sections, now); let mut out = Out::default(); self.listener = DVec3::new(p.x, p.y + 1.6, p.z); self.sim.night = game::get_clock().is_some_and(|c| (13000..23000).contains(&(c.day_time % 24000))); @@ -1069,6 +1088,10 @@ impl Mod for AirDef { if !wanted.is_empty() { render::query_ground(&wanted); } + let sections = self.ground.world.take_wanted(now); + if !sections.is_empty() { + render::query_sections(render::Policy::Collision, §ions); + } if let Some(mut r) = self.replay.take() { r.tick(&self.sim, self.listener, &mut out); if r.done { @@ -1451,12 +1474,14 @@ impl Mod for AirDef { Some(format!("Эффекты: {}", match *v { "off" => "выкл", "hd" => "HD", _ => "обычные" })) } ["stats"] => Some(format!( - "В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}", + "В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}, разбились о препятствия: {}; {}", self.sim.drones.iter().filter(|d| d.alive()).count(), self.sim.missiles.len(), self.sim.sams.len(), self.sim.kills, - self.sim.hits + self.sim.hits, + self.sim.crashes, + self.ground.world.text() )), _ => return Err(HELP.to_string()), }; diff --git a/airdef-mod/src/replay.rs b/airdef-mod/src/replay.rs index 15c6c86..73bb2d3 100644 --- a/airdef-mod/src/replay.rs +++ b/airdef-mod/src/replay.rs @@ -107,6 +107,7 @@ pub fn title(rec: &Recording, i: usize) -> String { EventKind::Hit { kind, .. } => format!("Прилёт: «{}»", kind.spec().name), EventKind::SamLost { kind, index, .. } => format!("{} №{} уничтожен", kind.name(), index + 1), EventKind::Split { .. } => "«Орешник»: разделение боевых блоков".into(), + EventKind::Crash { kind, .. } => format!("«{}» разбивается о препятствие", kind.spec().name), } } @@ -122,6 +123,7 @@ pub fn row(rec: &Recording, i: usize) -> String { EventKind::Hit { kind, .. } => format!("{}: прилёт", kind.spec().name), EventKind::SamLost { kind, index, .. } => format!("{}-{} уничтожен", kind.name(), index + 1), EventKind::Split { .. } => "Орешник: разделение".into(), + EventKind::Crash { kind, .. } => format!("{}: о рельеф", kind.spec().name), }; format!("{} {what}", clock(e.at as f64)) } @@ -152,6 +154,7 @@ fn interest(rec: &Recording, i: usize) -> f64 { power as f64 * 1.5 + if object.is_some() { 6.0 } else if sam.is_some() { 5.0 } else { 0.0 } } EventKind::SamLost { .. } => 12.0, + EventKind::Crash { power, .. } => power as f64, EventKind::Split { drone } => { // The bus's split, not each block's. let first = rec.events.iter().position(|o| matches!(o.kind, EventKind::Split { .. })); @@ -168,6 +171,7 @@ fn clip(rec: &Recording, i: usize) -> Clip { EventKind::Hit { .. } => (160.0, 60.0), EventKind::SamLost { .. } => (80.0, 90.0), EventKind::Split { .. } => (40.0, 170.0), + EventKind::Crash { .. } => (120.0, 60.0), }; Clip { event: i, from: (at - before).max(0.0), to: (at + after).min(rec.len as f64) } } @@ -352,7 +356,7 @@ impl Player { for e in rec.events.iter().filter(|e| (self.heard..self.t).contains(&(e.at as f64))) { let event = match e.kind { EventKind::Kill { .. } => "airdef:blast.air", - EventKind::Hit { power, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][(power as usize).min(4)], + EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][(power as usize).min(4)], EventKind::SamLost { .. } => "airdef:blast.big", EventKind::Split { .. } => "airdef:boom.sonic", }; @@ -412,7 +416,7 @@ impl Player { } } } - EventKind::Hit { drone, .. } => { + EventKind::Hit { drone, .. } | EventKind::Crash { drone, .. } => { if t < te - 50.0 { if let Some((eye, look)) = chase(drone, 12.0, 4.0) { return (eye, look, 55.0, 0); @@ -672,7 +676,7 @@ impl Player { s.push(line((x - 2.5, y - 2.5), (x + 2.5, y + 2.5), 1.2, 0xFFFFE040)); s.push(line((x + 2.5, y - 2.5), (x - 2.5, y + 2.5), 1.2, 0xFFFFE040)); } - EventKind::Hit { power, .. } => { + EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => { let r = 2.0 + power as f32; s.push(fill(x - r, y - r, r * 2.0, r * 2.0, 0xB0FF3020)); } @@ -705,6 +709,7 @@ impl Player { let c = match e.kind { EventKind::Kill { .. } => 0xFFFFE040, EventKind::Hit { .. } => 0xFFFF3020, + EventKind::Crash { .. } => 0xFF903020, EventKind::SamLost { .. } => 0xFFFF00FF, EventKind::Split { .. } => 0xFFFF9020, }; diff --git a/airdef-mod/src/sim/damage.rs b/airdef-mod/src/sim/damage.rs index ea82db6..f1965ee 100644 --- a/airdef-mod/src/sim/damage.rs +++ b/airdef-mod/src/sim/damage.rs @@ -28,7 +28,72 @@ impl MissileKind { } } +/// A strike that ends farther than this from its aim point misses (the lethal radius of a point target). +pub(super) const MISS_RADIUS: f64 = 3.0; +/// A system's hull, as a cylinder: its radius (× scale) and how far above the ground it reaches (× scale). +const HULL_RADIUS: f64 = 2.2; +const HULL_TOP: f64 = 3.0; + +impl DroneKind { + /// Strike accuracy: the CEP (blocks; half the strikes land within it, the scatter is Rayleigh) + /// and the share of strikes with a rare gross navigation fault, which lands beyond `MISS_RADIUS`. + pub(super) fn accuracy(self) -> (f64, f64) { + match self { + DroneKind::Shahed => (1.2, 0.03), + DroneKind::Geran3 => (1.0, 0.03), + DroneKind::Lancet => (0.8, 0.02), + DroneKind::Gerbera => (1.5, 0.04), + DroneKind::Kh101 => (1.3, 0.05), + DroneKind::Kalibr => (1.3, 0.05), + DroneKind::Kh22 => (1.6, 0.06), + DroneKind::Zircon => (1.5, 0.06), + DroneKind::Iskander => (1.4, 0.06), + DroneKind::Kinzhal => (1.5, 0.06), + // The bus and its blocks: the blocks are aimed by the split, so only the bus's own error applies. + DroneKind::Oreshnik | DroneKind::OreshnikBlock | DroneKind::OreshnikSub => (1.6, 0.08), + } + } +} + impl Sim { + /// The aim error of a new strike: drawn once from the seed, the drone keeps it for its whole flight. + pub(super) fn aim_error(&mut self, kind: DroneKind) -> DVec3 { + let (cep, gross) = kind.accuracy(); + let angle = self.rng.f() * std::f64::consts::TAU; + let r = if self.rng.chance(gross) { + MISS_RADIUS + cep * (0.5 + 2.5 * self.rng.f()) + } else { + // Rayleigh with its median at the CEP. + let u = 1.0 - self.rng.f(); + cep * (-2.0 * u.ln() / (2.0 * std::f64::consts::LN_2)).sqrt() + }; + DVec3::new(angle.cos() * r, 0.0, angle.sin() * r) + } + + /// An armed warhead reached `pos` (its last step was `vel`): a system whose hull it swept through is + /// destroyed at once, whatever the blast would have done to it. A near miss is only splash. + pub(super) fn direct_hit(&mut self, pos: DVec3, vel: DVec3, tnt: u32, out: &mut Out) { + if !self.damage || tnt == 0 { + return; + } + let from = pos - vel; + for i in 0..self.sams.len() { + let sam = &self.sams[i]; + if !sam.alive() { + continue; + } + let s = sam.kind.spec().scale; + let hit = (0..=8).any(|k| { + let p = from.lerp(pos, k as f64 / 8.0); + let across = DVec3::new(p.x - sam.pos.x, 0.0, p.z - sam.pos.z).length(); + across <= HULL_RADIUS * s && p.y >= sam.pos.y - 1.0 && p.y <= sam.pos.y + HULL_TOP * s + }); + if hit { + self.destroy(i, out); + } + } + } + /// A blast of `power` (0 to 4) at `pos`: the systems and objects round it take damage. pub(super) fn blast_damage(&mut self, pos: DVec3, power: usize, out: &mut Out) { let (reach, hit) = (REACH[power], HIT[power]); diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index 4fe495c..2a6bac1 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -19,6 +19,7 @@ mod logistics; mod orders; mod units; mod why; +pub mod world; #[cfg(test)] mod regress; pub use ew::EW_RADIUS; @@ -211,6 +212,8 @@ pub struct Ground { 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, + /// The blocks themselves (issues #42, #43): what stops a flight. + pub world: world::WorldCache, } /// Ticks between full clean-ups of a big ground cache. @@ -251,10 +254,32 @@ impl Ground { self.asked.clear(); std::mem::take(&mut self.wanted) } + /// The first solid block on the way from `a` to `b` (the real blocks, not the height of the land). + pub fn sweep(&mut self, a: DVec3, b: DVec3, skip_start: bool, now: u64) -> world::Sweep { + self.world.sweep(a, b, skip_start, now) + } + /// Has the sections along a flight read before it gets there. + pub fn ahead(&mut self, pos: DVec3, vel: DVec3, ticks: f64, now: u64) { + self.world.look_ahead(pos, vel, ticks, now); + } + /// Whether `pos` is in the land by the column's height (`below`, as `at` gave it), given what the sweep + /// of this step saw: over blocks known and open, the height only counts where its top block is known + /// to be open, a fluid's surface (not a roof or a bridge deck above the flight, nor a block unread). + pub fn ground_hit(&mut self, below: Option, pos: DVec3, offset: f64, sweep: &world::Sweep, now: u64) -> bool { + let Some(g) = below else { return false }; + if pos.y > g + offset { + return false; + } + match sweep { + world::Sweep::Clear => self.world.is_known_open(pos.x.floor() as i32, g.floor() as i32 - 1, pos.z.floor() as i32, now), + _ => true, + } + } /// Explosions change the land: forget what was known round them. fn forget(&mut self, at: DVec3, radius: i32) { let (cx, cz) = (at.x.floor() as i32, at.z.floor() as i32); self.known.retain(|&(x, z), _| (x - cx).abs() > radius || (z - cz).abs() > radius); + self.world.touched(at, f64::from(radius) + 2.0); } } @@ -1172,6 +1197,8 @@ pub struct Sim { pub tick: u64, pub kills: u32, pub hits: u32, + /// Drones that struck the land or a building short of their aim. + pub crashes: u32, pub launched: u32, /// Explosions damage the land (primed TNT through commands). pub damage: bool, @@ -1225,6 +1252,7 @@ impl Sim { tick: 0, kills: 0, hits: 0, + crashes: 0, launched: 0, damage: true, shader: false, @@ -1289,6 +1317,8 @@ impl Sim { fn add_drone(&mut self, kind: DroneKind, from: DVec3, dir: DVec3, target: DVec3) { let id = self.id(); let spec = kind.spec(); + // Its navigation error: it flies to, and strikes at, a point off the aim it was given. + let target = target + self.aim_error(kind); let (vel, flight) = if spec.ballistic.is_some() { // An arc from `from` to `target` at the kind's horizontal speed. let flat = horizontal(target - from); @@ -1373,7 +1403,7 @@ impl Sim { self.update_sight(ground); self.step_lights(out); self.step_sams(out); - self.step_missiles(out); + self.step_missiles(ground, out); self.step_fx(ground, out); self.draw_targets(out); self.step_wrecks(out); @@ -1394,10 +1424,14 @@ impl Sim { // 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(); + // Drones that struck the land or a building short of their aim: drone, kind, where, TNT. + let mut crashes: Vec<(u32, DroneKind, DVec3, u32)> = Vec::new(); for d in &mut self.drones { d.age += 1; let spec = d.kind.spec(); let below = ground.at(d.pos, now); + let from = d.pos; + ground.ahead(d.pos, d.vel, 20.0, 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 @@ -1412,11 +1446,15 @@ impl Sim { d.vel *= 0.985; d.spin += 0.35; d.pos += d.vel; + let sweep = ground.sweep(from, d.pos, d.age <= 3, now); + if let Some((_, p)) = sweep.hit() { + d.pos = p; + } if self.rng.chance(0.5) { out.particles("minecraft:large_smoke", d.pos, 0.2, DVec3::new(0.0, 0.05, 0.0), 0.02, 1); } out.particles("minecraft:flame", d.pos, 0.25, DVec3::ZERO, 0.02, 2); - if below.is_some_and(|g| d.pos.y <= g + 0.3) || d.age > 600 { + if sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.3, &sweep, now) || d.age > 600 { // The wreck's warhead may still go off where it lands. let warhead = if spec.decoy || !self.rng.chance(0.35) { 0 } else { spec.warhead.min(2) }; boom.push((d.pos, warhead, true)); @@ -1439,13 +1477,24 @@ impl Sim { d.vel = turn(d.vel, d.target - d.pos, spec.turn) * sp; } d.pos += d.vel; + let sweep = ground.sweep(from, d.pos, d.age <= 3, now); + if let Some((_, p)) = sweep.hit() { + d.pos = p; + } let speed = d.vel.length(); + let landed = sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.2, &sweep, now); let hit = d.pos.distance(d.target) < speed.max(2.0) - || below.is_some_and(|g| d.pos.y <= g + 0.2) + || landed || (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)); + // Into the land or a building short of its aim: a crash, not a hit. + if landed && d.pos.distance(d.target) >= speed.max(2.0) + 6.0 { + crashes.push((d.id, d.kind, d.pos, spec.warhead)); + removed.push(d.id); + continue; + } impacts.push((d.id, d.kind, d.pos, spec.warhead)); removed.push(d.id); self.hits += 1; @@ -1546,10 +1595,22 @@ impl Sim { let speed = if d.flight == Flight::Dive { spec.speed * 1.35 } else { spec.speed }; d.vel = new * speed; d.pos += d.vel; + let sweep = ground.sweep(from, d.pos, d.age <= 3, now); + if let Some((_, p)) = sweep.hit() { + d.pos = p; + } let at_target = d.pos.distance(d.target) < speed.max(1.5); - let in_ground = below.is_some_and(|g| d.pos.y <= g + 0.2); + let in_ground = sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.2, &sweep, now); if at_target || in_ground || d.age > 8000 { boom.push((d.pos, spec.warhead, spec.decoy)); + // Into the land or a building short of its aim: a crash, not a hit. + if in_ground && d.pos.distance(d.target) >= speed.max(1.5) + 3.0 { + if !spec.decoy { + crashes.push((d.id, d.kind, d.pos, spec.warhead)); + } + removed.push(d.id); + continue; + } if !spec.decoy { impacts.push((d.id, d.kind, d.pos, spec.warhead)); } @@ -1580,6 +1641,17 @@ impl Sim { for (drone, pos) in splits { self.log(pos, record::EventKind::Split { drone }); } + for (drone, kind, pos, tnt) in crashes { + self.crashes += 1; + let power = match tnt { + 0 => 0, + 1 | 2 => 1, + 3..=5 => 2, + 6 | 7 => 3, + _ => 4, + }; + self.log(pos, record::EventKind::Crash { drone, kind, power }); + } for (drone, kind, pos, tnt) in impacts { if let Some(sector) = self.drones.iter().find(|x| x.id == drone).map(|x| x.sector as usize) { self.learn(sector, false); @@ -1594,6 +1666,8 @@ impl Sim { let object = self.targets.iter().position(|t| t.distance(pos) < 16.0); let sam = self.sams.iter().enumerate().find(|(_, s)| s.alive() && s.pos.distance(pos) < 12.0).map(|(i, s)| (s.kind, i)); self.log(pos, record::EventKind::Hit { drone, kind, power, object, sam }); + let vel = self.drones.iter().find(|x| x.id == drone).map_or(DVec3::ZERO, |x| x.vel); + self.direct_hit(pos, vel, tnt, out); } for (pos, warhead, small) in boom { self.explode(pos, warhead, small, ground, player, out); @@ -2061,9 +2135,11 @@ impl Sim { // ------------------------------------------------ interceptors - fn step_missiles(&mut self, out: &mut Out) { + fn step_missiles(&mut self, ground: &mut Ground, out: &mut Out) { + let now = self.tick; let mut done: Vec<(u32, Option, bool, u32)> = Vec::new(); // (missile, target, kill, sam) let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32); + let mut blocked = 0u32; let sams_fit: HashMap = self.sams.iter().map(|s| (s.id, s.fitness())).collect(); for m in &mut self.missiles { m.age += 1; @@ -2091,13 +2167,16 @@ impl Sim { let before = m.pos; m.vel = new * speed; m.pos += m.vel; + // The first block on the way this tick; a fuse that goes off earlier along the way still counts. + ground.ahead(m.pos, m.vel, 10.0, now); + let world = ground.sweep(before, m.pos, m.age <= 12, now).hit(); if let Some(t) = target { // Closest approach this tick, both moving (fast targets cross a fuse in a tick). let rel0 = (t.pos - t.vel) - before; let rel1 = t.pos - m.pos; let delta = rel1 - rel0; let k = (-rel0.dot(delta) / delta.length_squared().max(1e-9)).clamp(0.0, 1.0); - if (rel0 + delta * k).length() < spec.fuse { + if (rel0 + delta * k).length() < spec.fuse && world.is_none_or(|(w, _)| k <= w) { // A target faster than the interceptor is hard to hit. let ratio = (spec.speed / t.vel.length().max(0.1)).min(1.0).powf(1.5); let fit = sams_fit.get(&m.sam).copied().unwrap_or(1.0); @@ -2107,6 +2186,13 @@ impl Sim { continue; } } + if let Some((_, p)) = world { + // The land or a building is in the way: the interceptor is lost there. + m.pos = p; + blocked += 1; + done.push((m.id, None, false, m.sam)); + continue; + } if m.age > spec.life || (target.is_none() && m.age > 30) { // Its target gone (shot down by another, or lost), or its life spent chasing one. if target.is_none() { @@ -2151,6 +2237,7 @@ impl Sim { t.missed += missed; t.expired += expired; t.orphaned += orphaned; + t.blocked += blocked; }); for (mid, target, kill, sam) in done { if let Some(i) = self.missiles.iter().position(|m| m.id == mid) { diff --git a/airdef-mod/src/sim/record.rs b/airdef-mod/src/sim/record.rs index d9b6b83..fc3305f 100644 --- a/airdef-mod/src/sim/record.rs +++ b/airdef-mod/src/sim/record.rs @@ -39,6 +39,8 @@ pub enum EventKind { SamLost { sam: u32, kind: SamKind, index: usize }, /// The Oreshnik's bus or a block splitting. Split { drone: u32 }, + /// A drone's warhead went off against the land or a building short of its aim. + Crash { drone: u32, kind: DroneKind, power: u32 }, } #[derive(Clone, Copy, Debug)] @@ -61,6 +63,8 @@ pub struct Tally { pub tracks_lost: u32, /// Times a system ran dry and began waiting for a reload truck. pub ammo_waits: u32, + /// Interceptors lost against the land or a building on their way. + pub blocked: u32, } impl Tally { @@ -71,6 +75,7 @@ impl Tally { orphaned: self.orphaned.saturating_sub(start.orphaned), tracks_lost: self.tracks_lost.saturating_sub(start.tracks_lost), ammo_waits: self.ammo_waits.saturating_sub(start.ammo_waits), + blocked: self.blocked.saturating_sub(start.blocked), } } /// The non-zero counts as a line, or nothing. @@ -81,6 +86,7 @@ impl Tally { (self.orphaned, "ракет остались без цели"), (self.tracks_lost, "потерь трека"), (self.ammo_waits, "ожиданий БК"), + (self.blocked, "ракет разбились о препятствие"), ] .into_iter() .filter(|p| p.0 > 0) @@ -312,7 +318,7 @@ impl Sim { } let size = match e.kind { EventKind::Kill { .. } => 3.5, - EventKind::Hit { power, .. } => [2.5, 4.5, 7.0, 10.0, 13.0][(power as usize).min(4)], + EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => [2.5, 4.5, 7.0, 10.0, 13.0][(power as usize).min(4)], EventKind::SamLost { .. } => 8.0, EventKind::Split { .. } => 2.0, }; diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index b5b27a8..705d00c 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -1,6 +1,7 @@ //! Regression tests for the issues filed on Gitea (each names its issue). use super::*; use super::raid::Grid; +use super::damage::MISS_RADIUS; // #1: [bug] Запросы рельефа голодают: часть направлений РЛС никогда не получает высоты #[test] @@ -222,3 +223,281 @@ fn drive_arrives_without_overshoot() { assert!(left.abs() < 1e-9 && vel == 0.0); } } + +// #49: a direct hit by an armed drone destroys the system at once, not with a dent of its health +#[test] +fn armed_strike_on_a_system_destroys_it_at_once() { + let centre = DVec3::new(0.0, 70.0, 0.0); + let hull = 2.2 * SamKind::Patriot.spec().scale; + for kind in [DroneKind::Lancet, DroneKind::Shahed] { + let mut destroyed = 0; + for seed in 1..=40 { + let mut sim = Sim::new(seed); + let id = sim.id(); + sim.sams.push(Sam::new(id, SamKind::Patriot, centre, 0.0)); + let mut ground = Ground::default(); + sim.add_drone(kind, DVec3::new(0.0, 120.0, 0.0), DVec3::NEG_Y, centre); + let mut out = Out::default(); + let mut impact = None; + for _ in 0..400 { + sim.step(&mut ground, None, &mut out); + if let Some(b) = sim.blasts.first() { + impact = Some(b.at); + break; + } + if sim.drones.is_empty() { + break; + } + } + let at = impact.expect("the strike must go off"); + let inside = DVec3::new(at.x, 0.0, at.z).distance(DVec3::new(centre.x, 0.0, centre.z)) <= hull && at.y < centre.y + 3.0; + let dead = !sim.sams[0].alive(); + if inside { + assert!(dead, "{kind:?} seed {seed} struck the hull at {at:?} but the system lives"); + } + destroyed += dead as u32; + } + assert!(destroyed >= 30, "{kind:?}: only {destroyed}/40 direct strikes destroyed the system"); + } +} + +// #49: a near miss is only splash, an unarmed body is no direct hit, the top of the hull counts +#[test] +fn near_miss_and_unarmed_body_are_not_direct_hits() { + let mut sim = Sim::new(1); + let id = sim.id(); + sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0)); + let down = DVec3::new(0.0, -2.0, 0.0); + sim.direct_hit(DVec3::new(6.0, 71.0, 0.0), down, 3, &mut Out::default()); + assert!(sim.sams[0].alive(), "a near miss six blocks off destroyed the system"); + sim.direct_hit(DVec3::new(0.0, 71.0, 0.0), down, 0, &mut Out::default()); + assert!(sim.sams[0].alive(), "an unarmed body destroyed the system"); + sim.direct_hit(DVec3::new(0.0, 73.0, 0.0), down, 1, &mut Out::default()); + assert!(!sim.sams[0].alive(), "an armed warhead into the top of the hull did not destroy the system"); +} + +// #59: a strike misses at a small, kind-specific rate; its scatter is the kind's CEP (half land within it) +#[test] +fn strike_scatter_matches_each_kinds_cep_and_miss_rate() { + const N: u64 = 5000; + for kind in DroneKind::ALL { + let mut sim = Sim::new(7); + let (cep, gross) = kind.accuracy(); + let sigma = cep / (2.0 * std::f64::consts::LN_2).sqrt(); + let expected = (1.0 - gross) * (-MISS_RADIUS * MISS_RADIUS / (2.0 * sigma * sigma)).exp() + gross; + let (mut misses, mut within, mut farthest) = (0u64, 0u64, 0.0f64); + for _ in 0..N { + let e = sim.aim_error(kind); + assert_eq!(e.y, 0.0); + let r = e.length(); + within += (r <= cep) as u64; + if r > MISS_RADIUS { + misses += 1; + farthest = farthest.max(r); + } + } + let rate = misses as f64 / N as f64; + let half = within as f64 / N as f64; + assert!((rate - expected).abs() < 0.015, "{kind:?}: miss rate {rate:.4}, expected {expected:.4}"); + assert!((0.005..=0.20).contains(&rate), "{kind:?}: miss rate {rate:.4} is not a small share (the IRBM bus is the loosest)"); + assert!((half - 0.5 * (1.0 - gross)).abs() < 0.03, "{kind:?}: {half:.3} of strikes within the CEP {cep}"); + assert!(farthest < MISS_RADIUS + 4.0 * cep, "{kind:?}: a miss {farthest:.2} blocks out, far from the aim"); + } +} + +// #59: the drone flies to its own error point and bursts there: the miss lands near the aim, by the CEP +#[test] +fn strikes_burst_near_their_aim_within_the_kinds_cep() { + let aim = DVec3::new(0.0, 70.0, 0.0); + for kind in [DroneKind::Shahed, DroneKind::Lancet] { + let cep = kind.accuracy().0; + let mut misses = 0; + for seed in 1..=60 { + let mut sim = Sim::new(seed); + let mut ground = Ground::default(); + sim.add_drone(kind, DVec3::new(0.0, 120.0, 0.0), DVec3::NEG_Y, aim); + let mut out = Out::default(); + let mut impact = None; + for _ in 0..400 { + sim.step(&mut ground, None, &mut out); + if let Some(b) = sim.blasts.first() { + impact = Some(b.at); + break; + } + if sim.drones.is_empty() { + break; + } + } + let at = impact.expect("the strike must go off"); + let off = DVec3::new(at.x - aim.x, 0.0, at.z - aim.z).length(); + assert!(off < MISS_RADIUS + 4.0 * cep + 2.0, "{kind:?} seed {seed} burst {off:.2} blocks from its aim"); + misses += (off > MISS_RADIUS) as u32; + } + assert!(misses <= 6, "{kind:?}: {misses}/60 strikes missed"); + } +} + +// ---- #42, #43: flights are swept against the blocks of the world (a synthetic world stands in for the game) + +/// Blocks from a function: `Some(solid)`, or `None` where there is no chunk. +struct Blocks Option>(F); +impl Option> world::WorldSource for Blocks { + fn solid(&self, x: i32, y: i32, z: i32) -> Option { + (self.0)(x, y, z) + } +} + +/// Reads the sections a flight along `a` to `b` needs, as the game would answer them over a few ticks. +fn warm(ground: &mut Ground, src: &dyn world::WorldSource, a: DVec3, b: DVec3) { + for _ in 0..4 { + ground.world.sweep(a, b, false, 0); + let wanted = ground.world.take_wanted(0); + ground.world.serve(src, wanted, 0); + } +} + +/// One tick of the drones with the game answering what was asked. +fn fly(sim: &mut Sim, ground: &mut Ground, src: &dyn world::WorldSource) { + sim.tick += 1; + sim.step_drones(ground, None, &mut Out::default()); + let wanted = ground.world.take_wanted(sim.tick); + ground.world.serve(src, wanted, sim.tick); +} + +// #42: a drone that crosses a one-block wall in a tick explodes at the wall, and that is no hit on its aim +#[test] +fn fast_drone_must_not_cross_a_one_block_wall() { + let src = Blocks(|x, y, z| Some(y < 64 || (x == 3 && (64..141).contains(&y) && z.abs() < 50))); + let mut sim = Sim::new(1); + sim.damage = false; + let mut ground = Ground::default(); + sim.add_drone(DroneKind::Zircon, DVec3::new(0.0, 100.0, 0.0), DVec3::X, DVec3::new(1000.0, 100.0, 0.0)); + sim.drones[0].flight = Flight::Dive; + warm(&mut ground, &src, DVec3::new(0.0, 100.0, 0.0), DVec3::new(30.0, 100.0, 0.0)); + fly(&mut sim, &mut ground, &src); + assert!(sim.drones.is_empty(), "crossed the wall in one tick"); + let at = sim.blasts.first().expect("no blast").at; + assert!((at.x - 3.0).abs() < 0.05 && (at.y - 100.0).abs() < 1.5, "burst at {at:?}, not at the wall face x=3"); + assert_eq!((sim.crashes, sim.hits), (1, 0), "a wall in the way is a crash, not a hit on the aim"); + assert!(sim.replays.iter().chain(sim.rec.iter()).all(|r| r.events.iter().all(|e| !matches!(e.kind, record::EventKind::Hit { .. })))); +} + +// #42: a drone flying into a hill side explodes at the face of the hill +#[test] +fn drone_flying_into_a_hill_explodes_at_its_face() { + // Flat at 64 to x=50, then a slope of 1:1. + let height = |x: i32| 64 + (x - 50).max(0); + let src = Blocks(move |x, y, _| Some(y < height(x))); + for kind in [DroneKind::Shahed, DroneKind::Zircon] { + let mut sim = Sim::new(3); + sim.damage = false; + let mut ground = Ground::default(); + sim.add_drone(kind, DVec3::new(0.0, 80.0, 0.0), DVec3::X, DVec3::new(600.0, 80.0, 0.0)); + sim.drones[0].flight = Flight::Dive; + for _ in 0..300 { + if sim.drones.is_empty() { + break; + } + fly(&mut sim, &mut ground, &src); + } + assert!(sim.drones.is_empty(), "{kind:?} flew on"); + let at = sim.blasts.first().expect("no blast").at; + // The slope meets y=80 at x=66. + assert!(at.x > 62.0 && at.x < 67.5, "{kind:?} burst at {at:?}, not at the hill face (x~66)"); + assert_eq!(sim.crashes, 1, "{kind:?}"); + } +} + +// #42: an interceptor cannot pass through a wall to its target, and does not kill it from behind the wall +#[test] +fn missile_cannot_pass_through_a_wall() { + let src = Blocks(|x, y, z| Some(y < 64 || (x == 3 && (64..141).contains(&y) && z.abs() < 50))); + let mut sim = Sim::new(1); + let mut ground = Ground::default(); + // The target stands still just behind the wall, inside the fuse's reach. + sim.add_drone(DroneKind::Shahed, DVec3::new(8.5, 100.0, 0.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 900.0)); + sim.drones[0].vel = DVec3::ZERO; + sim.drones[0].engaged = 1; + let (target, id) = (sim.drones[0].id, sim.id()); + sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::X * 3.0, target, sam: 0, age: 20 }); + warm(&mut ground, &src, DVec3::new(0.0, 100.0, 0.0), DVec3::new(30.0, 100.0, 0.0)); + sim.tick = 1; + sim.step_missiles(&mut ground, &mut Out::default()); + assert!(sim.missiles.is_empty(), "the missile is still flying"); + assert_eq!(sim.kills, 0, "a kill through the wall"); + assert!(sim.drones[0].alive(), "the target died behind the wall"); + assert_eq!(sim.drones[0].engaged, 0, "the target stays claimed by a missile that is gone"); + assert_eq!(sim.tally.blocked, 1); + assert!(sim.blasts.iter().all(|b| b.at.x < 3.5), "burst behind the wall"); +} + +// #42, #43: a bridge deck above the flight is no ground under it +#[test] +fn flight_under_a_bridge_is_not_a_ground_hit() { + let src = Blocks(|_, y, _| Some(y < 64 || y == 140)); + let mut sim = Sim::new(1); + sim.damage = false; + let mut ground = Ground::default(); + // The height map knows only the deck: 141 everywhere. + ground.answer((-5..400).map(|x| (x, 0, 141)).collect(), 0); + sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 100.0, 0.0), DVec3::X, DVec3::new(2000.0, 100.0, 0.0)); + warm(&mut ground, &src, DVec3::new(0.0, 100.0, 0.0), DVec3::new(300.0, 100.0, 0.0)); + for _ in 0..40 { + fly(&mut sim, &mut ground, &src); + } + assert_eq!(sim.drones.len(), 1, "the deck above was taken for the ground"); + assert_eq!(sim.crashes, 0); +} + +// #43: unknown chunks and spent budgets are never air; changes and another world void the cache +#[test] +fn unknown_is_not_clear_and_changes_invalidate() { + let mut ground = Ground::default(); + let (a, b) = (DVec3::new(0.5, 70.5, 0.5), DVec3::new(30.5, 70.5, 0.5)); + // Nothing read yet: the segment is unknown and the sections are asked for. + assert!(matches!(ground.world.sweep(a, b, false, 1), world::Sweep::Unknown { .. })); + let asked = ground.world.take_wanted(1); + assert!(asked.contains(&(0, 4, 0)), "{asked:?}"); + // The game has no chunk there: still unknown, not air. + let nothing = Blocks(|_, _, _| None); + ground.world.serve(¬hing, asked, 2); + assert!(matches!(ground.world.sweep(a, b, false, 3), world::Sweep::Unknown { .. })); + // A wall appears in a chunk that now is loaded: found after it is read again. + let wall = Blocks(|x, _, _| Some(x == 20)); + for now in [70, 71, 72] { + ground.world.sweep(a, b, false, now); + let w = ground.world.take_wanted(now); + ground.world.serve(&wall, w, now); + } + let hit = ground.world.sweep(a, b, false, 73); + assert!(matches!(hit, world::Sweep::Hit { block: [20, 70, 0], normal: [-1, 0, 0], .. }), "{hit:?}"); + // The wall is broken: the game says which section changed, and the next sweep asks again. + let open = Blocks(|_, _, _| Some(false)); + ground.world.changed(7, false, &[(1, 4, 0)]); + assert!(matches!(ground.world.sweep(a, b, false, 74), world::Sweep::Hit { .. }), "stale data is used until the answer"); + let w = ground.world.take_wanted(74); + assert!(w.contains(&(1, 4, 0)), "{w:?}"); + ground.world.serve(&open, w, 75); + assert_eq!(ground.world.sweep(a, b, false, 76), world::Sweep::Clear); + // Another world: everything goes. + ground.world.changed(8, false, &[]); + assert!(ground.world.is_empty()); +} + +// #43: the same wall and tunnel fixtures answer alike by sweep and by sections, and the cache is bounded +#[test] +fn wall_and_tunnel_fixtures_agree() { + let tunnel = Blocks(|x, y, z| Some(y < 60 || (x == 10 && !(y == 70 && z == 0)))); + let mut ground = Ground::default(); + for z in [0.5, 1.5] { + let (a, b) = (DVec3::new(0.5, 70.5, z), DVec3::new(30.5, 70.5, z)); + warm(&mut ground, &tunnel, a, b); + let r = ground.world.sweep(a, b, false, 1); + if z == 0.5 { + assert_eq!(r, world::Sweep::Clear, "the tunnel is open"); + } else { + assert!(matches!(r, world::Sweep::Hit { block: [10, 70, 1], .. }), "{r:?}"); + } + } + assert!(ground.world.bytes() < 64 * 1024); +} diff --git a/airdef-mod/src/sim/world.rs b/airdef-mod/src/sim/world.rs new file mode 100644 index 0000000..ec4c5d9 --- /dev/null +++ b/airdef-mod/src/sim/world.rs @@ -0,0 +1,399 @@ +//! What the game's blocks are to the simulation (issues #42, #43): a cache of +//! 16-block sections answered by `render.sections` (a bit per block that +//! stops a projectile), a swept segment through it, and the bookkeeping that +//! keeps the questions few: the sections along a flight asked for ahead of +//! time, at most 64 a tick, nearest need first; changes of the world marking +//! sections stale; a bounded cache. A chunk the game doesn't have answers +//! `Unknown`, which is never the same as air: the callers fall back to the +//! height of the land there and the counters say how often. +use glam::DVec3; +use std::collections::{HashMap, HashSet}; + +/// A section: block coordinates divided by 16. +pub type Sec = (i32, i32, i32); + +/// Sections asked in one tick (the game's budget). +pub const PER_TICK: usize = 64; +/// Sections kept; the least recently used go first beyond it. +const MAX_SECTIONS: usize = 12_000; +/// Ticks before an unanswered or unknown section is asked again. +const RETRY: u64 = 60; +/// Longest stretch of a flight asked for ahead, in blocks. +const AHEAD_MAX: f64 = 192.0; +/// Questions waiting: more are dropped (they are asked again when needed). +const QUEUE_MAX: usize = 512; + +/// What a section holds, as the game answered. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Fill { + /// The game has no such chunk. + Unknown, + Empty, + Full, + /// 512 bytes, one bit per block, index `(y & 15) * 256 + (z & 15) * 16 + (x & 15)`. + Mixed(Box<[u8; 512]>), +} + +/// An answer for a section, as the game gives it. +pub struct SectionAnswer { + pub sec: Sec, + pub fill: Fill, +} + +struct Entry { + fill: Fill, + /// A block changed (or may have) since it was read: asked again when next used. + stale: bool, + /// The tick it was last used in a sweep. + used: u64, + /// The tick it was answered. + since: u64, +} + +/// How a swept segment ended. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Sweep { + /// Every block along it is known and none stops it. + Clear, + /// The first solid block: where the segment meets it, which way the face looks, and the block. + Hit { t: f64, point: DVec3, normal: [i32; 3], block: [i32; 3] }, + /// The walk reached blocks the game doesn't know (before any solid one), `t` of the way. + Unknown { t: f64 }, +} + +impl Sweep { + pub fn hit(&self) -> Option<(f64, DVec3)> { + match *self { + Sweep::Hit { t, point, .. } => Some((t, point)), + _ => None, + } + } +} + +/// Counters for the report: questions, unknowns, memory, cost. +#[derive(Clone, Copy, Default, Debug)] +pub struct WorldStats { + pub sweeps: u64, + pub cells: u64, + pub hits: u64, + pub unknown_sweeps: u64, + pub sections_asked: u64, + pub sections_answered: u64, + pub sections_unknown: u64, + pub invalidated: u64, + pub evicted: u64, + pub world_changes: u64, +} + +#[derive(Default)] +pub struct WorldCache { + secs: HashMap, + urgent: Vec, + ahead: Vec, + queued: HashSet, + /// Sections asked, and when. + asked: HashMap, + epoch: u32, + pub stats: WorldStats, +} + +fn sec_of(x: i32, y: i32, z: i32) -> Sec { + (x >> 4, y >> 4, z >> 4) +} + +impl WorldCache { + /// Memory the cached sections hold, about. + pub fn bytes(&self) -> usize { + self.secs + .values() + .map(|e| 48 + if matches!(e.fill, Fill::Mixed(_)) { 512 } else { 0 }) + .sum() + } + pub fn len(&self) -> usize { + self.secs.len() + } + pub fn is_empty(&self) -> bool { + self.secs.is_empty() + } + /// One line for the report. + pub fn text(&self) -> String { + let s = &self.stats; + format!( + "мир: {} секций ({} КБ), запросов {}, неизвестно {}, лучей {} (ударов {}, неизв. {}), клеток {}", + self.len(), + self.bytes() / 1024, + s.sections_asked, + s.sections_unknown, + s.sweeps, + s.hits, + s.unknown_sweeps, + s.cells + ) + } + + /// The game's note of changes: another world voids everything; changed + /// sections (or too many to say) are read again when next used. + pub fn changed(&mut self, epoch: u32, overflow: bool, sections: &[Sec]) { + if epoch != self.epoch { + if self.epoch != 0 { + self.secs.clear(); + self.urgent.clear(); + self.ahead.clear(); + self.queued.clear(); + self.asked.clear(); + } + self.epoch = epoch; + } + if overflow { + for e in self.secs.values_mut() { + e.stale = true; + } + self.stats.invalidated += self.secs.len() as u64; + } + for s in sections { + if let Some(e) = self.secs.get_mut(s) { + e.stale = true; + self.stats.invalidated += 1; + } + } + self.stats.world_changes += sections.len() as u64; + } + + /// An explosion is about to change the land: the sections round it are read again. + pub fn touched(&mut self, at: DVec3, radius: f64) { + let lo = sec_of((at.x - radius).floor() as i32, (at.y - radius).floor() as i32, (at.z - radius).floor() as i32); + let hi = sec_of((at.x + radius).floor() as i32, (at.y + radius).floor() as i32, (at.z + radius).floor() as i32); + for x in lo.0..=hi.0 { + for y in lo.1..=hi.1 { + for z in lo.2..=hi.2 { + if let Some(e) = self.secs.get_mut(&(x, y, z)) { + e.stale = true; + } + } + } + } + } + + pub fn answer(&mut self, answers: Vec, now: u64) { + for a in answers { + self.asked.remove(&a.sec); + self.stats.sections_answered += 1; + if a.fill == Fill::Unknown { + self.stats.sections_unknown += 1; + } + self.secs.insert(a.sec, Entry { fill: a.fill, stale: false, used: now, since: now }); + } + if self.secs.len() > MAX_SECTIONS { + // Whatever wasn't used for a minute goes; if that's not enough, the older half. + self.secs.retain(|_, e| now.saturating_sub(e.used) < 1200); + if self.secs.len() > MAX_SECTIONS { + let mut used: Vec = self.secs.values().map(|e| e.used).collect(); + used.sort_unstable(); + let cut = used[used.len() / 2]; + self.secs.retain(|_, e| e.used >= cut); + } + self.stats.evicted += 1; + } + // Forget questions the game never answered, so they can be asked again. + if now % 64 == 0 { + self.asked.retain(|_, t| now.saturating_sub(*t) < RETRY); + } + } + + /// The sections to ask this tick (at most [`PER_TICK`]): the ones a sweep + /// needed first, then those along flights. + pub fn take_wanted(&mut self, now: u64) -> Vec { + let mut out = Vec::new(); + for list in [&mut self.urgent, &mut self.ahead] { + let room = PER_TICK - out.len(); + let n = room.min(list.len()); + out.extend(list.drain(..n)); + } + for s in &out { + self.queued.remove(s); + self.asked.insert(*s, now); + } + self.stats.sections_asked += out.len() as u64; + out + } + + fn want(&mut self, s: Sec, urgent: bool, now: u64) { + if self.queued.contains(&s) || self.asked.get(&s).is_some_and(|&t| now.saturating_sub(t) < RETRY) { + return; + } + if self.urgent.len() + self.ahead.len() >= QUEUE_MAX { + return; + } + if urgent { + self.urgent.push(s); + } else { + self.ahead.push(s); + } + self.queued.insert(s); + } + + /// Asks for the sections along a flight from `pos` at `vel` a tick, `ticks` ahead. + pub fn look_ahead(&mut self, pos: DVec3, vel: DVec3, ticks: f64, now: u64) { + let mut d = vel * ticks; + let len = d.length(); + if len < 1e-6 { + return; + } + if len > AHEAD_MAX { + d *= AHEAD_MAX / len; + } + // A section at a time along the segment (a section is 16 wide: half-section steps meet each). + let steps = (d.length() / 8.0).ceil().max(1.0) as i32; + let mut last = None; + for i in 0..=steps { + let p = pos + d * (i as f64 / steps as f64); + let s = sec_of(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32); + if last == Some(s) { + continue; + } + last = Some(s); + self.touch(s, false, now); + } + } + + /// Touches a section the way a sweep or a look needs it: used now, and + /// asked again when stale or (after a while) unknown. + fn touch(&mut self, s: Sec, urgent: bool, now: u64) -> bool { + let Some(e) = self.secs.get_mut(&s) else { + self.want(s, urgent, now); + return false; + }; + e.used = now; + let refresh = e.stale || (e.fill == Fill::Unknown && now.saturating_sub(e.since) >= RETRY); + if refresh { + self.want(s, urgent, now); + } + true + } + + /// Whether the block at `(x, y, z)` stops a projectile: `None` where the + /// game hasn't said. `last` is the section just touched. + fn solid(&mut self, last: &mut Option, x: i32, y: i32, z: i32, now: u64) -> Option { + let s = sec_of(x, y, z); + if *last != Some(s) { + *last = Some(s); + self.touch(s, true, now); + } + match &self.secs.get(&s)?.fill { + Fill::Unknown => None, + Fill::Empty => Some(false), + Fill::Full => Some(true), + Fill::Mixed(bits) => { + let i = ((y & 15) << 8 | (z & 15) << 4 | (x & 15)) as usize; + Some(bits[i >> 3] >> (i & 7) & 1 != 0) + } + } + } + + /// Whether the block is known to hold no solid block, with the sky above it + /// in view: used to tell a fluid surface from a roof. + pub fn is_known_open(&mut self, x: i32, y: i32, z: i32, now: u64) -> bool { + self.solid(&mut None, x, y, z, now) == Some(false) + } + + /// Whether the block is known to be solid. + pub fn is_known_solid(&mut self, x: i32, y: i32, z: i32, now: u64) -> bool { + self.solid(&mut None, x, y, z, now) == Some(true) + } + + /// The first solid block along `a` to `b`, by walking the blocks it + /// crosses; `skip_start` ignores the block it starts in (a launch from a + /// pad that is part of the land). + pub fn sweep(&mut self, a: DVec3, b: DVec3, skip_start: bool, now: u64) -> Sweep { + self.stats.sweeps += 1; + let d = b - a; + let mut pos = [a.x.floor() as i32, a.y.floor() as i32, a.z.floor() as i32]; + let dv = [d.x, d.y, d.z]; + let av = [a.x, a.y, a.z]; + let step: [i32; 3] = dv.map(|v| if v > 0.0 { 1 } else { -1 }); + let mut t_max = [f64::INFINITY; 3]; + let mut t_delta = [f64::INFINITY; 3]; + for i in 0..3 { + if dv[i] != 0.0 { + let edge = f64::from(pos[i]) + if dv[i] > 0.0 { 1.0 } else { 0.0 }; + t_max[i] = (edge - av[i]) / dv[i]; + t_delta[i] = (1.0 / dv[i]).abs(); + } + } + let mut last: Option = None; + let mut face = [0i32; 3]; + let mut t = 0.0f64; + let mut first = true; + for _ in 0..4096 { + self.stats.cells += 1; + match self.solid(&mut last, pos[0], pos[1], pos[2], now) { + None => { + self.stats.unknown_sweeps += 1; + return Sweep::Unknown { t }; + } + Some(true) if !(first && skip_start) => { + self.stats.hits += 1; + return Sweep::Hit { t, point: a + d * t, normal: face, block: pos }; + } + _ => {} + } + first = false; + let axis = if t_max[0] < t_max[1] { + if t_max[0] < t_max[2] { 0 } else { 2 } + } else if t_max[1] < t_max[2] { + 1 + } else { + 2 + }; + t = t_max[axis]; + if t > 1.0 { + return Sweep::Clear; + } + t_max[axis] += t_delta[axis]; + pos[axis] += step[axis]; + face = [0; 3]; + face[axis] = -step[axis]; + } + self.stats.unknown_sweeps += 1; + Sweep::Unknown { t } + } +} + +/// Where the game's blocks come from in tests and the native runner: any +/// world that can say what a block is. +pub trait WorldSource { + /// Whether the block stops a projectile; `None` where there is no chunk. + fn solid(&self, x: i32, y: i32, z: i32) -> Option; +} + +/// A [`WorldSource`] as the game would answer `render.query-sections` with it. +pub fn section_of(source: &dyn WorldSource, s: Sec) -> Fill { + let mut bits = Box::new([0u8; 512]); + let (mut set, mut known) = (0u32, 0u32); + for i in 0..4096usize { + let (x, y, z) = (s.0 * 16 + (i & 15) as i32, s.1 * 16 + (i >> 8) as i32, s.2 * 16 + ((i >> 4) & 15) as i32); + match source.solid(x, y, z) { + Some(true) => { + bits[i >> 3] |= 1 << (i & 7); + set += 1; + known += 1; + } + Some(false) => known += 1, + None => {} + } + } + match (known, set) { + (0, _) => Fill::Unknown, + (_, 0) if known == 4096 => Fill::Empty, + (_, 4096) => Fill::Full, + _ => Fill::Mixed(bits), + } +} + +impl WorldCache { + /// Answers the questions of the last tick from a synthetic world, as the game does one tick later. + pub fn serve(&mut self, source: &dyn WorldSource, wanted: Vec, now: u64) { + let answers = wanted.into_iter().map(|sec| SectionAnswer { sec, fill: section_of(source, sec) }).collect(); + self.answer(answers, now); + } +} diff --git a/docs/HD-RENDERER.md b/docs/HD-RENDERER.md index 2112a51..7871709 100644 --- a/docs/HD-RENDERER.md +++ b/docs/HD-RENDERER.md @@ -20,6 +20,7 @@ Horizon действует правило clean-room. Исключение — P | Что | Где | |---|---| | Весь HD-конвейер: цели, проходы, WGSL-шейдеры (строками), тайлы света, AO, тени, bloom, tonemap, TAA | `horizon/crates/horizon-client/src/gfx/hd.rs` | +| Объёмные облака (рэймарч слоя, тени облаков на землю) и объёмный туман (лучи света через тени каскадов), их цели и проходы; CPU-шум (2D, 3D Worley) | `horizon/crates/horizon-client/src/gfx/hd_volumes.rs`, `hd_noise.rs` | | Атмосфера: сборка текстуры неба (WGSL), чтение неба/дымки/тона света в шейдерах, цвет солнца (`sun_transmittance`) | `horizon/crates/horizon-client/src/gfx/hd_sky.rs` (+ `THIRD_PARTY_PHOTON.md`) | | Встраивание в кадр: G-буфер-проход, каскады теней, вызовы `hd.light` / `hd.finish`, джиттер, тайминги | `horizon/crates/horizon-client/src/gfx.rs` (`Renderer::render`, ищи `hd_on`, `AirDef fork:`) | | G-буфер террейна: `fs_gbuffer`, `fs_shadow` | `horizon/crates/horizon-client/src/terrain.wgsl` | @@ -62,6 +63,7 @@ cd .. && rm -f horizon-patches/*.patch && git -C horizon format-patch -q $(cat H землёй решают потребители). Пайплайны `gbuffer_water` (секции) и `flat.water` (flat-путь). 3a. **GTAO** (полразрешения, `Rg16Float`: окклюзия, дистанция) + depth-aware блюр 5×5. 3 среза × 5 шагов, радиус 1.8 блока, шум IGN по кадру. Сила — `HdFrame.ambient.w` (`HORIZON_HD_AO=<число>`, 0 = выкл). +3b. **Облака и туман** (`hd.volumes`, до освещения, после прохода неба): `hd cloud shadows` (карта 256² оптической толщины облаков по ходу солнца, ±1200 блоков вокруг камеры), `hd clouds` (половина разрешения мира, `Rgba16Float`: свет + пропускание; рэймарч слоя 192.33…264 блока, 8–26 шагов с джиттером IGN, свет — 3 шага с удвоением, небо — 1, 4 октавы многократного рассеяния Photon; история ping-pong с репроекцией по направлению и сдвигу камеры, вес до 0.9, отбрасывается при расхождении) и `hd fog` (половина разрешения: 18 шагов от камеры до глубины мира, не дальше 192 блоков, плотность — высотная дымка + утренний туман у земли + дождь + шум, свет — солнце × тень каскадов × фаза Photon + свет неба; вторая цель — длина луча для апсэмплинга). Ванильные облака при этом не рисуются (`hd_volumetric_clouds` в `gfx.rs`). `fs_light` накладывает облака на небо (`c·T + S`), туман на всё (`fog_at`: 4 текселя с весом по близости длины луча), а тень облаков (`cloud_shadow`) умножает прямой солнечный свет. `HORIZON_HD_CLOUDS=0` / `HORIZON_HD_VOLFOG=0` выключают (облака тогда ванильные, лучи — старые 14 шагов). 4. **Освещение** `fs_light` → `lit` (HDR): пиксель неба (глубина мира 0, Оверворлд, без env-тумана) — это небо атмосферы + то, что мир нарисовал поверх ванильного неба (`max(scene − vanilla_sky(view), 0)`: спрайты луны, звёзды; квадрат ванильного солнца вырезается вокруг направления солнца и заменяется круглым диском с гало); облака (глубина > 0, нет в G-буфере, y > 8, дальше 48 блоков) на закате/рассвете окрашиваются цветом солнца. Свет неба: ванильный `lightmap` × подкраска цветом неба (зенит сверху, горизонт сбоку; яркость 1) × форма по нормали (вверх 1.1, вниз 0.66), только там, где есть небесный свет. Тени: ближний каскад — PCSS-lite (поиск блокировщиков 8 выборок, ширина полутени от расстояния до блокировщика, 12 выборок спирали Фогеля, повёрнутых IGN; сглаживает TAA), дальний — 3×3. Туман: цвет — небо над горизонтом в направлении пикселя (`haze_colour`) + воздушная перспектива `1 − exp(−d/700)` × 0.5. Далее прежнее: пиксель — вода, если `water_depth >= g` и перед водой в мировой глубине ничего нет (`d - wd <= 0.2%`); тогда освещается **дно** по данным G-буфера (одна выборка тени, без AO), а в альфу `lit` пишется 0.5 (признак воды). `max(небо·ambient.x, блоки·ambient.y)·AO + солнце·N·L·тень + точечные источники` × albedo + эмиссия; затем ванильный туман заново, объёмные лучи (14 шагов, Henyey-Greenstein g=0.6), @@ -109,10 +111,12 @@ Bind groups кэшируются и пересоздаются только пр | 13 | AO | | 14 | глубина воды (`water_depth`) | | 15 | текстура неба (`sky_lut`) | +| 16, 17 | объёмный туман (свет+пропускание) и длина его луча | +| 18, 19 | объёмные облака, карта теней облаков | `HdFrame` (Rust и WGSL держать в синхроне!): `inverse_view_proj`, `resolution` (w, h, 1/w, 1/h), `params` (число источников, экспозиция, bloom, счётчик кадров), `cascades[2]`, `sun` (направление, сила), `sun_color` (rgb, ночь), -`fog_color`, `fog`, `ambient` (тень неба, блоки, плотность воздуха, AO), `shadow` (вкл, R0, R1, тайлов в строке), `prev_view_proj`, `taa`, `taa_params`, `view_proj` (с джиттером, для лучей воды), `water` (x: эффект идёт, y: секунды волн mod 600, zw: камера x, z mod 256), `sky_color` (w: есть небо), `sky_sun` (xyz: на само солнце, w: 1 — небо рисует атмосфера), `sky_extra` (x: конец ванильного тумана неба, z: пасмурность от дождя 0..0.85), `sunrise` (ванильная полоса восхода/заката — вычитается из мира). +`fog_color`, `fog`, `ambient` (тень неба, блоки, плотность воздуха, AO), `shadow` (вкл, R0, R1, тайлов в строке), `prev_view_proj`, `taa`, `taa_params`, `view_proj` (с джиттером, для лучей воды), `water` (x: эффект идёт, y: секунды волн mod 600, zw: камера x, z mod 256), `sky_color` (w: есть небо), `sky_sun` (xyz: на само солнце, w: 1 — небо рисует атмосфера), `sky_extra` (x: конец ванильного тумана неба, z: пасмурность от дождя 0..0.85), `sunrise` (ванильная полоса восхода/заката — вычитается из мира), `clouds` (низ слоя относительно камеры, толщина, вкл, секунды ветра), `cloud_cam` (x, z камеры по модулю 65536, добавка покрытия от дождя, история облаков годна), `cloud_dir` / `cloud_sun` (откуда и каким цветом светят облаку: солнце до чуть ниже горизонта, ночью луна), `vfog` (туман вкл, высота камеры, утренний туман). Композит воды (`WATER`, group 0): 0 `HdFrame`, 1 `lit`, 2 глубина G-буфера, 3 цвет воды, 4 нормаль воды, 5 глубина воды, 6–7 тени, 8 лайтмап, 9 сэмплер, 10 текстура неба. Проход неба (`SKY_PASS`, group 0): только `HdFrame`. @@ -172,6 +176,9 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс | Вода: проход `fs_water`, преломление, SSR, Френель, блик (`HORIZON_HD_WATER=0` — выкл) | готово | патч 19 (+20: без ванильной текстуры на поверхности, цвет темнее и глубже) | | Вода: светлее, густая мелкая рябь (26 волн), блик a=0.07 (мерцание на реке ≈1.4% пикселей >20/255) | готово | патч 21 | | Небо и атмосфера (Рэлей/Ми/озон, текстура неба, воздушная перспектива, цвет солнца из пропускания, голубоватый ambient по нормали, PCSS-lite ближнего каскада, диск солнца, цвет облаков на закате; порт констант и Chapman из Photon, MIT) | готово | патч 22 | +| Закат как у Photon: атмосфера в Rec. 2020 (коэффициенты и матрицы Photon), фаза Клейна-Нишины, тинт и усиление солнца на закате/рассвете/синем часе (`get_sun_tint`), насыщенность неба у низкого солнца | готово | патч 23 | +| Объёмные облака (рэймарч, мультирассеяние Photon, тени на землю, TAA-история, ванильные скрыты) | готово | патч 24 | +| Объёмный туман (лучи через тени каскадов, высотная дымка, утренний туман, полразрешения) | готово | патч 25 | | API моделей по хэндлам (#30, уровень API 11: `model-id`, `model-generation`, `set-models-by-id`) | готово | патч 17 | ## План (по порядку) и ТЗ @@ -190,7 +197,7 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс 3. **Небо и атмосфера** — СДЕЛАНО (патч 22, см. «Небо и свет» ниже). Исходное ТЗ: рассеяние Рэлея/Ми (предвычисленная LUT пропускания и небесного цвета в маленькой текстуре), солнце/луна дисками, звёзды из ванили; туман и лучи берут цвет из той же модели. Учесть дождь (`weather`), закат, Незер/Энд (там — ванильное небо). -4. **Объёмные облака** (рэймарч слоя с шумом) + их тени на земле (в карту/маску солнца). +4. **Объёмные облака** — СДЕЛАНО (патч 24, см. «Закат, облака и туман»). Осталось: перистые/высококучевые слои Photon, облака в отражении воды. 5. **Блики и материалы**: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из мода), мокрые поверхности и лужи в дождь. 6. **PCSS** (мягкость тени по расстоянию до тенеотбрасывателя) и цветные тени через стекло/листву. @@ -241,3 +248,12 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс (`light_of`: множитель (1, 0.93, 0.78)). Проверять баланс на `Polygon` в полдень (`time set 6000`) — белое должно оставаться белым с тёплым оттенком. + +## Закат, облака и туман (патчи 23–25): что знать преемнику + +- **Закат (23).** Небо марширует в Rec. 2020 (коэффициенты Photon сдвинуты матрицей `rec709_to_rec2020`, результат возвращается в 709): это убрало жёлто-зелёный горизонт. Добавлены фаза Клейна-Нишины для Ми и деполяризованная Рэлея, `sun_light_tint` (розоватый тинт и подъём яркости на закате, фиолетовый на синем часе, из `get_sun_tint`), сильнее насыщенность у низкого солнца (`sky_show`). Цвет прямого света (`light_of`) умножен на тот же тинт. Сверка: 12000/12500/13000 (запад) и 23000/23500 (восток) — оранжевый/розовый/пурпурный; полдень Polygon (6000) прежний. +- **Облака (24).** Слой 192.33…264.33 блока (`CLOUD_BASE/CLOUD_THICKNESS`), ветер 3 блока/с (`CLOUD_WIND`), шум — текстуры, сгенерированные на CPU при создании HD (`hd_noise.rs`: 2D 256² покрытие/форма, 3D 64³ Worley). Плотность — по `clouds_cumulus_density` Photon (порог покрытия × форма, «яйцо» по высоте, эрозия Worley, резкий верх), рассеяние — `clouds_cumulus_scattering` (октавы, пудра, фазы). Цвет света — `cloud_light` (Rust): пропускание атмосферы на высоте солнца; солнце светит облакам до −0.2 по высоте, дальше луна. Воздушная перспектива `exp(−d/3600)` к цвету дымки. История сбрасывается при resize, телепорте и без TAA. Опция ванильных облаков «выкл» → `frame.clouds` пуст → нет ни облаков, ни теней. Тень: `cloud_shadow` в `fs_light` (карта оптической толщины; гаснет при солнце ниже ~0.1–0.25 по высоте). +- **Туман (25).** `fs_fog`: 18 шагов, плотность `fog_sigma` (высотная дымка от y=62, масштаб 42; утренний туман — пока солнце на «утренней» стороне и низко; пятна из 3D-шума; дождь; затухание ниже y≈28–58, чтобы не светить в пещерах), свет — Photon-смесь двух HG, «вечернее свечение» ×1…3. Старые 14 шагов лучей остаются при `HORIZON_HD_VOLFOG=0`; ореолы вокруг источников мода — всегда. +- **Тайминги.** Метки Metal (`HORIZON_SHADER_PACK_TIMING=1`, Hills, 2560×1440): `hd clouds` ≈ 6 мс (завышено: метка ловит ожидание соседних проходов), `hd cloud shadows` 0.2–3 (шум), `hd fog` ≈ 1.3. По среднему кадру GPU «оба выкл → оба вкл»: +0.2…3.7 мс (замеры шумные: рядом шла чужая игра на том же GPU). Как удешевить: у облаков — длина шага (14) и потолок шагов (26) в `fs_cloud`, 3 шага света, 4 октавы, облака в четверть разрешения; у тумана — `FOG_STEPS`. +- **Разработка шейдеров.** `HORIZON_HD_SHADER_DIR=<папка>`: каждый HD-шейдер читается из `<папка>/<метка>.wgsl` (записывается из встроенного, если файла нет) — правки видны после перезапуска игры без пересборки; готовое переносится в константы вручную. +- **Слабые места.** Облака не отражаются в воде (SSR берёт небо без облаков); не закрывают далёкую землю выше слоя (только небо); карта теней облаков ±1200 блоков и почти не работает при солнце ниже ~10°; туман не знает рельефа (только высоту над y=62); нет перистых облаков; на быстром повороте камеры история облаков отбрасывается (кратковременный шум). diff --git a/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch b/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch index a84804b..d90577b 100644 --- a/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch +++ b/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch @@ -1,7 +1,7 @@ From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:37:45 +0300 -Subject: [PATCH 01/22] Mod API (AirDef fork): world models, client particles, +Subject: [PATCH 01/24] Mod API (AirDef fork): world models, client particles, sounds, ground heights render.set-models draws block models with an interpolated pose per id; diff --git a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch b/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch index 2c0b0a4..5d2b4a6 100644 --- a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch +++ b/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch @@ -1,7 +1,7 @@ From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:39:45 +0300 -Subject: [PATCH 02/22] cargo-horizon-mod: report particles and sounds +Subject: [PATCH 02/24] cargo-horizon-mod: report particles and sounds Co-Authored-By: Claude Opus 5.5 --- diff --git a/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch b/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch index c31de6d..290a0c4 100644 --- a/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch +++ b/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch @@ -1,7 +1,7 @@ From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 13:33:10 +0300 -Subject: [PATCH 03/22] Mod API (AirDef fork): full-screen post pass for mods +Subject: [PATCH 03/24] 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 diff --git a/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch b/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch index 92b56ef..1de27a2 100644 --- a/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch +++ b/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch @@ -1,7 +1,7 @@ From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 22:09:30 +0300 -Subject: [PATCH 04/22] Mod models: draw far models as scaled impostors past +Subject: [PATCH 04/24] Mod models: draw far models as scaled impostors past the far plane, cull at 4096 --- diff --git a/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch b/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch index bb96601..fb71040 100644 --- a/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch +++ b/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch @@ -1,7 +1,7 @@ From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 22:31:00 +0300 -Subject: [PATCH 05/22] Mod cameras: render.set-views draws the world from +Subject: [PATCH 05/24] 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 diff --git a/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch b/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch index a93b1e8..9e06aa5 100644 --- a/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch +++ b/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch @@ -1,7 +1,7 @@ From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 23:08:21 +0300 -Subject: [PATCH 06/22] Mods: more fuel per tick and per event (AirDef plans +Subject: [PATCH 06/24] Mods: more fuel per tick and per event (AirDef plans raids) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch b/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch index d9f23f2..e217139 100644 --- a/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch +++ b/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch @@ -1,7 +1,7 @@ From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 23:25:29 +0300 -Subject: [PATCH 07/22] Mod cameras up to 1920x1080 (full-screen replays) +Subject: [PATCH 07/24] Mod cameras up to 1920x1080 (full-screen replays) Co-Authored-By: Claude Opus 5.5 --- diff --git a/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch b/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch index 5d66ef5..141bf50 100644 --- a/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch +++ b/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch @@ -1,7 +1,7 @@ From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 11:15:11 +0300 -Subject: [PATCH 08/22] Mod cameras: distant terrain tiles, fog measured from +Subject: [PATCH 08/24] Mod cameras: distant terrain tiles, fog measured from the camera, no impostors while one is shown Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch b/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch index 8a049b4..57b0677 100644 --- a/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch +++ b/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch @@ -1,7 +1,7 @@ From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 11:25:45 +0300 -Subject: [PATCH 09/22] Mods: game.world-id, so mods keep state per world (API +Subject: [PATCH 09/24] Mods: game.world-id, so mods keep state per world (API level 9) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch b/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch index e56f979..b49cf28 100644 --- a/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch +++ b/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch @@ -1,7 +1,7 @@ From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 12:01:08 +0300 -Subject: [PATCH 10/22] cargo-horizon-mod: trial runs behind feature full, so a +Subject: [PATCH 10/24] cargo-horizon-mod: trial runs behind feature full, so a packing-only tool skips wasmtime Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch b/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch index a03229c..5bbea76 100644 --- a/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch +++ b/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch @@ -1,7 +1,7 @@ From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 13:18:43 +0300 -Subject: [PATCH 11/22] HD lighting: G-buffer pass, per-pixel mod point lights +Subject: [PATCH 11/24] 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) diff --git a/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch b/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch index 0379665..102b68d 100644 --- a/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch +++ b/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch @@ -1,7 +1,7 @@ From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 13:59:43 +0300 -Subject: [PATCH 12/22] HD lighting stage 2: sun and moon shadow cascades, the +Subject: [PATCH 12/24] 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 diff --git a/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch b/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch index 11d8d09..df745bc 100644 --- a/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch +++ b/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch @@ -1,7 +1,7 @@ From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:19:53 +0300 -Subject: [PATCH 13/22] HD: bind groups cached until a resize or a new source, +Subject: [PATCH 13/24] 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 diff --git a/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch b/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch index 5a01c61..80d0b3d 100644 --- a/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch +++ b/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch @@ -1,7 +1,7 @@ From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:25:44 +0300 -Subject: [PATCH 14/22] HD: lights culled per 16x16 tile (surface and halo +Subject: [PATCH 14/24] 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 diff --git a/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch b/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch index b5b4fd9..21c070b 100644 --- a/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch +++ b/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch @@ -1,7 +1,7 @@ From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:43:36 +0300 -Subject: [PATCH 15/22] HD: GPU time per pass in the F3 timing list, +Subject: [PATCH 15/24] HD: GPU time per pass in the F3 timing list, half-resolution GTAO with a depth-aware blur Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch b/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch index c332368..6364e32 100644 --- a/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch +++ b/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch @@ -1,7 +1,7 @@ From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:02:02 +0300 -Subject: [PATCH 16/22] HD: shadows draw only sun-facing quads and skip buried +Subject: [PATCH 16/24] HD: shadows draw only sun-facing quads and skip buried sections, the far cascade every second frame; TAA (Halton jitter, reprojection by depth, YCoCg variance clipping) before bloom diff --git a/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch b/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch index 3c85e52..a7c605e 100644 --- a/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch +++ b/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch @@ -1,7 +1,7 @@ From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:02:02 +0300 -Subject: [PATCH 17/22] Mods: model handles (render.model-id, model-generation, +Subject: [PATCH 17/24] Mods: model handles (render.model-id, model-generation, set-models-by-id; API level 11) so mods send no model names each tick; clear-post and set-lighting documented diff --git a/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch b/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch index a69face..204f3c8 100644 --- a/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch +++ b/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch @@ -1,7 +1,7 @@ From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:19:57 +0300 -Subject: [PATCH 18/22] HD: temporal upscaling (HORIZON_HD_SCALE: world, +Subject: [PATCH 18/24] HD: temporal upscaling (HORIZON_HD_SCALE: world, G-buffer and lighting at a lower size, rebuilt to the window in the TAA pass); fix flickering shadows near the player (the near cascade lost its sections on frames the far one was held, the held far matrix ignored the diff --git a/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch b/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch index a262523..d0330ae 100644 --- a/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch +++ b/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch @@ -1,7 +1,7 @@ From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:47:52 +0300 -Subject: [PATCH 19/22] HD water: its own pass (colour, normal, depth), the +Subject: [PATCH 19/24] HD water: its own pass (colour, normal, depth), the floor under it lit, a composite with world-space waves, refraction with depth absorption in the biome colour, screen-space reflections, Fresnel and a shadowed sun glint (HORIZON_HD_WATER=0 to switch off) diff --git a/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch b/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch index 1c09c6f..fb29ce9 100644 --- a/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch +++ b/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch @@ -1,7 +1,7 @@ From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:56:04 +0300 -Subject: [PATCH 20/22] HD water: no vanilla texture on the surface (the biome +Subject: [PATCH 20/24] HD water: no vanilla texture on the surface (the biome colour only), a darker, deeper colour Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch b/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch index 6522d4a..89eb17d 100644 --- a/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch +++ b/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch @@ -1,7 +1,7 @@ From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 18:38:19 +0300 -Subject: [PATCH 21/22] HD sky and light: a physical atmosphere (Rayleigh, Mie, +Subject: [PATCH 21/24] HD sky and light: a physical atmosphere (Rayleigh, Mie, ozone; constants and transmittance after Photon, MIT) for the sky, haze, fog and water reflections; warmer sunlight from the atmosphere's transmittance, a gentle sky-coloured ambient, soft PCSS-lite shadows near the player, a haze diff --git a/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch b/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch index 0defe6b..2601a5f 100644 --- a/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch +++ b/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch @@ -1,7 +1,7 @@ From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 19:02:16 +0300 -Subject: [PATCH 22/22] HD water: a darker blue, a little clearer, ripples a +Subject: [PATCH 22/24] HD water: a darker blue, a little clearer, ripples a little higher Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch b/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch new file mode 100644 index 0000000..e20f24e --- /dev/null +++ b/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch @@ -0,0 +1,1914 @@ +From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001 +From: chexit +Date: Thu, 29 Oct 2026 21:41:48 +0300 +Subject: [PATCH 23/24] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun + tint, blue hour; MIT), volumetric clouds with ground shadows instead of + vanilla's in HD, volumetric fog with sun shafts and morning mist + (HORIZON_HD_CLOUDS=0, HORIZON_HD_VOLFOG=0) + +Co-Authored-By: Claude Opus 5.5 +--- + crates/horizon-client/src/gfx.rs | 24 +- + .../src/gfx/THIRD_PARTY_PHOTON.md | 22 +- + crates/horizon-client/src/gfx/hd.rs | 345 ++++++- + crates/horizon-client/src/gfx/hd_noise.rs | 163 ++++ + crates/horizon-client/src/gfx/hd_sky.rs | 90 +- + crates/horizon-client/src/gfx/hd_volumes.rs | 857 ++++++++++++++++++ + 6 files changed, 1448 insertions(+), 53 deletions(-) + create mode 100644 crates/horizon-client/src/gfx/hd_noise.rs + create mode 100644 crates/horizon-client/src/gfx/hd_volumes.rs + +diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs +index d01131433..072090e33 100644 +--- a/crates/horizon-client/src/gfx.rs ++++ b/crates/horizon-client/src/gfx.rs +@@ -72,7 +72,9 @@ mod cull; + mod end_sky; + mod flat; + mod hd; ++mod hd_noise; + mod hd_sky; ++mod hd_volumes; + mod hdr; + pub mod lightning; + mod lod; +@@ -4278,6 +4280,17 @@ impl Renderer { + water: !frame.underwater, + sunrise: frame.sky.sunrise_color, + sky_fog_end: frame.fog.map_or(frame.distance.min(512.0), |f| f.sky_end), ++ // AirDef fork: volumetric clouds stand where vanilla's would (option on, Overworld). ++ clouds: frame.clouds.is_some() && hd::clouds_enabled(), ++ camera_world: [ ++ camera.x.rem_euclid(65536.0) as f32, ++ camera.z.rem_euclid(65536.0) as f32, ++ camera.y as f32, ++ ], ++ seconds: frame ++ .clouds ++ .as_ref() ++ .map_or(0.0, |c| ((c.time as f64 + c.partial_tick) / 20.0) as f32), + }; + let (shadow_dir, shadow_strength, _) = hd::light_of(&hd_scene); + let mut hd_cascades = hd::cascades(camera, shadow_dir); +@@ -5071,6 +5084,7 @@ impl Renderer { + } + } + let pack_reads_world = pack_post || native_post || mod_post || hd_on; ++ let hd_volumetric_clouds = hd_on && hd::clouds_enabled(); + // The pack's program reads the vertices this frame's draws bind. + let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat; + // The shadow camera's lists, drawn by the pack's shadow programs. +@@ -5128,9 +5142,9 @@ impl Renderer { + let format = self.config.format.remove_srgb_suffix(); + let device = &self.device; + let globals_size = std::mem::size_of::() as u64; +- let hd = self +- .hd +- .get_or_insert_with(|| hd::Hd::new(device, size, format, globals_size)); ++ let hd = self.hd.get_or_insert_with(|| { ++ hd::Hd::new(device, &self.queue, size, format, globals_size) ++ }); + hd.resize(device, size, [self.config.width, self.config.height]); + // Translucent terrain on screen: the water pass has something to draw. + hd.water_geometry = match self.flat.as_ref().filter(|_| flat_frame) { +@@ -5691,7 +5705,8 @@ impl Renderer { + 0..1, + ); + } +- if cloud_quads > 0 && !pack_draws_clouds { ++ // AirDef fork: HD draws the clouds volumetrically (in the lighting) instead. ++ if cloud_quads > 0 && !pack_draws_clouds && !hd_volumetric_clouds { + pass.set_pipeline(&self.cloud_pipeline); + pass.set_bind_group(0, &self.entity_group, &[]); + pass.set_bind_group(1, &self.cloud_group, &[]); +@@ -5821,6 +5836,7 @@ impl Renderer { + // 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. + hd.sky(&mut encoder); ++ hd.volumes(&self.device, &mut encoder, depth); + hd.light( + &self.device, + &mut encoder, +diff --git a/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md b/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md +index 03d945001..fd9f4d029 100644 +--- a/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md ++++ b/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md +@@ -1,14 +1,26 @@ + # Third-party code: Photon + + Parts of the HD renderer's atmosphere (`hd_sky.rs`) are ported from, or follow closely, the +-Photon shader pack by sixthsurge, `shaders/include/sky/atmosphere.glsl` (and the idea of the water ++Photon shader pack by sixthsurge, `shaders/include/sky/atmosphere.glsl`, `shaders/include/lighting/colors/light_color.glsl`, `shaders/include/utility/phase_functions.glsl`, `shaders/include/sky/clouds/{common,cumulus}.glsl`, `shaders/include/lighting/cloud_shadows.glsl`, `shaders/include/fog/air_fog_vl.glsl` (and the idea of the water + waves in `shaders/include/misc/water_normal.glsl`) at commit + 26c2906ee02cc484359b835fba6993d443d45966, https://github.com/sixthsurge/photon + +-What is taken: the air's scattering and absorption coefficients (Rayleigh, Mie, ozone), the scale +-heights, the ozone density profile, the Mie albedo and Henyey-Greenstein anisotropy, the sunlight +-colour outside the atmosphere, and the Chapman-function approximation of the sun's transmittance. +-The sky-view texture, its march, the sun's colour and everything else are our own. ++What is taken from `atmosphere.glsl`: the air's scattering and absorption coefficients (Rayleigh, Mie, ++ozone) moved to Rec. 2020 as Photon does (the march runs in that space, the result goes back to Rec. 709), ++the scale heights, the ozone density profile, the Mie albedo, the Klein-Nishina Mie phase and the ++depolarised Rayleigh phase (`phase_functions.glsl`), the sunlight colour outside the atmosphere, and the ++Chapman-function approximation of the sun's transmittance. From `light_color.glsl`: `get_sun_tint` and ++the blue-hour / sunset exposure lift (`sun_light_tint`, `sun_tint`), and `pulse`. The sky-view texture and ++its march are our own. ++ ++The volumetric clouds and fog (`hd_volumes.rs`, `hd_noise.rs` for the CPU-made noise textures) port ++from `include/sky/clouds/common.glsl` and `cumulus.glsl` (the density shaping with the egg shape and ++edge sharpening, Worley erosion, the single and multiple scattering phase functions, the powder effect, ++the eight-octave multiple-scattering loop, the light march with doubling steps, the transmittance remap, ++the aerial perspective), `include/lighting/cloud_shadows.glsl` (the cloud shadow map, here a lookup into a ++small map) and `include/fog/air_fog_vl.glsl` (the phase mix of two Henyey-Greenstein lobes, the evening ++glow). The 3D Worley and 2D noise textures are generated on our side; the ray march, the passes, the ++reprojection and the integration are our own. + + Photon is distributed under the MIT License: + +diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs +index f078d423c..3b9daabf4 100644 +--- a/crates/horizon-client/src/gfx/hd.rs ++++ b/crates/horizon-client/src/gfx/hd.rs +@@ -6,6 +6,8 @@ + //! bright parts bloom and a tone curve brings it to the screen — vanilla's + //! range (up to about 0.8) left as it is, only what is brighter rolled off. + use super::hd_sky; ++pub use super::hd_volumes::clouds_enabled; ++use super::hd_volumes::{self, HdVolumes}; + use super::pack_timing::PassTiming; + use glam::Mat4; + use std::sync::atomic::{AtomicU64, Ordering}; +@@ -108,6 +110,18 @@ struct HdFrame { + sky_extra: [f32; 4], + /// Vanilla's sunrise colour fan (rgb, alpha), taken out of the sky before the atmosphere is put in. + sunrise: [f32; 4], ++ /// x: the cloud slab's base height relative to the camera; y: its thickness; z: 1 when the ++ /// volumetric clouds are drawn; w: seconds (the wind). ++ clouds: [f32; 4], ++ /// xy: the camera's x and z in the world modulo 65536; z: extra coverage (rain); w: 1 when the ++ /// clouds' history is usable. ++ cloud_cam: [f32; 4], ++ /// xyz: the direction the clouds are lit from (the sun, or the moon at night). ++ cloud_dir: [f32; 4], ++ /// rgb: the colour of that light at the clouds; w: its strength. ++ cloud_sun: [f32; 4], ++ /// x: 1 when the volumetric fog is drawn; y: the camera's height in the world; z: the morning mist. ++ vfog: [f32; 4], + } + + /// What the world around looks like this frame (from the renderer's globals). +@@ -141,6 +155,11 @@ pub struct HdScene { + /// Vanilla's sunrise fan colour and its sky fog end (to subtract vanilla's sky from the world). + pub sunrise: [f32; 4], + pub sky_fog_end: f32, ++ /// The volumetric clouds are drawn (vanilla's option is on, in the Overworld). ++ pub clouds: bool, ++ /// The camera's world position, x and z modulo 65536 (precision), and y; seconds of game time. ++ pub camera_world: [f32; 3], ++ pub seconds: f32, + } + + /// The cascades' half sizes (blocks) and shadow map size. +@@ -168,6 +187,11 @@ struct HdFrame { + sky_sun: vec4, + sky_extra: vec4, + sunrise: vec4, ++ clouds: vec4, ++ cloud_cam: vec4, ++ cloud_dir: vec4, ++ cloud_sun: vec4, ++ vfog: vec4, + } + struct FullOut { + @builtin(position) position: vec4, +@@ -209,6 +233,13 @@ struct Light { + // The water pass's depth: the nearest water surface (0 where there is none). + @group(0) @binding(14) var water_depth: texture_depth_2d; + //SKY15 ++// The volumetric fog (half resolution: light in rgb, transmittance in a), the distance it was marched ++// to, the volumetric clouds (half resolution: light, transmittance) and the clouds' shadow map. ++@group(0) @binding(16) var fog_tex: texture_2d; ++@group(0) @binding(17) var fog_dist_tex: texture_2d; ++@group(0) @binding(18) var cloud_tex: texture_2d; ++@group(0) @binding(19) var cloud_shadow_tex: texture_2d; ++const CLOUD_SHADOW_R: f32 = 1200.0; + // The pixel being lit, for the shadow filter's noise. + var frag_px: vec2; + +@@ -327,6 +358,51 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 { + return (d - start) / (end - start); + } + ++// The share of the sunlight the clouds let through at a camera-relative point: the shadow map ++// holds, for the sun's way down through the slab from a point of its base, the optical depth ++// (Photon's cloud shadow map, as a lookup rather than a pass). ++fn cloud_shadow(p: vec3) -> f32 { ++ if frame.clouds.z < 0.5 { return 1.0; } ++ let l = frame.cloud_dir.xyz; ++ if l.y < 0.1 { return 1.0; } ++ let t = (frame.clouds.x - p.y) / l.y; ++ if t <= 0.0 { return 1.0; } ++ let q = p.xz + l.xz * t; ++ let uv = q / (2.0 * CLOUD_SHADOW_R) + vec2(0.5); ++ if any(uv < vec2(0.0)) || any(uv > vec2(1.0)) { return 1.0; } ++ let od = textureSampleLevel(cloud_shadow_tex, linear, uv, 0.0).r; ++ // Fade out as the sun gets low (the shadow then falls far from the cloud). ++ let fade = smoothstep(0.1, 0.25, l.y); ++ return mix(1.0, mix(1.0, exp(-od * 0.9), 0.9), fade); ++} ++ ++// The volumetric fog at a full-resolution pixel `dist` away: the four nearest half-resolution ++// texels weighted by closeness and by how near their march's length is (so it does not bleed over edges). ++fn fog_at(px: vec2, dist: f32) -> vec4 { ++ let size = vec2(textureDimensions(fog_tex)); ++ let f = vec2(px) * 0.5 - vec2(0.25); ++ let base = vec2(floor(f)); ++ let t = f - floor(f); ++ var sum = vec4(0.0); ++ var total = 0.0; ++ for (var j = 0; j < 2; j++) { ++ for (var i = 0; i < 2; i++) { ++ let q = clamp(base + vec2(i, j), vec2(0), size - 1); ++ let s = textureLoad(fog_tex, q, 0); ++ let sd = textureLoad(fog_dist_tex, q, 0).x; ++ let bw = select(1.0 - t.x, t.x, i == 1) * select(1.0 - t.y, t.y, j == 1); ++ let dz = (sd - dist) / (0.06 * dist + 0.4); ++ let w = bw / (1.0 + dz * dz * dz * dz); ++ sum += s * w; ++ total += w; ++ } ++ } ++ if total < 0.0001 { ++ return textureLoad(fog_tex, clamp(px / 2, vec2(0), size - 1), 0); ++ } ++ return sum / total; ++} ++ + @fragment + fn fs_light(in: FullOut) -> @location(0) vec4 { + let px = vec2(in.position.xy); +@@ -373,10 +449,15 @@ fn fs_light(in: FullOut) -> @location(0) vec4 { + let angle = acos(clamp(toward, -1.0, 1.0)); + sky += frame.sun_color.rgb * shine * (pow(glow, 24.0) * 0.07 + pow(glow, 400.0) * 0.4 + 6.0 * (1.0 - smoothstep(0.016, 0.022, angle))); + c = sky + kept; ++ // The volumetric clouds over the sky and the sun. ++ if frame.clouds.z > 0.5 { ++ let cl = textureSampleLevel(cloud_tex, linear, in.uv, 0.0); ++ c = c * cl.a + cl.rgb; ++ } + } + // Clouds (vanilla's, drawn into the world with depth but not into the G-buffer): lit by the low + // sun's colour at dusk and dawn instead of vanilla's flat white. +- if atmosphere && !water && d > 0.0 && g <= 0.0 && p.y > 8.0 && dot(p, p) > 2304.0 { ++ if atmosphere && frame.clouds.z < 0.5 && !water && d > 0.0 && g <= 0.0 && p.y > 8.0 && dot(p, p) > 2304.0 { + let low = 1.0 - smoothstep(0.05, 0.5, frame.sky_sun.y); + c = scene * mix(vec3(1.0), mix(frame.sun_color.rgb, vec3(1.0), 0.45) * 1.1, low * 0.7 * (1.0 - frame.sky_extra.z)); + } +@@ -391,7 +472,7 @@ fn fs_light(in: FullOut) -> @location(0) vec4 { + let sky_reach = smoothstep(0.35, 0.95, lm.x); + var s = 1.0; + let ndl = dot(n, sun); +- if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow_taps(p, n, select(1, 0, water)); } else { s = 0.0; } ++ if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow_taps(p, n, select(1, 0, water)) * cloud_shadow(p); } else { s = 0.0; } + // Shadowed sky light (ambient) keeps vanilla's look, blocks' light stays as is; the sun adds + // direct light by the face's angle. + let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach; +@@ -436,10 +517,27 @@ fn fs_light(in: FullOut) -> @location(0) vec4 { + c = mix(c, haze, (1.0 - exp(-sph / 700.0)) * 0.5); + } + } +- // Light in the air: the sun's rays between the shadows, and halos round the mods' lights. ++ // The volumetric fog: the light the air scatters towards the camera (shafts through the trees, ++ // mist, haze), and how much of the pixel it hides. + let far = select(min(length(p), frame.shadow.z), frame.shadow.z, d <= 0.0); + let density = frame.ambient.z; +- if sun_on > 0.0 && density > 0.0 { ++ let volumetric = frame.vfog.x > 0.5 && atmosphere; ++ if volumetric { ++ var fog_len = 192.0; ++ if d > 0.0 { ++ var pf = p; ++ if water { ++ let hf = frame.inverse_view_proj * vec4(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(d, 0.000001), 1.0); ++ pf = hf.xyz / hf.w; ++ } ++ fog_len = min(length(pf), 192.0); ++ } ++ let fg = fog_at(px, fog_len); ++ c = c * fg.a + fg.rgb; ++ } ++ // Light in the air (without the volumetric fog): the sun's rays between the shadows; always the ++ // halos round the mods' lights. ++ if !volumetric && sun_on > 0.0 && density > 0.0 { + let steps = 14; + let jitter = ign(in.position.xy); + var acc = 0.0; +@@ -1185,11 +1283,11 @@ fn fs_taau(in: FullOut) -> TaaOut { + } + "#; + +-struct Target { +- view: wgpu::TextureView, ++pub(super) struct Target { ++ pub(super) view: wgpu::TextureView, + } + +-fn target( ++pub(super) fn target( + device: &wgpu::Device, + label: &str, + size: [u32; 2], +@@ -1324,6 +1422,9 @@ pub struct Hd { + /// The frame's translucent terrain exists (set by the renderer before `write`). + pub water_geometry: bool, + water_composited: bool, ++ /// The volumetric clouds and the fog run this frame. ++ clouds_active: bool, ++ fog_active: bool, + /// The sky-view texture (see `hd_sky`), the pass that marches it and its bind group. + sky_lut: wgpu::TextureView, + sky_pipeline: wgpu::RenderPipeline, +@@ -1346,7 +1447,9 @@ pub struct Hd { + bloom: Vec, + /// 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)>, ++ light_group: [Option<(LightKey, wgpu::BindGroup)>; 2], ++ /// The volumetric clouds and fog (see `hd_volumes`). ++ volumes: HdVolumes, + 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 +@@ -1537,25 +1640,76 @@ fn ao_strength_default() -> f32 { + + /// The shader's source: the common part, then the body with the sky-reading functions where it + /// has a marker (`//SKY15`: the sky-view texture is binding 15, `//SKY10`: binding 10). +-fn assemble(body: &str) -> String { ++pub(super) fn assemble(body: &str) -> String { + let mut body = body.to_owned(); +- for binding in ["15", "10"] { ++ // Highest number first: `//SKY1` must not eat the start of `//SKY15`. ++ for binding in (0..32).rev() { + body = body.replace( + &format!("//SKY{binding}"), +- &hd_sky::SKY_FUNCS.replace("SKYBIND", binding), ++ &hd_sky::SKY_FUNCS.replace("SKYBIND", &binding.to_string()), + ); + } + format!("{COMMON}{body}") + } + +-fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule { ++pub(super) fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule { ++ // Development: with `HORIZON_HD_SHADER_DIR` set, a shader's body is read from ++ // `/