diff --git a/airdef-mod/shaders/effects.wgsl b/airdef-mod/shaders/effects.wgsl index f71a783..2eea9e2 100644 --- a/airdef-mod/shaders/effects.wgsl +++ b/airdef-mod/shaders/effects.wgsl @@ -201,7 +201,9 @@ fn post(uv: vec2, pixel: vec2) -> vec4 { let t1 = min(span.y, wall); if t1 <= t0 { continue; } let heat = 1.0 - smoothstep(0.05, 0.55, k); - let fade = 1.0 - smoothstep(0.65, 1.0, k); + // With the HD renderer's volumetric smoke (params[0].w = 2) only the fireball is drawn here. + var fade = 1.0 - smoothstep(0.65, 1.0, k); + if frame.params[0].w > 1.5 { fade = 1.0 - smoothstep(0.08, 0.28, k); } // Fewer steps for a fireball that is small on screen. let on_screen = radius / max(length(centre), 1.0); let steps = i32(clamp(on_screen * 40.0, 6.0, 14.0)); diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 42e12a9..f7a4a5c 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -16,6 +16,7 @@ mod monitor; mod panel; mod radar; mod replay; +mod smoke; pub mod sim; mod scenario; mod wave; @@ -54,6 +55,7 @@ const ITEMS: [&str; 29] = [ ]; struct AirDef { + smoke: smoke::Smoke, sim: Sim, ground: Ground, dirty: bool, @@ -331,7 +333,7 @@ impl AirDef { 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); } // Fires burn on for minutes: a flickering, wide glow that dies down with the flames. - for f in &self.sim.fires.list { + for f in self.sim.fires.list.iter().filter(|f| f.shown) { let (fl, sm) = f.level(tick); if f.quiet { continue; @@ -381,7 +383,7 @@ impl AirDef { fn shader_params(&self, out: &Out) -> Vec { let mut p = vec![0.0f32; 4]; p[0] = self.sim.tick as f32; - p[3] = if self.hd { 1.0 } else { 0.0 }; + p[3] = if self.smoke.hd { 2.0 } else if self.hd { 1.0 } else { 0.0 }; let mut blasts: Vec<&sim::Blast> = self.sim.blasts.iter().rev().take(48).collect(); // The shader composites the volumes front to back with one shared transmittance, so // the nearest to the player goes first. This is exact for volumes that do not @@ -459,14 +461,20 @@ impl AirDef { } if self.hd { render::set_lights(&self.lights(&out)); + self.smoke.hd = render::set_smoke(self.sim.tick as f64, &self.smoke.segments(&self.sim)); + } else { + self.smoke.hd = false; } if !out.models.is_empty() || self.sim.tick % 20 == 0 { self.send_models(&out.models); } if !out.particles.is_empty() { + let hd_smoke = self.smoke.hd; let spawns: Vec = out .particles .into_iter() + // The long smoke is the renderer's volume then. + .filter(|p| !(hd_smoke && matches!(p.kind, "minecraft:campfire_cosy_smoke" | "minecraft:campfire_signal_smoke" | "minecraft:large_smoke"))) .take(512) .map(|p| ParticleSpawn { particle: p.kind.to_string(), @@ -997,6 +1005,7 @@ impl Mod for AirDef { grid_ids: Vec::new(), wave: wave::WaveConfig::default(), replay: None, + smoke: Default::default(), far: true, moving: None, hd: false, @@ -1084,7 +1093,7 @@ impl Mod for AirDef { 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); + self.ground.world.changed_rev(changes.epoch, changes.revision, changes.overflow, &changes.sections); let sections = render::sections() .into_iter() .map(|s| sim::world::SectionAnswer { @@ -1101,15 +1110,39 @@ impl Mod for AirDef { }) .collect(); self.ground.world.answer(sections, now); + // Exact collision shapes of blocks a flight met (#63): the answers to last tick's rays. + let replies: Vec = render::rays() + .into_iter() + .map(|r| sim::world::RayReply { + id: r.id, + clear: matches!(r.verdict, render::Verdict::Clear), + hit: matches!(r.verdict, render::Verdict::Hit), + fraction: r.fraction, + normal: [r.normal.0, r.normal.1, r.normal.2], + block: [r.block.0, r.block.1, r.block.2], + }) + .collect(); + self.ground.world.answer_rays(&replies); 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))); self.sim.day_time = game::get_clock().map(|c| c.day_time); self.sim.step(&mut self.ground, Some(DVec3::new(p.x, p.y, p.z)), &mut out); + self.smoke.update(&self.sim); let wanted = self.ground.take_wanted(); if !wanted.is_empty() { render::query_ground(&wanted); } + let rays: Vec = self + .ground + .world + .take_rays(now) + .into_iter() + .map(|(id, a, b)| render::Ray { id, start: (a.x, a.y, a.z), end: (b.x, b.y, b.z), policy: render::Policy::Collision }) + .collect(); + if !rays.is_empty() { + render::query_rays(&rays); + } let sections = self.ground.world.take_wanted(now); if !sections.is_empty() { render::query_sections(render::Policy::Collision, §ions); diff --git a/airdef-mod/src/sim/ew.rs b/airdef-mod/src/sim/ew.rs index 42f459d..8337a1a 100644 --- a/airdef-mod/src/sim/ew.rs +++ b/airdef-mod/src/sim/ew.rs @@ -169,22 +169,22 @@ impl Sim { continue; } // Only those not yet sent at a unit. - if self.sams.iter().any(|s| s.pos.distance(d.target) < 6.0) { + if self.sams.iter().any(|s| s.pos.distance(d.target - d.err) < 6.0) { continue; } let to = if !ew.is_empty() { pick(n, &ew) } else { pick(n, &known) }; - d.target = to; + d.target = to + d.err; d.route.clear(); retargeted.push((true, qi)); n += 1; } // In flight: a target system destroyed — the next known one, or keep going. 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); + let aimed_at_wreck = self.sams.iter().any(|s| !s.alive() && s.pos.distance(d.target - d.err) < 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.target = *at + d.err; d.route.clear(); retargeted.push((false, di)); } diff --git a/airdef-mod/src/sim/fires.rs b/airdef-mod/src/sim/fires.rs index 9102895..21da2a9 100644 --- a/airdef-mod/src/sim/fires.rs +++ b/airdef-mod/src/sim/fires.rs @@ -6,8 +6,11 @@ //! oldest dying down first. A replay draws the same fires from its events (`replay_fire`). use super::*; -/// Fires burning at once. +/// Fires drawn at full strength at once (flames, smoke columns, lights, fire blocks): the strongest `MAX`. pub const MAX: usize = 12; +/// Fires kept at all, burning places the player may come back to: past it the weakest are put out (#69). +/// The rest of them, beyond `MAX`, only smoulder (a thin smoke, now and then), with no light. +pub const LIST_MAX: usize = 32; /// A fire put out by the water it fell in lasts this long (steam only). const STEAM: u32 = 160; /// Ticks the oldest fire has left when a newer one pushes it out. @@ -32,6 +35,8 @@ pub struct Fire { blocks: Vec<(i32, i32, i32)>, /// The host's wreck is flaming: this fire leaves the flames to it. pub quiet: bool, + /// Among the `MAX` strongest this tick: the only ones drawn in full and lit (#69). + pub shown: bool, } #[derive(Default)] @@ -117,7 +122,20 @@ impl Sim { asked: 0, blocks: Vec::new(), quiet: false, + shown: true, }); + // A hard bound on the places kept (#69): the weakest, soonest out, goes now and its fire blocks with it. + while self.fires.list.len() > LIST_MAX { + let weakest = (0..self.fires.list.len()) + .min_by(|&a, &b| { + let (fa, fb) = (&self.fires.list[a], &self.fires.list[b]); + let w = |f: &Fire| f.size as f64 * (f.life as f64 - now.saturating_sub(f.start) as f64).max(0.0); + w(fa).total_cmp(&w(fb)).then(fa.id.cmp(&fb.id)) + }) + .unwrap_or(0); + let gone = self.fires.list.swap_remove(weakest); + self.fires.cleanup.extend(gone.take_blocks()); + } if self.fires.list.len() > MAX { // The oldest dies down (and is gone) instead of burning on. let old = self.fires.list.iter_mut().filter(|f| now.saturating_sub(f.start) as u32 + FADE < f.life).min_by_key(|f| f.start); @@ -133,6 +151,19 @@ impl Sim { out.commands.append(&mut self.fires.cleanup); let damage = self.damage; let mut fires = std::mem::take(&mut self.fires.list); + // The strongest `MAX` are drawn in full and lit; the rest smoulder (#69). + let mut rank: Vec<(f32, u32, usize)> = fires + .iter() + .enumerate() + .map(|(i, f)| { + let (fl, sm) = f.level(now); + (f.size * (fl + 0.3 * sm), f.id, i) + }) + .collect(); + rank.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1))); + for (k, &(_, _, i)) in rank.iter().enumerate() { + fires[i].shown = k < MAX; + } for f in &mut fires { let age = now.saturating_sub(f.start); // Where the land is under it: it burns on the surface, not at the height of what fell. @@ -159,7 +190,12 @@ impl Sim { f.quiet = f.host.is_some_and(|id| self.sams.iter().any(|s| s.id == id && !s.alive() && s.burn > 0)); let (fl, sm) = f.level(now); let s = f.size; - if !f.quiet { + if !f.quiet && !f.shown { + // Beyond the budget: a thin smoke now and then, no flames, no light, no new fire blocks. + if sm > 0.04 && now % 24 == (f.id % 24) as u64 { + out.particles("minecraft:large_smoke", f.at + DVec3::Y, 0.4 + 0.3 * f.size, DVec3::new(0.0, 0.1, 0.0), 0.03, 1); + } + } else if !f.quiet { let top = f.at + DVec3::Y * 0.4; let spread = 0.4 + 0.5 * s; if fl > 0.04 && now % 2 == 0 { @@ -180,7 +216,7 @@ impl Sim { } } // Real fire, only where the damage is real: a few blocks on the solid ground. - if damage && f.land == Some(true) && fl > 0.3 && now % REFRESH == (f.id as u64) % REFRESH && age >= 20 { + if damage && f.shown && f.land == Some(true) && fl > 0.3 && now % REFRESH == (f.id as u64) % REFRESH && age >= 20 { let (x, y, z) = (f.at.x.floor() as i32, f.at.y.floor() as i32, f.at.z.floor() as i32); let n = if s >= 2.5 { 5 } else if s >= 1.5 { 3 } else { 1 }; for k in 0..n { diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index 25d7430..327359c 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -219,10 +219,19 @@ 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 tick of the last answer round: what the heights' ages are measured against. + now: u64, /// The blocks themselves (issues #42, #43): what stops a flight. pub world: world::WorldCache, } +/// The `when` of a height reported changed: due to be asked again. +const STALE: u64 = u64::MAX; +/// Ticks after which a land height used by the flights' look-ahead is asked again (#64), changes told of or not. +const HEIGHT_TTL: u64 = 1200; +/// Ticks a flight waits for an unanswered section, and for a chunk the game doesn't have while low over the land (#61). +pub const HOLD_PENDING: u16 = 45; +pub const HOLD_NO_CHUNK: u16 = 240; /// Ticks between full clean-ups of a big ground cache. const SWEEP_EVERY: u64 = 200; @@ -235,7 +244,7 @@ impl Ground { Some(&(y, when)) => { // 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) { + if (when == STALE || now.saturating_sub(when) > 200) && self.wanted.len() < 64 && self.asked.insert(key) { self.wanted.push(key); } (y > -64).then_some(f64::from(y)) @@ -249,14 +258,54 @@ impl Ground { } } pub fn answer(&mut self, answers: Vec<(i32, i32, i32)>, now: u64) { + self.now = now; for (x, z, y) in answers { self.known.insert((x, z), (y, now)); } + self.age_heights(); 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); } } + /// Heights over blocks that changed (the block cache's notes of changes, chunk replacements and a new world) + /// are marked old (`when` = STALE): still used, asked again within the budget. #64. + fn age_heights(&mut self) { + let all = std::mem::take(&mut self.world.changed_all); + let cols = std::mem::take(&mut self.world.changed_cols); + if !all && cols.is_empty() { + return; + } + if all || cols.len() > 24 { + for (&(x, z), v) in self.known.iter_mut() { + if all || cols.contains(&(x >> 4, z >> 4)) { + v.1 = STALE; + } + } + } else { + for &(sx, sz) in &cols { + for x in sx * 16..sx * 16 + 16 { + for z in sz * 16..sz * 16 + 16 { + if let Some(v) = self.known.get_mut(&(x, z)) { + v.1 = STALE; + } + } + } + // The lattice columns of the flights' look-ahead (see `terrain`). + for x in (sx * 16..sx * 16 + 16).step_by(8) { + for z in (sz * 16..sz * 16 + 16).step_by(8) { + if let Some(v) = self.known.get_mut(&(x + 4, z + 4)) { + v.1 = STALE; + } + } + } + } + } + } + /// Whether a known height is due to be asked again: reported old, or too old to trust. + fn due(&self, when: u64) -> bool { + when == STALE || self.now.saturating_sub(when) > HEIGHT_TTL + } pub fn take_wanted(&mut self) -> Vec<(i32, i32)> { self.asked.clear(); std::mem::take(&mut self.wanted) @@ -265,6 +314,48 @@ impl Ground { pub fn sweep(&mut self, a: DVec3, b: DVec3, skip_start: bool, now: u64) -> world::Sweep { self.world.sweep(a, b, skip_start, now) } + /// The step of flight `key` from `a` to `b`: the first solid block by the occupancy, then (#63) by the + /// block's exact collision shape, which the game answers a tick or two later (see `WorldCache::narrow`). + pub fn trace(&mut self, key: u32, a: DVec3, b: DVec3, skip_start: bool, now: u64) -> world::Sweep { + let s = self.world.sweep(a, b, skip_start, now); + self.world.narrow(key, s, a, b, now) + } + /// What to do with a step that ended in blocks the game hasn't told of (#61): `Some(t)` cuts the step at `t` + /// of the way (it holds there, the section is asked for urgently), `None` lets it go on. Unread is never + /// taken for clear: a section not answered yet holds a flight for up to `HOLD_PENDING` ticks, a chunk the + /// game doesn't have holds one that is low over the land (or inside it) for `HOLD_NO_CHUNK`; after that, and + /// high over the land, it goes on (the game has nothing there to hit) until a step is clear again. + pub fn unread(&mut self, sweep: &world::Sweep, hold: &mut u16, low: bool) -> Option { + match *sweep { + world::Sweep::Unknown { t, pending } => { + let limit = if pending { + HOLD_PENDING + } else if low { + HOLD_NO_CHUNK + } else { + 0 + }; + if *hold < limit { + *hold += 1; + self.world.stats.held += 1; + Some((t - 1e-6).max(0.0)) + } else { + self.world.stats.passed_unknown += 1; + None + } + } + world::Sweep::Clear => { + *hold = 0; + None + } + world::Sweep::Hit { .. } => None, + // The exact shape is being asked for: the flight holds at the face (a bounded wait, see `narrow`). + world::Sweep::Wait { t } => { + self.world.stats.held += 1; + Some((t - 1e-6).max(0.0)) + } + } + } /// 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); @@ -748,6 +839,11 @@ pub struct Drone { smart: bool, /// What the land ahead asks of its flight (see `terrain`). nav: Nav, + /// Its navigation error (#65): `target` is the nominal aim plus this, kept apart so a new aim (a retarget) + /// keeps the error this airframe was born with instead of becoming exact. + err: DVec3, + /// Ticks held at the edge of blocks the game has not told of (#61); reset by a clear step. + hold: u16, } /// A drone's terrain following: refreshed every few ticks, used every one. @@ -970,6 +1066,50 @@ pub struct Missile { /// The system that fired it. sam: u32, age: u32, + /// Ticks held at the edge of blocks the game has not told of (#61). + hold: u16, + /// What it knows of its target (#66): the last fix, from the firing system's track (the data link) + /// or from its own seeker. It steers on this, never on the target's true position. + fix: Option, + /// The tick its seeker last held the target (0: not yet). + seen: u64, +} + +/// A missile's knowledge of its target: where it was and how it went when measured, and when. +#[derive(Clone, Copy, Debug)] +pub struct Fix { + pub pos: DVec3, + pub vel: DVec3, + pub tick: u64, + /// Measured by its own seeker (else by the firing system's radar, passed on by the link). + pub seeker: bool, +} + +impl Fix { + /// Where the target should be by now, carrying the measurement on. + pub fn est(&self, now: u64) -> DVec3 { + self.pos + self.vel * now.saturating_sub(self.tick).min(MAX_COAST) as f64 + } +} + +/// Ticks a fix is carried on by its velocity; a missile with nothing newer than this has lost its target. +pub const MAX_COAST: u64 = 240; +/// Ticks the seeker keeps a target it lost sight of (it looks again round the last place). +const REACQUIRE: u64 = 12; + +impl MissileKind { + /// How far its own seeker holds a target of average size (blocks); 0: command guidance only. + pub fn seeker(self) -> f64 { + match self { + MissileKind::M57e6 => 0.0, + MissileKind::Pac3 => 110.0, + MissileKind::M48n6 | MissileKind::M77n6 => 120.0, + MissileKind::IrisT | MissileKind::M9m317 => 90.0, + MissileKind::Amraam => 130.0, + MissileKind::M9m338 => 70.0, + MissileKind::P1sun => 60.0, + } + } } // ---------------------------------------------------------------- air defence @@ -1379,37 +1519,9 @@ 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); - let time = (flat.length() / spec.speed).max(30.0); - let up = (target.y - from.y + 0.5 * GRAVITY * time * time) / time; - (flat / time + DVec3::Y * up, Flight::Ballistic) - } else { - (dir.normalize_or(DVec3::NEG_Z) * spec.speed, Flight::Cruise) - }; - self.drones.push(Drone { - id, - kind, - pos: from, - vel, - target, - flight, - roll: 0.0, - spin: 0.0, - age: 0, - engaged: 0, - route: Vec::new(), - low: false, - jammed: false, - sector: 0, - smart: false, - nav: Nav::default(), - }); + // Its navigation error is drawn in `make_drone`, the one path of manual and raid launches (#65). + let d = self.make_drone(kind, from, dir, target); + self.drones.push(d); self.launched += 1; } @@ -1513,9 +1625,11 @@ 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); + let sweep = ground.trace(d.id, from, d.pos, d.age <= 3, now); if let Some((_, p)) = sweep.hit() { d.pos = p; + } else if let Some(t) = ground.unread(&sweep, &mut d.hold, below.is_some_and(|g| from.y < g + 12.0)) { + d.pos = from.lerp(d.pos, t); } 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); @@ -1544,9 +1658,11 @@ 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); + let sweep = ground.trace(d.id, from, d.pos, d.age <= 3, now); if let Some((_, p)) = sweep.hit() { d.pos = p; + } else if let Some(t) = ground.unread(&sweep, &mut d.hold, below.is_some_and(|g| from.y < g + 12.0)) { + d.pos = from.lerp(d.pos, t); } let speed = d.vel.length(); let landed = sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.2, &sweep, now); @@ -1721,9 +1837,12 @@ 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); + let sweep = ground.trace(d.id, from, d.pos, d.age <= 3, now); if let Some((_, p)) = sweep.hit() { d.pos = p; + } else if let Some(t) = ground.unread(&sweep, &mut d.hold, below.is_some_and(|g| from.y < g + 12.0)) { + // Not told of what lies ahead: it doesn't fly into it unseen (a held flight waits, a tick or two). + d.pos = from.lerp(d.pos, t); } let at_target = d.pos.distance(d.target) < speed.max(1.5); let in_ground = sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.2, &sweep, now); @@ -1818,6 +1937,8 @@ impl Sim { sector, smart: false, nav: Nav::default(), + err: DVec3::ZERO, + hold: 0, }); } for d in &self.drones { @@ -2290,10 +2411,17 @@ impl Sim { if let Some(s) = self.sams.iter().find(|s| s.id == sam) { self.intel.revealed.push((sam, s.pos, self.tick)); } + // It leaves with the track the system fired on (#66). + let fix = self + .sams + .iter() + .find(|s| s.id == sam) + .and_then(|s| s.track(target)) + .map(|tr| Fix { pos: tr.pos, vel: tr.vel, tick: tr.tick, seeker: false }); let spec = kind.spec(); let id = self.id(); let v0 = if spec.cold > 0 { 0.7 } else { 0.8 }; - self.missiles.push(Missile { id, kind, pos: muzzle, vel: dir * v0, target, sam, age: 0 }); + self.missiles.push(Missile { id, kind, pos: muzzle, vel: dir * v0, target, sam, age: 0, hold: 0, fix, seen: 0 }); if spec.cold > 0 { // Thrown out by gas: a puff, a thump; the motor lights later. out.particles("minecraft:cloud", muzzle, 0.6, dir * 0.05, 0.05, 30); @@ -2328,6 +2456,16 @@ impl Sim { let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32); let (mut blocked, mut scrubbed) = (0u32, 0u32); let sams_fit: HashMap = self.sams.iter().map(|s| (s.id, 1.0 - 0.3 * (1.0 - s.health.clamp(0.0, 1.0)))).collect(); + // The data link (#66): the track the firing system holds of each missile's target, if it is up. + let links: HashMap<(u32, u32), Track> = self + .missiles + .iter() + .filter_map(|m| { + let s = self.sams.iter().find(|s| s.id == m.sam)?; + (s.alive() && !s.moving()).then_some(())?; + Some(((m.sam, m.target), *s.track(m.target)?)) + }) + .collect(); for m in &mut self.missiles { m.age += 1; let spec = m.kind.spec(); @@ -2339,23 +2477,52 @@ impl Sim { (m.vel.length() + spec.accel).min(spec.speed) }; let dir = m.vel.normalize_or(DVec3::Y); - let new = match target { - Some(t) if m.age >= spec.boost => { + // What it knows: the link's newest track, and its own seeker once the target is in its reach and in view. + if let Some(tr) = links.get(&(m.sam, m.target)) + && m.fix.is_none_or(|f| tr.tick > f.tick && !(f.seeker && now.saturating_sub(f.tick) <= REACQUIRE)) + { + m.fix = Some(Fix { pos: tr.pos, vel: tr.vel, tick: tr.tick, seeker: false }); + } + if let Some(t) = target + && lit + && m.age >= spec.boost + && m.kind.seeker() > 0.0 + { + let range = m.kind.seeker() * t.kind.rcs().powf(0.25).clamp(0.6, 1.6); + let to = t.pos - m.pos; + let in_view = to.length() < range && to.normalize_or_zero().dot(dir) > 0.5; + if in_view && !matches!(ground.world.probe(m.pos, t.pos, now), world::Sweep::Hit { .. }) { + m.seen = now; + } + if m.seen > 0 && now.saturating_sub(m.seen) <= REACQUIRE { + // The seeker measures the target (the track of its own, newer than any link's). + m.fix = Some(Fix { pos: if m.seen == now { t.pos } else { m.fix.map_or(t.pos, |f| f.pos) }, vel: t.vel, tick: if m.seen == now { now } else { m.fix.map_or(now, |f| f.tick) }, seeker: true }); + } + } + // Nothing new for too long and no seeker on it: the interceptor has lost its target. + if target.is_some() && m.fix.is_none_or(|f| now.saturating_sub(f.tick) > MAX_COAST) && m.age > spec.boost + 20 { + expired += 1; + done.push((m.id, None, false, m.sam)); + continue; + } + let aim = m.fix.map(|f| (f.est(now), f.vel)); + let new = match aim { + Some((at, vel)) if m.age >= spec.boost => { // Time to go by the closing speed: head-on targets come to meet it. - let los = (t.pos - m.pos).normalize_or_zero(); - let closing = (speed - t.vel.dot(los)).max(1.0); - let tgo = t.pos.distance(m.pos) / closing; + let los = (at - m.pos).normalize_or_zero(); + let closing = (speed - vel.dot(los)).max(1.0); + let tgo = at.distance(m.pos) / closing; // Over the land in the way: lofted as far as the missile can, else it blows up before the ridge. - let (want, fail) = terrain::steer(ground, m.kind, m.pos, speed, t.pos + t.vel * tgo); + let (want, fail) = terrain::steer(ground, m.kind, m.pos, speed, at + vel * tgo); if fail { scrubbed += 1; - done.push((m.id, Some(t.id), false, m.sam)); + done.push((m.id, target.map(|t| t.id), false, m.sam)); continue; } turn(dir, want, spec.turn) } // After a cold launch it tips over towards the target while the motor burns. - Some(t) if lit && spec.cold > 0 => turn(dir, t.pos - m.pos, spec.turn * 0.5), + Some((at, _)) if lit && spec.cold > 0 => turn(dir, at - m.pos, spec.turn * 0.5), _ => dir, }; let before = m.pos; @@ -2363,7 +2530,24 @@ impl Sim { 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(); + let sweep = ground.trace(m.id | 0x8000_0000, before, m.pos, m.age <= 12, now); + let mut world = sweep.hit(); + // Blocks not told of (#61): the interceptor holds at the edge of what is read, and no fuse is + // counted beyond it; low over a chunk the game doesn't have, it is lost there as against a wall. + if world.is_none() + && let world::Sweep::Unknown { t, pending } = sweep + { + let low = ground.at(m.pos, now).is_some_and(|g| before.y < g + 12.0); + if !pending && low { + world = Some((t, before.lerp(m.pos, t))); + } else if let Some(t) = ground.unread(&sweep, &mut m.hold, low || pending) { + m.pos = before.lerp(m.pos, t); + } + } else if let world::Sweep::Wait { t } = sweep { + m.pos = before.lerp(m.pos, (t - 1e-6).max(0.0)); + } else if matches!(sweep, world::Sweep::Clear) { + m.hold = 0; + } 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; diff --git a/airdef-mod/src/sim/raid.rs b/airdef-mod/src/sim/raid.rs index fc1a90e..67a8a86 100644 --- a/airdef-mod/src/sim/raid.rs +++ b/airdef-mod/src/sim/raid.rs @@ -350,9 +350,10 @@ impl Sim { from.y = floor; d.pos.y = floor; if spec.ballistic.is_some() { - let flat = horizontal(aim - from); + let aimed = aim + d.err; + let flat = horizontal(aimed - from); let time = (flat.length() / spec.speed).max(30.0); - d.vel = flat / time + DVec3::Y * ((aim.y - from.y + 0.5 * GRAVITY * time * time) / time); + d.vel = flat / time + DVec3::Y * ((aimed.y - from.y + 0.5 * GRAVITY * time * time) / time); } } } @@ -427,6 +428,10 @@ impl Sim { pub(super) fn make_drone(&mut self, kind: DroneKind, from: DVec3, dir: DVec3, target: DVec3) -> Drone { let id = self.id(); let spec = kind.spec(); + // Its navigation error (CEP and a rare gross fault, #59/#65): the same for a manual launch and a raid. + // The raid's own ±4 spread of aim points is the tactical dispersal and stays on top of it. + let err = self.aim_error(kind); + let target = target + err; let (vel, flight) = if spec.ballistic.is_some() { let flat = horizontal(target - from); let time = (flat.length() / spec.speed).max(30.0); @@ -452,6 +457,8 @@ impl Sim { sector: 0, smart: false, nav: Nav::default(), + err, + hold: 0, } } diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index 113117e..3981074 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -137,7 +137,7 @@ fn retargeted_smart_drone_gets_a_route_round_the_defences() { 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.target - d.err).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"); } @@ -375,8 +375,12 @@ fn fast_drone_must_not_cross_a_one_block_wall() { 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"); + // The wall's exact shape is asked of the game (#63): a tick or two, the drone holds at its face meanwhile. + for _ in 0..4 { + fly(&mut sim, &mut ground, &src); + assert!(sim.drones.first().is_none_or(|d| d.pos.x < 3.5), "crossed the wall"); + } + assert!(sim.drones.is_empty(), "stuck at the wall"); 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"); @@ -422,10 +426,14 @@ fn missile_cannot_pass_through_a_wall() { 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 }); + 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, hold: 0, fix: None, seen: 0 }); 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()); + for _ in 0..4 { + sim.tick += 1; + sim.step_missiles(&mut ground, &mut Out::default()); + let wanted = ground.world.take_wanted(sim.tick); + ground.world.serve(&src, wanted, sim.tick); + } 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"); @@ -667,7 +675,7 @@ fn behind_ridge(land: &Land i32>, crest: i32, kind: SamKind } // It crawls along the far side of the ridge, 20 blocks over the land. let there = (land.0)(x as i32, 0) as f64; - sim.add_drone(DroneKind::Gerbera, DVec3::new(x, there + 20.0, -100.0), DVec3::Z, DVec3::new(x, there, 3000.0)); + sim.add_drone(DroneKind::Shahed, DVec3::new(x, there + 20.0, -100.0), DVec3::Z, DVec3::new(x, there, 3000.0)); for _ in 0..ticks { sim.step(&mut ground, None, &mut out); answer_land(&mut ground, land, sim.tick); @@ -727,10 +735,15 @@ fn a_long_range_system_lofts_over_a_ridge_or_waits() { assert!(sim.sams[0].fired > 0, "an IRIS-T SLM must loft over a 25-block ridge"); assert_eq!(sim.tally.blocked + sim.tally.scrubbed, 0, "its interceptors hit the ridge"); let tall = ridge_at(150, 400, 100); - let sim = behind_ridge(&tall, 150, SamKind::IrisT, 230.0, 300); - assert_eq!(sim.sams[0].fired, 0, "no missile can climb a 400-block mountain at this range"); - assert_eq!(sim.tally.blocked + sim.tally.scrubbed, 0); - assert!(sim.why(0).iter().any(|c| !c.ok && c.text.contains("рельеф")), "{:?}", sim.why(0).iter().map(|c| &c.text).collect::>()); + // The track is fresh only for a while (the target creeps out of reach): the reason is read at several moments. + let mut told = false; + for ticks in (150..=300).step_by(10) { + let sim = behind_ridge(&tall, 150, SamKind::IrisT, 230.0, ticks); + assert_eq!(sim.sams[0].fired, 0, "no missile can climb a 400-block mountain at this range"); + assert_eq!(sim.tally.blocked + sim.tally.scrubbed, 0); + told |= sim.why(0).iter().any(|c| !c.ok && c.text.contains("рельеф")); + } + assert!(told, "`why` never named the land as the reason"); } // Interceptors fly over rolling land at a target low over it, and none ends in the ground. @@ -753,7 +766,7 @@ fn an_interceptor_over_rolling_land_does_not_hit_the_ground() { let (target, id) = (sim.drones[0].id, sim.id()); let from = DVec3::new(0.0, (rolling.0)(0, 0) as f64 + 3.0, 0.0); sim.drones[0].engaged = 1; - sim.missiles.push(Missile { id, kind, pos: from, vel: DVec3::new(0.5, 0.8, 0.0).normalize() * 0.8, target, sam: 0, age: 0 }); + sim.missiles.push(Missile { id, kind, pos: from, vel: DVec3::new(0.5, 0.8, 0.0).normalize() * 0.8, target, sam: 0, age: 0, hold: 0, fix: Some(fix_of(&sim.drones[0], sim.tick)), seen: 0 }); for _ in 0..500 { if sim.missiles.is_empty() { break; @@ -768,6 +781,11 @@ fn an_interceptor_over_rolling_land_does_not_hit_the_ground() { } } +/// The fix a link would have given on a target (a test stand-in for the firing system's track; these tests keep their targets still). +fn fix_of(d: &Drone, tick: u64) -> Fix { + Fix { pos: d.pos, vel: DVec3::ZERO, tick, seeker: false } +} + // An interceptor with no way over the land blows up in the air, and that is counted. #[test] fn an_interceptor_without_a_way_over_blows_up_high() { @@ -783,7 +801,7 @@ fn an_interceptor_without_a_way_over_blows_up_high() { sim.add_drone(DroneKind::Gerbera, DVec3::new(420.0, 90.0, 0.0), DVec3::X, DVec3::new(5000.0, 64.0, 0.0)); let (target, id) = (sim.drones[0].id, sim.id()); sim.drones[0].engaged = 1; - sim.missiles.push(Missile { id, kind: MissileKind::M57e6, pos: DVec3::new(230.0, 70.0, 0.0), vel: DVec3::X * 3.0, target, sam: 0, age: 20 }); + sim.missiles.push(Missile { id, kind: MissileKind::M57e6, pos: DVec3::new(230.0, 70.0, 0.0), vel: DVec3::X * 3.0, target, sam: 0, age: 20, hold: 0, fix: Some(fix_of(&sim.drones[0], sim.tick)), seen: 0 }); for _ in 0..200 { if sim.missiles.is_empty() { break; @@ -798,3 +816,395 @@ fn an_interceptor_without_a_way_over_blows_up_high() { let at = sim.blasts.first().expect("no blast").at; assert!(at.y > (wall.0)(at.x as i32, 0) as f64 + 3.0, "it burst in the ground at {at:?}"); } + +// ---- #61-#66, #69 (review of 6f3fc64): unread blocks, cache lifetime, exact shapes, heights, accuracy, guidance, fires + +/// Blocks with finer shapes: `shape` gives the boxes of a block (none: from `solid`). +struct Shaped Option>>(S); +impl Option>> world::WorldSource for Shaped { + fn solid(&self, x: i32, y: i32, z: i32) -> Option { + (self.0)(x, y, z).map(|b| !b.is_empty()) + } + fn boxes(&self, x: i32, y: i32, z: i32) -> Vec<[f64; 6]> { + (self.0)(x, y, z).unwrap_or_default() + } +} + +// #61: a flight on a cold cache does not cross the wall before the game has answered +#[test] +fn cold_cache_never_means_clear_for_a_drone() { + 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; + // No warm-up: nothing is read. One tick: the drone has not gone through, and asks for the sections. + sim.tick += 1; + sim.step_drones(&mut ground, None, &mut Out::default()); + assert!(sim.drones[0].pos.x < 3.5, "crossed the wall unread: x = {}", sim.drones[0].pos.x); + assert!(ground.world.stats.held > 0); + let mut hit_wall = false; + for _ in 0..8 { + let wanted = ground.world.take_wanted(sim.tick); + ground.world.serve(&src, wanted, sim.tick); + sim.tick += 1; + sim.step_drones(&mut ground, None, &mut Out::default()); + assert!(sim.drones.first().is_none_or(|d| d.pos.x < 3.5), "crossed the wall"); + hit_wall |= sim.drones.is_empty(); + } + assert!(hit_wall && sim.crashes == 1, "the wall was not found once read: crashes {}", sim.crashes); +} + +// #61: nor does an interceptor fuse past the unread part of its way +#[test] +fn cold_cache_does_not_fuse_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(); + 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()); + let fix = Some(fix_of(&sim.drones[0], 0)); + 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, hold: 0, fix, seen: 0 }); + sim.tick = 1; + sim.step_missiles(&mut ground, &mut Out::default()); + assert_eq!(sim.missiles.len(), 1, "the missile went on (held) at the edge of what is read"); + assert!(sim.missiles[0].pos.x < 3.5 && sim.kills == 0 && sim.tally.missed == 0, "fused unread"); + for _ in 0..6 { + let wanted = ground.world.take_wanted(sim.tick); + ground.world.serve(&src, wanted, sim.tick); + sim.tick += 1; + sim.step_missiles(&mut ground, &mut Out::default()); + } + assert!(sim.missiles.is_empty() && sim.kills == 0 && sim.tally.blocked == 1, "{:?}", sim.tally); +} + +// #61: a chunk the game does not have holds a low flight for a bounded time, then it goes on; a section +// that was queued as a look-ahead moves up when a step needs it, even with the queue full. +#[test] +fn unknown_policy_is_bounded_and_urgent_sections_jump_the_queue() { + let none = Blocks(|_, _, _| None); + let mut ground = Ground::default(); + // Ground known: the flight is 5 blocks over it, so it is low. + ground.answer((-40..400).map(|x| (x, 0, 64)).collect(), 0); + let mut sim = Sim::new(1); + sim.damage = false; + sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 69.0, 0.0), DVec3::X, DVec3::new(5000.0, 69.0, 0.0)); + let mut held = 0; + for _ in 0..900 { + let before = sim.drones[0].pos; + sim.tick += 1; + sim.step_drones(&mut ground, None, &mut Out::default()); + let wanted = ground.world.take_wanted(sim.tick); + ground.world.serve(&none, wanted, sim.tick); + held += (sim.drones[0].pos.distance(before) < 1e-3) as u32; + } + assert!(held > 20, "an unloaded chunk was no obstacle at all ({held})"); + assert!(sim.drones[0].pos.x > 100.0, "held for ever at x = {}", sim.drones[0].pos.x); + // Urgent over ahead. + let mut w = world::WorldCache::default(); + for i in 0..600 { + w.look_ahead(DVec3::new(f64::from(i) * 16.0, 70.0, 0.0), DVec3::X, 1.0, 1); + } + w.sweep(DVec3::new(500.5, 70.5, 0.5), DVec3::new(501.5, 70.5, 0.5), false, 1); + let first = w.take_wanted(1); + assert!(first.contains(&(31, 4, 0)), "the section a step needs waited behind look-aheads"); +} + +// #62: a chunk's unload or replacement voids what was known; light-only changes cost nothing +#[test] +fn chunk_replacement_and_age_invalidate_known_sections() { + let mut w = world::WorldCache::default(); + let (a, b) = (DVec3::new(0.5, 70.5, 0.5), DVec3::new(30.5, 70.5, 0.5)); + let empty = Blocks(|_, _, _| Some(false)); + let wall = Blocks(|x, _, _| Some(x == 20)); + w.changed_rev(1, 10, false, &[]); + for t in 1..4 { + w.sweep(a, b, false, t); + let q = w.take_wanted(t); + w.serve(&empty, q, t); + } + assert_eq!(w.sweep(a, b, false, 5), world::Sweep::Clear); + // Light only: the counter does not move, nothing happens. + let n = w.stats.invalidated; + w.changed_rev(1, 10, false, &[]); + assert_eq!(w.stats.invalidated, n, "a light update invalidated the cache"); + // The chunk is replaced by a packet with a wall: the game says which column (unload/replace note). + w.columns_changed(&[(0, 0), (1, 0)]); + assert!(matches!(w.sweep(a, b, false, 6), world::Sweep::Unknown { pending: true, .. }), "an unloaded chunk still answers from memory"); + for t in 7..10 { + w.sweep(a, b, false, t); + let q = w.take_wanted(t); + w.serve(&wall, q, t); + } + assert!(matches!(w.sweep(a, b, false, 10), world::Sweep::Hit { block: [20, 70, 0], .. })); + // A replacement nobody listed: the counter moved with no section named; everything read is read again. + w.changed_rev(1, 11, false, &[]); + assert!(matches!(w.sweep(a, b, false, 11), world::Sweep::Hit { .. }), "old data is used until the answer"); + let q = w.take_wanted(11); + assert!(!q.is_empty()); + w.serve(&empty, q, 12); + assert_eq!(w.sweep(a, b, false, 13), world::Sweep::Clear); + // And a section in use is read again after a while, told of or not. + for t in (14..14 + 1300).step_by(100) { + w.sweep(a, b, false, t); + } + assert!(w.stats.aged > 0 && !w.take_wanted(1400).is_empty(), "never read again"); + // Another world, as ever. + w.changed_rev(2, 0, false, &[]); + assert!(w.is_empty()); +} + +// #63: a lower slab does not stop what flies over its free half; a full wall and the slab's own half do +#[test] +fn partial_blocks_use_exact_shapes() { + let slab = Shaped(|x, y, z| { + if y < 64 { + Some(vec![[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]]) + } else if (x, y, z) == (10, 70, 0) { + Some(vec![[0.0, 0.0, 0.0, 1.0, 0.5, 1.0]]) + } else if x == 20 && z == 0 { + Some(vec![[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]]) + } else { + Some(vec![]) + } + }); + let mut g = Ground::default(); + let run = |g: &mut Ground, a: DVec3, b: DVec3| { + let mut r = g.trace(7, a, b, false, 1); + for t in 1..8 { + let q = g.world.take_wanted(t); + g.world.serve(&slab, q, t); + if !matches!(r, world::Sweep::Wait { .. } | world::Sweep::Unknown { .. }) { + break; + } + r = g.trace(7, a, b, false, t + 1); + } + r + }; + // The free upper half: clear (and the wall at x=20 is further on than the segment). + let r = run(&mut g, DVec3::new(9.5, 70.75, 0.5), DVec3::new(11.5, 70.75, 0.5)); + assert_eq!(r, world::Sweep::Clear, "the free half of a slab was an obstacle"); + // Through the slab's own half: a hit where its shape starts. + let mut g = Ground::default(); + let r = run(&mut g, DVec3::new(9.5, 70.25, 0.5), DVec3::new(11.5, 70.25, 0.5)); + let world::Sweep::Hit { point, normal, block, .. } = r else { panic!("{r:?}") }; + assert!((point.x - 10.0).abs() < 1e-9 && normal == [-1, 0, 0] && block == [10, 70, 0], "{r:?}"); + // A full-size wall is a hit at its face, like the section's cube. + let mut g = Ground::default(); + let r = run(&mut g, DVec3::new(18.5, 70.5, 0.5), DVec3::new(22.5, 70.5, 0.5)); + assert!(matches!(r, world::Sweep::Hit { block: [20, 70, 0], normal: [-1, 0, 0], .. }), "{r:?}"); + // The flight holds at the face meanwhile (a pending answer is a wait, not a pass). + let mut g = Ground::default(); + let w = world::WorldCache::default(); + drop(w); + for t in 0..4 { + let q = g.world.take_wanted(t); + g.world.serve(&slab, q, t); + g.world.sweep(DVec3::new(18.5, 70.5, 0.5), DVec3::new(22.5, 70.5, 0.5), false, t); + } + let first = g.trace(9, DVec3::new(18.5, 70.5, 0.5), DVec3::new(22.5, 70.5, 0.5), false, 5); + assert!(matches!(first, world::Sweep::Wait { .. }), "{first:?}"); + // Never answered: after the wait the solid cube of the sections stands. + let last = g.trace(9, DVec3::new(18.5, 70.5, 0.5), DVec3::new(22.5, 70.5, 0.5), false, 5 + 31); + assert!(matches!(last, world::Sweep::Hit { .. }), "{last:?}"); +} + +// #64: heights known are old when blocks change, a chunk is replaced, or time passes; they are asked again +#[test] +fn land_heights_follow_world_changes_and_age() { + let mut g = Ground::default(); + g.answer(vec![(4, 4, 150)], 0); + assert_eq!(g.land(DVec3::new(4.0, 70.0, 4.0)), Some(150.0)); + assert!(g.take_wanted().is_empty(), "a fresh height was asked again"); + // The mountain is blown away: the game says which section changed. + g.world.changed(1, false, &[(0, 9, 0)]); + g.answer(vec![], 10); + assert_eq!(g.land(DVec3::new(4.0, 70.0, 4.0)), Some(150.0), "the old height is used until the new one comes"); + assert!(g.take_wanted().contains(&(4, 4)), "a changed column was not asked again"); + g.answer(vec![(4, 4, 70)], 11); + assert_eq!(g.land(DVec3::new(4.0, 70.0, 4.0)), Some(70.0)); + // Another column is not touched by that. + g.answer(vec![(100, 100, 90)], 12); + g.world.changed(1, false, &[(0, 9, 0)]); + g.answer(vec![], 13); + assert_eq!(g.land(DVec3::new(100.0, 70.0, 100.0)), Some(90.0)); + assert!(!g.take_wanted().contains(&(100, 100))); + // Time alone: the issue's case, 500 seconds on. + g.answer(vec![], 10_000); + assert_eq!(g.land(DVec3::new(4.0, 70.0, 4.0)), Some(70.0)); + assert!(g.take_wanted().contains(&(4, 4)), "never asked again after 500 s"); + // A planner column too. + let mut g = Ground::default(); + g.answer(vec![(20, 20, 80)], 0); + g.answer(vec![], 5000); + assert_eq!(g.column(20, 20, 1000), Some(Some(80.0))); + assert!(g.take_wanted().contains(&(20, 20))); + // A chunk replaced. + let mut g = Ground::default(); + g.answer(vec![(4, 4, 150), (8, 8, 150)], 0); + g.world.columns_changed(&[(0, 0)]); + g.answer(vec![], 1); + g.land(DVec3::new(4.0, 70.0, 4.0)); + assert!(g.take_wanted().contains(&(4, 4))); +} + +// #65: manual launches and raids draw their navigation error the same way, and a retarget keeps it +#[test] +fn raids_have_the_same_strike_accuracy_as_manual_launches() { + for kind in [DroneKind::Kh101, DroneKind::Shahed, DroneKind::Zircon] { + let (cep, gross) = kind.accuracy(); + let aim = DVec3::new(0.0, 70.0, 1000.0); + let mut sim = Sim::new(1); + let (mut manual, mut raid) = (Vec::new(), Vec::new()); + for _ in 0..600 { + sim.add_drone(kind, DVec3::new(0.0, 120.0, 0.0), DVec3::Z, aim); + let d = sim.drones.pop().unwrap(); + manual.push(horizontal(d.target - aim).length()); + let d = sim.make_drone(kind, DVec3::new(0.0, 120.0, 0.0), DVec3::Z, aim); + raid.push(horizontal(d.target - aim).length()); + assert!(d.err != DVec3::ZERO && (d.target - aim - d.err).length() < 1e-9); + } + for (name, v) in [("manual", &mut manual), ("raid", &mut raid)] { + v.sort_by(f64::total_cmp); + let median = v[v.len() / 2]; + assert!((median - cep).abs() < cep * 0.25, "{kind:?} {name}: median {median} for a CEP of {cep}"); + let far = v.iter().filter(|r| **r > MISS_RADIUS).count() as f64 / v.len() as f64; + assert!(far > gross * 0.4 && far < gross * 2.5 + 0.02, "{kind:?} {name}: {far} beyond the blast for a fault rate of {gross}"); + } + } + // A planned raid's drones all carry their error, and a smart retarget gives the new aim on top of it. + let mut sim = Sim::new(3); + let id = sim.id(); + sim.sams.push(Sam::new(id, SamKind::Ew, DVec3::new(0.0, 70.0, 900.0), 0.0)); + sim.intel.revealed.push((id, DVec3::new(0.0, 70.0, 900.0), 0)); + let order = RaidOrder { kinds: vec![(DroneKind::Zircon, 20)], approach: Approach::Sector, dist: 400.0, aim: Aim::Objects }; + sim.order_raid(&order, &[DVec3::new(0.0, 70.0, 0.0)], &mut Out::default()); + for _ in 0..40 { + if !sim.planning() { + break; + } + sim.step(&mut Ground::default(), None, &mut Out::default()); + } + assert!(!sim.queue.is_empty() && sim.queue.iter().all(|(_, d)| d.err != DVec3::ZERO), "raid drones without a navigation error"); + let mut d = sim.make_drone(DroneKind::Zircon, DVec3::new(0.0, 100.0, -500.0), DVec3::Z, DVec3::new(0.0, 70.0, 0.0)); + let err = d.err; + d.smart = true; + sim.queue.clear(); + sim.queue.push((0, d)); + sim.tick = 40; + sim.adapt(); + let d = &sim.queue[0].1; + assert!((d.target - d.err - DVec3::new(0.0, 70.0, 900.0)).length() < 1e-9 && d.err == err, "the retarget lost or doubled the error"); +} + +// #66: an interceptor steers on what it was told or what its seeker holds, not on the true position +#[test] +fn interceptors_guide_on_tracks_and_seekers_not_on_truth() { + let fly = |hidden: DVec3, fix: Option| { + let mut sim = Sim::new(1); + sim.add_drone(DroneKind::Shahed, hidden, DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0)); + sim.drones[0].vel = DVec3::ZERO; + 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::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix, seen: 0 }); + sim.tick = 100; + sim.step_missiles(&mut Ground::default(), &mut Out::default()); + sim.missiles.first().map(|m| m.vel) + }; + // The issue's case: no system, no track, a target 600 away that is somewhere else in the second copy. + let a = fly(DVec3::new(0.0, 100.0, 600.0), None); + let b = fly(DVec3::new(300.0, 100.0, 600.0), None); + assert_eq!(a, b, "the missile knows where the target is without any measurement"); + // The data link's fix is what it steers on, wherever the target went since. + let told = Some(Fix { pos: DVec3::new(300.0, 100.0, 600.0), vel: DVec3::ZERO, tick: 100, seeker: false }); + let (c, d) = (fly(DVec3::new(0.0, 100.0, 600.0), told), fly(DVec3::new(-300.0, 100.0, 600.0), told)); + assert_eq!(c, d); + assert!(c.unwrap().x > 0.05, "it did not turn to the fix: {c:?}"); + // The seeker takes over once the target is in its reach: now the truth steers it. + let near = |at: DVec3| { + let mut sim = Sim::new(1); + sim.add_drone(DroneKind::Shahed, at, DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0)); + sim.drones[0].vel = DVec3::ZERO; + let (target, id) = (sim.drones[0].id, sim.id()); + let stale = Some(Fix { pos: DVec3::new(-60.0, 100.0, 80.0), vel: DVec3::ZERO, tick: 90, seeker: false }); + sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix: stale, seen: 0 }); + sim.tick = 100; + sim.step_missiles(&mut Ground::default(), &mut Out::default()); + (sim.missiles[0].vel, sim.missiles[0].seen) + }; + let (v, seen) = near(DVec3::new(40.0, 100.0, 70.0)); + assert!(seen == 100 && v.x > 0.05, "the seeker did not hold a target in its reach: {v:?}"); + let (v, seen) = near(DVec3::new(300.0, 100.0, 70.0)); + assert!(seen == 0 && v.x < -0.05, "a target outside its reach steered it: {v:?}"); + // Behind a mountain the seeker sees nothing. + let mut sim = Sim::new(1); + sim.add_drone(DroneKind::Shahed, DVec3::new(40.0, 100.0, 70.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0)); + sim.drones[0].vel = DVec3::ZERO; + 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::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix: Some(Fix { pos: DVec3::new(0.0, 100.0, 300.0), vel: DVec3::ZERO, tick: 90, seeker: false }), seen: 0 }); + let hill = Blocks(|x, y, z| Some(y < 64 || ((10..14).contains(&x) && (0..40).contains(&z) && y < 300))); + let mut ground = Ground::default(); + for t in 0..6 { + ground.world.probe(DVec3::new(0.0, 100.0, 0.0), DVec3::new(40.0, 100.0, 70.0), t); + let q = ground.world.take_wanted(t); + ground.world.serve(&hill, q, t); + } + sim.tick = 100; + sim.step_missiles(&mut ground, &mut Out::default()); + assert_eq!(sim.missiles[0].seen, 0, "the seeker saw through a mountain"); + // No fix for too long and no seeker on it: the interceptor is lost, and counted so. + let mut sim = Sim::new(1); + sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 100.0, 3000.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0)); + sim.drones[0].vel = DVec3::ZERO; + let (target, id) = (sim.drones[0].id, sim.id()); + let old = Some(Fix { pos: DVec3::new(0.0, 100.0, 300.0), vel: DVec3::ZERO, tick: 0, seeker: false }); + sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix: old, seen: 0 }); + sim.tick = MAX_COAST + 50; + sim.step_missiles(&mut Ground::default(), &mut Out::default()); + assert!(sim.missiles.is_empty() && sim.tally.expired == 1, "{:?}", sim.tally); +} + +// #69: many separate strikes: the list, what is drawn and the lights are bounded; the rest end properly +#[test] +fn mass_strikes_keep_fires_effects_and_lights_bounded() { + let mut sim = Sim::new(1); + sim.damage = true; + let mut ground = Ground::default(); + ground.answer((-100..2000).flat_map(|x| (-10..10).map(move |z| (x, z, 70))).collect(), 0); + for i in 0..120 { + sim.ignite(DVec3::new(f64::from(i) * 10.0, 70.0, 0.0), (i % 5) as u32, None); + } + assert!(sim.fires.len() <= fires::LIST_MAX, "{} fires kept", sim.fires.len()); + let mut peak_particles = 0; + for _ in 0..200 { + sim.tick += 1; + let mut out = Out::default(); + sim.step_fires(&mut ground, &mut out); + peak_particles = peak_particles.max(out.particles.iter().map(|p| p.count as usize).sum::()); + assert!(sim.fires.list.iter().filter(|f| f.shown).count() <= fires::MAX); + assert!(sim.fires.len() <= fires::LIST_MAX); + } + // Twelve fires at full strength: well under what 120 would have been. + assert!(peak_particles < 12 * 24 + fires::LIST_MAX, "{peak_particles} particles in a tick"); + // A long stream of strikes, one every few ticks, stays bounded; at the end all go out and the blocks go with them. + for i in 0..400 { + sim.tick += 5; + sim.ignite(DVec3::new(f64::from(i % 150) * 12.0, 70.0, 0.0), 2, None); + sim.step_fires(&mut ground, &mut Out::default()); + assert!(sim.fires.len() <= fires::LIST_MAX); + } + let mut commands = 0; + for _ in 0..8000 { + sim.tick += 1; + let mut out = Out::default(); + sim.step_fires(&mut ground, &mut out); + commands += out.commands.len(); + if sim.fires.is_empty() { + break; + } + } + assert!(sim.fires.is_empty(), "fires burn for ever"); + let _ = commands; +} diff --git a/airdef-mod/src/sim/terrain.rs b/airdef-mod/src/sim/terrain.rs index 1827e1e..57832bd 100644 --- a/airdef-mod/src/sim/terrain.rs +++ b/airdef-mod/src/sim/terrain.rs @@ -27,7 +27,7 @@ pub(super) enum Rise { impl Ground { /// One above the highest solid block at the lattice cell holding `p`, if known; asked for when it isn't. - /// Unlike `at` it never asks again for a column it has, and it leaves room for the questions that matter more. + /// Like `at` it asks again for a column that is old or was reported changed (#64), and it leaves room for the questions that matter more. pub fn land(&mut self, p: DVec3) -> Option { match self.rise(p) { Rise::At(g) => Some(g), @@ -42,8 +42,13 @@ impl Ground { (p.z.floor() as i32).div_euclid(LATTICE) * LATTICE + LATTICE / 2, ); match self.known.get(&key) { - Some(&(y, _)) if y > -64 => Rise::At(f64::from(y)), - Some(_) => Rise::Void, + Some(&(y, when)) => { + // Old or reported changed: still used, asked again with the questions to spare. + if self.due(when) && self.wanted.len() < ASK_MAX / 2 && self.asked.insert(key) { + self.wanted.push(key); + } + if y > -64 { Rise::At(f64::from(y)) } else { Rise::Void } + } None => { if self.wanted.len() < ASK_MAX && self.asked.insert(key) { self.wanted.push(key); @@ -57,7 +62,12 @@ impl Ground { /// where it has no land to tell of. Asks, up to `cap` questions waiting. pub(super) fn column(&mut self, x: i32, z: i32, cap: usize) -> Option> { match self.known.get(&(x, z)) { - Some(&(y, _)) => Some((y > -64).then_some(f64::from(y))), + Some(&(y, when)) => { + if self.due(when) && self.wanted.len() < cap / 2 && self.asked.insert((x, z)) { + self.wanted.push((x, z)); + } + Some((y > -64).then_some(f64::from(y))) + } None => { if self.wanted.len() < cap && self.asked.insert((x, z)) { self.wanted.push((x, z)); diff --git a/airdef-mod/src/sim/world.rs b/airdef-mod/src/sim/world.rs index 2f69728..d9f1b3c 100644 --- a/airdef-mod/src/sim/world.rs +++ b/airdef-mod/src/sim/world.rs @@ -18,6 +18,8 @@ pub const PER_TICK: usize = 64; const MAX_SECTIONS: usize = 12_000; /// Ticks before an unanswered or unknown section is asked again. const RETRY: u64 = 60; +/// Ticks after which a section in use is read again, whether or not a change was reported (#62). +const RECHECK: u64 = 1200; /// 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). @@ -57,8 +59,11 @@ pub enum Sweep { 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 }, + /// The walk reached blocks the game doesn't know (before any solid one), `t` of the way. `pending`: the + /// section hasn't been answered yet (it is asked for, urgently); else the game said it has no chunk there. + Unknown { t: f64, pending: bool }, + /// A solid block was met at `t` and its exact shape is being asked for (#63): the flight holds at its face. + Wait { t: f64 }, } impl Sweep { @@ -83,10 +88,60 @@ pub struct WorldStats { pub invalidated: u64, pub evicted: u64, pub world_changes: u64, + /// Steps cut short at unread blocks (#61), and steps let through after waiting for too long. + pub held: u64, + pub passed_unknown: u64, + /// Sections dropped or re-read for a chunk's unload or replacement, or for age (#62). + pub aged: u64, + pub columns_changed: u64, +} + +/// What the game's exact ray said of a flight's step, once it answered (#63). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Exact { + Pending, + /// The collision shapes leave the way open. + Clear, + /// Where the shapes stop it: the point, the face and the block. + Hit { point: DVec3, normal: [i32; 3], block: [i32; 3] }, +} + +struct RayEntry { + id: u32, + a: DVec3, + b: DVec3, + state: Exact, + asked: u64, +} + +/// The game's verdict on an exact ray, as `render.rays` gives it. +#[derive(Clone, Copy, Debug)] +pub struct RayReply { + pub id: u32, + pub clear: bool, + pub hit: bool, + pub fraction: f64, + pub normal: [i32; 3], + pub block: [i32; 3], +} + +/// Ticks an exact ray may take to be answered before the block counts as the solid cube the sections say. +const RAY_WAIT: u64 = 30; +/// Exact rays asked in one tick (the game's budget is 256). +const RAYS_PER_TICK: usize = 128; + +#[derive(Default)] +struct RayDesk { + next: u32, + /// By flight and block: a flight that meets the same block again finds its answer. + entries: HashMap<(u32, [i32; 3]), RayEntry>, + by_id: HashMap, + out: Vec<(u32, DVec3, DVec3)>, } #[derive(Default)] pub struct WorldCache { + rays: RayDesk, secs: HashMap, urgent: Vec, ahead: Vec, @@ -94,8 +149,15 @@ pub struct WorldCache { /// Sections asked, and when. asked: HashMap, epoch: u32, + /// The game's count of block changes, as of the last note, and the one the section entries were read at. + revision: u64, + /// The last `solid` that could not tell: the section was not answered yet. + unread_pending: bool, /// Sweeps made as a look ahead (flight planning) ask for sections after the urgent ones. quiet: bool, + /// Columns (section x, z) where blocks changed since the land heights last looked (#64); `all`: too many to say. + pub(super) changed_cols: HashSet<(i32, i32)>, + pub(super) changed_all: bool, pub stats: WorldStats, } @@ -136,6 +198,22 @@ impl WorldCache { /// 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]) { + self.changed_rev(epoch, self.revision, overflow, sections); + } + + /// The same with the game's change counter: a jump in it that the listed sections don't explain + /// (changes the game didn't list, a chunk replaced behind the subscription's back) makes every section + /// read before it due for a new read when it is next used (#62). + pub fn changed_rev(&mut self, epoch: u32, revision: u64, overflow: bool, sections: &[Sec]) { + let unexplained = self.epoch == epoch && revision != self.revision && sections.is_empty() && !overflow; + self.revision = revision; + if unexplained { + // Not named, so not known which: a light update alone doesn't move the counter, a block change does. + for e in self.secs.values_mut() { + e.stale = true; + } + self.stats.invalidated += self.secs.len() as u64; + } if epoch != self.epoch { if self.epoch != 0 { self.secs.clear(); @@ -143,9 +221,13 @@ impl WorldCache { self.ahead.clear(); self.queued.clear(); self.asked.clear(); + self.changed_all = true; } self.epoch = epoch; } + if overflow || unexplained { + self.changed_all = true; + } if overflow { for e in self.secs.values_mut() { e.stale = true; @@ -153,6 +235,7 @@ impl WorldCache { self.stats.invalidated += self.secs.len() as u64; } for s in sections { + self.note_column(s.0, s.2); if let Some(e) = self.secs.get_mut(s) { e.stale = true; self.stats.invalidated += 1; @@ -161,6 +244,38 @@ impl WorldCache { self.stats.world_changes += sections.len() as u64; } + fn note_column(&mut self, sx: i32, sz: i32) { + if self.changed_cols.len() >= 4096 { + self.changed_all = true; + } else { + self.changed_cols.insert((sx, sz)); + } + } + + /// Chunk columns (x, z in chunks) the game unloaded or replaced (#62): what is known of their sections is + /// void (`Unknown`) until they are read again. A new chunk packet replaces the blocks wholesale, which no + /// per-block note says. + pub fn columns_changed(&mut self, columns: &[(i32, i32)]) { + if columns.is_empty() { + return; + } + let set: HashSet<(i32, i32)> = columns.iter().copied().collect(); + let mut n = 0u64; + for &(cx, cz) in columns { + self.note_column(cx, cz); + } + for (s, e) in self.secs.iter_mut() { + if set.contains(&(s.0, s.2)) { + e.fill = Fill::Unknown; + e.stale = true; + e.since = 0; + n += 1; + } + } + self.stats.invalidated += n; + self.stats.columns_changed += columns.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); @@ -220,11 +335,24 @@ impl WorldCache { } 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) { + if self.queued.contains(&s) { + // Already waiting as a look-ahead, and a step needs it now: it moves up (#61). + if urgent && let Some(i) = self.ahead.iter().position(|a| *a == s) { + self.ahead.swap_remove(i); + self.urgent.push(s); + } + return; + } + if self.asked.get(&s).is_some_and(|&t| now.saturating_sub(t) < RETRY) { return; } if self.urgent.len() + self.ahead.len() >= QUEUE_MAX { - return; + // Full: an urgent need pushes a look-ahead out, a look-ahead waits. + if !urgent || self.ahead.is_empty() { + return; + } + let gone = self.ahead.swap_remove(0); + self.queued.remove(&gone); } if urgent { self.urgent.push(s); @@ -266,7 +394,12 @@ impl WorldCache { return false; }; e.used = now; - let refresh = e.stale || (e.fill == Fill::Unknown && now.saturating_sub(e.since) >= RETRY); + // Whatever is in use is read again now and then, bounded: a change nobody told of ends up seen (#62). + let aged = e.fill != Fill::Unknown && now.saturating_sub(e.since) >= RECHECK; + if aged { + self.stats.aged += 1; + } + let refresh = e.stale || aged || (e.fill == Fill::Unknown && now.saturating_sub(e.since) >= RETRY); if refresh { self.want(s, urgent, now); } @@ -281,8 +414,17 @@ impl WorldCache { *last = Some(s); self.touch(s, !self.quiet, now); } - match &self.secs.get(&s)?.fill { - Fill::Unknown => None, + self.unread_pending = false; + let Some(entry) = self.secs.get(&s) else { + self.unread_pending = true; + return None; + }; + match &entry.fill { + Fill::Unknown => { + // Asked and answered unknown: no chunk there. Asked again (or not at all yet): waiting. + self.unread_pending = entry.stale && entry.since == 0; + None + } Fill::Empty => Some(false), Fill::Full => Some(true), Fill::Mixed(bits) => { @@ -312,6 +454,75 @@ impl WorldCache { r } + /// A step of flight `key` from `a` to `b` that met a solid block (`hit`): the occupancy says "stops + /// something", the shape may leave room (slabs, stairs, fences, panes). The block's exact shapes are asked + /// of the game (a ray over the step) and the flight holds at the face meanwhile; a `Hit` comes back as the + /// exact contact, a `Clear` lets this flight through that block. An answer that doesn't come in + /// [`RAY_WAIT`] ticks, or `unknown`, leaves the hit as the solid cube. + pub fn narrow(&mut self, key: u32, hit: Sweep, a: DVec3, b: DVec3, now: u64) -> Sweep { + let Sweep::Hit { t, block, .. } = hit else { return hit }; + let k = (key, block); + let Some(e) = self.rays.entries.get_mut(&k) else { + if self.rays.entries.len() >= 4096 { + return hit; + } + self.rays.next = self.rays.next.wrapping_add(1); + let id = self.rays.next; + self.rays.entries.insert(k, RayEntry { id, a, b, state: Exact::Pending, asked: now }); + self.rays.by_id.insert(id, k); + self.rays.out.push((id, a, b)); + return Sweep::Wait { t }; + }; + if e.state == Exact::Pending && now.saturating_sub(e.asked) > RAY_WAIT { + e.state = Exact::Hit { point: a + (b - a) * t, normal: [0; 3], block }; + } + match e.state { + Exact::Pending => Sweep::Wait { t }, + Exact::Clear => Sweep::Clear, + Exact::Hit { point, normal, block } => { + let d = b - a; + let f = ((point - a).dot(d) / d.length_squared().max(1e-12)).max(0.0); + if f > 1.0 { + // The shape's face is beyond this step: nothing is met yet. + Sweep::Clear + } else { + Sweep::Hit { t: f, point, normal, block } + } + } + } + } + + /// The exact rays to ask this tick: id, start, end. + pub fn take_rays(&mut self, now: u64) -> Vec<(u32, DVec3, DVec3)> { + let n = RAYS_PER_TICK.min(self.rays.out.len()); + let out: Vec<_> = self.rays.out.drain(..n).collect(); + if now % 128 == 0 { + let dead: Vec<_> = self.rays.entries.iter().filter(|(_, e)| now.saturating_sub(e.asked) > 600).map(|(k, _)| *k).collect(); + for k in dead { + if let Some(e) = self.rays.entries.remove(&k) { + self.rays.by_id.remove(&e.id); + } + } + } + out + } + + /// The game's answers to the exact rays. + pub fn answer_rays(&mut self, replies: &[RayReply]) { + for r in replies { + let Some(k) = self.rays.by_id.get(&r.id).copied() else { continue }; + let Some(e) = self.rays.entries.get_mut(&k) else { continue }; + e.state = if r.clear { + Exact::Clear + } else if r.hit { + Exact::Hit { point: e.a + (e.b - e.a) * r.fraction, normal: r.normal, block: r.block } + } else { + // No verdict: the solid cube the sections said. + Exact::Hit { point: e.a, normal: [0; 3], block: k.1 } + }; + } + } + /// 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). @@ -340,7 +551,7 @@ impl WorldCache { match self.solid(&mut last, pos[0], pos[1], pos[2], now) { None => { self.stats.unknown_sweeps += 1; - return Sweep::Unknown { t }; + return Sweep::Unknown { t, pending: self.unread_pending }; } Some(true) if !(first && skip_start) => { self.stats.hits += 1; @@ -366,7 +577,7 @@ impl WorldCache { face[axis] = -step[axis]; } self.stats.unknown_sweeps += 1; - Sweep::Unknown { t } + Sweep::Unknown { t, pending: false } } } @@ -375,6 +586,38 @@ impl WorldCache { 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; + /// The collision boxes of the block (`[x0, y0, z0, x1, y1, z1]` inside the block): a full cube where + /// it is solid, unless the world has finer shapes. + fn boxes(&self, x: i32, y: i32, z: i32) -> Vec<[f64; 6]> { + if self.solid(x, y, z) == Some(true) { vec![[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]] } else { Vec::new() } + } +} + +/// The first entry of segment `a` to `b` into the box (slab test): the fraction and the axis. +fn enter_box(a: DVec3, d: DVec3, lo: DVec3, hi: DVec3) -> Option<(f64, usize)> { + let (mut t0, mut t1, mut axis) = (0.0f64, 1.0f64, usize::MAX); + for i in 0..3 { + let (o, v) = (a[i], d[i]); + if v.abs() < 1e-12 { + if o < lo[i] || o > hi[i] { + return None; + } + } else { + let (mut n, mut f) = ((lo[i] - o) / v, (hi[i] - o) / v); + if n > f { + std::mem::swap(&mut n, &mut f); + } + if n > t0 { + t0 = n; + axis = i; + } + t1 = t1.min(f); + if t0 > t1 { + return None; + } + } + } + Some((t0, axis)) } /// A [`WorldSource`] as the game would answer `render.query-sections` with it. @@ -402,9 +645,48 @@ pub fn section_of(source: &dyn WorldSource, s: Sec) -> Fill { } impl WorldCache { + /// Answers the exact rays of the last tick from a synthetic world, as the game does one tick later. + pub fn serve_rays(&mut self, source: &dyn WorldSource, rays: Vec<(u32, DVec3, DVec3)>) { + let replies: Vec = rays + .into_iter() + .map(|(id, a, b)| { + let d = b - a; + let mut best: Option<(f64, usize, [i32; 3])> = None; + let (lo, hi) = (a.min(b).floor(), a.max(b).floor()); + for x in lo.x as i32..=hi.x as i32 { + for y in lo.y as i32..=hi.y as i32 { + for z in lo.z as i32..=hi.z as i32 { + for bx in source.boxes(x, y, z) { + let o = DVec3::new(f64::from(x), f64::from(y), f64::from(z)); + if let Some((t, axis)) = enter_box(a, d, o + DVec3::new(bx[0], bx[1], bx[2]), o + DVec3::new(bx[3], bx[4], bx[5])) + && best.is_none_or(|b| t < b.0) + { + best = Some((t, axis, [x, y, z])); + } + } + } + } + } + match best { + None => RayReply { id, clear: true, hit: false, fraction: 0.0, normal: [0; 3], block: [0; 3] }, + Some((t, axis, block)) => { + let mut normal = [0; 3]; + if axis < 3 { + normal[axis] = if d[axis] > 0.0 { -1 } else { 1 }; + } + RayReply { id, clear: false, hit: true, fraction: t, normal, block } + } + } + }) + .collect(); + self.answer_rays(&replies); + } + /// 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); + let rays = self.take_rays(now); + self.serve_rays(source, rays); } } diff --git a/airdef-mod/src/smoke.rs b/airdef-mod/src/smoke.rs new file mode 100644 index 0000000..2cb7920 --- /dev/null +++ b/airdef-mod/src/smoke.rs @@ -0,0 +1,179 @@ +//! Smoke for the HD renderer (`render.set-smoke`): exhaust trails of drones and missiles that leave +//! a clear tunnel in fog and cloud, smoke columns over fires, and the smoke of blasts. The game +//! derives everything from a segment's age, so the columns and blasts are a pure function of the +//! tick (nothing grows twice when a frame is shown again); trails are the only state, and they are +//! cut back when the tick goes backwards. +use crate::sim::{Class, Sim}; +use glam::DVec3; +use horizon_mod_sdk::render::SmokeSegment; +use std::collections::HashMap; + +const MAX_TRAIL: usize = 2400; +const MAX_TOTAL: usize = 4000; + +struct Trail { + seg: SmokeSegment, +} + +#[derive(Default)] +pub struct Smoke { + trails: Vec, + /// Where each flying thing last left a segment: (kind, id) -> (position, tick). + last: HashMap<(u8, u32), (DVec3, u64)>, + last_tick: u64, + next: u32, + /// The renderer draws the smoke (the answer of the last call). + pub hd: bool, +} + +fn seg(id: u32, a: DVec3, b: DVec3, born: u64, life: u32) -> SmokeSegment { + SmokeSegment { + id, + ax: a.x, + ay: a.y, + az: a.z, + bx: b.x, + by: b.y, + bz: b.z, + born: born as f64, + life: life as f32, + radius_start: 1.0, + radius_end: 6.0, + density: 0.5, + heat: 0.0, + soot: 0.4, + rise: 0.2, + clear: 0.0, + } +} + +impl Smoke { + fn trail(&mut self, key: (u8, u32), at: DVec3, tick: u64, style: impl Fn(&mut SmokeSegment)) { + let Some(&(from, when)) = self.last.get(&key) else { + self.last.insert(key, (at, tick)); + return; + }; + if at.distance(from) < 5.0 && tick - when < 10 { + return; + } + // A jump (a teleport, a respawn) is no trail. + if at.distance(from) < 400.0 { + self.next = self.next.wrapping_add(1); + let mut s = seg(self.next, from, at, tick, 1200); + style(&mut s); + self.trails.push(Trail { seg: s }); + } + self.last.insert(key, (at, tick)); + } + + /// Follows the simulation one tick: new trail segments for what flies, old ones dropped. + pub fn update(&mut self, sim: &Sim) { + let tick = sim.tick; + if tick < self.last_tick { + self.trails.retain(|t| t.seg.born as u64 <= tick); + self.last.clear(); + } + self.last_tick = tick; + for d in &sim.drones { + let class = d.kind.spec().class; + let (r0, r1, dens, heat, soot, clear, life): (f32, f32, f32, f32, f32, f32, f32) = match class { + Class::Drone => (0.9, 5.0, 2.0, 0.0, 0.45, 3.0, 1200.0), + Class::Jet | Class::Cruise => (1.1, 7.0, 2.5, 0.15, 0.35, 4.0, 1300.0), + Class::Supersonic => (1.4, 9.0, 3.2, 0.5, 0.2, 5.0, 1300.0), + Class::Ballistic => (2.0, 13.0, 4.0, 0.7, 0.15, 7.0, 1500.0), + }; + self.trail((0, d.id), d.pos, tick, |s| { + s.radius_start = r0; + s.radius_end = r1; + s.density = dens; + s.heat = heat; + s.soot = soot; + s.clear = clear; + s.life = life; + }); + } + for m in &sim.missiles { + self.trail((1, m.id), m.pos, tick, |s| { + s.radius_start = 0.6; + s.radius_end = 5.0; + s.density = 3.0; + s.heat = 0.8; + s.soot = 0.05; + s.clear = 3.0; + s.life = 1000.0; + }); + } + // Forget what is gone. + if tick % 100 == 0 { + let alive: std::collections::HashSet<(u8, u32)> = sim + .drones + .iter() + .map(|d| (0, d.id)) + .chain(sim.missiles.iter().map(|m| (1, m.id))) + .collect(); + self.last.retain(|k, _| alive.contains(k)); + } + self.trails.retain(|t| tick < t.seg.born as u64 + t.seg.life as u64); + if self.trails.len() > MAX_TRAIL { + let cut = self.trails.len() - MAX_TRAIL; + self.trails.drain(..cut); + } + } + + /// Every segment alive at the simulation's tick: trails, fire columns, blast smoke. + pub fn segments(&self, sim: &Sim) -> Vec { + let tick = sim.tick; + let mut out: Vec = self.trails.iter().map(|t| t.seg.clone()).collect(); + // A column over each fire: a puff every 15 ticks that rises, spreads and thins. + for f in &sim.fires.list { + if f.quiet && f.size < 1.0 { + continue; + } + // Only the strongest fires get a full column; the rest a thin puff now and then. + let step = if f.shown { 15 } else { 90 }; + let first = f.start.max(tick.saturating_sub(520)); + let mut k = first.div_ceil(step) * step; + while k <= tick && out.len() < MAX_TOTAL { + let (flame, smoke) = f.level(k); + if smoke > 0.05 && k >= f.start { + let id = 0x4000_0000 | (f.id.wrapping_mul(131) ^ (k / step) as u32) & 0x3fff_ffff; + let at = f.at + DVec3::Y * 0.6; + let mut s = seg(id, at, at, k, 540); + s.radius_start = 1.0 * f.size; + s.radius_end = 8.0 * f.size + 2.0; + s.density = 2.6 * smoke * f.size.sqrt() * if f.shown { 1.0 } else { 0.5 }; + s.heat = flame * 0.6; + s.soot = 0.85; + s.rise = 2.6; + out.push(s); + } + k += step; + } + } + // Blasts: a billow of puffs going up. + for b in &sim.blasts { + let age = tick.saturating_sub(b.start); + if age > 900 { + continue; + } + for j in 0..6u64 { + let born = b.start + j * 3; + if born > tick { + break; + } + let h = b.size as f64 * (0.2 + 0.5 * j as f64); + let at = b.at + DVec3::Y * h; + let mut s = seg(0x5000_0000 | (b.id.wrapping_mul(7) + j as u32) & 0x0fff_ffff, at, at, born, 800); + s.radius_start = b.size * 0.6; + s.radius_end = b.size * 3.2 + 4.0; + s.density = 2.4 / (1.0 + j as f32 * 0.25); + s.heat = if j < 3 { 1.0 } else { 0.4 }; + s.soot = 0.55; + s.rise = 4.0 - 0.3 * j as f32; + out.push(s); + } + } + out.truncate(MAX_TOTAL); + out + } +} diff --git a/docs/HD-RENDERER.md b/docs/HD-RENDERER.md index 4227296..e3b67db 100644 --- a/docs/HD-RENDERER.md +++ b/docs/HD-RENDERER.md @@ -29,6 +29,7 @@ Horizon действует правило clean-room. Исключение — P | Встраивание в кадр: 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` | | G-буфер сущностей и моделей мода: `fs_entity_gbuffer`, `fs_entity_shadow` | `horizon/crates/horizon-client/src/entity.wgsl` | +| Материалы (классы спрайтов: листва, растения, металл, полированное), ветер, мокрые поверхности | `gfx/hd/materials.rs`; `terrain.wgsl` (`terrain_vertex`/`wind_offset`, `material_params`), `entity.wgsl`, `gfx/hd/light.wgsl` (`shade_surface`) | | Пост-проход мода (взрывы, дым, вспышки, свечение) и его прелюдия | `airdef-mod/shaders/effects.wgsl`, прелюдия в `horizon/.../gfx/mod_post.rs` | | Источники света мода (пламя, ракеты, взрывы, прожекторы) | `airdef-mod/src/lib.rs`, `fn lights` | | API мода: `render.set-lights`, `render.set-lighting` (уровень API 10) | `horizon/crates/horizon-mod-sdk/wit/horizon.wit`, `horizon/crates/horizon-mods/src/runtime.rs` | @@ -186,7 +187,10 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс Графический полигон: `python3 tools/hd_bench.py` (`--list` — сцены; `--only polygon_noon` — одна сцена; `--env HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home-bench/out/before` — diff ×6 и пороги; `--budget-ms 18` — GPU-бюджет). Сам копирует `Polygon`/`Hills` в `run-home-bench/home`, сокет -`run-home-bench/c.sock`, одна игра за раз; отчёт `report.md` с таблицей, проходами GPU и кадрами. +`run-home-bench/c.sock`, одна игра за раз; отчёт `report.md` с таблицей, проходами GPU и кадрами. Время и погода +заморожены (`gamerule advance_time/advance_weather false`); вода и листва идут по часам стены, поэтому кадры сравниваются +по статичным пикселям: 3 кадра подряд, пиксели с размахом > 16/255 (вода, облака, листва) не участвуют в сравнении с базой +и в мерцании (маска `<сцена>_motion.png`); `--no-freeze` — живой замер производительности; `--help` — все пороги. Отладка: временно выводи промежуточные величины цветом в `fs_light` (так нашли пустую карту теней и неверное направление солнца). Помни: солнце в мире идёт по оси X (`(-sin a, cos a, 0)`), а `Globals.sun` неба — в своей @@ -221,6 +225,10 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home | #45: `gfx/hd.rs` разделён на модуль `gfx/hd/` (по файлу на проход: `shadows`, `gbuffer`, `ao`, `sky`, `volumes`, `lighting`+`tiles`, `water`, `taa`, `bloom`, `tonemap`, `frame`), WGSL вынесен в `.wgsl` (`include_str!`, общая прелюдия), у каждого прохода своя структура ресурсов/пайплайнов; контракты цвета, глубины, альф и времени записаны в `hd/mod.rs` и в начале файлов проходов; тесты привязок и размера `HdFrame`. Вид не менялся (сверка `tools/hd_bench.py`) | готово | в рабочем дереве форка | | #39: веер восхода `vanilla_sunrise_weight` отсекается до цикла по 16 треугольникам выше горизонта (знак x стороны солнца и наклон ≤ α/3; проверено перебором 200 тыс. лучей без пропусков); `hd lighting` на рассвете 5.3 → 3.3 мс (M-серия, 2560×1440) | готово | в рабочем дереве форка | +| Материалы и блики (план 5): G-буфер `levels.zw` = шероховатость, металличность; GGX-блик солнца с Френелем и отражение неба (`sky_along`/`atmosphere_sky`) в `shade_surface`; мокрые поверхности и лужи в дождь (`HdFrame.surface`); `HORIZON_HD_SPECULAR=0` — выкл. | готово | в рабочем дереве форка | +| Ветер (план 7): листва и растения качаются в вершинной стадии террейна (`Globals.hd_wind`, `hd_materials`), те же смещения в тенях; `HORIZON_HD_WIND=0` — выкл. | готово | в рабочем дереве форка | +| Объёмный дым и следы ракет (#54, п. 8 плана): API уровня 14 `render.set-smoke`, сетка дыма 128×64×128 по 3 блока (`gfx/hd/smoke.rs`, `smoke_grid.wgsl`), марш в проходе тумана, «туннель» в тумане и облаках, `HORIZON_HD_SMOKE=0`; мод: `airdef-mod/src/smoke.rs` (следы дронов и ракет, колонны над пожарами, дым взрывов) | готово, в рабочем дереве форка; вид не настраивался (см. «Дым и следы») | не закоммичено | + ## План (по порядку) и ТЗ Каждый пункт — самостоятельная задача. Перед началом прочитай `gfx/hd/mod.rs` (контракты) и файл нужного прохода. @@ -238,10 +246,10 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home текстуре), солнце/луна дисками, звёзды из ванили; туман и лучи берут цвет из той же модели. Учесть дождь (`weather`), закат, Незер/Энд (там — ванильное небо). 4. **Объёмные облака** — СДЕЛАНО (патч 24, см. «Закат, облака и туман»). Осталось: перистые/высококучевые слои Photon, облака в отражении воды. -5. **Блики и материалы**: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из +5. **Блики и материалы** — СДЕЛАНО (см. «Материалы, мокрое, ветер»). Исходное ТЗ: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из мода), мокрые поверхности и лужи в дождь. 6. **PCSS** (мягкость тени по расстоянию до тенеотбрасывателя) и цветные тени через стекло/листву. -7. **Ветер** листвы/травы (вершинный шейдер террейна по типу блока), рябь от дождя. +7. **Ветер** — СДЕЛАНО (рябь от дождя на воде осталась). Исходное ТЗ: листвы/травы (вершинный шейдер террейна по типу блока), рябь от дождя. 8. **Объёмный дым** пожаров и взрывов, принимающий свет и тени (сейчас дым — в `effects.wgsl`, без теней). 9. Опционально: motion blur, DoF (для кинокамеры повторов). 10. **RT-пресет**: `wgpu` `EXPERIMENTAL_RAY_QUERY` (Vulkan/DX12/Metal M3+), BLAS по секциям, ray-query тени и @@ -304,3 +312,19 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home - **Ванильная вода в HD.** Пока `hd.water_active()`, `fs_translucent` не рисует цвет воды (альфа 0), но пишет её глубину; флаг — `Globals.hd_flags.x` (поле добавлено в Rust-`Globals`, `terrain.wgsl`, `entity.wgsl`; пишется после `hd.write` и в `hd.main_globals`). Вода без дна (`g = 0`) считается водой толщиной 48. - **Не сделано.** Полупрозрачное перед водой берёт вес 0.7 на «вклад воды» (приближение: настоящий порядок «вода → прозрачное» потребовал бы разделить проход сцены). Частицы перед водой, `HORIZON_HD_SCALE=0.67`, поиск мерцания на рябь после этих правок не прогонялись. - **`HORIZON_HD_TAA=0`.** На Polygon в полдень (`tp @s 40 76 40 135 15`) HD с выключенным TAA выглядит так же (дымка, небо, тени на месте), GPU 16.5 мс против 18.1 мс с TAA (минус ≈ проход TAA); потери вида и падения до 4.8 мс не воспроизвелись. В коде `HORIZON_HD_TAA` читают только `taa_enabled()` (джиттер, проход `taa`, история), остальные проходы от него не зависят. Если проявится снова — проверь, что до шота прошёл `/airdef shader hd` (при неготовой команде кадр без HD ~5 мс). + +## Материалы, мокрое, ветер: что знать преемнику + +- **Класс спрайта.** `gfx/hd/materials.rs` по имени спрайта (`minecraft:block/<имя>`) даёт класс 0..8 (обычный, листва, растение, нижняя/верхняя половина двойного растения, металл, гладкое, глянец, дерево). Таблица — по 4 бита на слой, первые 2048 слоёв (`Globals.hd_materials`, 64 × `vec4`, считается в `game.rs` и кладётся в `Renderer`); спрайты модов дальше 2048 — обычные. Числа `MAT_*` в `terrain.wgsl` держать в синхроне. Добавить блок в класс — список имён в `classify`. +- **G-буфер.** `levels.zw` (было не занято) = шероховатость и металличность (`material_params` в `terrain.wgsl`; для сущностей — по оверлею в `fs_entity_gbuffer`: обычные 0.9/0; модели мода `41` — крашеный металл 0.42/0.3 (по умолчанию для всех моделей мода, `game/mod_models.rs`), `42` — голый металл 0.28/1 (мод пока не умеет выбирать), `40` — свечение 0.5/0). Вода своим проходом, без материала. +- **Блик.** В `shade_surface` (`light.wgsl`): GGX (α = rough², не ниже 0.03) × Смит × Френель Шлика, у шероховатых Френель почти не растёт (`max(gloss, F0)`); цвет — солнце × тень × `sky_reach`; потолок 6. Отражение неба: `atmosphere_sky(reflect)` × Френель × gloss² × доступ к небесному свету × AO, без SSR на террейне. Металл сохраняет 35% диффузного (карты окружения нет). Дно под водой блеск не получает. +- **Мокро.** `HdFrame.surface.x` — влажность 0..1 (CPU, `Hd::wetness`): растёт за ~15 с дождя к уровню дождя мира, сохнет ~150 с. Под открытым небом (`lm.x` > 0.8) поверхности темнеют до ×0.7 (боковые грани слабее) и их шероховатость падает к 0.12; лужи — на плоских гранях вверх, по 2-октавному value-шуму в мировых координатах (`wet_noise`, период 256 блоков), с влажности ~0.3, альбедо ×0.6 и шероховатость 0.03, отражают небо. +- **Ветер.** `terrain_vertex` смещает вершины листвы (±0.04 блока, непрерывно по мировой позиции, чтобы блоки не расходились) и растений (до 0.14 блока вверху спрайта; низ — по `uv.y`; двойные растения стыкуются на середине). Волны — целые циклы на 256 блоков и 600 секунд (как у воды). Данные ветра — в `Globals.hd_wind` (сила 1 + 1.2·дождь, секунды, камера mod 256); они же в глобалах каскадов теней, поэтому смещение одно и то же во всех проходах (ванильный, G-буфер, тени, глубина совпадают). Вне HD сила 0 — вершинный шейдер не меняется. TAA не знает о движении листвы (малые амплитуды). +- **Переключатели.** `HORIZON_HD_WIND=0`, `HORIZON_HD_SPECULAR=0` (блик, отражения и мокрое). + +## Проверка материалов/мокрого/ветра (что видно и что нет) + +- Увидено в игре (Hills, `run-home-bench/out/mat1`, `out/rain`): ветер работает, листва и трава двигаются, артефактов швов нет; в дождь сцена заметно темнее (мокрое), небо серое. Не проверено глазами: блики на технике (в Polygon сцена бенча сейчас без техники, не сопоставима с базой `before-mat`), отдельные лужи (кадр дождя смотрит в небо), высыхание (`out/rain/drying_30s.png` снят, не разобран), ночь. +- Мерцание: `hills_river_noon` 9% без ветра (`out/nowind`) против 31% с ветром — это движение листвы вблизи камеры, не шум; амплитуда листвы снижена с 0.04 до 0.025 после этого замера (повторно не мерилось). +- Тайминги **не сняты**: параллельно шли игры других агентов (числа 25–155 мс недействительны). Перед замером брать `run-home/.gpu-lock`. +- Известно: `shaders_translate_to_hlsl_for_directx_12` падает на `cloud.wgsl` (не мой файл). 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 cb33b93..6e2bbcb 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/27] Mod API (AirDef fork): world models, client particles, +Subject: [PATCH 01/28] 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 946dba9..84c671f 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/27] cargo-horizon-mod: report particles and sounds +Subject: [PATCH 02/28] 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 0ceff45..446ffb3 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/27] Mod API (AirDef fork): full-screen post pass for mods +Subject: [PATCH 03/28] 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 5d83e47..fcae780 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/27] Mod models: draw far models as scaled impostors past +Subject: [PATCH 04/28] 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 1f03148..bca1c6c 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/27] Mod cameras: render.set-views draws the world from +Subject: [PATCH 05/28] 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 8cd636f..c72a2c1 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/27] Mods: more fuel per tick and per event (AirDef plans +Subject: [PATCH 06/28] 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 5c60249..acd7e5a 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/27] Mod cameras up to 1920x1080 (full-screen replays) +Subject: [PATCH 07/28] 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 4256dd0..ce3ce78 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/27] Mod cameras: distant terrain tiles, fog measured from +Subject: [PATCH 08/28] 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 14aafb4..2e4f044 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/27] Mods: game.world-id, so mods keep state per world (API +Subject: [PATCH 09/28] 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 37daee2..2dbe9fb 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/27] cargo-horizon-mod: trial runs behind feature full, so a +Subject: [PATCH 10/28] 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 5b1a429..a7263b8 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/27] HD lighting: G-buffer pass, per-pixel mod point lights +Subject: [PATCH 11/28] 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 4ef7699..13a6dff 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/27] HD lighting stage 2: sun and moon shadow cascades, the +Subject: [PATCH 12/28] 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 1cea5bc..cebbe94 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/27] HD: bind groups cached until a resize or a new source, +Subject: [PATCH 13/28] 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 795996a..c9c7ce3 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/27] HD: lights culled per 16x16 tile (surface and halo +Subject: [PATCH 14/28] 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 a7c94f3..98a24e5 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/27] HD: GPU time per pass in the F3 timing list, +Subject: [PATCH 15/28] 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 f4fd807..3e13f05 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/27] HD: shadows draw only sun-facing quads and skip buried +Subject: [PATCH 16/28] 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 d5ff475..8bc9909 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/27] Mods: model handles (render.model-id, model-generation, +Subject: [PATCH 17/28] 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 3526ebc..7816d8e 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/27] HD: temporal upscaling (HORIZON_HD_SCALE: world, +Subject: [PATCH 18/28] 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 42d6c67..a783f0a 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/27] HD water: its own pass (colour, normal, depth), the +Subject: [PATCH 19/28] 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 81018ce..3ff1641 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/27] HD water: no vanilla texture on the surface (the biome +Subject: [PATCH 20/28] 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 ce02767..23cb1d8 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/27] HD sky and light: a physical atmosphere (Rayleigh, Mie, +Subject: [PATCH 21/28] 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 055702e..e9fc0d6 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/27] HD water: a darker blue, a little clearer, ripples a +Subject: [PATCH 22/28] 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 index 09938d1..28929e3 100644 --- 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 @@ -1,7 +1,7 @@ From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 21:41:48 +0300 -Subject: [PATCH 23/27] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun +Subject: [PATCH 23/28] 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) diff --git a/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch b/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch index 2068d67..3e890a3 100644 --- a/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch +++ b/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch @@ -1,7 +1,7 @@ From 24971b3c67d8c87673eb2e0d204cedf8a2f46d1c Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 21:43:22 +0300 -Subject: [PATCH 24/27] Mods: world queries for simulations (render.query-rays, +Subject: [PATCH 24/28] Mods: world queries for simulations (render.query-rays, query-sections, world-changes; API level 12): block-exact rays and section occupancy by collision, sight or sound, answered next tick within budgets, and a log of changed sections diff --git a/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch b/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch index 833241e..9bca46d 100644 --- a/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch +++ b/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch @@ -1,7 +1,7 @@ From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 22:56:15 +0300 -Subject: [PATCH 25/27] HD fixes: rain level from the world not from rain +Subject: [PATCH 25/28] HD fixes: rain level from the world not from rain geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint without a depth heuristic (#35), air fog once over water (#37), translucent things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick diff --git a/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch b/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch index 4cc6b71..0f99d02 100644 --- a/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch +++ b/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch @@ -1,7 +1,7 @@ From 21391afb26f49505d6cdaf454a6355cd4e747503 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 23:19:53 +0300 -Subject: [PATCH 26/27] HD split into modules (gfx/hd/: one Rust file and one +Subject: [PATCH 26/28] HD split into modules (gfx/hd/: one Rust file and one WGSL file per pass, a shared prelude), contracts for colour, depth, alpha, time and coordinates written down, tests that the frame layout and bindings match between Rust and WGSL (#45) diff --git a/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch b/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch index d5da79a..d18d550 100644 --- a/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch +++ b/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch @@ -1,7 +1,7 @@ From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 23:22:19 +0300 -Subject: [PATCH 27/27] HD clouds: noise scales that divide the camera's +Subject: [PATCH 27/28] HD clouds: noise scales that divide the camera's 65536-block wrap, so the clouds and their shadows no longer jump at x or z = 0 diff --git a/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch b/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch new file mode 100644 index 0000000..96dac8e --- /dev/null +++ b/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch @@ -0,0 +1,2075 @@ +From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001 +From: chexit +Date: Fri, 30 Oct 2026 03:17:11 +0300 +Subject: [PATCH 28/28] HD: volumetric smoke and missile trails with clear + tunnels through fog and cloud (render.set-smoke, API level 14; #54); sun + speculars by material, wet surfaces and puddles in rain, wind in foliage + (HORIZON_HD_SPECULAR=0, HORIZON_HD_WIND=0, HORIZON_HD_SMOKE=0); HD shaders + checked for HLSL translation in their own test + +Co-Authored-By: Claude Opus 5.5 +--- + crates/horizon-client/src/entity.wgsl | 12 +- + crates/horizon-client/src/game.rs | 7 + + crates/horizon-client/src/game/mod_models.rs | 74 ++- + crates/horizon-client/src/gfx.rs | 42 +- + crates/horizon-client/src/gfx/hd/cloud.wgsl | 3 + + crates/horizon-client/src/gfx/hd/fog.wgsl | 91 +++- + crates/horizon-client/src/gfx/hd/frame.rs | 11 + + crates/horizon-client/src/gfx/hd/light.wgsl | 64 ++- + crates/horizon-client/src/gfx/hd/materials.rs | 242 +++++++++ + crates/horizon-client/src/gfx/hd/mod.rs | 96 ++++ + crates/horizon-client/src/gfx/hd/prelude.wgsl | 4 + + crates/horizon-client/src/gfx/hd/smoke.rs | 477 ++++++++++++++++++ + .../horizon-client/src/gfx/hd/smoke_grid.wgsl | 61 +++ + .../src/gfx/hd/smoke_sample.wgsl | 29 ++ + crates/horizon-client/src/gfx/hd/volumes.rs | 29 +- + crates/horizon-client/src/terrain.wgsl | 67 ++- + crates/horizon-mod-sdk/wit/horizon.wit | 41 ++ + crates/horizon-mods/src/api_level.rs | 6 +- + crates/horizon-mods/src/runtime.rs | 61 ++- + docs/api/mods-reference.md | 24 + + 20 files changed, 1417 insertions(+), 24 deletions(-) + create mode 100644 crates/horizon-client/src/gfx/hd/materials.rs + create mode 100644 crates/horizon-client/src/gfx/hd/smoke.rs + create mode 100644 crates/horizon-client/src/gfx/hd/smoke_grid.wgsl + create mode 100644 crates/horizon-client/src/gfx/hd/smoke_sample.wgsl + +diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl +index b3fc9bc82..55b93fd7d 100644 +--- a/crates/horizon-client/src/entity.wgsl ++++ b/crates/horizon-client/src/entity.wgsl +@@ -24,6 +24,10 @@ struct Globals { + hd_flags: vec4, + // Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs). + dynamic_lights: array, 16>, ++ // AirDef fork, HD wind. x: strength (0 off), y: seconds (wraps at 600), zw: camera x, z mod 256. ++ hd_wind: vec4, ++ // AirDef fork: the sprite layers' material classes, 4 bits each (gfx/hd/materials.rs). ++ hd_materials: array, 64>, + } + @group(0) @binding(0) var globals: Globals; + @group(0) @binding(1) var atlas: texture_2d; +@@ -285,7 +289,13 @@ struct EntityGBufferOut { + var out: EntityGBufferOut; + out.albedo = vec4(color.rgb, glow); + out.normal = vec4(n, 1.0); +- out.light = vec4(clamp(in.lm, vec2(0.0), vec2(1.0)), 0.0, 1.0); ++ // Roughness and metalness: creatures and items are matte; the mods' models (overlay 41) are ++ // painted metal, 42 bare metal (game/mod_models.rs). ++ var material = vec2(0.9, 0.0); ++ if in.overlay == 41u { material = vec2(0.42, 0.3); } ++ if in.overlay == 42u { material = vec2(0.28, 1.0); } ++ if in.overlay == 40u { material = vec2(0.5, 0.0); } ++ out.light = vec4(clamp(in.lm, vec2(0.0), vec2(1.0)), material); + return out; + } + // AirDef fork: entities in the sun's shadow map. +diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs +index fad8de187..2130d8d88 100644 +--- a/crates/horizon-client/src/game.rs ++++ b/crates/horizon-client/src/game.rs +@@ -11819,10 +11819,13 @@ impl Game<'_> { + let glowing = std::mem::take(self.glowing_vertices.get_mut()); + let mod_views = self.mod_views(alpha); + let hd_lights = self.mod_lights(camera, alpha); ++ let (hd_smoke, hd_smoke_clock) = self.mod_smoke(camera, alpha); + let frame = Frame { + mod_views: &mod_views, + hd: self.mod_hd(), + hd_lights: &hd_lights, ++ hd_smoke: &hd_smoke, ++ hd_smoke_clock, + weather: &weather, + weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32), + nether_lighting: self.dim == Dim::Nether, +@@ -14676,6 +14679,7 @@ fn build_renderer( + .copied() + .unwrap_or(0xffff) + }), ++ horizon_client::gfx::hd_material_table(&assets.sprite_layers), + ))?; + renderer.hud.sprite_animations = atlas + .sprite_animations +@@ -14736,6 +14740,7 @@ fn loading_renderer( + previous, + api, + [0xffff; 2], ++ [[0; 4]; 64], + ))?; + if let Some(atlas) = atlas { + renderer.hud.sprite_animations = atlas +@@ -14753,6 +14758,8 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> { + mod_views: &[], + hd: None, + hd_lights: &[], ++ hd_smoke: &[], ++ hd_smoke_clock: 0.0, + weather: &[], + weather_time: horizon_client::weather::Time::new(0, 0.0), + camera: glam::DVec3::new(0.5, 100.0, 0.5), +diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs +index f2329a123..f5e68566a 100644 +--- a/crates/horizon-client/src/game/mod_models.rs ++++ b/crates/horizon-client/src/game/mod_models.rs +@@ -44,6 +44,15 @@ pub(super) struct ModModels { + lights: HashMap<(usize, u32), (PointLight, PointLight)>, + } + ++/// The overlays of the mods' models (entity.wgsl): glowing. ++#[cfg(feature = "mods")] ++const MODEL_GLOW: u32 = 40; ++#[cfg(feature = "mods")] ++const MODEL_PAINTED: u32 = 41; ++/// Bare metal: smooth and fully metallic (entity.wgsl's fs_entity_gbuffer). ++#[cfg(feature = "mods")] ++#[allow(dead_code)] ++const MODEL_BARE_METAL: u32 = 42; + /// Full brightness: sky and block light 15, as the entity shader packs them. + #[cfg(feature = "mods")] + const GLOW: u32 = 240 | (240 << 8); +@@ -72,6 +81,13 @@ impl Game<'_> { + ) -> Vec { + Vec::new() + } ++ pub(super) fn mod_smoke( ++ &mut self, ++ _camera: glam::DVec3, ++ _alpha: f64, ++ ) -> (Vec, f32) { ++ (Vec::new(), 0.0) ++ } + } + + #[cfg(feature = "mods")] +@@ -211,6 +227,56 @@ impl Game<'_> { + out.into_iter().take(512).map(|(_, l)| l).collect() + } + ++ /// The mods' smoke segments for this frame, relative to the camera and aged to the clock the ++ /// mod gave (between its last two calls, as the lights); the nearest first when there are ++ /// too many. Also tells the mods whether HD draws it. ++ pub(super) fn mod_smoke( ++ &mut self, ++ camera: DVec3, ++ alpha: f64, ++ ) -> (Vec, f32) { ++ let on = self.mod_hd().is_some() && horizon_client::gfx::hd_smoke_enabled(); ++ self.mods.mods.set_smoke_active(on); ++ let Some((before, now)) = self.mods.mods.smoke_clock().filter(|_| on) else { ++ return (Vec::new(), 0.0); ++ }; ++ let clock = before + (now - before) * alpha.clamp(0.0, 1.0); ++ let mut out: Vec<(f64, horizon_client::gfx::HdSmoke)> = self ++ .mods ++ .mods ++ .smoke() ++ .filter_map(|s| { ++ let age = (clock - s.born) / 20.0; ++ if age < 0.0 || age >= f64::from(s.life) / 20.0 { ++ return None; ++ } ++ let a = DVec3::new(s.ax, s.ay, s.az) - camera; ++ let b = DVec3::new(s.bx, s.by, s.bz) - camera; ++ let near = a.length().min(b.length()); ++ // Far past the grid, nothing of it can show. ++ if near > 600.0 { ++ return None; ++ } ++ let seed = (s.id.wrapping_mul(2_654_435_761) >> 8) as f32 / 16_777_216.0; ++ Some(( ++ near, ++ horizon_client::gfx::HdSmoke { ++ a: [a.x as f32, a.y as f32, a.z as f32, age as f32], ++ b: [b.x as f32, b.y as f32, b.z as f32, s.life / 20.0], ++ shape: [s.radius_start, s.radius_end, s.density, s.heat], ++ motion: [s.rise, s.clear, s.soot, seed], ++ }, ++ )) ++ }) ++ .collect(); ++ out.sort_by(|x, y| x.0.total_cmp(&y.0)); ++ out.truncate(3072); ++ ( ++ out.into_iter().map(|(_, s)| s).collect(), ++ clock.rem_euclid(86_400.0 * 20.0) as f32 / 20.0, ++ ) ++ } ++ + /// The mods' cameras between their last two settings, for this frame. + pub(super) fn mod_views(&self, alpha: f64) -> Vec { + let t = alpha.clamp(0.0, 1.0); +@@ -285,10 +351,10 @@ impl Game<'_> { + let start = out.len(); + self.block_model_vertices(p.state, 0, m, light, true, out); + // Overlay 40 marks a glowing part for the HD lighting (entity.wgsl): drawn as any other. +- if p.glow { +- for v in &mut out[start..] { +- v.layer = 40; +- } ++ // Any other model is overlay 41, painted metal (medium rough, a little metallic); ++ // 42 (MODEL_BARE_METAL) is bare metal, for a mod to ask for later. ++ for v in &mut out[start..] { ++ v.layer = if p.glow { MODEL_GLOW } else { MODEL_PAINTED }; + } + } + } +diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs +index bdc5b867a..93a2f7c63 100644 +--- a/crates/horizon-client/src/gfx.rs ++++ b/crates/horizon-client/src/gfx.rs +@@ -72,6 +72,10 @@ mod cull; + mod end_sky; + mod flat; + mod hd; ++/// AirDef fork: the HD material classes of the sprite layers (`hd::materials`). ++pub fn hd_material_table(layers: &std::collections::BTreeMap) -> hd::MaterialTable { ++ hd::material_table(layers) ++} + mod hdr; + pub mod lightning; + mod lod; +@@ -80,7 +84,7 @@ mod mod_views; + mod native_material; + mod native_pack; + mod native_post; +-pub use hd::{HdLight, HdSettings}; ++pub use hd::{HdLight, HdSettings, HdSmoke, smoke_enabled as hd_smoke_enabled}; + pub use mod_views::ModView; + mod native_sky; + mod occlusion; +@@ -196,6 +200,11 @@ struct Globals { + hd_flags: [f32; 4], + /// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`). + dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX], ++ /// AirDef fork, HD wind. x: strength (0 off); y: seconds (wraps at 600); zw: the camera's x ++ /// and z modulo 256. ++ hd_wind: [f32; 4], ++ /// AirDef fork: the sprite layers' material classes, 4 bits each (`hd::materials`). ++ hd_materials: hd::MaterialTable, + } + pub type SectionKey = (i32, i32, i32); + struct SectionGpu { +@@ -226,6 +235,10 @@ pub struct Frame<'a> { + /// The HD lighting (AirDef fork): on with its settings, and the mods' lights. + pub hd: Option, + pub hd_lights: &'a [HdLight], ++ /// The mods' smoke segments for HD's volumetric smoke, camera-relative, and the mod's clock ++ /// in seconds. ++ pub hd_smoke: &'a [HdSmoke], ++ pub hd_smoke_clock: f32, + pub yaw: f32, + pub pitch: f32, + pub fov_degrees: f32, +@@ -567,6 +580,8 @@ pub struct Renderer { + mod_views: Option, + /// AirDef fork: the HD lighting's G-buffer pipelines (terrain, entities) and its targets. + gbuffer_terrain: wgpu::RenderPipeline, ++ /// The sprite layers' material classes (leaves, plants, metal) for the HD wind and specular. ++ hd_materials: hd::MaterialTable, + /// The water quads alone into the HD water targets (section draws; `flat.water` for flat ones). + gbuffer_water: wgpu::RenderPipeline, + gbuffer_entity: wgpu::RenderPipeline, +@@ -1089,6 +1104,7 @@ impl Renderer { + previous: Option<&Self>, + api: GraphicsApi, + water_layers: [u32; 2], ++ hd_materials: hd::MaterialTable, + ) -> Result { + let split = sprite_split(); + if layers > split * SPRITE_ARRAYS { +@@ -2877,6 +2893,7 @@ impl Renderer { + mod_post_hdr: false, + mod_views: None, + gbuffer_terrain, ++ hd_materials, + gbuffer_water, + gbuffer_entity, + shadow_terrain, +@@ -4141,6 +4158,19 @@ impl Renderer { + } + lights + }, ++ hd_wind: if hd_wanted && hd::wind_enabled() { ++ // Stronger gusts in rain. ++ let rain = frame.sky.rain.clamp(0.0, 1.0); ++ [ ++ 1.0 + rain * 1.2, ++ hd::wind_seconds(), ++ frame.camera.x.rem_euclid(256.0) as f32, ++ frame.camera.z.rem_euclid(256.0) as f32, ++ ] ++ } else { ++ [0.0; 4] ++ }, ++ hd_materials: self.hd_materials, + }; + self.uploads + .buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals)); +@@ -5167,6 +5197,13 @@ impl Renderer { + None => translucent_draws > 0, + }; + if let Some(settings) = frame.hd { ++ hd.write_smoke( ++ &self.queue, ++ &hd_scene, ++ frame.hd_smoke, ++ frame.hd_lights, ++ frame.hd_smoke_clock, ++ ); + hd.write(&self.queue, &hd_scene, frame.hd_lights, settings); + } + // vanilla's translucent water leaves the colour while the HD water draws it. +@@ -6454,7 +6491,8 @@ mod tests { + fn wgsl_files(dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); +- if path.is_dir() { ++ // AirDef fork: the HD shaders need their prelude; gfx/hd's own test translates them. ++ if path.is_dir() && !path.ends_with("gfx/hd") { + wgsl_files(&path, out); + } else if path.extension().is_some_and(|e| e == "wgsl") { + out.push(path); +diff --git a/crates/horizon-client/src/gfx/hd/cloud.wgsl b/crates/horizon-client/src/gfx/hd/cloud.wgsl +index a3c0fffae..fad234a46 100644 +--- a/crates/horizon-client/src/gfx/hd/cloud.wgsl ++++ b/crates/horizon-client/src/gfx/hd/cloud.wgsl +@@ -1,5 +1,6 @@ + + @group(0) @binding(6) var history: texture_2d; ++@group(0) @binding(7) var smoke_tex: texture_2d; + + fn ign(px: vec2) -> f32 { + let p = px + vec2(5.588238 * frame.params.w); +@@ -98,6 +99,8 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4 { + var d = cloud_density(pos, 1.0); + if d < 0.002 { continue; } + d *= 1.0 - smoothstep(0.8, 1.0, t / 4200.0); ++ // A flying thing's tunnel through the cloud. ++ if frame.smoke.x > 0.5 { d *= 1.0 - smoke_at(smoke_tex, linear, pos).a; } + let step_od = d * ext * step_len; + let step_t = exp(-step_od); + let h = clamp((pos.y - y0) / frame.clouds.y, 0.0, 1.0); +diff --git a/crates/horizon-client/src/gfx/hd/fog.wgsl b/crates/horizon-client/src/gfx/hd/fog.wgsl +index 8979534d7..38b9098e3 100644 +--- a/crates/horizon-client/src/gfx/hd/fog.wgsl ++++ b/crates/horizon-client/src/gfx/hd/fog.wgsl +@@ -7,6 +7,7 @@ + @group(0) @binding(5) var repeat_s: sampler; + @group(0) @binding(6) var linear: sampler; + //SKY7 ++@group(0) @binding(8) var smoke_tex: texture_2d; + + const FOG_MAX: f32 = 192.0; + const FOG_STEPS: i32 = 18; +@@ -55,6 +56,85 @@ fn fog_sigma(p: vec3) -> f32 { + return s * patchy * smoothstep(28.0, 58.0, wy); + } + ++const SMOKE_MAX: f32 = 200.0; ++const SMOKE_STEP: f32 = 3.5; ++const SMOKE_WIND: vec2 = vec2(1.6, 1.2); ++ ++fn fire_colour(h: f32) -> vec3 { ++ return mix(vec3(1.0, 0.38, 0.08), vec3(1.0, 0.8, 0.45), clamp(h, 0.0, 1.0)); ++} ++ ++// The smoke grid's density at a point with the fine detail on it: two Worley octaves that ride the ++// wind with the smoke (the noise lives in the world, the smoke drifts with it). ++fn smoke_detail(pos: vec3, core: f32) -> f32 { ++ let w = vec3(pos.x + frame.cloud_cam.x, pos.y + frame.vfog.y, pos.z + frame.cloud_cam.y) ++ - vec3(SMOKE_WIND.x, 0.0, SMOKE_WIND.y) * frame.smoke.z; ++ let n1 = textureSampleLevel(noise3, repeat_s, w / 64.0, 0.0).r; ++ let n2 = textureSampleLevel(noise3, repeat_s, w / 22.0, 0.0).g; ++ let n = 0.55 * n1 + 0.45 * n2; ++ // Thin smoke breaks up into wisps, thick smoke stays whole. ++ let keep = smoothstep(0.18, 0.62, n + 0.45 * min(core, 1.5) - 0.12); ++ return core * mix(0.35, 1.45, keep) * mix(0.6, 1.0, smoothstep(0.0, 0.6, core)); ++} ++ ++// The smoke between the camera and `len` along `dir`: light and transmittance (a). ++fn march_smoke(dir: vec3, len: f32, jitter: f32, haze: vec3, cos_v: f32) -> vec4 { ++ let lo = frame.smoke_min.xyz; ++ let hi = lo + vec3(128.0, 64.0, 128.0) * frame.smoke.y; ++ let inv = vec3(1.0) / select(dir, vec3(1e-5), abs(dir) < vec3(1e-5)); ++ let t0v = (lo - vec3(0.0)) * inv; ++ let t1v = (hi - vec3(0.0)) * inv; ++ let t_in = max(max(min(t0v.x, t1v.x), min(t0v.y, t1v.y)), max(min(t0v.z, t1v.z), 0.0)); ++ let t_out = min(min(max(t0v.x, t1v.x), max(t0v.y, t1v.y)), min(max(t0v.z, t1v.z), len)); ++ if t_out <= t_in { return vec4(0.0, 0.0, 0.0, 1.0); } ++ let n = clamp(i32(ceil((t_out - t_in) / SMOKE_STEP)), 1, 64); ++ let ds = (t_out - t_in) / f32(n); ++ let sun = frame.sun.xyz; ++ let sun_on = frame.sun.w; ++ let sun_light = frame.sun_color.rgb * sun_on; ++ let phase = 0.45 + 0.55 * min(hgn(cos_v, 0.5), 6.0) * 0.5 + 0.2 * hgn(cos_v, -0.3); ++ let up_sky = haze; ++ var trans = 1.0; ++ var light = vec3(0.0); ++ for (var i = 0; i < n; i++) { ++ if trans < 0.02 { break; } ++ let pos = dir * (t_in + (f32(i) + jitter) * ds); ++ if smoke_probe(smoke_tex, linear, pos) < 0.003 { continue; } ++ let g = smoke_at(smoke_tex, linear, pos); ++ if g.x < 0.004 { continue; } ++ let dens = smoke_detail(pos, g.x); ++ let a = 1.0 - exp(-dens * ds); ++ let inv_d = 1.0 / max(g.x, 1e-4); ++ let soot = clamp(g.y * inv_d, 0.0, 1.0); ++ let heat = clamp(g.z * inv_d, 0.0, 1.0); ++ let albedo = mix(0.9, 0.16, soot); ++ // Sunlight: the cascades' shadow, then the smoke's own along the way to the sun. ++ var vis = 1.0; ++ if sun_on > 0.0 { vis = sun_visible(pos); } ++ var od = 0.0; ++ for (var k = 0; k < 3; k++) { ++ od += smoke_at(smoke_tex, linear, pos + sun * (f32(k) + 0.5 + 0.5 * jitter) * 4.0).x * 4.0; ++ } ++ let through = 0.65 * exp(-od * 0.9) + 0.35 * exp(-od * 0.22); ++ var glow = vec3(0.0); ++ let count = i32(frame.smoke_min.w); ++ for (var j = 0; j < count; j++) { ++ let l = frame.smoke_lights[j * 2]; ++ let to = l.xyz - pos; ++ let dist = length(to); ++ if dist >= l.w { continue; } ++ let window = pow(clamp(1.0 - pow(dist / l.w, 4.0), 0.0, 1.0), 2.0); ++ glow += frame.smoke_lights[j * 2 + 1].rgb / (dist * dist + 6.0) * window; ++ } ++ let ambient = up_sky * (0.25 + 0.75 * exp(-od * 0.35)) * FOG_AMBIENT; ++ var s = albedo * (sun_light * vis * phase * through * FOG_SUN + ambient + glow * 1.5); ++ s += fire_colour(heat) * heat * heat * 9.0; ++ light += trans * a * s; ++ trans *= 1.0 - a; ++ } ++ return vec4(light, trans); ++} ++ + @fragment + fn fs_fog(in: FullOut) -> FogOut { + // The nearest of the four world pixels this texel stands for (so edges keep the near depth). +@@ -100,7 +180,8 @@ fn fs_fog(in: FullOut) -> FogOut { + let t = len * f0; + let ds = len * (f1 - f0); + let pos = dir * (t + 0.5 * ds); +- let sigma = fog_sigma(pos); ++ var sigma = fog_sigma(pos); ++ if frame.smoke.x > 0.5 { sigma *= 1.0 - smoke_at(smoke_tex, linear, pos).a; } + let a = 1.0 - exp(-sigma * ds); + var vis = 1.0; + if sun_on > 0.0 { vis = sun_visible(pos); } +@@ -113,7 +194,13 @@ fn fs_fog(in: FullOut) -> FogOut { + // Photon brightens the fog a little in the early morning and the evening. + let sy = frame.sky_sun.y + 0.02; + let glow = 1.0 + 2.0 * linear_step(0.05, 1.0, exp(-300.0 * sy * sy)); +- out.fog = vec4(light * glow, trans); ++ var fog = vec4(light * glow, trans); ++ if frame.smoke.x > 0.5 { ++ let sm = march_smoke(dir, select(SMOKE_MAX, min(length(p), SMOKE_MAX), d > 0.0), jitter, haze, cos_v); ++ // The smoke is lit by the sun itself and stands in the haze: it is laid over the fog. ++ fog = vec4(fog.rgb + fog.a * sm.rgb, fog.a * sm.a); ++ } ++ out.fog = fog; + return out; + } + +diff --git a/crates/horizon-client/src/gfx/hd/frame.rs b/crates/horizon-client/src/gfx/hd/frame.rs +index 9d4dfabdb..415b12cdb 100644 +--- a/crates/horizon-client/src/gfx/hd/frame.rs ++++ b/crates/horizon-client/src/gfx/hd/frame.rs +@@ -90,6 +90,17 @@ pub(super) struct HdFrame { + pub(super) cloud_sun: [f32; 4], + /// x: 1 when the volumetric fog is drawn; y: the camera's height in the world; z: the morning mist. + pub(super) vfog: [f32; 4], ++ /// x: how wet the surfaces are (0 dry .. 1 soaked; follows the rain, dries slowly); y: 1 when ++ /// the specular and the wet look are on. ++ pub(super) surface: [f32; 4], ++ /// Smoke (see `smoke.rs`). x: 1 when the smoke grid is drawn; y: the grid's cell size in blocks; ++ /// z: the smoke clock in seconds modulo 86400 (the noise rides the wind with it); w: unused. ++ pub(super) smoke: [f32; 4], ++ /// xyz: the grid's minimum corner relative to the camera; w: the number of glow lights. ++ pub(super) smoke_min: [f32; 4], ++ /// Up to 16 lights that glow on the smoke: xyz position relative to the camera, w radius; then ++ /// rgb colour times intensity. ++ pub(super) smoke_lights: [[f32; 4]; 32], + } + + /// What the world around looks like this frame (from the renderer's globals). +diff --git a/crates/horizon-client/src/gfx/hd/light.wgsl b/crates/horizon-client/src/gfx/hd/light.wgsl +index 034a04607..2534a3db8 100644 +--- a/crates/horizon-client/src/gfx/hd/light.wgsl ++++ b/crates/horizon-client/src/gfx/hd/light.wgsl +@@ -203,6 +203,21 @@ struct LightOut { + @location(1) air: vec4, + } + ++// Value noise on an integer lattice that repeats every 256 cells, for the puddles. ++fn wet_hash(c: vec2) -> f32 { ++ var h = u32(c.x & 255) * 374761393u + u32(c.y & 255) * 668265263u; ++ h = (h ^ (h >> 13u)) * 1274126177u; ++ h = h ^ (h >> 16u); ++ return f32(h & 0xffffu) / 65535.0; ++} ++fn wet_noise(p: vec2) -> f32 { ++ let i = vec2(floor(p)); ++ let f = fract(p); ++ let u = f * f * (3.0 - 2.0 * f); ++ return mix(mix(wet_hash(i), wet_hash(i + vec2(1, 0)), u.x), ++ mix(wet_hash(i + vec2(0, 1)), wet_hash(i + vec2(1, 1)), u.x), u.y); ++} ++ + // An opaque surface lit: the ambient, the sun and the mods' lights over its albedo. + fn shade_surface(px: vec2, p: vec3, water: bool) -> vec3 { + let sun = frame.sun.xyz; +@@ -216,6 +231,27 @@ fn shade_surface(px: vec2, p: vec3, water: bool) -> vec3 { + let n = normalize(textureLoad(normal_tex, px, 0).xyz); + let albedo = textureLoad(albedo_tex, px, 0); + let lm = textureLoad(light_tex, px, 0).xy; ++ // Material: roughness and metalness (the G-buffer's light-level texture, zw). ++ let material = textureLoad(light_tex, px, 0).zw; ++ var rough = material.x; ++ let metal = material.y; ++ var surface_rgb = albedo.rgb; ++ var puddle = 0.0; ++ let shiny = frame.surface.y > 0.5 && !water; ++ // Rain: surfaces under open sky darken and turn glossy; flat ones grow puddles in patches of a ++ // world-space noise. ++ let wet = frame.surface.x * select(0.0, 1.0, shiny); ++ if wet > 0.0 { ++ let open_sky = smoothstep(0.8, 0.97, lm.x); ++ let flat_up = smoothstep(0.9, 0.98, n.y); ++ let soak = wet * open_sky * (0.35 + 0.65 * flat_up); ++ let wp = frame.cloud_cam.xy + p.xz; ++ let nz = 0.6 * wet_noise(wp * 0.45) + 0.4 * wet_noise(wp * 1.3 + vec2(17.0, 5.0)); ++ puddle = smoothstep(0.66 - 0.16 * wet, 0.74 - 0.16 * wet, nz) * smoothstep(0.3, 0.7, wet) * open_sky * flat_up; ++ surface_rgb *= mix(1.0, 0.7, soak) * mix(1.0, 0.6, puddle); ++ rough = mix(rough, 0.12, soak * (1.0 - 0.5 * metal)); ++ rough = mix(rough, 0.03, puddle); ++ } + // Vanilla's light (sky and blocks through the lightmap), shaded where the sun can't reach. + let base = textureSampleLevel(lightmap, linear, clamp(vec2(lm.y, lm.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2(0.5 / 16.0), vec2(15.5 / 16.0)), 0.0).rgb; + let block = textureSampleLevel(lightmap, linear, vec2(lm.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb; +@@ -255,7 +291,33 @@ fn shade_surface(px: vec2, p: vec3, water: bool) -> vec3 { + let k = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0); + light += l.color.rgb * falloff * k * ao_lights; + } +- return albedo.rgb * light + albedo.rgb * albedo.a * 1.5; ++ // Metal keeps most of its diffuse look (there is no environment map to light it with). ++ var out = surface_rgb * (1.0 - metal * 0.65) * light + albedo.rgb * albedo.a * 1.5; ++ if shiny { ++ // The sun's GGX highlight and the sky's reflection, with Fresnel that rough surfaces barely show. ++ let v = -normalize(p); ++ let ndv = max(dot(n, v), 0.001); ++ let gloss = 1.0 - rough; ++ let f0 = mix(vec3(0.04), surface_rgb, metal); ++ let fresnel = f0 + (max(vec3(gloss), f0) - f0) * pow(1.0 - ndv, 5.0); ++ if sun_on > 0.0 && ndl > 0.0 && s > 0.0 { ++ let h = normalize(sun + v); ++ let ndh = max(dot(n, h), 0.0); ++ let a = max(rough * rough, 0.03); ++ let a2 = a * a; ++ let dd = ndh * ndh * (a2 - 1.0) + 1.0; ++ let d = a2 / (3.14159265 * dd * dd); ++ let vis = 0.5 / (ndl * sqrt(ndv * ndv * (1.0 - a2) + a2) + ndv * sqrt(ndl * ndl * (1.0 - a2) + a2)); ++ out += min(fresnel * (d * vis * ndl) * frame.sun_color.rgb * (sun_on * s * sky_reach), vec3(6.0)); ++ } ++ if atmosphere && gloss > 0.12 { ++ let r = reflect(-v, n); ++ let sky = atmosphere_sky(normalize(vec3(r.x, max(r.y, 0.02), r.z))); ++ let reach = smoothstep(0.35, 0.95, lm.x) * smoothstep(-0.2, 0.15, r.y); ++ out += sky * fresnel * (gloss * gloss * reach * ao_ambient); ++ } ++ } ++ return out; + } + + @fragment +diff --git a/crates/horizon-client/src/gfx/hd/materials.rs b/crates/horizon-client/src/gfx/hd/materials.rs +new file mode 100644 +index 000000000..32d080878 +--- /dev/null ++++ b/crates/horizon-client/src/gfx/hd/materials.rs +@@ -0,0 +1,242 @@ ++//! Surface materials and wind for the HD renderer: which sprite layers are leaves, plants or metal. ++//! ++//! The terrain shader (`terrain.wgsl`) cannot ask a sprite its name, so the renderer gives it a table: ++//! 4 bits per sprite layer (the first `LAYERS` layers; mods' sprites past them are plain), packed ++//! 8 to a word and 4 words to a `vec4` in the `Globals` block (`hd_materials`). The classes ++//! are used twice: the vertex stage sways leaves and plants (`HORIZON_HD_WIND=0` stops it), the ++//! G-buffer stage writes roughness and metalness for the sun's specular (`HORIZON_HD_SPECULAR=0` ++//! turns that off in the lighting). Keep the numbers in sync with `terrain.wgsl` (`MAT_*`). ++use std::collections::BTreeMap; ++ ++/// Sprite layers the table covers (the first sprite array's size). ++pub const LAYERS: usize = 2048; ++/// The table as it sits in `Globals`: `LAYERS` 4-bit entries. ++pub type Table = [[u32; 4]; LAYERS / 32]; ++ ++pub const PLAIN: u8 = 0; ++pub const LEAVES: u8 = 1; ++/// A plant: crossed quads sway from the bottom edge of the sprite. ++pub const PLANT: u8 = 2; ++/// The lower and the upper half of a two-block plant (they share the bend at the join). ++pub const PLANT_LOWER: u8 = 3; ++pub const PLANT_UPPER: u8 = 4; ++pub const METAL: u8 = 5; ++/// Polished stone, quartz, concrete: smoother than a natural block. ++pub const SMOOTH: u8 = 6; ++/// Glazed, glassy or gem-like blocks. ++pub const GLOSSY: u8 = 7; ++pub const WOOD: u8 = 8; ++ ++/// The class of a block sprite by its name (`minecraft:block/`). ++pub fn classify(name: &str) -> u8 { ++ let Some(n) = name.strip_prefix("minecraft:block/") else { ++ return PLAIN; ++ }; ++ if n.ends_with("_leaves") { ++ return LEAVES; ++ } ++ if n == "vine" { ++ return LEAVES; ++ } ++ // Two-block plants: tall grass, ferns, flowers, the sunflower. ++ for double in [ ++ "tall_grass", ++ "large_fern", ++ "lilac", ++ "rose_bush", ++ "peony", ++ "sunflower", ++ ] { ++ if n == format!("{double}_bottom") { ++ return PLANT_LOWER; ++ } ++ if n == format!("{double}_top") { ++ return PLANT_UPPER; ++ } ++ } ++ const PLANTS: &[&str] = &[ ++ "short_grass", ++ "fern", ++ "dead_bush", ++ "dandelion", ++ "golden_dandelion", ++ "poppy", ++ "blue_orchid", ++ "allium", ++ "azure_bluet", ++ "red_tulip", ++ "orange_tulip", ++ "white_tulip", ++ "pink_tulip", ++ "oxeye_daisy", ++ "cornflower", ++ "lily_of_the_valley", ++ "torchflower", ++ "open_eyeblossom", ++ "closed_eyeblossom", ++ "wither_rose", ++ "short_dry_grass", ++ "tall_dry_grass", ++ "sugar_cane", ++ "bush", ++ "firefly_bush", ++ "sweet_berry_bush_stage0", ++ "sweet_berry_bush_stage1", ++ "sweet_berry_bush_stage2", ++ "sweet_berry_bush_stage3", ++ ]; ++ if PLANTS.contains(&n) ++ || n.ends_with("_sapling") ++ || [ ++ "wheat_stage", ++ "carrots_stage", ++ "potatoes_stage", ++ "beetroots_stage", ++ ] ++ .iter() ++ .any(|s| n.starts_with(s)) ++ { ++ return PLANT; ++ } ++ // Metal: whole blocks and fittings. ++ let metal_block = [ ++ "iron_block", ++ "gold_block", ++ "netherite_block", ++ "copper_block", ++ "exposed_copper", ++ "weathered_copper", ++ "oxidized_copper", ++ "chain", ++ "iron_bars", ++ "iron_door_top", ++ "iron_door_bottom", ++ "iron_trapdoor", ++ "copper_door_top", ++ "copper_door_bottom", ++ "copper_trapdoor", ++ "heavy_core", ++ "anvil", ++ "anvil_top", ++ "chipped_anvil_top", ++ "damaged_anvil_top", ++ "cauldron_side", ++ "cauldron_top", ++ "cauldron_inner", ++ "hopper_outside", ++ "hopper_top", ++ "lightning_rod", ++ ]; ++ if metal_block.contains(&n) ++ || n.contains("cut_copper") ++ || n.contains("chiseled_copper") ++ || n.contains("copper_grate") ++ || n.contains("copper_bulb") ++ || n.contains("waxed_") && n.contains("copper") ++ { ++ return METAL; ++ } ++ if n.starts_with("polished_") ++ || n.starts_with("smooth_") ++ || n.starts_with("quartz_block") ++ || n.starts_with("chiseled_quartz") ++ || n.ends_with("_concrete") ++ || n.starts_with("purpur_block") ++ || n.starts_with("prismarine") ++ || n.starts_with("dark_prismarine") ++ || n == "calcite" ++ || n == "stone" ++ { ++ return SMOOTH; ++ } ++ if n.ends_with("_glazed_terracotta") ++ || [ ++ "obsidian", ++ "crying_obsidian", ++ "diamond_block", ++ "emerald_block", ++ "lapis_block", ++ ] ++ .contains(&n) ++ || n.starts_with("amethyst_block") ++ || n == "gilded_blackstone" ++ { ++ return GLOSSY; ++ } ++ if n.ends_with("_planks") { ++ return WOOD; ++ } ++ PLAIN ++} ++ ++/// The table for the renderer's sprite layers (`name -> layer`). ++pub fn table(layers: &BTreeMap) -> Table { ++ let mut nibbles = vec![0u8; LAYERS]; ++ for (name, &layer) in layers { ++ if let Some(slot) = nibbles.get_mut(layer as usize) { ++ *slot = classify(name); ++ } ++ } ++ let mut out = [[0u32; 4]; LAYERS / 32]; ++ for (layer, class) in nibbles.into_iter().enumerate() { ++ let word = layer / 8; ++ out[word / 4][word % 4] |= u32::from(class) << ((layer % 8) * 4); ++ } ++ out ++} ++ ++/// Whether the wind sways leaves and plants (`HORIZON_HD_WIND=0` stops it). ++pub fn wind_enabled() -> bool { ++ std::env::var("HORIZON_HD_WIND").is_ok_and(|v| v != "0") ++ || std::env::var("HORIZON_HD_WIND").is_err() ++} ++ ++/// Whether surfaces get the sun's specular and the wet look (`HORIZON_HD_SPECULAR=0` turns both off). ++pub fn specular_enabled() -> bool { ++ std::env::var("HORIZON_HD_SPECULAR").map_or(true, |v| v != "0") ++} ++ ++/// Seconds for the wind's waves, wrapping at 600 (the shader's waves repeat in whole cycles of it). ++pub fn wind_seconds() -> f32 { ++ static START: std::sync::OnceLock = std::sync::OnceLock::new(); ++ let start = START.get_or_init(std::time::Instant::now); ++ (start.elapsed().as_secs_f64() % 600.0) as f32 ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn names_get_their_classes() { ++ let c = |n: &str| classify(&format!("minecraft:block/{n}")); ++ assert_eq!(c("oak_leaves"), LEAVES); ++ assert_eq!(c("short_grass"), PLANT); ++ assert_eq!(c("tall_grass_top"), PLANT_UPPER); ++ assert_eq!(c("lilac_bottom"), PLANT_LOWER); ++ assert_eq!(c("wheat_stage3"), PLANT); ++ assert_eq!(c("oak_sapling"), PLANT); ++ assert_eq!(c("iron_block"), METAL); ++ assert_eq!(c("cut_copper"), METAL); ++ assert_eq!(c("polished_andesite"), SMOOTH); ++ assert_eq!(c("white_concrete"), SMOOTH); ++ assert_eq!(c("dirt"), PLAIN); ++ assert_eq!(c("grass_block_top"), PLAIN); ++ assert_eq!(classify("airdef:block/iron_block"), PLAIN); ++ } ++ ++ #[test] ++ fn the_table_packs_four_bits_a_layer() { ++ let mut layers = BTreeMap::new(); ++ layers.insert("minecraft:block/oak_leaves".to_owned(), 0); ++ layers.insert("minecraft:block/iron_block".to_owned(), 9); ++ layers.insert("minecraft:block/short_grass".to_owned(), 2047); ++ layers.insert("minecraft:block/fern".to_owned(), 5000); ++ let t = table(&layers); ++ let at = |l: usize| (t[l / 32][(l / 8) % 4] >> ((l % 8) * 4)) & 15; ++ assert_eq!(at(0), u32::from(LEAVES)); ++ assert_eq!(at(9), u32::from(METAL)); ++ assert_eq!(at(2047), u32::from(PLANT)); ++ assert_eq!(at(1), 0); ++ } ++} +diff --git a/crates/horizon-client/src/gfx/hd/mod.rs b/crates/horizon-client/src/gfx/hd/mod.rs +index ed336692d..1199e073e 100644 +--- a/crates/horizon-client/src/gfx/hd/mod.rs ++++ b/crates/horizon-client/src/gfx/hd/mod.rs +@@ -64,9 +64,11 @@ mod bloom; + mod frame; + mod gbuffer; + mod lighting; ++mod materials; + mod noise; + mod shadows; + mod sky; ++mod smoke; + mod taa; + mod tiles; + mod tonemap; +@@ -80,6 +82,7 @@ use gbuffer::GBuffer; + use lighting::Lighting; + use shadows::Shadows; + use sky::Sky; ++use smoke::Smoke; + use std::sync::atomic::{AtomicU64, Ordering}; + use taa::Taa; + use tonemap::Tonemap; +@@ -88,7 +91,9 @@ use water::Water; + + pub use frame::{HdLight, HdScene, HdSettings, light_of, vanilla_cloud_tint}; + pub use gbuffer::{ALBEDO, LIGHT_LEVELS, NORMAL}; ++pub use materials::{Table as MaterialTable, table as material_table, wind_enabled, wind_seconds}; + pub use shadows::{CASCADES, cascades}; ++pub use smoke::{HdSmoke, smoke_enabled}; + pub use taa::{jitter, jitter_count, scale_override, taa_enabled}; + pub use volumes::clouds_enabled; + pub use water::{WATER_NORMAL, WATER_TINT}; +@@ -356,7 +361,11 @@ pub struct Hd { + clouds_active: bool, + fog_active: bool, + sky: Sky, ++ /// The volumetric smoke's grid (see `smoke`). ++ smoke: Smoke, + started: std::time::Instant, ++ /// The surfaces' wetness and when it was last updated (see `wetness`). ++ wet: (f32, std::time::Instant), + taa: Taa, + bloom: Bloom, + tonemap: Tonemap, +@@ -421,6 +430,8 @@ impl Hd { + clouds_active: false, + fog_active: false, + sky: Sky::new(device, &frame), ++ smoke: Smoke::new(device, &frame), ++ wet: (0.0, std::time::Instant::now()), + started: std::time::Instant::now(), + taa: Taa::new(device), + bloom: Bloom::new(device), +@@ -462,6 +473,25 @@ impl Hd { + } + + /// The world is smaller than the output, so TAA upsamples it. ++ /// How wet the world is: soaks in over ~15 s of rain, dries out over ~2.5 minutes after it. ++ fn wetness(&mut self, scene: &HdScene) -> f32 { ++ let now = std::time::Instant::now(); ++ let dt = now.duration_since(self.wet.1).as_secs_f32().min(1.0); ++ let target = if scene.sky { ++ (scene.rain / 0.7).clamp(0.0, 1.0) ++ } else { ++ 0.0 ++ }; ++ let wet = self.wet.0; ++ let wet = if target > wet { ++ (wet + dt / 15.0).min(target) ++ } else { ++ (wet - dt / 150.0).max(target) ++ }; ++ self.wet = (wet, now); ++ wet ++ } ++ + fn upscaling(&self) -> bool { + self.out != self.size + } +@@ -580,6 +610,19 @@ impl Hd { + mist, + 0.0, + ], ++ smoke: self.smoke.frame.smoke, ++ smoke_min: self.smoke.frame.min, ++ smoke_lights: self.smoke.frame.lights, ++ surface: [ ++ self.wetness(scene), ++ if materials::specular_enabled() { ++ 1.0 ++ } else { ++ 0.0 ++ }, ++ 0.0, ++ 0.0, ++ ], + taa_params: [ + if self.upscaling() { + UPSCALE_HISTORY +@@ -627,6 +670,24 @@ impl Hd { + queue.write_buffer(&self.frame, 0, bytemuck::bytes_of(&frame)); + } + ++ /// This frame's smoke segments (camera-relative, ages in seconds) and the mod's clock in ++ /// seconds; call before `write`. The mod's lights glow on it. ++ pub fn write_smoke( ++ &mut self, ++ queue: &wgpu::Queue, ++ scene: &HdScene, ++ smoke: &[HdSmoke], ++ lights: &[HdLight], ++ clock: f32, ++ ) { ++ if scene.sky && volumes::fog_enabled() && smoke_enabled() { ++ self.smoke.prepare(queue, scene, smoke, lights, clock); ++ } else { ++ self.smoke.frame = Default::default(); ++ self.smoke.count = 0; ++ } ++ } ++ + fn draw( + encoder: &mut wgpu::CommandEncoder, + label: &str, +@@ -735,6 +796,7 @@ mod tests { + ("sky", sky::SKY_PASS.to_owned(), &sky::SKY_BINDINGS), + ]; + all.extend(volumes::shader_sources()); ++ all.extend(smoke::shader_sources()); + all + } + +@@ -759,6 +821,40 @@ mod tests { + } + } + ++ /// Each assembled HD shader translates to HLSL as wgpu's DirectX 12 backend writes it, entry ++ /// point by entry point (gfx.rs's test of the loose files skips these: they need the prelude). ++ #[test] ++ fn hd_shaders_translate_to_hlsl_for_directx_12() { ++ for (name, body, _) in shaders() { ++ let module = parse(name, &body); ++ let info = naga::valid::Validator::new( ++ naga::valid::ValidationFlags::all(), ++ naga::valid::Capabilities::all(), ++ ) ++ .validate(&module) ++ .unwrap(); ++ for entry in &module.entry_points { ++ let (module, info) = naga::back::pipeline_constants::process_overrides( ++ &module, ++ &info, ++ Some((entry.stage, &entry.name)), ++ &Default::default(), ++ ) ++ .unwrap(); ++ let mut hlsl = String::new(); ++ naga::back::hlsl::Writer::new( ++ &mut hlsl, ++ &naga::back::hlsl::Options::default(), ++ &naga::back::hlsl::PipelineOptions { ++ entry_point: Some((entry.stage, entry.name.clone())), ++ }, ++ ) ++ .write(&module, &info, None) ++ .unwrap_or_else(|e| panic!("{name} {}: {e}", entry.name)); ++ } ++ } ++ } ++ + /// Each shader's file is part of some tested shader, so a new `.wgsl` cannot be forgotten. + #[test] + fn every_wgsl_file_is_tested() { +diff --git a/crates/horizon-client/src/gfx/hd/prelude.wgsl b/crates/horizon-client/src/gfx/hd/prelude.wgsl +index bd43a7c75..c62af3563 100644 +--- a/crates/horizon-client/src/gfx/hd/prelude.wgsl ++++ b/crates/horizon-client/src/gfx/hd/prelude.wgsl +@@ -24,6 +24,10 @@ struct HdFrame { + cloud_dir: vec4, + cloud_sun: vec4, + vfog: vec4, ++ surface: vec4, ++ smoke: vec4, ++ smoke_min: vec4, ++ smoke_lights: array, 32>, + } + struct FullOut { + @builtin(position) position: vec4, +diff --git a/crates/horizon-client/src/gfx/hd/smoke.rs b/crates/horizon-client/src/gfx/hd/smoke.rs +new file mode 100644 +index 000000000..dee0932e8 +--- /dev/null ++++ b/crates/horizon-client/src/gfx/hd/smoke.rs +@@ -0,0 +1,477 @@ ++//! AirDef fork: the HD renderer's volumetric smoke and exhaust trails (#54, plan item 8). ++//! ++//! A mod hands over segments of smoke (`render.set-smoke`: a tube or a puff with a birth time, a life, ++//! radii, a density, heat, soot, a rise and the radius of the clear tunnel it leaves in fog and cloud). ++//! Everything about a segment follows from its *age*, so nothing is simulated here and a replay that ++//! seeks, pauses or slows down just sends other segments or another clock. Each frame: ++//! ++//! 1. The CPU bakes every segment for its age (wind drift, rise, radius, thinning, cooling, the tunnel ++//! filling back in), sorts them nearest first, keeps `MAX_SEGMENTS` and bins them into 8-cell ++//! bricks of a camera-centred grid (`brick lists`: start and count, then the indices). ++//! 2. `fs_smoke_grid` (pass `hd smoke grid`) fills the grid, a 128 x 64 x 128 lattice of 3-block cells ++//! snapped to the world (so it does not swim) and laid out as a 1024 x 1024 atlas of 64 slices: ++//! r density, g soot x density, b heat x density, a clear amount. Each cell sums the capsules of ++//! its brick. ++//! 3. `fog.wgsl` ray-marches the grid inside the half-resolution fog pass (so TAA resolves its ++//! jitter and `fs_light` needs no change): sun with the cascades' shadows, self shadow along the ++//! sun, sky ambient and the nearest glowing lights of the mod. The clear amount thins the fog; ++//! `cloud.wgsl` thins the clouds with it. ++//! ++//! Contract: ++//! - Input segments (`HdSmoke`) are camera-relative, ages and lives in seconds. The grid reaches ++//! `GRID_XZ * CELL / 2` = 192 blocks round the camera horizontally, 48 below and 144 above. ++//! - The grid atlas is `Rgba16Float`, rewritten every frame the smoke is active (`active`). ++//! - Nothing here changes unless the mod sends segments; `HORIZON_HD_SMOKE=0` turns it off (the mod is ++//! told, and draws its own smoke). ++ ++use super::frame::{HdLight, HdScene}; ++use super::{STORAGE, UNIFORM, entries, fullscreen, module, stamped_pass, target}; ++ ++pub const GRID_XZ: u32 = 128; ++pub const GRID_Y: u32 = 64; ++pub const CELL: f32 = 3.0; ++/// Cells under the camera's height the grid reaches. ++const CELLS_BELOW: f32 = 16.0; ++const BRICK: u32 = 8; ++const BRICKS_XZ: u32 = GRID_XZ / BRICK; ++const BRICKS_Y: u32 = GRID_Y / BRICK; ++const BRICKS: usize = (BRICKS_XZ * BRICKS_XZ * BRICKS_Y) as usize; ++/// The atlas holds the slices in tiles of GRID_XZ, `TILES` to a row. ++const TILES: u32 = 8; ++pub const ATLAS: u32 = GRID_XZ * TILES; ++pub const ATLAS_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; ++pub const MAX_SEGMENTS: usize = 3072; ++/// Brick-list entries: a segment is in every brick its box touches. ++const MAX_INDICES: usize = 120_000; ++pub const MAX_LIGHTS: usize = 16; ++/// How fast the wind carries smoke (blocks per second, x and z). ++const WIND: [f32; 2] = [1.6, 1.2]; ++ ++/// Smoke is on unless `HORIZON_HD_SMOKE=0`. ++pub fn smoke_enabled() -> bool { ++ static ON: std::sync::OnceLock = std::sync::OnceLock::new(); ++ *ON.get_or_init(|| std::env::var("HORIZON_HD_SMOKE").map_or(true, |v| v != "0")) ++} ++ ++/// A segment of smoke as the game hands it over for a frame, relative to the camera. ++#[repr(C)] ++#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)] ++pub struct HdSmoke { ++ /// xyz: where it starts; w: its age in seconds. ++ pub a: [f32; 4], ++ /// xyz: where it ends; w: its life in seconds. ++ pub b: [f32; 4], ++ /// Radius at birth, radius at the end, density, heat. ++ pub shape: [f32; 4], ++ /// Rise (blocks per second), clear radius, soot, seed (0..1). ++ pub motion: [f32; 4], ++} ++ ++/// A segment baked for its age: what `fs_smoke_grid` reads (4 x vec4). ++#[repr(C)] ++#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)] ++struct Baked { ++ /// xyz start (drifted), w radius. ++ pa: [f32; 4], ++ /// xyz end, w density. ++ pb: [f32; 4], ++ /// soot, heat, clear radius, clear amount. ++ extra: [f32; 4], ++ /// seed, 0, 0, 0. ++ seed: [f32; 4], ++} ++ ++/// `fs_smoke_grid`'s group 0: frame, the segments, the brick lists. ++pub(super) const SMOKE_GRID_BINDINGS: [wgpu::BindingType; 3] = [UNIFORM, STORAGE, STORAGE]; ++ ++pub(super) const SMOKE_GRID: &str = include_str!("smoke_grid.wgsl"); ++/// The grid's trilinear lookup, shared by the fog and the clouds. Needs `frame` in scope. ++pub(super) const SMOKE_SAMPLE: &str = include_str!("smoke_sample.wgsl"); ++ ++/// What `write` copies into the frame block. ++#[derive(Clone, Copy, Default)] ++pub(super) struct SmokeFrame { ++ pub smoke: [f32; 4], ++ pub min: [f32; 4], ++ pub lights: [[f32; 4]; 32], ++} ++ ++pub struct Smoke { ++ pub atlas: wgpu::TextureView, ++ segments: wgpu::Buffer, ++ bricks: wgpu::Buffer, ++ pipeline: wgpu::RenderPipeline, ++ group: wgpu::BindGroup, ++ /// Segments in the grid this frame (0: nothing is drawn). ++ pub count: usize, ++ pub frame: SmokeFrame, ++ baked: Vec, ++ order: Vec<(f32, usize)>, ++ bricks_cpu: Vec, ++ lights: Vec<(f32, [f32; 4], [f32; 4])>, ++} ++ ++impl Smoke { ++ pub fn new(device: &wgpu::Device, frame: &wgpu::Buffer) -> Self { ++ let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { ++ label: Some("HD smoke grid"), ++ entries: &entries(&SMOKE_GRID_BINDINGS), ++ }); ++ let storage = |label, size: usize| { ++ device.create_buffer(&wgpu::BufferDescriptor { ++ label: Some(label), ++ size: size as u64, ++ usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, ++ mapped_at_creation: false, ++ }) ++ }; ++ let segments = storage( ++ "HD smoke segments", ++ MAX_SEGMENTS * std::mem::size_of::(), ++ ); ++ let bricks = storage("HD smoke bricks", (BRICKS * 2 + MAX_INDICES) * 4); ++ let module = module(device, "HD smoke grid", SMOKE_GRID); ++ let pipeline = fullscreen( ++ device, ++ "HD smoke grid", ++ &layout, ++ &module, ++ "fs_smoke_grid", ++ ATLAS_FORMAT, ++ None, ++ ); ++ let group = device.create_bind_group(&wgpu::BindGroupDescriptor { ++ label: Some("HD smoke grid"), ++ layout: &layout, ++ entries: &[ ++ wgpu::BindGroupEntry { ++ binding: 0, ++ resource: frame.as_entire_binding(), ++ }, ++ wgpu::BindGroupEntry { ++ binding: 1, ++ resource: segments.as_entire_binding(), ++ }, ++ wgpu::BindGroupEntry { ++ binding: 2, ++ resource: bricks.as_entire_binding(), ++ }, ++ ], ++ }); ++ Self { ++ atlas: target(device, "HD smoke grid", [ATLAS, ATLAS], ATLAS_FORMAT).view, ++ segments, ++ bricks, ++ pipeline, ++ group, ++ count: 0, ++ frame: SmokeFrame::default(), ++ baked: Vec::new(), ++ order: Vec::new(), ++ bricks_cpu: Vec::new(), ++ lights: Vec::new(), ++ } ++ } ++ ++ /// Bakes this frame's segments, bins them into the grid's bricks and uploads them; picks the ++ /// lights that glow on the smoke. `clock` is the mod's clock in seconds. ++ pub fn prepare( ++ &mut self, ++ queue: &wgpu::Queue, ++ scene: &HdScene, ++ smoke: &[HdSmoke], ++ lights: &[HdLight], ++ clock: f32, ++ ) { ++ self.count = 0; ++ self.frame = SmokeFrame::default(); ++ if std::env::var_os("HORIZON_HD_SMOKE_LOG").is_some() && !smoke.is_empty() { ++ log::info!("HD smoke: {} segments given", smoke.len()); ++ } ++ if smoke.is_empty() { ++ return; ++ } ++ // The grid's corner on the world's lattice, relative to the camera. ++ let cam = [ ++ scene.camera_world[0], ++ scene.camera_world[2], ++ scene.camera_world[1], ++ ]; ++ let min = [ ++ (cam[0] / CELL).floor() * CELL - (GRID_XZ / 2) as f32 * CELL - cam[0], ++ (cam[1] / CELL).floor() * CELL - CELLS_BELOW * CELL - cam[1], ++ (cam[2] / CELL).floor() * CELL - (GRID_XZ / 2) as f32 * CELL - cam[2], ++ ]; ++ let max = [ ++ min[0] + GRID_XZ as f32 * CELL, ++ min[1] + GRID_Y as f32 * CELL, ++ min[2] + GRID_XZ as f32 * CELL, ++ ]; ++ // Bake, keep the ones in reach, nearest first. ++ self.baked.clear(); ++ self.order.clear(); ++ for s in smoke { ++ let Some(b) = bake(s) else { continue }; ++ let reach = b.pa[3].max(b.extra[2]) * 1.3 + CELL; ++ let lo = [ ++ b.pa[0].min(b.pb[0]) - reach, ++ b.pa[1].min(b.pb[1]) - reach, ++ b.pa[2].min(b.pb[2]) - reach, ++ ]; ++ let hi = [ ++ b.pa[0].max(b.pb[0]) + reach, ++ b.pa[1].max(b.pb[1]) + reach, ++ b.pa[2].max(b.pb[2]) + reach, ++ ]; ++ if (0..3).any(|k| hi[k] < min[k] || lo[k] > max[k]) { ++ continue; ++ } ++ let mid = [ ++ (b.pa[0] + b.pb[0]) * 0.5, ++ (b.pa[1] + b.pb[1]) * 0.5, ++ (b.pa[2] + b.pb[2]) * 0.5, ++ ]; ++ // The nearest point of the tube counts: a long trail close by is kept whole. ++ let near = ++ mid[0] * mid[0] + mid[1] * mid[1] + mid[2] * mid[2] - reach * reach.min(100.0); ++ self.order.push((near, self.baked.len())); ++ self.baked.push(b); ++ } ++ if self.baked.is_empty() { ++ return; ++ } ++ self.order.sort_by(|x, y| x.0.total_cmp(&y.0)); ++ self.order.truncate(MAX_SEGMENTS); ++ // Bricks: count per brick, then fill; the nearest segments first, so a full list drops the ++ // farthest. ++ let brick_of = |lo: f32, hi: f32, axis: usize, bricks: u32| -> (i32, i32) { ++ let a = ((lo - min[axis]) / (CELL * BRICK as f32)).floor() as i32; ++ let b = ((hi - min[axis]) / (CELL * BRICK as f32)).floor() as i32; ++ (a.max(0), b.min(bricks as i32 - 1)) ++ }; ++ let mut counts = vec![0u32; BRICKS]; ++ let mut boxes = Vec::with_capacity(self.order.len()); ++ let mut total = 0usize; ++ let mut kept: Vec = Vec::with_capacity(self.order.len()); ++ for &(_, i) in &self.order { ++ let b = self.baked[i]; ++ let reach = b.pa[3].max(b.extra[2]) * 1.3 + CELL; ++ let (x0, x1) = brick_of( ++ b.pa[0].min(b.pb[0]) - reach, ++ b.pa[0].max(b.pb[0]) + reach, ++ 0, ++ BRICKS_XZ, ++ ); ++ let (y0, y1) = brick_of( ++ b.pa[1].min(b.pb[1]) - reach, ++ b.pa[1].max(b.pb[1]) + reach, ++ 1, ++ BRICKS_Y, ++ ); ++ let (z0, z1) = brick_of( ++ b.pa[2].min(b.pb[2]) - reach, ++ b.pa[2].max(b.pb[2]) + reach, ++ 2, ++ BRICKS_XZ, ++ ); ++ if x0 > x1 || y0 > y1 || z0 > z1 { ++ continue; ++ } ++ let n = ((x1 - x0 + 1) * (y1 - y0 + 1) * (z1 - z0 + 1)) as usize; ++ if total + n > MAX_INDICES { ++ break; ++ } ++ total += n; ++ let slot = kept.len() as u32; ++ for z in z0..=z1 { ++ for y in y0..=y1 { ++ for x in x0..=x1 { ++ counts[brick_index(x as u32, y as u32, z as u32)] += 1; ++ } ++ } ++ } ++ boxes.push((x0, x1, y0, y1, z0, z1, slot)); ++ kept.push(b); ++ } ++ if kept.is_empty() { ++ return; ++ } ++ let mut data = vec![0u32; BRICKS * 2 + total]; ++ let mut start = 0u32; ++ for (b, &c) in counts.iter().enumerate() { ++ data[b * 2] = start; ++ data[b * 2 + 1] = 0; ++ start += c; ++ } ++ for &(x0, x1, y0, y1, z0, z1, slot) in &boxes { ++ for z in z0..=z1 { ++ for y in y0..=y1 { ++ for x in x0..=x1 { ++ let b = brick_index(x as u32, y as u32, z as u32); ++ let at = data[b * 2] + data[b * 2 + 1]; ++ data[BRICKS * 2 + at as usize] = slot; ++ data[b * 2 + 1] += 1; ++ } ++ } ++ } ++ } ++ self.bricks_cpu = data; ++ queue.write_buffer(&self.segments, 0, bytemuck::cast_slice(&kept)); ++ queue.write_buffer(&self.bricks, 0, bytemuck::cast_slice(&self.bricks_cpu)); ++ self.count = kept.len(); ++ if std::env::var_os("HORIZON_HD_SMOKE_LOG").is_some() { ++ let n = &self.baked[0]; ++ log::info!( ++ "HD smoke: {} in, {} baked, {} in the grid; first pa {:?} pb {:?} extra {:?} min {:?}", ++ smoke.len(), ++ self.baked.len(), ++ self.count, ++ n.pa, ++ n.pb, ++ n.extra, ++ min ++ ); ++ } ++ // The lights that glow on the smoke: the brightest near ones. ++ self.lights.clear(); ++ for l in lights { ++ let d2 = l.pos[0] * l.pos[0] + l.pos[1] * l.pos[1] + l.pos[2] * l.pos[2]; ++ let d = d2.sqrt(); ++ if d > 300.0 || l.color[3] < 1.0 { ++ continue; ++ } ++ let rank = l.color[3] / (1.0 + d2 * 0.0004); ++ self.lights.push((rank, l.pos, l.color)); ++ } ++ self.lights.sort_by(|a, b| b.0.total_cmp(&a.0)); ++ self.lights.truncate(MAX_LIGHTS); ++ let mut out = SmokeFrame { ++ smoke: [1.0, CELL, clock.rem_euclid(86_400.0), 0.0], ++ min: [min[0], min[1], min[2], self.lights.len() as f32], ++ lights: [[0.0; 4]; 32], ++ }; ++ for (i, (_, pos, color)) in self.lights.iter().enumerate() { ++ out.lights[i * 2] = *pos; ++ out.lights[i * 2 + 1] = [ ++ color[0] * color[3], ++ color[1] * color[3], ++ color[2] * color[3], ++ 0.0, ++ ]; ++ } ++ self.frame = out; ++ } ++ ++ /// Fills the grid from the segments (when there are any). ++ pub fn draw( ++ &self, ++ encoder: &mut wgpu::CommandEncoder, ++ stamp: Option>, ++ ) { ++ if self.count == 0 { ++ return; ++ } ++ stamped_pass( ++ encoder, ++ "HD smoke grid", ++ &[&self.atlas], ++ &self.pipeline, ++ &self.group, ++ stamp, ++ ); ++ } ++} ++ ++fn brick_index(x: u32, y: u32, z: u32) -> usize { ++ (x + BRICKS_XZ * (z + BRICKS_XZ * y)) as usize ++} ++ ++/// A segment at its age: drifted with the wind, risen, grown, thinned, cooled; None once spent. ++fn bake(s: &HdSmoke) -> Option { ++ let t = s.a[3]; ++ let life = s.b[3].max(0.05); ++ if !(t >= 0.0) || t >= life { ++ return None; ++ } ++ let u = t / life; ++ let rise = s.motion[0] * 8.0 * (1.0 - (-t / 8.0).exp()); ++ let drift = [WIND[0] * t, rise, WIND[1] * t]; ++ let r0 = s.shape[0].max(0.05); ++ let radius = r0 + (s.shape[1] - r0) * u.powf(0.6); ++ let radius = radius.max(0.2); ++ // Spreading thins it (the core's mass over a wider tube), and it fades out towards its end. ++ let thin = (r0 / radius).powf(0.5); ++ let density = s.shape[2] * (1.0 - u).powi(2) * thin * (t / 0.25).min(1.0).max(0.2); ++ let heat = s.shape[3] * (-t / 1.5).exp(); ++ let clear_amount = (1.0 - u).powf(0.8); ++ let clear = s.motion[1] * (1.0 + 0.4 * u); ++ if density < 0.003 && !(clear > 0.0 && clear_amount > 0.02) { ++ return None; ++ } ++ Some(Baked { ++ pa: [ ++ s.a[0] + drift[0], ++ s.a[1] + drift[1], ++ s.a[2] + drift[2], ++ radius, ++ ], ++ pb: [ ++ s.b[0] + drift[0], ++ s.b[1] + drift[1], ++ s.b[2] + drift[2], ++ density, ++ ], ++ extra: [s.motion[2], heat, clear, clear_amount], ++ seed: [s.motion[3], 0.0, 0.0, 0.0], ++ }) ++} ++ ++#[cfg(test)] ++pub fn shader_sources() -> Vec<(&'static str, String, &'static [wgpu::BindingType])> { ++ vec![("smoke grid", SMOKE_GRID.to_owned(), &SMOKE_GRID_BINDINGS)] ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ fn puff(age: f32, life: f32) -> HdSmoke { ++ HdSmoke { ++ a: [1.0, 2.0, 3.0, age], ++ b: [1.0, 2.0, 3.0, life], ++ shape: [2.0, 10.0, 1.0, 0.5], ++ motion: [1.0, 0.0, 0.5, 0.0], ++ } ++ } ++ ++ #[test] ++ fn a_segment_grows_thins_and_ends() { ++ let young = bake(&puff(1.0, 40.0)).unwrap(); ++ let old = bake(&puff(30.0, 40.0)).unwrap(); ++ assert!(old.pa[3] > young.pa[3], "grows"); ++ assert!(old.pb[3] < young.pb[3], "thins"); ++ assert!( ++ old.pa[0] > young.pa[0] && old.pa[1] > young.pa[1], ++ "drifts and rises" ++ ); ++ assert!(bake(&puff(40.0, 40.0)).is_none(), "gone at its life"); ++ assert!(bake(&puff(-1.0, 40.0)).is_none(), "not born yet"); ++ } ++ ++ #[test] ++ fn the_same_age_bakes_the_same() { ++ // Showing a frame again (a pause, a seek back to it) gives the same smoke. ++ let a = bake(&puff(12.5, 40.0)).unwrap(); ++ let b = bake(&puff(12.5, 40.0)).unwrap(); ++ assert_eq!(bytemuck::bytes_of(&a), bytemuck::bytes_of(&b)); ++ } ++ ++ #[test] ++ fn a_tunnel_without_smoke_is_kept_until_it_fills() { ++ let mut s = puff(10.0, 40.0); ++ s.shape[2] = 0.0; ++ s.motion[1] = 3.0; ++ let b = bake(&s).unwrap(); ++ assert!(b.extra[3] > 0.5 && b.extra[2] >= 3.0); ++ } ++} +diff --git a/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl b/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl +new file mode 100644 +index 000000000..01b6e082a +--- /dev/null ++++ b/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl +@@ -0,0 +1,61 @@ ++// The smoke grid: one texel of a 1024 x 1024 atlas per cell of a 128 x 64 x 128 lattice (64 slices ++// of 128 x 128, 8 to a row). Each cell sums the capsules of its 8-cell brick. ++// r density, g soot x density, b heat x density, a clear amount (the largest of the tunnels). ++// Segments: 4 vec4 each (see smoke.rs `Baked`); bricks: (start, count) per brick, then the indices. ++ ++@group(0) @binding(0) var frame: HdFrame; ++@group(0) @binding(1) var segs: array>; ++@group(0) @binding(2) var bricks: array; ++ ++const BRICKS_XZ: u32 = 16u; ++const BRICK_LISTS: u32 = 4096u; ++ ++@fragment ++fn fs_smoke_grid(in: FullOut) -> @location(0) vec4 { ++ let px = vec2(in.position.xy); ++ let tile = px / 128u; ++ let c = vec3(px.x % 128u, tile.y * 8u + tile.x, px.y % 128u); ++ let p = frame.smoke_min.xyz + (vec3(c) + vec3(0.5)) * frame.smoke.y; ++ let b = (c.x >> 3u) + BRICKS_XZ * ((c.z >> 3u) + BRICKS_XZ * (c.y >> 3u)); ++ let start = bricks[2u * b]; ++ let count = bricks[2u * b + 1u]; ++ var dens = 0.0; ++ var soot = 0.0; ++ var heat = 0.0; ++ var clear = 0.0; ++ for (var k = 0u; k < count; k++) { ++ let i = bricks[BRICK_LISTS + start + k] * 4u; ++ let pa = segs[i]; ++ let pb = segs[i + 1u]; ++ let ex = segs[i + 2u]; ++ let ab = pb.xyz - pa.xyz; ++ let len2 = dot(ab, ab); ++ let along = dot(p - pa.xyz, ab) / max(len2, 1e-4); ++ let t = clamp(along, 0.0, 1.0); ++ let d = length(p - (pa.xyz + ab * t)); ++ if ex.z > 0.0 && d < ex.z { ++ let f = 1.0 - d / ex.z; ++ clear = max(clear, f * f * (3.0 - 2.0 * f) * ex.w); ++ } ++ let seed = segs[i + 3u].x; ++ let len = sqrt(len2); ++ // Bulges along the tube, so it is not a pipe. ++ let r = pa.w * (1.0 + 0.22 * sin(along * len * 0.6 + seed * 40.0)); ++ if d < r && pb.w > 0.0 { ++ let q = d / r; ++ var w = (1.0 - q * q); ++ w *= w; ++ // The ends of a tube fade over half a radius, so joined tubes do not double up. ++ let h = 0.5 * pa.w; ++ if len2 > 0.01 { ++ let s = along * len; ++ w *= smoothstep(-h, h, s) * smoothstep(-h, h, len - s); ++ } ++ let m = w * pb.w; ++ dens += m; ++ soot += m * ex.x; ++ heat += m * ex.y; ++ } ++ } ++ return vec4(dens, soot, heat, clear); ++} +diff --git a/crates/horizon-client/src/gfx/hd/smoke_sample.wgsl b/crates/horizon-client/src/gfx/hd/smoke_sample.wgsl +new file mode 100644 +index 000000000..024a1ac8e +--- /dev/null ++++ b/crates/horizon-client/src/gfx/hd/smoke_sample.wgsl +@@ -0,0 +1,29 @@ ++ ++// The smoke grid (smoke_grid.wgsl) read trilinearly at a camera-relative point: r density, g soot x ++// density, b heat x density, a clear amount. Zero outside it, fading over its last cells. ++fn smoke_slice(tex: texture_2d, s: sampler, x: f32, z: f32, y: f32) -> vec4 { ++ let tile = vec2(y % 8.0, floor(y / 8.0)); ++ return textureSampleLevel(tex, s, (tile * 128.0 + vec2(x + 0.5, z + 0.5)) / 1024.0, 0.0); ++} ++ ++fn smoke_at(tex: texture_2d, s: sampler, p: vec3) -> vec4 { ++ let g = (p - frame.smoke_min.xyz) / frame.smoke.y - vec3(0.5); ++ if g.x < 0.0 || g.z < 0.0 || g.y < 0.0 || g.x > 127.0 || g.z > 127.0 || g.y > 63.0 { ++ return vec4(0.0); ++ } ++ let y0 = floor(g.y); ++ let y1 = min(y0 + 1.0, 63.0); ++ let a = smoke_slice(tex, s, g.x, g.z, y0); ++ let b = smoke_slice(tex, s, g.x, g.z, y1); ++ let edge = min(min(g.x, 127.0 - g.x), min(g.z, 127.0 - g.z)); ++ return mix(a, b, g.y - y0) * smoothstep(0.0, 6.0, edge); ++} ++ ++// Only the density, from the nearer slice: a cheap look to skip empty space. ++fn smoke_probe(tex: texture_2d, s: sampler, p: vec3) -> f32 { ++ let g = (p - frame.smoke_min.xyz) / frame.smoke.y - vec3(0.5); ++ if g.x < 0.0 || g.z < 0.0 || g.y < 0.0 || g.x > 127.0 || g.z > 127.0 || g.y > 63.0 { ++ return 0.0; ++ } ++ return smoke_slice(tex, s, g.x, g.z, floor(g.y + 0.5)).x; ++} +diff --git a/crates/horizon-client/src/gfx/hd/volumes.rs b/crates/horizon-client/src/gfx/hd/volumes.rs +index a49c58f86..a338d756c 100644 +--- a/crates/horizon-client/src/gfx/hd/volumes.rs ++++ b/crates/horizon-client/src/gfx/hd/volumes.rs +@@ -67,11 +67,16 @@ const CLOUD_SHADOW: &str = include_str!("cloud_shadow.wgsl"); + /// texture. Targets: the in-scattered light (rgb) with the transmittance (a), and the distance the + /// ray was marched (for the depth-aware upsample in `fs_light`). + const FOG: &str = include_str!("fog.wgsl"); ++use super::smoke::SMOKE_SAMPLE; + + fn cloud_body(extra: &str) -> String { + format!("{CLOUD_COMMON}{extra}") + } + ++fn fog_body() -> String { ++ format!("{FOG}{SMOKE_SAMPLE}") ++} ++ + /// The cloud and fog targets, noise, pipelines and bind groups. + pub struct HdVolumes { + noise3: wgpu::TextureView, +@@ -107,14 +112,14 @@ const D3: wgpu::BindingType = wgpu::BindingType::Texture { + + /// `fs_cloud`'s group 0: frame, 3D noise, 2D noise, clamped and repeating samplers, the sky + /// texture, the previous cloud frame. +-pub(super) const CLOUD_BINDINGS: [wgpu::BindingType; 7] = +- [UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT, FLOAT]; ++pub(super) const CLOUD_BINDINGS: [wgpu::BindingType; 8] = ++ [UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT, FLOAT, FLOAT]; + /// `fs_cloud_shadow`'s group 0: as the clouds' without the history. + pub(super) const CLOUD_SHADOW_BINDINGS: [wgpu::BindingType; 6] = + [UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT]; + /// `fs_fog`'s group 0: frame, the world's depth, the shadow cascades and their sampler, 3D noise, + /// repeating and clamped samplers, the sky texture. +-pub(super) const FOG_BINDINGS: [wgpu::BindingType; 8] = [ ++pub(super) const FOG_BINDINGS: [wgpu::BindingType; 9] = [ + UNIFORM, + DEPTH, + SHADOW_ARRAY, +@@ -123,6 +128,7 @@ pub(super) const FOG_BINDINGS: [wgpu::BindingType; 8] = [ + SAMPLER, + SAMPLER, + FLOAT, ++ FLOAT, + ]; + + impl HdVolumes { +@@ -204,14 +210,14 @@ impl HdVolumes { + label: Some("HD fog"), + entries: &entries(&FOG_BINDINGS), + }); +- let cloud_src = format!("{}{CLOUD}", cloud_body("")); ++ let cloud_src = format!("{}{CLOUD}{SMOKE_SAMPLE}", cloud_body("")); + let shadow_src = format!( + "const SHADOW_R: f32 = {SHADOW_RADIUS:?};\n{}{CLOUD_SHADOW}", + cloud_body("") + ); + let cloud_module = module(device, "HD clouds", &cloud_src); + let shadow_module = module(device, "HD cloud shadows", &shadow_src); +- let fog_module = module(device, "HD fog", FOG); ++ let fog_module = module(device, "HD fog", &fog_body()); + let cloud_pipeline = fullscreen( + device, + "HD clouds", +@@ -365,6 +371,7 @@ impl HdVolumes { + device: &wgpu::Device, + frame: &wgpu::Buffer, + sky_lut: &wgpu::TextureView, ++ atlas: &wgpu::TextureView, + ) { + if self.shadow_group.is_none() { + let layout = self.shadow_layout.clone(); +@@ -411,6 +418,7 @@ impl HdVolumes { + wgpu::BindingResource::Sampler(&repeat), + wgpu::BindingResource::TextureView(sky_lut), + wgpu::BindingResource::TextureView(&hist[read]), ++ wgpu::BindingResource::TextureView(atlas), + ], + ) + }); +@@ -466,6 +474,7 @@ impl HdVolumes { + depth: &wgpu::TextureView, + shadow_array: &wgpu::TextureView, + sky_lut: &wgpu::TextureView, ++ atlas: &wgpu::TextureView, + stamp: Option>, + ) { + if self.fog_group.as_ref().is_none_or(|(v, _)| v != depth) { +@@ -489,6 +498,7 @@ impl HdVolumes { + wgpu::BindingResource::Sampler(&repeat), + wgpu::BindingResource::Sampler(&clamp), + wgpu::BindingResource::TextureView(sky_lut), ++ wgpu::BindingResource::TextureView(atlas), + ], + ); + self.fog_group = Some((depth.clone(), group)); +@@ -516,7 +526,7 @@ impl Hd { + ) { + if self.clouds_active { + self.volumes +- .prepare_clouds(device, &self.frame, &self.sky.lut); ++ .prepare_clouds(device, &self.frame, &self.sky.lut, &self.smoke.atlas); + self.volumes + .draw_cloud_shadow(encoder, stamp(&mut self.timing, "hd cloud shadows")); + self.volumes +@@ -525,6 +535,8 @@ impl Hd { + self.volumes.reset_history(); + } + if self.fog_active { ++ self.smoke ++ .draw(encoder, stamp(&mut self.timing, "hd smoke grid")); + self.volumes.draw_fog( + device, + encoder, +@@ -532,6 +544,7 @@ impl Hd { + depth, + &self.shadows.array, + &self.sky.lut, ++ &self.smoke.atlas, + stamp(&mut self.timing, "hd fog"), + ); + } +@@ -543,7 +556,7 @@ pub fn shader_sources() -> Vec<(&'static str, String, &'static [wgpu::BindingTyp + vec![ + ( + "clouds", +- format!("{}{CLOUD}", cloud_body("")), ++ format!("{}{CLOUD}{SMOKE_SAMPLE}", cloud_body("")), + &CLOUD_BINDINGS, + ), + ( +@@ -554,6 +567,6 @@ pub fn shader_sources() -> Vec<(&'static str, String, &'static [wgpu::BindingTyp + ), + &CLOUD_SHADOW_BINDINGS, + ), +- ("fog", FOG.to_owned(), &FOG_BINDINGS), ++ ("fog", fog_body(), &FOG_BINDINGS), + ] + } +diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl +index cb098a64a..de96226e9 100644 +--- a/crates/horizon-client/src/terrain.wgsl ++++ b/crates/horizon-client/src/terrain.wgsl +@@ -26,6 +26,10 @@ struct Globals { + hd_flags: vec4, + // Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs). + dynamic_lights: array, 16>, ++ // AirDef fork, HD wind. x: strength (0 off), y: seconds (wraps at 600), zw: camera x, z mod 256. ++ hd_wind: vec4, ++ // AirDef fork: the sprite layers' material classes, 4 bits each (gfx/hd/materials.rs). ++ hd_materials: array, 64>, + } + @group(0) @binding(0) var globals: Globals; + @group(0) @binding(1) var sprites: texture_2d_array; +@@ -122,9 +126,67 @@ fn terrain_uv(packed: vec2, step: f32) -> vec2 { + let origin = slot_origins[block_slots[vertex >> 8u]]; + return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz); + } ++// AirDef fork, HD materials: the mat_class of a sprite layer (gfx/hd/materials.rs, same numbers). ++const MAT_LEAVES: u32 = 1u; ++const MAT_PLANT_LOWER: u32 = 3u; ++const MAT_PLANT_UPPER: u32 = 4u; ++const MAT_METAL: u32 = 5u; ++const MAT_SMOOTH: u32 = 6u; ++const MAT_GLOSSY: u32 = 7u; ++const MAT_WOOD: u32 = 8u; ++fn material_class(layer: u32) -> u32 { ++ let l = layer & 0x7fffu; ++ if l >= 2048u { return 0u; } ++ let word = globals.hd_materials[l >> 5u][(l >> 3u) & 3u]; ++ return (word >> ((l & 7u) * 4u)) & 15u; ++} ++// Roughness and metalness of a mat_class. ++fn material_params(mat_class: u32) -> vec2 { ++ switch mat_class { ++ case 1u, 2u, 3u, 4u: { return vec2(0.92, 0.0); } ++ case 5u: { return vec2(0.3, 1.0); } ++ case 6u: { return vec2(0.42, 0.0); } ++ case 7u: { return vec2(0.22, 0.0); } ++ case 8u: { return vec2(0.7, 0.0); } ++ default: { return vec2(0.88, 0.0); } ++ } ++} ++// AirDef fork, HD wind: waves of whole cycles over 256 blocks and 600 seconds, so they hold still ++// in the world and do not jump when the camera's coordinates wrap. Returns the sway along the ++// wind (x, z) and a flutter (y), each about -1..1. ++fn wind_sway(w: vec2) -> vec3 { ++ let tau = 6.2831853; ++ let t = globals.hd_wind.y; ++ let a1 = tau * (5.0 * w.x + 3.0 * w.y) / 256.0 + tau * 120.0 / 600.0 * t; ++ let a2 = tau * (-2.0 * w.x + 7.0 * w.y) / 256.0 + tau * 200.0 / 600.0 * t; ++ let a3 = tau * (23.0 * w.x - 17.0 * w.y) / 256.0 + tau * 350.0 / 600.0 * t; ++ let push = 0.45 + 0.35 * sin(a1) + 0.2 * sin(a2); ++ let dir = vec2(0.8, 0.6); ++ let side = 0.25 * sin(a2 + 1.3); ++ let xz = dir * push + vec2(-dir.y, dir.x) * side; ++ return vec3(xz.x, sin(a3), xz.y); ++} ++// How far the vertex of a leaf or a plant moves (camera-relative blocks). Everything in it is a ++// continuous function of the world position, so neighbouring blocks stay joined; plants are ++// pinned at the bottom edge of their sprite. ++fn wind_offset(pos: vec3, uv: vec2, layer: u32) -> vec3 { ++ if globals.hd_wind.x <= 0.0 { return vec3(0.0); } ++ let mat_class = material_class(layer); ++ if mat_class == 0u || mat_class > MAT_PLANT_UPPER { return vec3(0.0); } ++ let s = wind_sway(pos.xz + globals.hd_wind.zw); ++ let k = globals.hd_wind.x; ++ if mat_class == MAT_LEAVES { ++ return vec3(s.x, s.y * 0.5, s.z) * (0.025 * k); ++ } ++ var h = clamp(1.0 - uv.y, 0.0, 1.0); ++ if mat_class == MAT_PLANT_LOWER { h = h * 0.5; } ++ if mat_class == MAT_PLANT_UPPER { h = 0.5 + h * 0.5; } ++ let a = 0.14 * k * h * h; ++ return vec3(s.x * a, -abs(s.x * a + s.z * a) * 0.25, s.z * a); ++} + fn terrain_vertex(position: vec3, uv: vec2, layer: u32, color: vec4, + light: vec2, origin: vec3) -> TerrainOut { +- let pos = position + origin; ++ let pos = position + origin + wind_offset(position + origin, uv, layer); + var out: TerrainOut; + out.clip = globals.view_proj * vec4(pos, 1.0); + out.uv = uv; +@@ -181,7 +243,8 @@ fn face_normal(pos: vec3) -> vec3 { + var out: GBufferOut; + out.albedo = vec4(color.rgb, f32(in.emission) / 15.0); + out.normal = vec4(face_normal(in.pos), 1.0); +- out.light = vec4(clamp(in.lm, vec2(0.0), vec2(1.0)), 0.0, 1.0); ++ // Roughness and metalness for the sun's specular and the wet look. ++ out.light = vec4(clamp(in.lm, vec2(0.0), vec2(1.0)), material_params(material_class(in.layer))); + return out; + } + // AirDef fork, HD water: the water quads once more, after the G-buffer, into small targets of +diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit +index 5fa5e8a27..fba8f162d 100644 +--- a/crates/horizon-mod-sdk/wit/horizon.wit ++++ b/crates/horizon-mod-sdk/wit/horizon.wit +@@ -699,6 +699,47 @@ interface render { + /// as `clock`). Only what the player sees changes: the world's own clock, + /// its saves and its rules do not. None shows the world's own time. + set-time-override: func(day-time: option); ++ /// A piece of smoke or exhaust trail (AirDef fork): a tube from `a` to `b` ++ /// (equal ends: a puff) that the HD renderer ray-marches as a volume, lit by ++ /// the sun, the sky and the mod's lights, and that thins the fog and clouds ++ /// it passes through. The game derives everything from the age ++ /// (`clock - born`): the radius grows from `radius-start` to `radius-end`, ++ /// the density fades to nothing at `life`, the smoke drifts with the wind ++ /// and rises by `rise`, so a segment is sent once per change of the set, not ++ /// per frame, and a replay that seeks sends the segments of that moment. ++ record smoke-segment { ++ /// Seeds the smoke's noise. ++ id: u32, ++ ax: f64, ++ ay: f64, ++ az: f64, ++ bx: f64, ++ by: f64, ++ bz: f64, ++ /// When it was made, in the `clock` of `set-smoke` (ticks). ++ born: f64, ++ /// How long it lasts, in the same ticks (1 to 72000). ++ life: f32, ++ /// Radius of the tube in blocks at birth and at the end of its life. ++ radius-start: f32, ++ radius-end: f32, ++ /// How thick it is at the core at birth: extinction per block (0 to 8). ++ density: f32, ++ /// How hot it glows at birth, 0 soot to 1 white hot; it cools in seconds. ++ heat: f32, ++ /// How dark it is, 0 white vapour to 1 black soot. ++ soot: f32, ++ /// Blocks per second it floats up (negative sinks). ++ rise: f32, ++ /// Radius in blocks of the clear tunnel it leaves in fog and cloud, filling ++ /// back in over `life` (0 none). ++ clear: f32, ++ } ++ /// Replaces the mod's smoke: up to 4096 segments, `clock` being now in ticks ++ /// (the game shows the age between the last two calls). Returns whether the ++ /// HD renderer draws them; when it does not (the effects preset, HD smoke ++ /// off), the mod draws its own smoke. ++ set-smoke: func(clock: f64, segments: list) -> bool; + } + + /// What the game calls. Only the hooks `init` asks for are ever called. +diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs +index bc1ade067..f438eadbd 100644 +--- a/crates/horizon-mods/src/api_level.rs ++++ b/crates/horizon-mods/src/api_level.rs +@@ -7,13 +7,13 @@ + //! version's level so the launcher can tell before it installs. + + /// The newest level this Horizon serves. +-pub const LEVEL: u32 = 13; ++pub const LEVEL: u32 = 14; + + /// Every function the game offers mods, as `interface.function`, with its + /// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new + /// function takes the next level and raises [`LEVEL`] (the `api_reference` + /// test fails until it is listed here). +-pub const FUNCTIONS: [(&str, u32); 67] = [ ++pub const FUNCTIONS: [(&str, u32); 68] = [ + ("log.write", 0), + ("game.get-player", 0), + ("game.get-clock", 0), +@@ -84,6 +84,8 @@ pub const FUNCTIONS: [(&str, u32); 67] = [ + ("render.world-changes", 12), + // AirDef fork: a replay shows its recorded time of day. + ("render.set-time-override", 13), ++ // AirDef fork: volumetric smoke and exhaust trails. ++ ("render.set-smoke", 14), + ]; + + /// What the game's program carries so the launcher can read its level +diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs +index fb6180f1c..495ef6cae 100644 +--- a/crates/horizon-mods/src/runtime.rs ++++ b/crates/horizon-mods/src/runtime.rs +@@ -83,8 +83,8 @@ pub use render_bindings::horizon::mods::render::{ + FillShape, FrameRate, ItemShape, Lighting as ModLighting, Minimap as ModMinimap, ModelInstance, + ModelInstanceId, ParticleSpawn, Point as RenderPoint, PointLight, Policy as WorldPolicy, + QuadShape, Ray as WorldRay, RayAnswer as WorldRayAnswer, Rect as RenderRect, +- Section as WorldSection, SectionFill, Shape, SpriteShape, TextShape, Verdict as WorldVerdict, +- ViewShape, Zoom as ModZoom, ++ Section as WorldSection, SectionFill, Shape, SmokeSegment, SpriteShape, TextShape, ++ Verdict as WorldVerdict, ViewShape, Zoom as ModZoom, + }; + use render_bindings::{RenderHost, horizon::mods::render}; + /// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items. +@@ -304,6 +304,8 @@ pub struct ModelTable { + key: usize, + generation: u32, + resolve: Option, ++ /// The renderer draws the mods' smoke (`render.set-smoke` says so to the mod). ++ smoke_active: bool, + } + + type ModelSlot = Arc>; +@@ -509,6 +511,9 @@ struct State { + lighting: Option, + /// `render.set-time-override` (AirDef fork). + time_override: Option, ++ /// `render.set-smoke` (AirDef fork): the segments, the clock of this call and of the one before. ++ smoke: Vec, ++ smoke_clock: (f64, f64), + input: InputState, + browser: BrowserState, + /// Capability `storage`'s folder; made on first `set`. +@@ -752,6 +757,8 @@ impl Mods { + lights: Vec::new(), + lighting: None, + time_override: None, ++ smoke: Vec::new(), ++ smoke_clock: (0.0, 0.0), + input: InputState::default(), + browser: BrowserState::default(), + data_dir, +@@ -1337,6 +1344,25 @@ impl Mods { + .iter() + .find_map(|e| e.run.as_ref()?.store.data().lighting) + } ++ /// Every running mod's smoke segments (AirDef fork). ++ pub fn smoke(&self) -> impl Iterator { ++ self.entries ++ .iter() ++ .flat_map(|e| e.run.iter().flat_map(|r| r.store.data().smoke.iter())) ++ } ++ /// The clocks (ticks) of the first mod that set smoke: of its last call and of the one before. ++ pub fn smoke_clock(&self) -> Option<(f64, f64)> { ++ self.entries ++ .iter() ++ .find_map(|e| e.run.as_ref().filter(|r| !r.store.data().smoke.is_empty())) ++ .map(|r| r.store.data().smoke_clock) ++ } ++ /// Tells the mods whether the renderer draws their smoke (the answer of `render.set-smoke`). ++ pub fn set_smoke_active(&mut self, on: bool) { ++ if let Ok(mut t) = self.model_table.lock() { ++ t.smoke_active = on; ++ } ++ } + /// The time of day the first running mod asked to show, if any. + pub fn time_override(&self) -> Option { + self.entries +@@ -2246,6 +2272,37 @@ impl render::Host for State { + }; + self.lighting = Some(lighting); + } ++ fn set_smoke(&mut self, clock: f64, segments: Vec) -> bool { ++ let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0; ++ let num = |v: f32, lo: f32, hi: f32, default: f32| { ++ if v.is_finite() { ++ v.clamp(lo, hi) ++ } else { ++ default ++ } ++ }; ++ let clock = if clock.is_finite() { clock } else { 0.0 }; ++ self.smoke_clock = (self.smoke_clock.1, clock); ++ self.smoke = segments ++ .into_iter() ++ .filter(|s| { ++ [s.ax, s.ay, s.az, s.bx, s.by, s.bz].into_iter().all(finite) && s.born.is_finite() ++ }) ++ .take(4096) ++ .map(|mut s| { ++ s.life = num(s.life, 1.0, 72_000.0, 1.0); ++ s.radius_start = num(s.radius_start, 0.0, 64.0, 1.0); ++ s.radius_end = num(s.radius_end, 0.0, 64.0, 1.0); ++ s.density = num(s.density, 0.0, 8.0, 0.0); ++ s.heat = num(s.heat, 0.0, 1.0, 0.0); ++ s.soot = num(s.soot, 0.0, 1.0, 0.5); ++ s.rise = num(s.rise, -64.0, 64.0, 0.0); ++ s.clear = num(s.clear, 0.0, 64.0, 0.0); ++ s ++ }) ++ .collect(); ++ self.model_table.lock().is_ok_and(|t| t.smoke_active) ++ } + fn set_time_override(&mut self, day_time: Option) { + self.time_override = day_time.map(|t| t.min(1 << 40)); + } +diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md +index b58f863c2..fefc4903f 100644 +--- a/docs/api/mods-reference.md ++++ b/docs/api/mods-reference.md +@@ -615,6 +615,29 @@ The HD lighting (AirDef fork): the world lit per pixel by the mods' lights, in h + | `exposure` | `f32` | 1 as is; more brightens. | + | `bloom` | `f32` | How much bright things glow (0 none, 1 strong). | + ++### record `smoke-segment` ++ ++A piece of smoke or exhaust trail (AirDef fork): a tube from `a` to `b` (equal ends: a puff) that the HD renderer ray-marches as a volume, lit by the sun, the sky and the mod's lights, and that thins the fog and clouds it passes through. The game derives everything from the age (`clock - born`): the radius grows from `radius-start` to `radius-end`, the density fades to nothing at `life`, the smoke drifts with the wind and rises by `rise`, so a segment is sent once per change of the set, not per frame, and a replay that seeks sends the segments of that moment. ++ ++| Field | Type | | ++| --- | --- | --- | ++| `id` | `u32` | Seeds the smoke's noise. | ++| `ax` | `f64` | | ++| `ay` | `f64` | | ++| `az` | `f64` | | ++| `bx` | `f64` | | ++| `by` | `f64` | | ++| `bz` | `f64` | | ++| `born` | `f64` | When it was made, in the `clock` of `set-smoke` (ticks). | ++| `life` | `f32` | How long it lasts, in the same ticks (1 to 72000). | ++| `radius-start` | `f32` | Radius of the tube in blocks at birth and at the end of its life. | ++| `radius-end` | `f32` | | ++| `density` | `f32` | How thick it is at the core at birth: extinction per block (0 to 8). | ++| `heat` | `f32` | How hot it glows at birth, 0 soot to 1 white hot; it cools in seconds. | ++| `soot` | `f32` | How dark it is, 0 white vapour to 1 black soot. | ++| `rise` | `f32` | Blocks per second it floats up (negative sinks). | ++| `clear` | `f32` | Radius in blocks of the clear tunnel it leaves in fog and cloud, filling back in over `life` (0 none). | ++ + | Function | Signature | What it does | + | --- | --- | --- | + | `set-hud` | `func(shapes: list)` | Replaces the mod's HUD shapes: up to 1024, drawn every frame in order over the HUD (after the hotbar, under chat and screens), hidden with the HUD (F1). An empty list removes them. | +@@ -647,6 +670,7 @@ The HD lighting (AirDef fork): the world lit per pixel by the mods' lights, in h + | `set-lights` | `func(lights: list)` | Replaces the mod's lights: up to 512, interpolated per id between the last two calls. | + | `set-lighting` | `func(lighting: lighting)` | Switches the HD lighting on or off for this mod's world, with its exposure and bloom; off, the world is drawn as the game draws it. | + | `set-time-override` | `func(day-time: option)` | Shows the world at this time of day (AirDef fork): the sky, the sun and moon, the sky light and the fog, as `day-time` ticks (24000 a day, as `clock`). Only what the player sees changes: the world's own clock, its saves and its rules do not. None shows the world's own time. | ++| `set-smoke` | `func(clock: f64, segments: list) -> bool` | Replaces the mod's smoke: up to 4096 segments, `clock` being now in ticks (the game shows the age between the last two calls). Returns whether the HD renderer draws them; when it does not (the effects preset, HD smoke off), the mod draws its own smoke. | + + ## `events` + +-- +2.54.0 (Apple Git-157) +