Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bab572293 | |||
| f43fa5ee66 | |||
| 336ae9f7cc | |||
| 5f8bff31b9 |
@@ -6,3 +6,4 @@ __pycache__/
|
||||
/out/
|
||||
.DS_Store
|
||||
run-home-bench/
|
||||
run-home-smoke/
|
||||
|
||||
@@ -201,7 +201,9 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
|
||||
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));
|
||||
|
||||
+36
-3
@@ -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<f32> {
|
||||
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<ParticleSpawn> = 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<sim::world::RayReply> = 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<render::Ray> = 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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+229
-45
@@ -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<f64> {
|
||||
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<Fix>,
|
||||
/// 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<u32, f64> = 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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+423
-13
@@ -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<impl Fn(i32, i32) -> 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::<Vec<_>>());
|
||||
// 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<S: Fn(i32, i32, i32) -> Option<Vec<[f64; 6]>>>(S);
|
||||
impl<S: Fn(i32, i32, i32) -> Option<Vec<[f64; 6]>>> world::WorldSource for Shaped<S> {
|
||||
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
|
||||
(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<Fix>| {
|
||||
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::<usize>());
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<f64> {
|
||||
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<Option<f64>> {
|
||||
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));
|
||||
|
||||
+291
-9
@@ -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<u32, (u32, [i32; 3])>,
|
||||
out: Vec<(u32, DVec3, DVec3)>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WorldCache {
|
||||
rays: RayDesk,
|
||||
secs: HashMap<Sec, Entry>,
|
||||
urgent: Vec<Sec>,
|
||||
ahead: Vec<Sec>,
|
||||
@@ -94,8 +149,15 @@ pub struct WorldCache {
|
||||
/// Sections asked, and when.
|
||||
asked: HashMap<Sec, u64>,
|
||||
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<bool>;
|
||||
/// 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<RayReply> = 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<Sec>, 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Trail>,
|
||||
/// 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<SmokeSegment> {
|
||||
let tick = sim.tick;
|
||||
let mut out: Vec<SmokeSegment> = 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
|
||||
}
|
||||
}
|
||||
+31
-3
@@ -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` |
|
||||
@@ -117,6 +118,7 @@ Bind groups кэшируются и пересоздаются только пр
|
||||
| 15 | текстура неба (`sky_lut`) |
|
||||
| 16, 17 | объёмный туман (свет+пропускание) и длина его луча |
|
||||
| 18, 19 | объёмные облака, карта теней облаков |
|
||||
| 20 | длина луча каждого текселя облаков (`cloud_dist_tex`, для апсэмплинга по глубине) |
|
||||
|
||||
`HdFrame` (Rust и WGSL держать в синхроне!): `inverse_view_proj`, `resolution` (w, h, 1/w, 1/h), `params` (число
|
||||
источников, экспозиция, bloom, счётчик кадров), `cascades[2]`, `sun` (направление, сила), `sun_color` (rgb, ночь),
|
||||
@@ -186,7 +188,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 +226,13 @@ 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` — выкл. | готово | в рабочем дереве форка |
|
||||
| #67: облака марш идут до ближайшей поверхности (`fs_cloud` читает глубину мира, вторая цель — длина луча, история отбрасывается при другой длине); `fs_light` кладёт облака на все пиксели (`cloud_at`: 4 текселя с весом по близости длины луча), под туман и над поверхностью, для воды — в `air`; луч внутри слоя вдоль горизонта больше не пропускается | готово; проверено только пустой полигон (нет гор/техники в слое, снимки `scratchpad/c*.png`), бюджет GPU на Mac не мерился | в рабочем дереве форка |
|
||||
| #68: шум облаков по Y — от низа слоя, а не от камеры; шкалы тумана 170→128, дыма 22→32, луж 0.45/1.3→0.5/1.25 делят период обёртки 65536/256; тест `noise_scales_divide_the_camera_wrap` | готово | в рабочем дереве форка |
|
||||
| #70: `Game::sky_darken` — всегда реальное время мира (ИИ, спавн, блоки, статистика); `shown_sky_darken` (время реплея) — только тени сущностей и свет глаза; небо/луна/цвета остаются по показанному времени | готово (собрано, игровой проверки день/ночь реплея нет) | в рабочем дереве форка |
|
||||
| Объёмный дым и следы ракет (#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 +250,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 +316,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<u32>`, считается в `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` (не мой файл).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 12:37:45 +0300
|
||||
Subject: [PATCH 01/27] Mod API (AirDef fork): world models, client particles,
|
||||
Subject: [PATCH 01/30] Mod API (AirDef fork): world models, client particles,
|
||||
sounds, ground heights
|
||||
|
||||
render.set-models draws block models with an interpolated pose per id;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 12:39:45 +0300
|
||||
Subject: [PATCH 02/27] cargo-horizon-mod: report particles and sounds
|
||||
Subject: [PATCH 02/30] cargo-horizon-mod: report particles and sounds
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 13:33:10 +0300
|
||||
Subject: [PATCH 03/27] Mod API (AirDef fork): full-screen post pass for mods
|
||||
Subject: [PATCH 03/30] Mod API (AirDef fork): full-screen post pass for mods
|
||||
|
||||
render.set-post compiles a mod's WGSL after a prelude (camera matrices,
|
||||
time, 256 vec4 of params, the world's colour and depth) and runs it after
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 22:09:30 +0300
|
||||
Subject: [PATCH 04/27] Mod models: draw far models as scaled impostors past
|
||||
Subject: [PATCH 04/30] Mod models: draw far models as scaled impostors past
|
||||
the far plane, cull at 4096
|
||||
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 22:31:00 +0300
|
||||
Subject: [PATCH 05/27] Mod cameras: render.set-views draws the world from
|
||||
Subject: [PATCH 05/30] Mod cameras: render.set-views draws the world from
|
||||
other points into pictures that HUD view shapes show (API level 9)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 23:08:21 +0300
|
||||
Subject: [PATCH 06/27] Mods: more fuel per tick and per event (AirDef plans
|
||||
Subject: [PATCH 06/30] Mods: more fuel per tick and per event (AirDef plans
|
||||
raids)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 23:25:29 +0300
|
||||
Subject: [PATCH 07/27] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
Subject: [PATCH 07/30] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 11:15:11 +0300
|
||||
Subject: [PATCH 08/27] Mod cameras: distant terrain tiles, fog measured from
|
||||
Subject: [PATCH 08/30] Mod cameras: distant terrain tiles, fog measured from
|
||||
the camera, no impostors while one is shown
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 11:25:45 +0300
|
||||
Subject: [PATCH 09/27] Mods: game.world-id, so mods keep state per world (API
|
||||
Subject: [PATCH 09/30] Mods: game.world-id, so mods keep state per world (API
|
||||
level 9)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 12:01:08 +0300
|
||||
Subject: [PATCH 10/27] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
Subject: [PATCH 10/30] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
packing-only tool skips wasmtime
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 13:18:43 +0300
|
||||
Subject: [PATCH 11/27] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
Subject: [PATCH 11/30] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API
|
||||
level 10)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 13:59:43 +0300
|
||||
Subject: [PATCH 12/27] HD lighting stage 2: sun and moon shadow cascades, the
|
||||
Subject: [PATCH 12/30] 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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:19:53 +0300
|
||||
Subject: [PATCH 13/27] HD: bind groups cached until a resize or a new source,
|
||||
Subject: [PATCH 13/30] HD: bind groups cached until a resize or a new source,
|
||||
shadow passes draw only the entities that cast shadows
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:25:44 +0300
|
||||
Subject: [PATCH 14/27] HD: lights culled per 16x16 tile (surface and halo
|
||||
Subject: [PATCH 14/30] HD: lights culled per 16x16 tile (surface and halo
|
||||
lists built on the CPU), a naga test for the lighting shaders
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
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/30] 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 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
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/30] 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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:02:02 +0300
|
||||
Subject: [PATCH 17/27] Mods: model handles (render.model-id, model-generation,
|
||||
Subject: [PATCH 17/30] 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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:19:57 +0300
|
||||
Subject: [PATCH 18/27] HD: temporal upscaling (HORIZON_HD_SCALE: world,
|
||||
Subject: [PATCH 18/30] 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
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/30] 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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
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/30] HD water: no vanilla texture on the surface (the biome
|
||||
colour only), a darker, deeper colour
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
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/30] 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
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/30] HD water: a darker blue, a little clearer, ripples a
|
||||
little higher
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 21:41:48 +0300
|
||||
Subject: [PATCH 23/27] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
|
||||
Subject: [PATCH 23/30] 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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 24971b3c67d8c87673eb2e0d204cedf8a2f46d1c Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 21:43:22 +0300
|
||||
Subject: [PATCH 24/27] Mods: world queries for simulations (render.query-rays,
|
||||
Subject: [PATCH 24/30] 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
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/30] 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 21391afb26f49505d6cdaf454a6355cd4e747503 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
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/30] 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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
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/30] 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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,603 @@
|
||||
From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:26:09 +0300
|
||||
Subject: [PATCH 29/30] HD clouds composited by depth so terrain and vehicles
|
||||
inside or behind the layer are hidden and those in front stay in front (#67);
|
||||
world-anchored noise everywhere (cloud detail by height above the layer, fog,
|
||||
smoke and puddle scales dividing the wrap) (#68); the replay's time is visual
|
||||
only, gameplay sky darkening uses the world's time (#70)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/game.rs | 37 +++--
|
||||
crates/horizon-client/src/game/render_tick.rs | 2 +-
|
||||
crates/horizon-client/src/gfx/hd/cloud.wgsl | 54 ++++++-
|
||||
.../src/gfx/hd/cloud_common.wgsl | 3 +-
|
||||
crates/horizon-client/src/gfx/hd/fog.wgsl | 6 +-
|
||||
crates/horizon-client/src/gfx/hd/light.wgsl | 42 +++++-
|
||||
crates/horizon-client/src/gfx/hd/lighting.rs | 4 +-
|
||||
crates/horizon-client/src/gfx/hd/volumes.rs | 135 +++++++++++++++---
|
||||
8 files changed, 239 insertions(+), 44 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
|
||||
index 2130d8d88..f21b3dcdd 100644
|
||||
--- a/crates/horizon-client/src/game.rs
|
||||
+++ b/crates/horizon-client/src/game.rs
|
||||
@@ -1838,6 +1838,8 @@ struct Game<'a> {
|
||||
events: events::Events,
|
||||
/// The overworld's last `sky_darken` and its inputs (time, rain, thunder).
|
||||
sky_darken_memo: std::cell::Cell<Option<((i64, u32, u32), i32)>>,
|
||||
+ /// The same for the time a mod shows (a replay's): render only.
|
||||
+ shown_darken_memo: std::cell::Cell<Option<((i64, u32, u32), i32)>>,
|
||||
persist: persist::EntityMemory,
|
||||
/// Connected to a server: the world comes from the network (game/remote.rs).
|
||||
remote: Option<remote::Remote>,
|
||||
@@ -8020,7 +8022,7 @@ impl Game<'_> {
|
||||
.flatten();
|
||||
if let Some(sprite) = shadow_sprite {
|
||||
let ambient = if self.dim == Dim::Nether { 0.1 } else { 0.0 };
|
||||
- let darken = self.sky_darken();
|
||||
+ let darken = self.shown_sky_darken();
|
||||
let a = alpha;
|
||||
let lerp = |p: Vec3, q: Vec3| {
|
||||
glam::DVec3::new(
|
||||
@@ -8378,33 +8380,43 @@ impl Game<'_> {
|
||||
.map_or(self.world.day_time, |t| t as i64)
|
||||
}
|
||||
|
||||
+ /// The sky's darkening for the game's rules (mob AI, spawning, block behaviour, stats): always
|
||||
+ /// the world's real time, never a mod's render-only override.
|
||||
fn sky_darken(&self) -> i32 {
|
||||
+ self.sky_darken_at(self.world.day_time, &self.sky_darken_memo)
|
||||
+ }
|
||||
+
|
||||
+ /// The sky's darkening as the player sees it (entity shadows, particle light): follows the mod's
|
||||
+ /// time override, so a replay's night looks dark without touching the rules.
|
||||
+ fn shown_sky_darken(&self) -> i32 {
|
||||
+ self.sky_darken_at(self.shown_day_time(), &self.shown_darken_memo)
|
||||
+ }
|
||||
+
|
||||
+ fn sky_darken_at(
|
||||
+ &self,
|
||||
+ time: i64,
|
||||
+ memo: &std::cell::Cell<Option<((i64, u32, u32), i32)>>,
|
||||
+ ) -> i32 {
|
||||
if self.dim == Dim::Nether {
|
||||
return 11;
|
||||
}
|
||||
if self.dim == Dim::End {
|
||||
- let sky = self
|
||||
- .res
|
||||
- .end_environment()
|
||||
- .sample(self.shown_day_time(), None, None);
|
||||
+ let sky = self.res.end_environment().sample(time, None, None);
|
||||
return (15.0 - 15.0 * sky.sky_light_level).round() as i32;
|
||||
}
|
||||
let (rain, thunder) = self.world.weather.levels(1.0);
|
||||
// Sampled once per (time, weather): every mob asks each tick.
|
||||
- let key = (self.shown_day_time(), rain.to_bits(), thunder.to_bits());
|
||||
- if let Some((k, darken)) = self.sky_darken_memo.get()
|
||||
+ let key = (time, rain.to_bits(), thunder.to_bits());
|
||||
+ if let Some((k, darken)) = memo.get()
|
||||
&& k == key
|
||||
{
|
||||
return darken;
|
||||
}
|
||||
- let mut sky = self
|
||||
- .res
|
||||
- .environment()
|
||||
- .sample(self.shown_day_time(), None, None);
|
||||
+ let mut sky = self.res.environment().sample(time, None, None);
|
||||
horizon_client::environment::apply_weather(&mut sky, rain, thunder);
|
||||
// Level.updateSkyBrightness: darkening from the sky light level.
|
||||
let darken = (15.0 - 15.0 * sky.sky_light_level).round() as i32;
|
||||
- self.sky_darken_memo.set(Some((key, darken)));
|
||||
+ memo.set(Some((key, darken)));
|
||||
darken
|
||||
}
|
||||
fn mob_hit(&self) -> Option<(usize, f64)> {
|
||||
@@ -13954,6 +13966,7 @@ impl<'a> Game<'a> {
|
||||
mods: Default::default(),
|
||||
native_shaders: Default::default(),
|
||||
sky_darken_memo: Default::default(),
|
||||
+ shown_darken_memo: Default::default(),
|
||||
persist: Default::default(),
|
||||
remote: None,
|
||||
lan: None,
|
||||
diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs
|
||||
index 6807d7718..9912970a5 100644
|
||||
--- a/crates/horizon-client/src/game/render_tick.rs
|
||||
+++ b/crates/horizon-client/src/game/render_tick.rs
|
||||
@@ -241,7 +241,7 @@ impl Game<'_> {
|
||||
eye[2].floor() as i32,
|
||||
];
|
||||
let packed = self.world.light(b[0], b[1], b[2]);
|
||||
- let sky = i32::from(packed >> 4) - self.sky_darken();
|
||||
+ let sky = i32::from(packed >> 4) - self.shown_sky_darken();
|
||||
let raw = sky.max(i32::from(packed & 15)).clamp(0, 15) as u8;
|
||||
lightmap_brightness(if self.dim == Dim::Nether { 0.1 } else { 0.0 }, raw)
|
||||
}
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/cloud.wgsl b/crates/horizon-client/src/gfx/hd/cloud.wgsl
|
||||
index fad234a46..39a239f4d 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/cloud.wgsl
|
||||
+++ b/crates/horizon-client/src/gfx/hd/cloud.wgsl
|
||||
@@ -1,6 +1,17 @@
|
||||
|
||||
@group(0) @binding(6) var history: texture_2d<f32>;
|
||||
@group(0) @binding(7) var smoke_tex: texture_2d<f32>;
|
||||
+@group(0) @binding(8) var scene_depth: texture_depth_2d;
|
||||
+@group(0) @binding(9) var history_dist: texture_2d<f32>;
|
||||
+
|
||||
+// What the sky pixels report as the length of their ray (nothing stops them).
|
||||
+const SKY_LEN: f32 = 20000.0;
|
||||
+
|
||||
+struct CloudOut {
|
||||
+ @location(0) cloud: vec4<f32>,
|
||||
+ // How far the ray was marched: the surface behind this texel, or SKY_LEN for sky.
|
||||
+ @location(1) dist: vec4<f32>,
|
||||
+}
|
||||
|
||||
fn ign(px: vec2<f32>) -> f32 {
|
||||
let p = px + vec2<f32>(5.588238 * frame.params.w);
|
||||
@@ -65,7 +76,26 @@ fn cloud_scatter(
|
||||
}
|
||||
|
||||
@fragment
|
||||
-fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
|
||||
+fn fs_cloud(in: FullOut) -> CloudOut {
|
||||
+ // The nearest of the four world pixels this texel stands for: the march ends at the surface
|
||||
+ // there (the sky counts as infinitely far), so what stands in front of a cloud stays in front
|
||||
+ // and what stands behind it is hidden by it.
|
||||
+ let size = vec2<i32>(frame.resolution.xy);
|
||||
+ let base = vec2<i32>(in.position.xy) * 2;
|
||||
+ var zmax = 0.0;
|
||||
+ var best = base;
|
||||
+ for (var k = 0; k < 4; k++) {
|
||||
+ let q = min(base + vec2<i32>(k & 1, k >> 1), size - 1);
|
||||
+ let z = textureLoad(scene_depth, q, 0);
|
||||
+ if z > zmax {
|
||||
+ zmax = z;
|
||||
+ best = q;
|
||||
+ }
|
||||
+ }
|
||||
+ let suv = (vec2<f32>(best) + 0.5) * frame.resolution.zw;
|
||||
+ let sndc = vec4<f32>(suv.x * 2.0 - 1.0, 1.0 - suv.y * 2.0, max(zmax, 0.000001), 1.0);
|
||||
+ let sh = frame.inverse_view_proj * sndc;
|
||||
+ let scene_len = select(SKY_LEN, length(sh.xyz / sh.w), zmax > 0.0);
|
||||
let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, 0.5, 1.0);
|
||||
let hh = frame.inverse_view_proj * ndc;
|
||||
let dir = normalize(hh.xyz / hh.w);
|
||||
@@ -73,11 +103,13 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
|
||||
var hit_t = 900.0;
|
||||
let y0 = frame.clouds.x;
|
||||
let y1 = y0 + frame.clouds.y;
|
||||
- if abs(dir.y) > 0.004 {
|
||||
- let ta = y0 / dir.y;
|
||||
- let tb = y1 / dir.y;
|
||||
+ // A level ray stays in the slab if the camera is in it (it never leaves), else misses it.
|
||||
+ let dy = select(dir.y, select(-1.0e-4, 1.0e-4, dir.y >= 0.0), abs(dir.y) < 1.0e-4);
|
||||
+ {
|
||||
+ let ta = y0 / dy;
|
||||
+ let tb = y1 / dy;
|
||||
let lo = max(min(ta, tb), 0.0);
|
||||
- let hi = min(max(ta, tb), 4200.0);
|
||||
+ let hi = min(max(ta, tb), min(4200.0, scene_len));
|
||||
if hi > lo {
|
||||
let dither = ign(in.position.xy);
|
||||
let n = clamp(i32(ceil((hi - lo) / 14.0)), 8, 26);
|
||||
@@ -131,6 +163,8 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
|
||||
cur = vec4<f32>(colour, trans);
|
||||
}
|
||||
}
|
||||
+ var out: CloudOut;
|
||||
+ out.dist = vec4<f32>(scene_len, 0.0, 0.0, 0.0);
|
||||
// Reproject the history by where the clouds are (rotation and the camera's step) and blend.
|
||||
if frame.cloud_cam.w > 0.5 {
|
||||
let hp = frame.prev_view_proj * vec4<f32>(dir * hit_t + frame.taa.xyz, 1.0);
|
||||
@@ -138,12 +172,18 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let huv = vec2<f32>(hp.x / hp.w * 0.5 + 0.5, 0.5 - hp.y / hp.w * 0.5);
|
||||
if all(huv > vec2<f32>(0.0)) && all(huv < vec2<f32>(1.0)) {
|
||||
let old = textureSampleLevel(history, linear, huv, 0.0);
|
||||
+ // A history that was marched to another surface (an edge, something that moved in
|
||||
+ // front) does not describe this ray.
|
||||
+ let hs = vec2<f32>(textureDimensions(history_dist));
|
||||
+ let old_len = textureLoad(history_dist, vec2<i32>(huv * hs), 0).x;
|
||||
+ let same_len = 1.0 - smoothstep(0.5, 1.0, abs(old_len - scene_len) / (0.1 * min(scene_len, old_len) + 2.0));
|
||||
// Not too much of a history that disagrees by a lot (a gust, a fast turn).
|
||||
let diff = length(old.rgb - cur.rgb) + abs(old.a - cur.a);
|
||||
- let keep = 0.9 * (1.0 - smoothstep(0.35, 1.2, diff));
|
||||
+ let keep = 0.9 * (1.0 - smoothstep(0.35, 1.2, diff)) * same_len;
|
||||
cur = mix(cur, old, keep);
|
||||
}
|
||||
}
|
||||
}
|
||||
- return cur;
|
||||
+ out.cloud = cur;
|
||||
+ return out;
|
||||
}
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
|
||||
index e2352f008..8ee014420 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
|
||||
+++ b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
|
||||
@@ -44,7 +44,8 @@ fn cloud_density(pos: vec3<f32>, detail: f32) -> f32 {
|
||||
d *= smoothstep(0.0, 0.2, h);
|
||||
if d < 0.002 { return 0.0; }
|
||||
if detail > 0.0 {
|
||||
- let p3 = vec3<f32>(w.x, pos.y, w.y);
|
||||
+ // Height above the layer's base, not the camera's height: the noise stays put in the world.
|
||||
+ let p3 = vec3<f32>(w.x, pos.y - frame.clouds.x, w.y);
|
||||
let warp = (textureSampleLevel(noise3, repeat_s, p3 / 1024.0, 0.0).b - 0.5) * 0.4;
|
||||
let w0 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 512.0 + vec3<f32>(warp), 0.0).r;
|
||||
let w1 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 64.0 + vec3<f32>(warp * 3.0), 0.0).g;
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/fog.wgsl b/crates/horizon-client/src/gfx/hd/fog.wgsl
|
||||
index 38b9098e3..d035bbf0d 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/fog.wgsl
|
||||
+++ b/crates/horizon-client/src/gfx/hd/fog.wgsl
|
||||
@@ -40,7 +40,7 @@ fn sun_visible(p: vec3<f32>) -> f32 {
|
||||
}
|
||||
|
||||
// The air's extinction per block at a camera-relative point: a haze that thins with height above
|
||||
-// the sea, a morning mist hugging the ground, rain, patches from a 3D noise that drifts; none
|
||||
+// the sea, a morning mist hugging the ground, rain, patches from a 3D noise that drifts (its scale divides 65536, the wrap of the camera's x, z, so it does not jump at x or z = 0); none
|
||||
// deep underground.
|
||||
fn fog_sigma(p: vec3<f32>) -> f32 {
|
||||
let wy = p.y + frame.vfog.y;
|
||||
@@ -50,7 +50,7 @@ fn fog_sigma(p: vec3<f32>) -> f32 {
|
||||
s += frame.sky_extra.z * 0.003;
|
||||
let w = vec3<f32>(p.x + frame.cloud_cam.x, wy, p.z + frame.cloud_cam.y)
|
||||
+ vec3<f32>(0.5, 0.0, 0.35) * frame.clouds.w;
|
||||
- let n = textureSampleLevel(noise3, repeat_s, w / 170.0, 0.0).b;
|
||||
+ let n = textureSampleLevel(noise3, repeat_s, w / 128.0, 0.0).b;
|
||||
let patchy = 0.35 + 1.3 * smoothstep(0.25, 0.75, n);
|
||||
// Fade out far below the sea's height: caves keep their own dark air.
|
||||
return s * patchy * smoothstep(28.0, 58.0, wy);
|
||||
@@ -70,7 +70,7 @@ fn smoke_detail(pos: vec3<f32>, core: f32) -> f32 {
|
||||
let w = vec3<f32>(pos.x + frame.cloud_cam.x, pos.y + frame.vfog.y, pos.z + frame.cloud_cam.y)
|
||||
- vec3<f32>(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 n2 = textureSampleLevel(noise3, repeat_s, w / 32.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);
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/light.wgsl b/crates/horizon-client/src/gfx/hd/light.wgsl
|
||||
index 2534a3db8..870ae0d7d 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/light.wgsl
|
||||
+++ b/crates/horizon-client/src/gfx/hd/light.wgsl
|
||||
@@ -30,6 +30,9 @@ struct Light {
|
||||
@group(0) @binding(17) var fog_dist_tex: texture_2d<f32>;
|
||||
@group(0) @binding(18) var cloud_tex: texture_2d<f32>;
|
||||
@group(0) @binding(19) var cloud_shadow_tex: texture_2d<f32>;
|
||||
+// How far each cloud texel's ray was marched (the surface behind it, or SKY_RAY for sky).
|
||||
+@group(0) @binding(20) var cloud_dist_tex: texture_2d<f32>;
|
||||
+const SKY_RAY: f32 = 20000.0;
|
||||
const CLOUD_SHADOW_R: f32 = 1200.0;
|
||||
// The pixel being lit, for the shadow filter's noise.
|
||||
var<private> frag_px: vec2<f32>;
|
||||
@@ -194,6 +197,34 @@ fn fog_at(px: vec2<i32>, dist: f32) -> vec4<f32> {
|
||||
return sum / total;
|
||||
}
|
||||
|
||||
+// The volumetric clouds between the camera and a surface `dist` away (SKY_RAY for sky): the four
|
||||
+// nearest half-resolution texels weighted by closeness and by how near the length they were
|
||||
+// marched to is, so a cloud in front of a mountain does not bleed onto the sky beside it.
|
||||
+fn cloud_at(px: vec2<i32>, dist: f32) -> vec4<f32> {
|
||||
+ let size = vec2<i32>(textureDimensions(cloud_tex));
|
||||
+ let f = vec2<f32>(px) * 0.5 - vec2<f32>(0.25);
|
||||
+ let base = vec2<i32>(floor(f));
|
||||
+ let t = f - floor(f);
|
||||
+ var sum = vec4<f32>(0.0);
|
||||
+ var total = 0.0;
|
||||
+ for (var j = 0; j < 2; j++) {
|
||||
+ for (var i = 0; i < 2; i++) {
|
||||
+ let q = clamp(base + vec2<i32>(i, j), vec2<i32>(0), size - 1);
|
||||
+ let s = textureLoad(cloud_tex, q, 0);
|
||||
+ let sd = textureLoad(cloud_dist_tex, q, 0).x;
|
||||
+ let bw = select(1.0 - t.x, t.x, i == 1) * select(1.0 - t.y, t.y, j == 1);
|
||||
+ let dz = (sd - dist) / (0.06 * min(sd, dist) + 0.4);
|
||||
+ let w = bw / (1.0 + dz * dz * dz * dz);
|
||||
+ sum += s * w;
|
||||
+ total += w;
|
||||
+ }
|
||||
+ }
|
||||
+ if total < 0.0001 {
|
||||
+ return textureLoad(cloud_tex, clamp(px / 2, vec2<i32>(0), size - 1), 0);
|
||||
+ }
|
||||
+ return sum / total;
|
||||
+}
|
||||
+
|
||||
struct LightOut {
|
||||
// The lit colour; alpha 0.5 marks water (the floor under it, lit without any air), 0.6 an
|
||||
// object in front of the water (the scene as drawn).
|
||||
@@ -246,7 +277,7 @@ fn shade_surface(px: vec2<i32>, p: vec3<f32>, water: bool) -> vec3<f32> {
|
||||
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<f32>(17.0, 5.0));
|
||||
+ let nz = 0.6 * wet_noise(wp * 0.5) + 0.4 * wet_noise(wp * 1.25 + vec2<f32>(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));
|
||||
@@ -377,7 +408,7 @@ fn fs_light(in: FullOut) -> LightOut {
|
||||
c = sky + kept;
|
||||
// The volumetric clouds over the sky and the sun.
|
||||
if frame.clouds.z > 0.5 {
|
||||
- let cl = textureSampleLevel(cloud_tex, linear, in.uv, 0.0);
|
||||
+ let cl = cloud_at(px, SKY_RAY);
|
||||
c = c * cl.a + cl.rgb;
|
||||
}
|
||||
}
|
||||
@@ -421,6 +452,13 @@ fn fs_light(in: FullOut) -> LightOut {
|
||||
air_t = fg.a;
|
||||
air_s = fg.rgb;
|
||||
}
|
||||
+ // The clouds between the camera and the surface (inside the layer, or in front of a mountain
|
||||
+ // or a flying thing): laid over the surface and under the fog, as the sky's are.
|
||||
+ if d > 0.0 && atmosphere && frame.clouds.z > 0.5 {
|
||||
+ let cl = cloud_at(px, length(ps));
|
||||
+ air_s += air_t * cl.rgb;
|
||||
+ air_t *= cl.a;
|
||||
+ }
|
||||
if !volumetric && sun_on > 0.0 && density > 0.0 {
|
||||
let steps = 14;
|
||||
let jitter = ign(in.position.xy);
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/lighting.rs b/crates/horizon-client/src/gfx/hd/lighting.rs
|
||||
index da56d00ba..d26f1e513 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/lighting.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd/lighting.rs
|
||||
@@ -34,7 +34,7 @@ pub const MAX_LIGHTS: usize = 512;
|
||||
|
||||
pub(super) const LIGHT: &str = include_str!("light.wgsl");
|
||||
/// The lighting's group 0, binding by binding (see the contract above).
|
||||
-pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 20] = [
|
||||
+pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 21] = [
|
||||
UNIFORM,
|
||||
UNFILTERED,
|
||||
DEPTH,
|
||||
@@ -55,6 +55,7 @@ pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 20] = [
|
||||
UNFILTERED,
|
||||
FLOAT,
|
||||
FLOAT,
|
||||
+ UNFILTERED,
|
||||
];
|
||||
|
||||
/// The outside views and sampler the lighting group reads.
|
||||
@@ -219,6 +220,7 @@ impl Hd {
|
||||
wgpu::BindingResource::TextureView(&self.volumes.fog_dist),
|
||||
wgpu::BindingResource::TextureView(self.volumes.cloud_view()),
|
||||
wgpu::BindingResource::TextureView(&self.volumes.cloud_shadow),
|
||||
+ wgpu::BindingResource::TextureView(self.volumes.cloud_dist_view()),
|
||||
];
|
||||
let entries: Vec<_> = resources
|
||||
.into_iter()
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/volumes.rs b/crates/horizon-client/src/gfx/hd/volumes.rs
|
||||
index a338d756c..d3b55e2f2 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/volumes.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd/volumes.rs
|
||||
@@ -26,8 +26,8 @@
|
||||
|
||||
use super::noise as hd_noise;
|
||||
use super::{
|
||||
- DEPTH, FLOAT, Hd, SAMPLER, SHADOW_ARRAY, SHADOW_SAMPLER, UNIFORM, entries, fullscreen, module,
|
||||
- stamp, stamped_pass, target,
|
||||
+ DEPTH, FLOAT, Hd, SAMPLER, SHADOW_ARRAY, SHADOW_SAMPLER, UNFILTERED, UNIFORM, entries,
|
||||
+ fullscreen, module, stamp, stamped_pass, target,
|
||||
};
|
||||
|
||||
pub const CLOUD_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
@@ -86,6 +86,9 @@ pub struct HdVolumes {
|
||||
shadow_sampler: wgpu::Sampler,
|
||||
/// The clouds' half-resolution target and its history (read one, write the other).
|
||||
pub clouds: [wgpu::TextureView; 2],
|
||||
+ /// How far each cloud texel's ray was marched (ping-pong with `clouds`).
|
||||
+ cloud_dist: [wgpu::TextureView; 2],
|
||||
+ cloud_depth: Option<wgpu::TextureView>,
|
||||
flip: usize,
|
||||
history_ready: bool,
|
||||
pub cloud_shadow: wgpu::TextureView,
|
||||
@@ -111,9 +114,10 @@ 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; 8] =
|
||||
- [UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT, FLOAT, FLOAT];
|
||||
+/// texture, the previous cloud frame, the smoke atlas, the world's depth, the previous march lengths.
|
||||
+pub(super) const CLOUD_BINDINGS: [wgpu::BindingType; 10] = [
|
||||
+ UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT, FLOAT, FLOAT, DEPTH, UNFILTERED,
|
||||
+];
|
||||
/// `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];
|
||||
@@ -218,15 +222,40 @@ impl HdVolumes {
|
||||
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_body());
|
||||
- let cloud_pipeline = fullscreen(
|
||||
- device,
|
||||
- "HD clouds",
|
||||
- &cloud_layout,
|
||||
- &cloud_module,
|
||||
- "fs_cloud",
|
||||
- CLOUD_FORMAT,
|
||||
- None,
|
||||
- );
|
||||
+ let cloud_pipeline = {
|
||||
+ let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
+ label: Some("HD clouds"),
|
||||
+ bind_group_layouts: &[Some(&cloud_layout)],
|
||||
+ ..Default::default()
|
||||
+ });
|
||||
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
+ label: Some("HD clouds"),
|
||||
+ layout: Some(&layout),
|
||||
+ vertex: wgpu::VertexState {
|
||||
+ module: &cloud_module,
|
||||
+ entry_point: Some("vs_full"),
|
||||
+ compilation_options: Default::default(),
|
||||
+ buffers: &[],
|
||||
+ },
|
||||
+ fragment: Some(wgpu::FragmentState {
|
||||
+ module: &cloud_module,
|
||||
+ entry_point: Some("fs_cloud"),
|
||||
+ compilation_options: Default::default(),
|
||||
+ targets: &[CLOUD_FORMAT, FOG_DIST_FORMAT].map(|format| {
|
||||
+ Some(wgpu::ColorTargetState {
|
||||
+ format,
|
||||
+ blend: None,
|
||||
+ write_mask: wgpu::ColorWrites::ALL,
|
||||
+ })
|
||||
+ }),
|
||||
+ }),
|
||||
+ primitive: Default::default(),
|
||||
+ depth_stencil: None,
|
||||
+ multisample: Default::default(),
|
||||
+ multiview_mask: None,
|
||||
+ cache: None,
|
||||
+ })
|
||||
+ };
|
||||
let shadow_pipeline = fullscreen(
|
||||
device,
|
||||
"HD cloud shadows",
|
||||
@@ -286,6 +315,11 @@ impl HdVolumes {
|
||||
one("HD clouds", [1, 1], CLOUD_FORMAT),
|
||||
one("HD clouds", [1, 1], CLOUD_FORMAT),
|
||||
],
|
||||
+ cloud_dist: [
|
||||
+ one("HD cloud distance", [1, 1], FOG_DIST_FORMAT),
|
||||
+ one("HD cloud distance", [1, 1], FOG_DIST_FORMAT),
|
||||
+ ],
|
||||
+ cloud_depth: None,
|
||||
flip: 0,
|
||||
history_ready: false,
|
||||
cloud_shadow: one(
|
||||
@@ -316,6 +350,10 @@ impl HdVolumes {
|
||||
one("HD clouds", CLOUD_FORMAT),
|
||||
one("HD clouds", CLOUD_FORMAT),
|
||||
];
|
||||
+ self.cloud_dist = [
|
||||
+ one("HD cloud distance", FOG_DIST_FORMAT),
|
||||
+ one("HD cloud distance", FOG_DIST_FORMAT),
|
||||
+ ];
|
||||
self.fog = one("HD fog", FOG_FORMAT);
|
||||
self.fog_dist = one("HD fog distance", FOG_DIST_FORMAT);
|
||||
self.history_ready = false;
|
||||
@@ -342,6 +380,11 @@ impl HdVolumes {
|
||||
&self.clouds[self.flip]
|
||||
}
|
||||
|
||||
+ /// The march lengths that go with `cloud_view`.
|
||||
+ pub fn cloud_dist_view(&self) -> &wgpu::TextureView {
|
||||
+ &self.cloud_dist[self.flip]
|
||||
+ }
|
||||
+
|
||||
fn group(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
@@ -372,7 +415,12 @@ impl HdVolumes {
|
||||
frame: &wgpu::Buffer,
|
||||
sky_lut: &wgpu::TextureView,
|
||||
atlas: &wgpu::TextureView,
|
||||
+ depth: &wgpu::TextureView,
|
||||
) {
|
||||
+ if self.cloud_depth.as_ref() != Some(depth) {
|
||||
+ self.cloud_groups = None;
|
||||
+ self.cloud_depth = Some(depth.clone());
|
||||
+ }
|
||||
if self.shadow_group.is_none() {
|
||||
let layout = self.shadow_layout.clone();
|
||||
let (n3, n2, clamp, repeat) = (
|
||||
@@ -405,6 +453,7 @@ impl HdVolumes {
|
||||
self.repeat.clone(),
|
||||
);
|
||||
let hist = self.clouds.clone();
|
||||
+ let hist_dist = self.cloud_dist.clone();
|
||||
let groups = [0, 1].map(|read| {
|
||||
self.group(
|
||||
device,
|
||||
@@ -419,6 +468,8 @@ impl HdVolumes {
|
||||
wgpu::BindingResource::TextureView(sky_lut),
|
||||
wgpu::BindingResource::TextureView(&hist[read]),
|
||||
wgpu::BindingResource::TextureView(atlas),
|
||||
+ wgpu::BindingResource::TextureView(depth),
|
||||
+ wgpu::BindingResource::TextureView(&hist_dist[read]),
|
||||
],
|
||||
)
|
||||
});
|
||||
@@ -455,7 +506,7 @@ impl HdVolumes {
|
||||
stamped_pass(
|
||||
encoder,
|
||||
"HD clouds",
|
||||
- &[&self.clouds[write]],
|
||||
+ &[&self.clouds[write], &self.cloud_dist[write]],
|
||||
&self.cloud_pipeline,
|
||||
&groups[read],
|
||||
stamp,
|
||||
@@ -525,8 +576,13 @@ impl Hd {
|
||||
depth: &wgpu::TextureView,
|
||||
) {
|
||||
if self.clouds_active {
|
||||
- self.volumes
|
||||
- .prepare_clouds(device, &self.frame, &self.sky.lut, &self.smoke.atlas);
|
||||
+ self.volumes.prepare_clouds(
|
||||
+ device,
|
||||
+ &self.frame,
|
||||
+ &self.sky.lut,
|
||||
+ &self.smoke.atlas,
|
||||
+ depth,
|
||||
+ );
|
||||
self.volumes
|
||||
.draw_cloud_shadow(encoder, stamp(&mut self.timing, "hd cloud shadows"));
|
||||
self.volumes
|
||||
@@ -570,3 +626,48 @@ pub fn shader_sources() -> Vec<(&'static str, String, &'static [wgpu::BindingTyp
|
||||
("fog", fog_body(), &FOG_BINDINGS),
|
||||
]
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+
|
||||
+ /// Every world-anchored noise lookup divides the world coordinate (the camera's x, z wrapped to
|
||||
+ /// 65536) by a scale that divides 65536, or the pattern jumps where the wrap happens (x or z = 0).
|
||||
+ #[test]
|
||||
+ fn noise_scales_divide_the_camera_wrap() {
|
||||
+ let mut found = 0;
|
||||
+ for (name, src) in [("cloud_common", CLOUD_COMMON), ("fog", FOG)] {
|
||||
+ for key in ["w / ", "p3 / "] {
|
||||
+ for part in src.split(key).skip(1) {
|
||||
+ let num: String = part
|
||||
+ .chars()
|
||||
+ .take_while(|c| c.is_ascii_digit() || *c == '.')
|
||||
+ .collect();
|
||||
+ let Ok(scale) = num.parse::<f64>() else {
|
||||
+ continue;
|
||||
+ };
|
||||
+ found += 1;
|
||||
+ assert!(
|
||||
+ scale >= 1.0 && scale.fract() == 0.0 && 65536 % (scale as u64) == 0,
|
||||
+ "{name}: the scale {scale} does not divide 65536"
|
||||
+ );
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ assert!(found >= 8, "found only {found} noise lookups");
|
||||
+ }
|
||||
+
|
||||
+ /// The cloud noise's height is measured from the layer's base, not from the camera: the same
|
||||
+ /// world point gives the same noise coordinate at any camera height.
|
||||
+ #[test]
|
||||
+ fn cloud_noise_height_is_anchored_to_the_layer() {
|
||||
+ assert!(CLOUD_COMMON.contains("pos.y - frame.clouds.x"));
|
||||
+ // frame.clouds.x = CLOUD_BASE - camera height: y_world - CLOUD_BASE however high the camera.
|
||||
+ for cam in [64.0f32, 210.0, 1000.0] {
|
||||
+ let world_y = 230.0f32;
|
||||
+ let rel = world_y - cam;
|
||||
+ let base_rel = CLOUD_BASE - cam;
|
||||
+ assert!((rel - base_rel - (world_y - CLOUD_BASE)).abs() < 1e-3);
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:44:45 +0300
|
||||
Subject: [PATCH 30/30] HD smoke: young trails drawn at least as wide as a grid
|
||||
cell (density scaled to keep the mass), gentler bulges, so a trail is a
|
||||
smooth ribbon and not a chain of puffs
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx/hd/smoke.rs | 12 ++++++++++++
|
||||
crates/horizon-client/src/gfx/hd/smoke_grid.wgsl | 2 +-
|
||||
2 files changed, 13 insertions(+), 1 deletion(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/smoke.rs b/crates/horizon-client/src/gfx/hd/smoke.rs
|
||||
index dee0932e8..c751df4f4 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/smoke.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd/smoke.rs
|
||||
@@ -46,6 +46,8 @@ 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];
|
||||
+/// The thinnest a trail is drawn, in blocks of radius (a cell is `CELL`).
|
||||
+const MIN_TUBE: f32 = 3.6;
|
||||
|
||||
/// Smoke is on unless `HORIZON_HD_SMOKE=0`.
|
||||
pub fn smoke_enabled() -> bool {
|
||||
@@ -402,6 +404,16 @@ fn bake(s: &HdSmoke) -> Option<Baked> {
|
||||
// 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);
|
||||
+ // A tube thinner than the grid's cells is sampled unevenly (cells near its axis get it, their
|
||||
+ // neighbours miss it): a chain of blobs. Trails are kept at least a cell and a bit wide, with
|
||||
+ // the same mass per length, so a young trail is a continuous ribbon.
|
||||
+ let d = [s.b[0] - s.a[0], s.b[1] - s.a[1], s.b[2] - s.a[2]];
|
||||
+ let tube = d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 0.25;
|
||||
+ let (radius, density) = if tube && radius < MIN_TUBE {
|
||||
+ (MIN_TUBE, density * radius / MIN_TUBE)
|
||||
+ } else {
|
||||
+ (radius, density)
|
||||
+ };
|
||||
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);
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl b/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
|
||||
index 01b6e082a..0adcd55f6 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
|
||||
+++ b/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
|
||||
@@ -40,7 +40,7 @@ fn fs_smoke_grid(in: FullOut) -> @location(0) vec4<f32> {
|
||||
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));
|
||||
+ let r = pa.w * (1.0 + 0.07 * sin(along * len * 0.35 + seed * 40.0));
|
||||
if d < r && pb.w > 0.0 {
|
||||
let q = d / r;
|
||||
var w = (1.0 - q * q);
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
Reference in New Issue
Block a user