Compare commits

...

3 Commits

74 changed files with 2649 additions and 224 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 B

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 B

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 B

After

Width:  |  Height:  |  Size: 241 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 B

After

Width:  |  Height:  |  Size: 278 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 B

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 B

After

Width:  |  Height:  |  Size: 249 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 B

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 B

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 B

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 B

After

Width:  |  Height:  |  Size: 285 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 B

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 399 B

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 407 B

After

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 B

After

Width:  |  Height:  |  Size: 338 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 B

After

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 B

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 B

After

Width:  |  Height:  |  Size: 139 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 B

After

Width:  |  Height:  |  Size: 223 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 B

After

Width:  |  Height:  |  Size: 210 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 B

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 B

After

Width:  |  Height:  |  Size: 184 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 409 B

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 B

After

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 B

After

Width:  |  Height:  |  Size: 149 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 B

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 B

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 B

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 B

After

Width:  |  Height:  |  Size: 464 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 B

After

Width:  |  Height:  |  Size: 257 B

+190 -80
View File
@@ -243,21 +243,42 @@ fn placements<F: Fn(f64, f64) -> Rig>(mk: F) -> Vec<Rig> {
vec![mk(1.0, 0.0), mk(-1.0, 0.0), mk(1.0, 28.0), mk(-1.0, 28.0)]
}
/// The shots of event `i`.
fn plan_event(rec: &Recording, i: usize) -> Vec<Shot> {
/// Shortest shot worth cutting to, in replay ticks.
const MIN_SHOT: f64 = 8.0;
/// The replay tick a shot ends at.
fn shot_end(s: &Shot) -> f64 {
s.table.last().copied().unwrap_or(0.0)
}
/// The shots of event `i`, with replay time never running back: no shot starts before `floor` (the
/// end of the one before it, or of the previous event's last), a stage the real intervals leave no
/// room for is merged into its neighbour or left out, and what happens in the story comes in the
/// order it happened (a launch before its kill; an interceptor not before its first point).
fn plan_event(rec: &Recording, i: usize, floor: f64) -> Vec<Shot> {
let e = rec.events[i];
let te = e.at as f64;
let o = rec.origin;
let end = rec.len as f64;
let pos = e.pos;
let mut shots: Vec<Shot> = Vec::new();
let mut add = |shot: Shot| shots.push(shot);
let cur = std::cell::Cell::new(floor);
let mut add = |shot: Shot| {
cur.set(shot_end(&shot));
shots.push(shot);
};
// The interval a stage may use: not before the cursor, and long enough to be a shot.
let span = |a: f64, b: f64| -> Option<(f64, f64)> {
let a = a.max(0.0).max(cur.get());
(b - a >= MIN_SHOT).then_some((a, b))
};
// The slow moment: half speed into it, a tenth through it, back to normal after.
let slow = |lead: f64, after: f64| -> Vec<(f64, f64)> {
vec![(te - lead, 0.6), (te - 4.0, 0.15), (te + 6.0, 0.15), (te + 22.0, 0.6), (te + after, 1.0)]
};
let kill_close = |from: DVec3, aim_id: Option<u32>, head: &str, sub: String| -> Shot {
let a = (te - 10.0).max(0.0);
// Never before the story so far; held to at least a few ticks.
let a = (te - 10.0).max(0.0).max(cur.get());
let b = (te + 48.0).min(end).max(a + 4.0);
let variants = placements(|side, lift| {
let s = across(pos, from) * side;
@@ -272,8 +293,7 @@ fn plan_event(rec: &Recording, i: usize) -> Vec<Shot> {
Shot::new(a, b, &slow(10.0, 48.0), variants, head, &sub, i)
};
let aftermath = |from: DVec3, head: &str| -> Option<Shot> {
let a = (te + 48.0).min(end);
let b = (te + 150.0).min(end);
let (a, b) = span((te + 48.0).min(end), (te + 150.0).min(end))?;
if b - a < 20.0 {
return None;
}
@@ -295,60 +315,78 @@ fn plan_event(rec: &Recording, i: usize) -> Vec<Shot> {
let mt = missile.and_then(|m| rec.track(m).map(|t| (m, t.first as f64)));
match mt {
Some((m, tl)) => {
// The hero: the launcher, three-quarters, a little before the launch.
let a = (tl - 60.0).max(0.0);
let b = tl + 6.0;
let launcher = sp + DVec3::Y * 4.0;
let variants = placements(|side, lift| {
let dir = (pos - sp).with_y(0.0).normalize_or(DVec3::X);
let s = across(pos, sp) * side;
let base = sp - dir * 38.0 + s * 34.0;
Rig {
eye: Eye::Dolly(base + DVec3::Y * (7.0 + lift), base + s * 6.0 - dir * 4.0 + DVec3::Y * (9.0 + lift)),
aim: Aim::Point(launcher),
fov: (46.0, 40.0),
}
});
add(Shot::new(a, b.max(a + 20.0), &[(a, 1.0)], variants, &name, &sub, i));
// The launch, from the side, the camera low and the interceptor climbing away.
let a2 = b.max(a + 20.0);
let b2 = (a2 + 44.0).min(te - 20.0).max(a2 + 10.0);
let variants = placements(|side, lift| {
let s = across(pos, sp) * side;
Rig { eye: Eye::Static(sp + s * 46.0 + DVec3::Y * (3.0 + lift * 0.3)), aim: Aim::Track(m, sp), fov: (42.0, 24.0) }
});
add(Shot::new(a2, b2, &[(a2, 1.0)], variants, "Пуск", "", i));
// The flight, from behind the interceptor with the target ahead; long flights run faster.
let a3 = b2;
let b3 = te - 32.0;
if b3 - a3 > 14.0 {
let sp3 = (((b3 - a3) / 6.0) / 20.0).clamp(1.0, 4.0);
let variants = placements(|side, lift| Rig {
eye: Eye::Behind { track: m, back: 15.0, up: 3.5 + lift * 0.2, side: 5.0 * side },
aim: Aim::Track(drone, pos),
fov: (56.0, 56.0),
// The facts, in order: the launcher waits, the interceptor leaves it at `tl`, it
// flies, it meets the target at `te`. Each stage gets the time the real flight
// has for it; what has none is merged into the one before, or left out.
let tl = tl.min(te);
// The hero: the launcher, three-quarters, until the launch.
if let Some((a, b)) = span(tl - 60.0, tl) {
let launcher = sp + DVec3::Y * 4.0;
let variants = placements(|side, lift| {
let dir = (pos - sp).with_y(0.0).normalize_or(DVec3::X);
let s = across(pos, sp) * side;
let base = sp - dir * 38.0 + s * 34.0;
Rig {
eye: Eye::Dolly(base + DVec3::Y * (7.0 + lift), base + s * 6.0 - dir * 4.0 + DVec3::Y * (9.0 + lift)),
aim: Aim::Point(launcher),
fov: (46.0, 40.0),
}
});
add(Shot::new(a3, b3, &[(a3, sp3)], variants, "Сближение", "", i));
add(Shot::new(a, b, &[(a, 1.0)], variants, &name, &sub, i));
}
// The flight proper runs from the launch to ten ticks before the kill (where the
// close-up begins); under 6 ticks of it there is no room for more than the close-up.
let from = tl.max(cur.get());
let ke = te - 10.0;
let room = ke - from;
if room >= 6.0 {
let launch_len = (room * 0.4).clamp(6.0, 44.0);
let mut b2 = from + launch_len;
let rest = ke - b2;
// The launch, from the side, the camera low and the interceptor climbing away.
let launch_at = |b2: f64| {
let variants = placements(|side, lift| {
let s = across(pos, sp) * side;
Rig { eye: Eye::Static(sp + s * 46.0 + DVec3::Y * (3.0 + lift * 0.3)), aim: Aim::Track(m, sp), fov: (42.0, 24.0) }
});
Shot::new(from, b2, &[(from, 1.0)], variants, "Пуск", "", i)
};
if rest < 14.0 {
// Too short to fly on camera: the launch shot runs to the close-up.
b2 = ke;
add(launch_at(b2));
} else {
add(launch_at(b2));
// The flight, from behind the interceptor with the target ahead; long flights run faster.
let b3 = if rest >= 36.0 { te - 32.0 } else { ke };
let sp3 = (((b3 - b2) / 6.0) / 20.0).clamp(1.0, 4.0);
let variants = placements(|side, lift| Rig {
eye: Eye::Behind { track: m, back: 15.0, up: 3.5 + lift * 0.2, side: 5.0 * side },
aim: Aim::Track(drone, pos),
fov: (56.0, 56.0),
});
add(Shot::new(b2, b3, &[(b2, sp3)], variants, "Сближение", "", i));
// Ahead of the target, looking back at the interceptor closing.
if rest >= 36.0 {
let variants = placements(|side, lift| Rig {
eye: Eye::Ahead { track: drone, ahead: 48.0, up: 6.0 + lift * 0.3, side: 14.0 * side },
aim: Aim::Mid(m, drone, pos),
fov: (46.0, 38.0),
});
add(Shot::new(b3, ke, &[(b3, 0.6)], variants, "", "", i));
}
}
}
// Ahead of the target, looking back at the interceptor closing.
let a4 = te - 32.0;
let b4 = te - 10.0;
let variants = placements(|side, lift| Rig {
eye: Eye::Ahead { track: drone, ahead: 48.0, up: 6.0 + lift * 0.3, side: 14.0 * side },
aim: Aim::Mid(m, drone, pos),
fov: (46.0, 38.0),
});
add(Shot::new(a4.max(0.0), b4.max(a4 + 6.0), &[(a4, 0.6)], variants, "", "", i));
}
None => {
// A gun kill: the gun's view of the target.
let a = (te - 110.0).max(0.0);
let b = te - 10.0;
let variants = placements(|side, lift| {
let s = across(pos, sp) * side;
Rig { eye: Eye::Static(sp + s * 14.0 + DVec3::Y * (5.0 + lift * 0.3)), aim: Aim::Track(drone, pos), fov: (34.0, 18.0) }
});
add(Shot::new(a, b.max(a + 12.0), &[(a, 1.0)], variants, &name, &sub, i));
// A gun kill: the gun's view of the target (if the recording has room before it).
if let Some((a, b)) = span(te - 110.0, te - 10.0) {
let variants = placements(|side, lift| {
let s = across(pos, sp) * side;
Rig { eye: Eye::Static(sp + s * 14.0 + DVec3::Y * (5.0 + lift * 0.3)), aim: Aim::Track(drone, pos), fov: (34.0, 18.0) }
});
add(Shot::new(a, b, &[(a, 1.0)], variants, &name, &sub, i));
}
}
}
let high = (pos.y - sp.y).max(0.0);
@@ -360,20 +398,21 @@ fn plan_event(rec: &Recording, i: usize) -> Vec<Shot> {
EventKind::Hit { drone, .. } | EventKind::Crash { drone, .. } => {
let from = rec.track(drone).and_then(|t| t.at(o, te - 60.0)).unwrap_or(o);
let head = title(rec, i);
let a = (te - 150.0).max(0.0);
let b = te - 55.0;
let variants = placements(|side, lift| Rig {
eye: Eye::Behind { track: drone, back: 16.0, up: 4.0 + lift * 0.2, side: 6.0 * side },
aim: Aim::Point(pos),
fov: (55.0, 55.0),
});
add(Shot::new(a, b.max(a + 16.0), &[(a, 1.4)], variants, &head, "", i));
let a2 = te - 55.0;
let variants = placements(|side, lift| {
let s = across(pos, from) * side;
Rig { eye: Eye::Static(pos + s * 80.0 + DVec3::Y * (3.0 + lift * 0.3)), aim: Aim::Track(drone, pos), fov: (40.0, 28.0) }
});
add(Shot::new(a2.max(0.0), (te - 10.0).max(a2 + 8.0), &[(a2, 0.8)], variants, "", "", i));
if let Some((a, b)) = span(te - 150.0, te - 55.0) {
let variants = placements(|side, lift| Rig {
eye: Eye::Behind { track: drone, back: 16.0, up: 4.0 + lift * 0.2, side: 6.0 * side },
aim: Aim::Point(pos),
fov: (55.0, 55.0),
});
add(Shot::new(a, b, &[(a, 1.4)], variants, &head, "", i));
}
if let Some((a2, b2)) = span(te - 55.0, te - 10.0) {
let variants = placements(|side, lift| {
let s = across(pos, from) * side;
Rig { eye: Eye::Static(pos + s * 80.0 + DVec3::Y * (3.0 + lift * 0.3)), aim: Aim::Track(drone, pos), fov: (40.0, 28.0) }
});
add(Shot::new(a2, b2, &[(a2, 0.8)], variants, "", "", i));
}
let sub = match e.kind {
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => {
format!("сила взрыва: {}", ["хлопок", "средний", "сильный", "тяжёлый", "мощнейший"][(power as usize).min(4)])
@@ -387,12 +426,13 @@ fn plan_event(rec: &Recording, i: usize) -> Vec<Shot> {
}
EventKind::SamLost { .. } => {
let from = pos + DVec3::new(300.0, 0.0, 0.0);
let a = (te - 70.0).max(0.0);
let variants = placements(|side, lift| {
let s = across(pos, from) * side;
Rig { eye: Eye::Dolly(pos + s * 70.0 + DVec3::Y * (12.0 + lift), pos + s * 52.0 + DVec3::Y * (9.0 + lift)), aim: Aim::Point(pos + DVec3::Y * 3.0), fov: (50.0, 44.0) }
});
add(Shot::new(a, (te - 10.0).max(a + 10.0), &[(a, 1.0)], variants, &title(rec, i), "", i));
if let Some((a, b)) = span(te - 70.0, te - 10.0) {
let variants = placements(|side, lift| {
let s = across(pos, from) * side;
Rig { eye: Eye::Dolly(pos + s * 70.0 + DVec3::Y * (12.0 + lift), pos + s * 52.0 + DVec3::Y * (9.0 + lift)), aim: Aim::Point(pos + DVec3::Y * 3.0), fov: (50.0, 44.0) }
});
add(Shot::new(a, b, &[(a, 1.0)], variants, &title(rec, i), "", i));
}
add(kill_close(from, None, "Комплекс уничтожен", String::new()));
if let Some(s) = aftermath(from, "") {
add(s);
@@ -400,14 +440,14 @@ fn plan_event(rec: &Recording, i: usize) -> Vec<Shot> {
}
EventKind::Split { .. } => {
let ground = DVec3::new(pos.x, o.y, pos.z);
let a = (te - 40.0).max(0.0);
let b = (te + 170.0).min(end);
let a = (te - 40.0).max(0.0).max(cur.get());
let b = (te + 170.0).min(end).max(a + 20.0);
let variants = placements(|side, lift| Rig {
eye: Eye::Dolly(ground + DVec3::new(170.0 * side, 8.0 + lift, 40.0), ground + DVec3::new(150.0 * side, 14.0 + lift, 20.0)),
aim: Aim::Point(pos.lerp(ground, 0.4)),
fov: (60.0, 52.0),
});
add(Shot::new(a, b.max(a + 20.0), &[(a, 1.0), (te - 4.0, 0.3), (te + 40.0, 1.0)], variants, &title(rec, i), "", i));
add(Shot::new(a, b, &[(a, 1.0), (te - 4.0, 0.3), (te + 40.0, 1.0)], variants, &title(rec, i), "", i));
}
}
shots
@@ -421,8 +461,14 @@ impl Film {
return None;
}
let mut shots: Vec<Shot> = Vec::new();
// Replay time only moves on through the film: an event's shots start where the last ended.
let mut floor = 0.0;
for i in events {
shots.extend(plan_event(rec, i));
let planned = plan_event(rec, i, floor);
if let Some(last) = planned.last() {
floor = shot_end(last);
}
shots.extend(planned);
}
let mut start = 0.0;
for s in &mut shots {
@@ -573,3 +619,67 @@ impl Film {
format!("{} моментов, {} планов, {:.0} с", events.len(), self.shots.len(), self.len)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sim::record::{Event, What};
use crate::sim::{DroneKind, MissileKind, SamKind};
/// A recording with one interception: the interceptor leaves at `launch`, the kill is at `kill`.
fn intercept(len: u32, launch: u32, kill: u32, missile: bool) -> Recording {
let mut rec = Recording::blank(len);
let pos = DVec3::new(0.0, 100.0, 300.0);
rec.sams.push((5, SamKind::S400, DVec3::new(0.0, 64.0, 0.0)));
rec.straight(1, What::Drone(DroneKind::Geran3), 0, kill + 20, DVec3::new(0.0, 100.0, 900.0), pos);
if missile {
rec.straight(2, What::Missile(MissileKind::M48n6, 5), launch, kill, DVec3::new(0.0, 66.0, 0.0), pos);
}
rec.events.push(Event {
at: kill,
pos,
kind: EventKind::Kill { drone: 1, kind: DroneKind::Geran3, sam: 5, sam_kind: SamKind::S400, sam_index: 0, missile: missile.then_some(2) },
});
rec
}
/// (head, start, end) of each shot in replay ticks.
fn spans(rec: &Recording, floor: f64) -> Vec<(String, f64, f64)> {
plan_event(rec, 0, floor).iter().map(|s| (s.head.clone(), s.table[0], shot_end(s))).collect()
}
// #77: the shots follow the real intervals: replay time never runs back, the launch is not after
// the kill, nothing about the interceptor is shown before its first point.
#[test]
fn a_short_interception_is_cut_in_the_order_it_happened() {
// The issue's case (flight 100 to 105), then 1, 5, 15 and 100+ ticks, the kill near the
// start and near the end of the recording, and a gun kill.
let cases = [(100, 105, true, 400), (100, 101, true, 400), (100, 105, true, 120), (100, 115, true, 400), (100, 260, true, 400), (3, 8, true, 400), (0, 4, true, 30), (0, 300, true, 300), (250, 255, false, 400), (5, 12, false, 400)];
for (launch, kill, missile, len) in cases {
let rec = intercept(len, launch, kill, missile);
let sp = spans(&rec, 0.0);
assert!(!sp.is_empty(), "{launch}->{kill}");
let mut prev_end = 0.0;
for (head, a, b) in &sp {
assert!(b >= a, "{launch}->{kill} {head}: {a}..{b}");
assert!(*a >= prev_end - 1e-9, "{launch}->{kill}: {head} starts at {a}, before the shot before it ended at {prev_end}: {sp:?}");
prev_end = *b;
if head == "Пуск" || head == "Сближение" {
assert!(*a >= launch as f64 - 1e-9 && *b <= kill as f64, "{launch}->{kill}: {head} {a}..{b} outside the flight");
}
}
// The launch comes before the close-up of the kill, which holds the kill itself.
let close = sp.iter().position(|s| s.0 == "Поражение цели").expect("close-up");
assert!(sp[..close].iter().all(|s| s.2 <= kill as f64 + 1e-9), "{launch}->{kill}: {sp:?}");
assert!(sp[close].2 >= kill as f64 || sp[close].2 >= len as f64 - 1.0, "{launch}->{kill}: {sp:?}");
}
}
#[test]
fn a_second_event_does_not_run_the_film_back() {
let rec = intercept(600, 100, 300, true);
// The previous event's last shot ended late: this one starts there.
let sp = spans(&rec, 180.0);
assert!(sp.iter().all(|s| s.1 >= 180.0 - 1e-9), "{sp:?}");
}
}
+69 -16
View File
@@ -81,6 +81,8 @@ struct AirDef {
export: Option<Export>,
/// The effects preset a film replaced: (shader, hd).
film_prev: Option<(bool, bool)>,
/// Counts the replay/film sessions started; an export belongs to the one it began with.
session: u64,
/// The world drawn beyond the render distance (replays and cameras see far off).
far: bool,
/// The game's log of block changes (`render.world-log-start`): a replay shows the world as
@@ -130,11 +132,22 @@ enum Stage {
Finishing,
}
struct Export {
/// The session (`AirDef::session`) whose film this export records.
session: u64,
opts: ExportOpts,
stage: Stage,
ticks: u32,
}
/// Whether a replay or film may be started now: not while an export records (stop it first).
fn switch_allowed(exporting: bool) -> Result<(), String> {
if exporting {
Err("Экспорт идёт: дождитесь конца или /airdef replay stop".into())
} else {
Ok(())
}
}
/// A sound heard later: when, what, where, and how loud and high it was.
struct PendingSound {
due: u64,
@@ -426,13 +439,18 @@ impl AirDef {
/// The searchlights' spot lights (HD): a cone from the head along its real direction, as far
/// as the beam runs free; at most 16, the renderer draws the nearest.
fn spots(&self) -> Vec<SpotLight> {
if self.shown().replay {
return Vec::new();
}
self.sim
.sams
.iter()
.filter_map(|s| s.spot_beam())
let shown = self.shown();
// A replay lights with the recording's searchlights, where and as they pointed.
let beams: Vec<sim::lights::Beam> = if shown.replay {
match self.replay.as_ref().and_then(|r| self.sim.replay(r.rec).map(|rec| rec.beams(f64::from(shown.now)))) {
Some(b) => b,
None => Vec::new(),
}
} else {
self.sim.sams.iter().filter_map(|s| s.spot_beam()).collect()
};
beams
.into_iter()
.take(16)
.map(|b| SpotLight {
id: b.id,
@@ -536,13 +554,11 @@ impl AirDef {
if !shown.replay {
return (self.sim.tick as f64, self.smoke.segments(&self.sim));
}
let blasts = std::mem::replace(&mut self.sim.blasts, shown.blasts);
let fires = std::mem::take(&mut self.sim.fires.list);
let tick = std::mem::replace(&mut self.sim.tick, shown.now as u64);
let segments = smoke::Smoke::default().segments(&self.sim);
self.sim.blasts = blasts;
self.sim.fires.list = fires;
self.sim.tick = tick;
// The recording alone, as a function of its time: the live Sim is not touched.
let segments = match self.sim.replay(self.replay.as_ref().map_or(0, |r| r.rec)) {
Some(rec) => smoke::replay_smoke(rec, f64::from(shown.now)),
None => Vec::new(),
};
(f64::from(shown.now), segments)
}
@@ -962,6 +978,7 @@ impl AirDef {
/// Plays the last recorded raid: "map", "full", "reel" or (with `event`) a clip.
fn start_replay(&mut self, what: &str, event: Option<usize>) -> Result<(), String> {
switch_allowed(self.export.is_some())?;
let n = self.sim.replays.len();
if n == 0 {
return Err("Повторов пока нет: запишется первый же налёт (когда небо очистится)".into());
@@ -980,7 +997,11 @@ impl AirDef {
_ => replay::Mode::Map,
};
self.say_block_log(rec);
self.replay = Some(replay::Player::new(rec.number, mode, &self.sim));
let player = replay::Player::new(rec.number, mode, &self.sim);
// A film (its quality and effects) or another replay ends properly before this one.
self.end_session();
self.session += 1;
self.replay = Some(player);
self.grid = false;
input::claim_wheel(true);
Ok(())
@@ -991,6 +1012,12 @@ impl AirDef {
render::capture_stop(true);
say("Экспорт отменён");
}
self.end_session();
}
/// Ends whatever replay or film shows, on every path: the film quality, the effects, the
/// clean HUD, the views and the time and camera overrides go back to the live game's.
fn end_session(&mut self) {
render::set_film_quality(false);
if let Some((shader, hd)) = self.film_prev.take() {
self.set_effects(shader, hd);
@@ -1007,6 +1034,9 @@ impl AirDef {
/// Plays the director's film of the last raid at the best quality: HD effects on (restored after).
fn start_film(&mut self, which: &str, export: Option<&ExportOpts>) -> Result<(), String> {
if export.is_none() {
switch_allowed(self.export.is_some())?;
}
let n = self.sim.replays.len();
if n == 0 {
return Err("Повторов пока нет: запишется первый же налёт (когда небо очистится)".into());
@@ -1025,6 +1055,9 @@ impl AirDef {
player.clean = true;
player.limit = o.seconds;
}
// What was shown ends first (its effects are remembered below, not lost).
self.end_session();
self.session += 1;
self.replay = Some(player);
self.grid = false;
input::claim_wheel(true);
@@ -1068,13 +1101,20 @@ impl AirDef {
let which = o.which.clone();
self.start_film(&which, Some(&o))?;
say(&format!("Экспорт «{}»: {} fps, {}x{}; планирую кадры…", o.name, o.fps, o.size.0, o.size.1));
self.export = Some(Export { opts: o, stage: Stage::Planning, ticks: 0 });
self.export = Some(Export { session: self.session, opts: o, stage: Stage::Planning, ticks: 0 });
Ok(())
}
/// Drives an export: plan, start the recorder, wait for its first frame, report, stop at the end.
fn export_step(&mut self, r: &mut replay::Player) {
let Some(ex) = &mut self.export else { return };
if ex.session != self.session {
// The film this export recorded was replaced: the recorder stops, nothing else drives it.
render::capture_stop(true);
self.export = None;
say("Экспорт отменён: показ заменён другим");
return;
}
ex.ticks += 1;
let st = render::capture_status();
let failed = |s: &render::CaptureState| s.phase == render::CapturePhase::Failed;
@@ -1349,6 +1389,7 @@ impl Mod for AirDef {
time_view: false,
export: None,
film_prev: None,
session: 0,
smoke: Default::default(),
far: true,
moving: None,
@@ -1913,3 +1954,15 @@ impl Mod for AirDef {
}
horizon_mod_sdk::export_mod!(AirDef);
#[cfg(test)]
mod tests {
use super::*;
/// #83: while an export records no other replay or film replaces it; after it, they may.
#[test]
fn nothing_replaces_a_running_export() {
assert!(switch_allowed(true).is_err());
assert!(switch_allowed(false).is_ok());
}
}
+37 -4
View File
@@ -13,6 +13,17 @@ use crate::sim::{Class, DroneKind, Out, Sim};
use glam::DVec3;
use horizon_mod_sdk::render::{CameraView, FillShape, Point, QuadShape, Rect, Shape, TextShape};
/// Metres the camera keeps above the surface (land or water) it stands over.
const SURFACE_CLEAR: f64 = 2.5;
/// `eye` lifted above the surface under it (`Ground::land_state`: unanswered or no land: unchanged).
fn above_surface(eye: DVec3, land: Option<Option<f64>>) -> DVec3 {
match land {
Some(Some(h)) if eye.y < h + SURFACE_CLEAR => DVec3::new(eye.x, h + SURFACE_CLEAR, eye.z),
_ => eye,
}
}
/// Ghost models' ids, clear of the live ones.
const GHOSTS: u32 = 0x5000_0000;
/// The camera view's id (a new one each cut, so the game doesn't sweep between shots).
@@ -419,7 +430,7 @@ impl Player {
return;
}
sim.draw_ghosts(rec, self.t, GHOSTS, out);
self.direct(rec);
self.direct(rec, ground);
self.sounds(rec, listener, out);
}
@@ -565,7 +576,7 @@ impl Player {
}
/// The event the camera films now: (eye, look, fov, shot).
fn shoot(&mut self, rec: &Recording, i: usize) -> (DVec3, DVec3, f32, u8) {
fn shoot(&mut self, rec: &Recording, i: usize, ground: &mut Ground) -> (DVec3, DVec3, f32, u8) {
let e = rec.events[i];
let t = self.t;
let te = e.at as f64;
@@ -602,6 +613,10 @@ impl Player {
None => {
// A gun kill, from the gun.
let at = rec.sam(sam).map_or(e.pos + DVec3::Y * 20.0, |s| s.1 + DVec3::Y * 4.0);
// A system on the sea floor (or a lake's) must not film from under the water:
// the game's ground height is the top of the highest block that blocks motion
// or holds a fluid, i.e. the water's surface there.
let at = above_surface(at, ground.land_state(at));
let dist = target.distance(at).max(1.0);
let fov = (2.0 * (10.0 / dist).atan()).to_degrees().clamp(6.0, 60.0) as f32;
(at, target, fov, 1)
@@ -630,7 +645,7 @@ impl Player {
}
/// Chooses the shot and moves the camera; a new shot is a cut.
fn direct(&mut self, rec: &Recording) {
fn direct(&mut self, rec: &Recording, ground: &mut Ground) {
let focus = match &self.mode {
Mode::Clips { list, at, .. } => Some(list[*at].event),
// The full replay follows what comes next: the next event within 6 s, else the drone
@@ -644,7 +659,7 @@ impl Player {
};
let (eye, look, fov, shot) = match focus {
Some(i) => {
let s = self.shoot(rec, i);
let s = self.shoot(rec, i, ground);
(s.0, s.1, s.2, (i, s.3))
}
None => {
@@ -957,3 +972,21 @@ impl Player {
Mode::Clips { list: vec![clip(rec, i)], at: 0, reel: false }
}
}
#[cfg(test)]
mod surface_tests {
use super::*;
/// A gun standing on the sea floor films from above the water, not from under it.
#[test]
fn the_gun_camera_is_lifted_above_the_water() {
let eye = DVec3::new(10.0, 44.0, 5.0); // 4 above a Gepard on the floor at 40
let lifted = above_surface(eye, Some(Some(62.0))); // the surface
assert!(lifted.y >= 62.0 + SURFACE_CLEAR - 1e-9);
assert_eq!((lifted.x, lifted.z), (10.0, 5.0));
// On dry land, or with no answer yet, the camera stays where it was.
assert_eq!(above_surface(DVec3::new(0.0, 74.0, 0.0), Some(Some(70.0))), DVec3::new(0.0, 74.0, 0.0));
assert_eq!(above_surface(eye, None), eye);
assert_eq!(above_surface(eye, Some(None)), eye);
}
}
+85
View File
@@ -18,6 +18,8 @@ pub struct SamKey {
pitch: f32,
/// The antenna's turn.
dish: f32,
/// A searchlight's beam: how far it runs free (to 8 blocks), 0 when it is off.
beam: f32,
}
impl SamKey {
@@ -30,6 +32,7 @@ impl SamKey {
yaw: sam.yaw as f32,
pitch: sam.pitch as f32,
dish: sam.dish as f32,
beam: if sam.beam_on { ((sam.beam_free / 8.0).round() * 8.0).max(8.0) as f32 } else { 0.0 },
}
}
/// The same pose (to within what could be seen).
@@ -40,6 +43,7 @@ impl SamKey {
&& near(self.yaw, o.yaw, 0.002)
&& near(self.pitch, o.pitch, 0.002)
&& near(self.dish, o.dish, 0.002)
&& self.beam == o.beam
}
}
@@ -79,6 +83,7 @@ impl SamTrack {
yaw: turn(a.yaw, b.yaw, f),
pitch: l(a.pitch, b.pitch),
dish: turn(a.dish, b.dish, f),
beam: a.beam,
})
}
/// The system as it stood at `t`, as a ghost to draw with the ids from `id`.
@@ -89,11 +94,54 @@ impl SamTrack {
sam.yaw = k.yaw as f64;
sam.pitch = k.pitch as f64;
sam.dish = k.dish as f64;
sam.beam_on = k.beam > 0.0;
sam.beam_free = k.beam as f64;
sam.health = if self.lost.is_some_and(|l| t >= l as f64) { 0.0 } else { 1.0 };
Some(sam)
}
}
#[cfg(test)]
impl Recording {
/// A recording of `len` ticks with nothing in it (for the planner's tests).
pub(crate) fn blank(len: u32) -> Self {
Recording {
number: 1,
origin: DVec3::ZERO,
len,
tracks: Vec::new(),
index: HashMap::new(),
events: Vec::new(),
tally: Tally::default(),
sams: Vec::new(),
objects: Vec::new(),
sam_tracks: Vec::new(),
sam_index: HashMap::new(),
ticks: Vec::new(),
times: Vec::new(),
quiet: 0,
}
}
/// A straight track from `from` to `to` over the ticks `first..=last` (every `STEP`).
pub(crate) fn straight(&mut self, id: u32, what: What, first: u32, last: u32, from: DVec3, to: DVec3) {
let n = ((last - first) / STEP).max(1) as usize;
let points = (0..=n).map(|k| {
let p = from.lerp(to, k as f64 / n as f64);
[p.x as f32, p.y as f32, p.z as f32]
});
self.index.insert(id, self.tracks.len());
self.tracks.push(Track { id, what, first, points: points.collect(), fell: None });
}
}
impl Recording {
/// The searchlights' beams at `t` as the recording had them (position and direction from the
/// recorded poses, none for a lost light), never the live state.
pub fn beams(&self, t: f64) -> Vec<lights::Beam> {
self.sam_tracks.iter().filter_map(|st| st.ghost(self.origin, t, st.id)?.spot_beam()).collect()
}
}
/// Ticks between a track's points.
pub const STEP: u32 = 2;
/// The longest recording (10 minutes) and how many are kept.
@@ -244,6 +292,10 @@ impl Track {
_ => DVec3::NEG_Z,
}
}
/// Its `i`-th point (every `STEP` ticks from `first`), if it has one.
pub fn sample(&self, origin: DVec3, i: usize) -> Option<DVec3> {
self.points.get(i).map(|q| origin + DVec3::new(q[0] as f64, q[1] as f64, q[2] as f64))
}
/// The tick of its last point.
pub fn last(&self) -> u32 {
self.first + (self.points.len().max(1) as u32 - 1) * STEP
@@ -587,6 +639,13 @@ impl Sim {
units::draw_sam_model(&ghost, &mut rng, out);
}
}
// The protected objects as marked in the raid (struck ones shrink), not the live ones.
for (i, p) in rec.objects.iter().enumerate() {
let struck = rec.events.iter().any(|e| e.at as f64 <= t && matches!(e.kind, EventKind::Hit { object: Some(o), .. } if o == i));
let bob = (t * 0.1 + i as f64).sin() * 0.25;
let rot = DQuat::from_rotation_y(t * 0.08);
out.model(base + 0x00e0_0000 + i as u32 * PARTS, "airdef:target", *p + DVec3::Y * (1.6 + bob), rot, if struck { 0.4 } else { 0.8 }, true);
}
// Fires: what the events left burning (the flames dim and the smoke thickens with time),
// out when their life ends.
for f in replay_fires(rec, t) {
@@ -673,6 +732,32 @@ mod tests {
assert!((pose(100.0).pos[0] - 4.0).abs() < 1e-6);
}
// #76: a replay's searchlights point where the recording had them, from the recorded pose.
#[test]
fn a_replay_has_the_recorded_searchlight_beams() {
let mut r = recording();
let mut light = Sam::new(9, SamKind::Light, DVec3::new(10.0, 64.0, 0.0), 0.0);
light.beam_on = true;
light.beam_free = 300.0;
light.yaw = 1.0;
light.pitch = 0.8;
r.note_sams(&[light], 0);
// Swung round and switched off later.
let mut later = Sam::new(9, SamKind::Light, DVec3::new(10.0, 64.0, 0.0), 0.0);
later.yaw = 2.0;
later.pitch = 0.8;
r.note_sams(&[later], 100);
assert!(r.beams(-1.0).is_empty(), "nothing before it was seen");
let b = r.beams(0.0);
assert_eq!(b.len(), 1);
assert_eq!(b[0].id, 9);
assert!((b[0].free - 304.0).abs() < 1e-6 || (b[0].free - 296.0).abs() < 1e-6, "free {}", b[0].free);
assert!(b[0].dir.y > 0.5, "pointing up: {:?}", b[0].dir);
assert!(r.beams(100.0).is_empty(), "switched off");
r.sam_tracks[0].lost = Some(50);
assert!(r.beams(10.0).len() == 1 && r.beams(60.0).is_empty(), "a lost light has no beam");
}
#[test]
fn a_system_first_seen_later_is_not_drawn_before() {
let mut r = recording();
+63
View File
@@ -654,6 +654,69 @@ fn replay_fires_and_blasts_follow_the_recording() {
assert!(record::replay_blasts(&rec, 10.0 + a[0].life as f64).is_empty(), "burnt out");
}
// #78: a replay's smoke is rebuilt from the recording as a function of its time: trails behind
// what flew (clearing the fog), a column over the fire, billows that outlive the fireball; a seek
// shows what playing through shows, and the live Sim is not touched.
#[test]
fn replay_smoke_has_trails_and_fire_columns_and_is_a_function_of_time() {
use record::{Event, EventKind};
let mut sim = Sim::new(1);
sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 2000.0));
sim.add_drone(DroneKind::Geran3, DVec3::new(30.0, 80.0, 0.0), DVec3::Z, DVec3::new(30.0, 70.0, 2000.0));
let mut g = Ground::default();
let mut out = Out::default();
for _ in 0..160 {
sim.step(&mut g, None, &mut out);
}
let mut rec = sim.rec.take().unwrap();
assert_eq!(rec.tracks.len(), 2);
rec.events = vec![Event { at: 40, pos: DVec3::new(5.0, 70.0, 5.0), kind: EventKind::Hit { drone: 1, kind: DroneKind::Geran3, power: 2, object: None, sam: None } }];
let live_tick = sim.tick;
let live_fires = sim.fires.list.len();
let t = 130.0;
let a = crate::smoke::replay_smoke(&rec, t);
let trails = a.iter().filter(|s| s.clear > 0.0).count();
let columns = a.iter().filter(|s| s.soot > 0.8).count();
let billows = a.iter().filter(|s| s.soot == 0.55).count();
assert!(trails > 0, "trails of both tracks: {trails}");
assert!(columns > 0, "a column over the fire: {columns}");
assert!(billows > 0, "the blast's billows");
// Seeking away and back, or forward from nothing, gives the same smoke.
let _ = crate::smoke::replay_smoke(&rec, 10.0);
let _ = crate::smoke::replay_smoke(&rec, 155.0);
let b = crate::smoke::replay_smoke(&rec, t);
assert_eq!(a.iter().map(|s| (s.id, s.born as u64)).collect::<Vec<_>>(), b.iter().map(|s| (s.id, s.born as u64)).collect::<Vec<_>>());
// Nothing from the future; before the raid, nothing.
assert!(a.iter().all(|s| s.born <= t));
assert!(crate::smoke::replay_smoke(&rec, -5.0).is_empty());
// The billows go on long after the short fireball is out.
let late = crate::smoke::replay_smoke(&rec, 400.0);
assert!(late.iter().any(|s| s.soot == 0.55));
// The live state is not touched.
assert_eq!((sim.tick, sim.fires.list.len()), (live_tick, live_fires));
}
// #76: the protected objects of a replay are the recording's, not the live marks.
#[test]
fn a_replay_draws_the_recorded_objects_not_the_live_ones() {
let mut sim = Sim::new(1);
sim.targets.push(DVec3::new(0.0, 70.0, 0.0));
sim.target_health.push(100.0);
sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 500.0));
let mut g = Ground::default();
let mut out = Out::default();
sim.step(&mut g, None, &mut out);
let rec = sim.rec.take().unwrap();
assert_eq!(rec.objects.len(), 1);
// The live mark is moved away after the raid; the replay still shows it where it was.
sim.targets[0] = DVec3::new(900.0, 70.0, 900.0);
let mut shown = Out::default();
sim.draw_ghosts(&rec, 0.0, 0x1000, &mut shown);
let marks: Vec<_> = shown.models.iter().filter(|m| m.name == "airdef:target").collect();
assert_eq!(marks.len(), 1);
assert!(marks[0].at.x.abs() < 1.0 && marks[0].at.z.abs() < 1.0, "{:?}", marks[0].at);
}
fn sim_first(r: &record::Recording) -> u64 {
r.tracks.first().map_or(0, |t| t.first as u64)
}
+148 -67
View File
@@ -47,6 +47,150 @@ fn seg(id: u32, a: DVec3, b: DVec3, born: u64, life: u32) -> SmokeSegment {
}
}
/// The column over one fire up to `tick`: a puff every 15 ticks (90 for the weaker fires) that
/// rises, spreads and thins. A pure function of the tick.
fn fire_column(out: &mut Vec<SmokeSegment>, fire_id: u32, at: DVec3, start: u64, size: f32, shown: bool, tick: u64, level: &dyn Fn(u64) -> (f32, f32)) {
// Only the strongest fires get a full column; the rest a thin puff now and then.
let step = if shown { 15 } else { 90 };
let first = start.max(tick.saturating_sub(520));
let mut k = first.div_ceil(step) * step;
while k <= tick && out.len() < MAX_TOTAL {
let (flame, smoke) = level(k);
if smoke > 0.05 && k >= start {
let id = 0x4000_0000 | (fire_id.wrapping_mul(131) ^ (k / step) as u32) & 0x3fff_ffff;
let at = at + DVec3::Y * 0.6;
let mut s = seg(id, at, at, k, 540);
s.radius_start = 1.0 * size;
s.radius_end = 8.0 * size + 2.0;
s.density = 2.6 * smoke * size.sqrt() * if 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 900 ticks after each.
fn blast_billows(out: &mut Vec<SmokeSegment>, blasts: &[crate::sim::Blast], tick: u64) {
for b in 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);
}
}
}
fn style_drone(s: &mut SmokeSegment, class: 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),
};
s.radius_start = r0;
s.radius_end = r1;
s.density = dens;
s.heat = heat;
s.soot = soot;
s.clear = clear;
s.life = life;
}
fn style_missile(s: &mut SmokeSegment) {
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;
}
/// Points between a replay trail segment's ends (a segment every `TRAIL_GRID * STEP` ticks).
const TRAIL_GRID: usize = 4;
/// The smoke a recording shows at its tick `t`: trails behind everything that flew, columns over
/// the fires its events lit, billows over its blasts. A pure function of `t` (nothing is kept
/// between calls, so a seek, a pause or a cut shows exactly what playing through would), and it
/// touches no live state. Trail segments sit on a fixed grid of each track's points.
pub fn replay_smoke(rec: &crate::sim::record::Recording, t: f64) -> Vec<SmokeSegment> {
use crate::sim::record::{STEP, What};
let tick = t.max(0.0) as u64;
let mut out: Vec<SmokeSegment> = Vec::new();
let stride = (TRAIL_GRID as u32 * STEP) as f64;
for (n, tr) in rec.tracks.iter().enumerate() {
let (life, style): (f64, Box<dyn Fn(&mut SmokeSegment)>) = match tr.what {
What::Drone(kind) => {
let class = kind.spec().class;
let mut probe = seg(0, DVec3::ZERO, DVec3::ZERO, 0, 0);
style_drone(&mut probe, class);
(probe.life as f64, Box::new(move |s| style_drone(s, class)))
}
What::Missile(..) => (1000.0, Box::new(style_missile)),
};
// The segments whose end point has been reached and whose life is not over.
let lo = (((t - life - tr.first as f64) / stride).floor().max(0.0)) as usize;
let hi = (((t - tr.first as f64) / stride).floor().max(0.0)) as usize;
for g in lo.max(1)..=hi {
let (Some(a), Some(b)) = (tr.sample(rec.origin, (g - 1) * TRAIL_GRID), tr.sample(rec.origin, g * TRAIL_GRID)) else { break };
let born = tr.first as u64 + (g * TRAIL_GRID) as u64 * STEP as u64;
if born > tick || a.distance(b) >= 400.0 {
continue;
}
let mut s = seg(0x2000_0000 | ((n as u32).wrapping_mul(7919) ^ g as u32) & 0x0fff_ffff, a, b, born, 1200);
style(&mut s);
if (tick as f64) < born as f64 + s.life as f64 {
out.push(s);
}
}
}
if out.len() > MAX_TRAIL {
out.sort_by_key(|s| s.born as u64);
let cut = out.len() - MAX_TRAIL;
out.drain(..cut);
}
// Fires, the strongest dozen in full (as the live ones are).
let mut fires = crate::sim::record::replay_fires(rec, t);
fires.sort_by(|a, b| b.size.total_cmp(&a.size).then(a.start.cmp(&b.start)));
for (rank, f) in fires.iter().enumerate() {
let life = f.life as f64;
fire_column(&mut out, 0x1000_0000 | f.key as u32, f.at, f.start as u64, f.size, rank < crate::sim::fires::MAX, tick, &|k| crate::sim::fires::level(k.saturating_sub(f.start as u64) as f64, life));
}
// Blasts keep their billows long after the fireball is out.
let blasts: Vec<crate::sim::Blast> = rec
.events
.iter()
.enumerate()
.filter(|(_, e)| e.at as u64 <= tick)
.map(|(k, e)| {
let (size, life) = crate::sim::record::blast_spec(e.kind);
crate::sim::Blast { id: 0x4000_0000 | k as u32, at: e.pos, size, start: e.at as u64, life }
})
.collect();
blast_billows(&mut out, &blasts, tick);
out.truncate(MAX_TOTAL);
out
}
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 {
@@ -76,32 +220,10 @@ impl Smoke {
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;
});
self.trail((0, d.id), d.pos, tick, |s| style_drone(s, class));
}
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;
});
self.trail((1, m.id), m.pos, tick, style_missile);
}
// Forget what is gone.
if tick % 100 == 0 {
@@ -129,50 +251,9 @@ impl Smoke {
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);
}
fire_column(&mut out, f.id, f.at, f.start, f.size, f.shown, tick, &|k| f.level(k));
}
blast_billows(&mut out, &sim.blasts, tick);
out.truncate(MAX_TOTAL);
out
}
+13 -3
View File
@@ -233,6 +233,9 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home
| #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` (следы дронов и ракет, колонны над пожарами, дым взрывов) | готово, в рабочем дереве форка; вид не настраивался (см. «Дым и следы») | не закоммичено |
| Цветные тени (план 6): ближний каскад без листвы в глубине + карта цвета 1024² (`Rgba16Float`, `shadow_tint_pass`): стекло/лёд умножают свет на цвет, листва пропускает ×0.55 зелёного; в альфе расстояние до источника света (min-blend); `fs_light` берёт 8 отсчётов (`shadow_tint`); `HORIZON_HD_COLOR_SHADOW=0` — выкл. | готово; радуга от цветного стекла видна в игре, листва — слабым зеленоватым пятном; без A/B с выключенным | в рабочем дереве форка |
| Рябь от дождя на воде (остаток плана 7): кольца в сетке 1.6 блока (`ripple_gradient`, `water.wgsl`), уровень дождя в `sky_extra.w`; `HORIZON_HD_RAIN_RIPPLES=0` — выкл. | готово; в игре едва различимы дуги в бликах (частицы дождя закрывают), не настраивалось | в рабочем дереве форка |
| Motion blur и DoF кинокамеры (план 9): `gfx/hd/film.rs` + `film.wgsl`, проход после TAA, только при `film_quality()`; `HORIZON_HD_MOTION_BLUR=0`, `HORIZON_HD_DOF=0`, `HORIZON_HD_DOF_FOCUS`, `HORIZON_HD_DOF_STRENGTH`, `HORIZON_HD_FILM_QUALITY=1` (принудительно, для проб) | DoF виден в игре (до правки стоил 14–17 мс); motion blur в игре не подтверждён; правка стоимости DoF (планарная глубина, 24 отсчёта, радиус 0.7%) собрана и прошла тесты, но глазами не проверена | в рабочем дереве форка |
## План (по порядку) и ТЗ
@@ -253,10 +256,10 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home
4. **Объёмные облака** — СДЕЛАНО (патч 24, см. «Закат, облака и туман»). Перистые и облака в воде сделаны (см. «Облака, перистые, туман по рельефу»).
5. **Блики и материалы** — СДЕЛАНО (см. «Материалы, мокрое, ветер»). Исходное ТЗ: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из
мода), мокрые поверхности и лужи в дождь.
6. **PCSS** (мягкость тени по расстоянию до тенеотбрасывателя) и цветные тени через стекло/листву.
7. **Ветер** — СДЕЛАНО (рябь от дождя на воде осталась). Исходное ТЗ: листвы/травы (вершинный шейдер террейна по типу блока), рябь от дождя.
6. **PCSS** и цветные тени через стекло/листву — СДЕЛАНО (см. «Цветные тени, рябь, кино-пост»). Не сделано: цвет в лучах тумана и в тени под водой, цветная тень дальнего каскада.
7. **Ветер** и рябь от дождя — СДЕЛАНО. Исходное ТЗ: листвы/травы (вершинный шейдер террейна по типу блока), рябь от дождя.
8. **Объёмный дым** пожаров и взрывов, принимающий свет и тени (сейчас дым — в `effects.wgsl`, без теней).
9. Опционально: motion blur, DoF (для кинокамеры повторов).
9. Motion blur, DoF для кинокамеры — СДЕЛАНО в коде (см. ниже), проверено частично.
10. **RT-пресет**: `wgpu` `EXPERIMENTAL_RAY_QUERY` (Vulkan/DX12/Metal M3+), BLAS по секциям, ray-query тени и
отражения, затем GI. Отдельный пресет, HD остаётся.
@@ -364,3 +367,10 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home
- **Захват** (`render.capture-start/stop/status`, `game/film.rs`, `Renderer::capture_*`): часы игры идут ровно `1/fps` секунды за кадр (тики, интерполяция, `tick` мода), кадр копируется с GPU через очередь из ≤3 буферов и уходит в поток-писатель → `ffmpeg` (libx264, иначе h264_videotoolbox; иначе PNG-последовательность). Звук: микшер OpenAL-подобный (`audio.rs`) работает офлайн (`film_begin/film_mix`), слышны только звуки мода, ориентация — по камере; дорожка вмуксивается AAC. После склейки рисуются `warmup` кадров без записи, чтобы сошлись TAA и объёмы. Размер: offscreen-игра ресайзится, окно просится (`request_inner_size`).
- Мод-HUD (`set-hud`) теперь рисуется и при `set-main-view` (титры, леттербокс фильма); ванильный HUD по-прежнему скрыт.
- Режиссура: `airdef-mod/src/film.rs` (план по записи, варианты ракурсов, проверка рельефа по высотам `Ground::land_state`, ускорение/замедление времени).
## Цветные тени, рябь, кино-пост: что знать преемнику
- **Цветные тени.** Ближний каскад (c = 0) рисуется `fs_shadow_near` (листва `MAT_LEAVES` отброшена), потом проход `hd shadow colour` (`Hd::shadow_tint_pass`, цель `Shadows::tint`, очистка в белое) рисует листву (`shadow_tint_leaves`, твёрдые квады, `SHADOW_SOLID`) и полупрозрачные квады секций (`shadow_tint_glass`, по отсортированным индексам; вода не рисуется) тем же `fs_shadow_tint`: rgb умножаются (blend `Dst*src`), альфа = `(расстояние от света − 320)/160` с blend Min — ближайший такой квад. Приёмник в `shadow_tint` (`light.wgsl`, привязка 22) окрашивается, если квад ближе к свету, чем он; 8 точек Vogel радиусом 0.4 блока размывают листву в «пятна». Дальний каскад по-прежнему рисует листву полностью (на границе 36 блоков шов: ближе листва светлее). Цвет не доходит до лучей тумана, воды и дальнего каскада. Цена: `hd shadow colour` 0.25–0.35 мс (Mac, малая сцена).
- **Рябь.** `ripple_gradient` берёт часы воды (`water.y`, часы фильма): частоты — целые циклы на 600 с, сетка 160 клеток на 256 блоков, поэтому рисунок не прыгает на обороте. Гаснет по размеру пикселя, только под открытым небом (`nt.a`). Амплитуда 0.012 не подбиралась.
- **Кино-пост.** `Hd::film` между `taa` и `finish` (`HdSource::Film`), только при `film_quality()`; цели создаются при первом фильме. Motion blur — только от движения камеры (глубина + `prev_view_proj`, затвор 180°, мёртвая зона 1–2.5 px из-за джиттера; движущиеся объекты не размываются; нет после склейки/телепорта: `taa.w`). DoF — диск 24 отсчёта, фокус авто (гармоническое среднее 9 глубин у центра) или `HORIZON_HD_DOF_FOCUS`. Детерминизм: единственное время в шейдере — `water.y` (часы фильма) для шума. Проверять на реальном фильме `/airdef replay film`; `HORIZON_HD_FILM_QUALITY=1` принудительно включает качество фильма для проб. DoF в игре был слишком сильным и дорогим; параметры уменьшены без повторного снимка. Motion blur на снимке (поворот 40° за 1.5 с) не разобран.
- Из Photon (MIT) ничего нового не переносилось: THIRD_PARTY_PHOTON.md не менялся.
@@ -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/33] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 01/35] 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/33] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 02/35] 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/33] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 03/35] 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/33] Mod models: draw far models as scaled impostors past
Subject: [PATCH 04/35] 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/33] Mod cameras: render.set-views draws the world from
Subject: [PATCH 05/35] 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/33] Mods: more fuel per tick and per event (AirDef plans
Subject: [PATCH 06/35] 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/33] Mod cameras up to 1920x1080 (full-screen replays)
Subject: [PATCH 07/35] 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/33] Mod cameras: distant terrain tiles, fog measured from
Subject: [PATCH 08/35] 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/33] Mods: game.world-id, so mods keep state per world (API
Subject: [PATCH 09/35] 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/33] cargo-horizon-mod: trial runs behind feature full, so a
Subject: [PATCH 10/35] 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/33] HD lighting: G-buffer pass, per-pixel mod point lights
Subject: [PATCH 11/35] 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/33] HD lighting stage 2: sun and moon shadow cascades, the
Subject: [PATCH 12/35] 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/33] HD: bind groups cached until a resize or a new source,
Subject: [PATCH 13/35] 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/33] HD: lights culled per 16x16 tile (surface and halo
Subject: [PATCH 14/35] 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/33] HD: GPU time per pass in the F3 timing list,
Subject: [PATCH 15/35] 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/33] HD: shadows draw only sun-facing quads and skip buried
Subject: [PATCH 16/35] 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/33] Mods: model handles (render.model-id, model-generation,
Subject: [PATCH 17/35] 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/33] HD: temporal upscaling (HORIZON_HD_SCALE: world,
Subject: [PATCH 18/35] 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/33] HD water: its own pass (colour, normal, depth), the
Subject: [PATCH 19/35] 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/33] HD water: no vanilla texture on the surface (the biome
Subject: [PATCH 20/35] 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/33] HD sky and light: a physical atmosphere (Rayleigh, Mie,
Subject: [PATCH 21/35] 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/33] HD water: a darker blue, a little clearer, ripples a
Subject: [PATCH 22/35] 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/33] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
Subject: [PATCH 23/35] 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/33] Mods: world queries for simulations (render.query-rays,
Subject: [PATCH 24/35] 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/33] HD fixes: rain level from the world not from rain
Subject: [PATCH 25/35] 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/33] HD split into modules (gfx/hd/: one Rust file and one
Subject: [PATCH 26/35] 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/33] HD clouds: noise scales that divide the camera's
Subject: [PATCH 27/35] 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
@@ -1,7 +1,7 @@
From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:17:11 +0300
Subject: [PATCH 28/33] HD: volumetric smoke and missile trails with clear
Subject: [PATCH 28/35] HD: volumetric smoke and missile trails with clear
tunnels through fog and cloud (render.set-smoke, API level 14; #54); sun
speculars by material, wet surfaces and puddles in rain, wind in foliage
(HORIZON_HD_SPECULAR=0, HORIZON_HD_WIND=0, HORIZON_HD_SMOKE=0); HD shaders
@@ -1,7 +1,7 @@
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/33] HD clouds composited by depth so terrain and vehicles
Subject: [PATCH 29/35] 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
@@ -1,7 +1,7 @@
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/33] HD smoke: young trails drawn at least as wide as a grid
Subject: [PATCH 30/35] 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
@@ -1,7 +1,7 @@
From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:59:41 +0300
Subject: [PATCH 31/33] Mods: render.set-main-view (API level 15) renders the
Subject: [PATCH 31/35] Mods: render.set-main-view (API level 15) renders the
screen's world from a mod camera with the full pipeline, so replays get HD
and the post effects; small mod views get the post pass with their own camera
@@ -1,7 +1,7 @@
From 27e24527550942695164dd3a769a42a659e797ca Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 09:28:05 +0300
Subject: [PATCH 32/33] Spot lights for mods (render.set-spot-lights, API level
Subject: [PATCH 32/35] Spot lights for mods (render.set-spot-lights, API level
16): surfaces lit in the cone, a volumetric beam stopped by depth (#60); film
capture (render.capture-*, set-film-quality, API level 17): fixed-step clock,
frames piped to ffmpeg or PNGs, offline audio, no chat in the frames (#58);
@@ -1,7 +1,7 @@
From 615f5c3fa7c19a9895f8972a88333c6f250ad78a Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 14:30:39 +0300
Subject: [PATCH 33/33] HD clouds and fog polish: cloud panorama, cirrus,
Subject: [PATCH 33/35] HD clouds and fog polish: cloud panorama, cirrus,
terrain-aware fog, far smoke cascade; replay world log and film clock;
material overlays by sprite name
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,446 @@
From 9645d3f1f47d8bb358d5630288bdd801bd874911 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 15:19:32 +0300
Subject: [PATCH 35/35] Film export: every frame in flight reaches the writer,
listener follows the main view, cloud wind on film frames, set-smoke reports
real activity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/film_clock.rs | 32 +++--
crates/horizon-client/src/game.rs | 25 +++-
crates/horizon-client/src/game/film.rs | 140 ++++++++++++++++---
crates/horizon-client/src/game/mod_models.rs | 6 +-
crates/horizon-client/src/gfx.rs | 2 +-
crates/horizon-client/src/gfx/hd/mod.rs | 4 +-
crates/horizon-client/src/gfx/hd/smoke.rs | 24 ++++
7 files changed, 196 insertions(+), 37 deletions(-)
diff --git a/crates/horizon-client/src/film_clock.rs b/crates/horizon-client/src/film_clock.rs
index 6396e4c1f..f001f0e78 100644
--- a/crates/horizon-client/src/film_clock.rs
+++ b/crates/horizon-client/src/film_clock.rs
@@ -6,26 +6,34 @@
use std::sync::Mutex;
use std::time::{Duration, Instant};
-static FILM: Mutex<Option<Instant>> = Mutex::new(None);
+/// While a film records: where its clock began (real time) and how much film time has passed.
+static FILM: Mutex<Option<(Instant, Duration)>> = Mutex::new(None);
-/// The film's moment (frozen for the frame), or None to run on real time again.
-pub fn set(now: Option<Instant>) {
+/// The film's moment (frozen for the frame) as (its origin, film time since it), or None to run on
+/// real time again.
+pub fn set(now: Option<(Instant, Duration)>) {
if let Ok(mut f) = FILM.lock() {
*f = now;
}
}
+fn film() -> Option<(Instant, Duration)> {
+ FILM.lock().ok().and_then(|f| *f)
+}
+
/// What the animations take as now.
pub fn now() -> Instant {
- FILM.lock()
- .ok()
- .and_then(|f| *f)
- .unwrap_or_else(Instant::now)
+ film().map_or_else(Instant::now, |(origin, t)| origin + t)
}
-/// Time since `start` on this clock.
+/// Time since `start` on this clock. In a film that is the film's own time since it began
+/// whatever `start` was (an animation that began with the program would otherwise carry the
+/// real time the program ran before the film): two exports of one recording agree frame by frame.
pub fn since(start: Instant) -> Duration {
- now().saturating_duration_since(start)
+ match film() {
+ Some((_, t)) => t,
+ None => Instant::now().saturating_duration_since(start),
+ }
}
#[cfg(test)]
@@ -35,10 +43,14 @@ mod tests {
#[test]
fn film_time_is_the_frames_not_the_wall() {
let origin = Instant::now();
- set(Some(origin + Duration::from_millis(50)));
+ // Whatever the animation started at, a film second is a second of its animation.
+ let long_ago = origin.checked_sub(Duration::from_secs(1000)).unwrap_or(origin);
+ set(Some((origin, Duration::from_millis(50))));
assert_eq!(since(origin), Duration::from_millis(50));
+ assert_eq!(since(long_ago), Duration::from_millis(50));
std::thread::sleep(Duration::from_millis(5));
assert_eq!(since(origin), Duration::from_millis(50));
+ assert_eq!(now(), origin + Duration::from_millis(50));
set(None);
assert!(since(origin) >= Duration::from_millis(5));
}
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index 3f776fd3f..7a87e9ffb 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -11584,6 +11584,8 @@ impl Game<'_> {
),
};
let (view_yaw, view_pitch) = self.shaken_view(view_yaw, view_pitch);
+ // AirDef fork: the position stays the player's (a mod places its sounds round it); the
+ // orientation is the main view's below, in every filming mode, preview and export alike.
self.audio
.set_listener([camera.x, camera.y, camera.z], view_yaw, view_pitch);
// AirDef fork: a mod's camera as the screen's own view (replays): the listener stays the
@@ -11598,11 +11600,10 @@ impl Game<'_> {
r.cut = true;
}
}
- if let Some(v) = &main_view
- && self.film.recording()
- {
+ if let Some(v) = &main_view {
+ let (yaw, pitch) = film::listener_orientation(Some((v.yaw, v.pitch)), (view_yaw, view_pitch));
self.audio
- .set_listener([camera.x, camera.y, camera.z], v.yaw, v.pitch);
+ .set_listener([camera.x, camera.y, camera.z], yaw, pitch);
}
let (camera, underwater, view_yaw, view_pitch) = match &main_view {
Some(v) => (v.camera, false, v.yaw, v.pitch),
@@ -11701,6 +11702,9 @@ impl Game<'_> {
let debug_lines = horizon_client::gizmos::camera_relative(&lines, at, view);
let debug_lines_on_top = horizon_client::gizmos::camera_relative(&on_top, at, view);
let debug_crosshair = horizon_client::gizmos::camera_relative(&crosshair.on_top, at, view);
+ // AirDef fork: while a film records, the clouds' wind runs on the film's own frames, not the
+ // world's ticks (so two exports of one recording show the same sky at frame N).
+ let cloud_clock = self.film_cloud_clock();
// LevelExtractor.extractBlockOutline: model translucency controls draw order.
let mut outline_after_terrain = false;
// GameRenderer.shouldRenderBlockOutline: not with the HUD hidden (F1), and only for a
@@ -11880,7 +11884,14 @@ impl Game<'_> {
.collect();
let hd_lights = self.mod_lights(camera, alpha);
let hd_spots = self.mod_spots(camera, alpha);
- let (hd_smoke, hd_smoke_clock) = self.mod_smoke(camera, alpha);
+ // The smoke volume is ray-marched inside the fog pass: it is drawn only where that runs (fog
+ // on, an Overworld sky, not under water). The mods are told exactly that, so a mod keeps its
+ // own smoke where HD would draw none.
+ let smoke_drawn = horizon_client::gfx::hd_smoke_possible()
+ && self.dim == Dim::Overworld
+ && self.settings.horizon.sky
+ && !underwater;
+ let (hd_smoke, hd_smoke_clock) = self.mod_smoke(camera, alpha, smoke_drawn);
// AirDef fork: HD's fog reads the ground's height round the camera.
let hd_ground = |x: i32, z: i32| {
self.world
@@ -11969,8 +11980,8 @@ impl Game<'_> {
texture: &self.pack.clouds,
uv: [(white.u0 + white.u1) / 2.0, (white.v0 + white.v1) / 2.0],
camera,
- time: self.world.game_time,
- partial_tick: alpha,
+ time: cloud_clock.map_or(self.world.game_time, |c| c.0),
+ partial_tick: cloud_clock.map_or(alpha, |c| c.1),
range: self.settings.cloud_range,
fast: self.settings.clouds == "fast",
color: sky.cloud_color,
diff --git a/crates/horizon-client/src/game/film.rs b/crates/horizon-client/src/game/film.rs
index 2ae449fc5..1535eb281 100644
--- a/crates/horizon-client/src/game/film.rs
+++ b/crates/horizon-client/src/game/film.rs
@@ -446,6 +446,40 @@ fn write_wav(raw: &Path, wav: &Path) -> Result<bool, String> {
Ok(true)
}
+/// The frames left in flight at the end, in order, each to the writer exactly once. A readback
+/// or send error is returned (the film is then not passed off as complete).
+fn send_rest(
+ rest: Result<Vec<CapturedFrame>, String>,
+ sink: Option<&mpsc::SyncSender<Msg>>,
+ sent: &mut u32,
+) -> Result<(), String> {
+ let frames = rest.map_err(|e| format!("the last frames could not be read back: {e}"))?;
+ for f in frames {
+ let Some(tx) = sink else {
+ return Err("the writer is gone: the last frames were lost".into());
+ };
+ tx.send(Msg::Frame(f))
+ .map_err(|_| "the writer stopped: the last frames were lost".to_string())?;
+ *sent += 1;
+ }
+ Ok(())
+}
+
+/// The cloud clock of frame number `stepped` of a film at `fps`: (whole game ticks, the fraction
+/// of a tick), from the film's own frame count alone.
+pub(super) fn film_cloud_time(stepped: u64, fps: u32) -> (i64, f64) {
+ let fps = u64::from(fps.max(1));
+ let ticks = stepped * 20 / fps;
+ let rest = (stepped * 20 % fps) as f64 / fps as f64;
+ (ticks as i64, rest)
+}
+
+/// The listener's orientation: the main view's (a replay or film camera) whenever there is one,
+/// else the player's own. Only the orientation: the position is the player's either way.
+pub(super) fn listener_orientation(main: Option<(f32, f32)>, own: (f32, f32)) -> (f32, f32) {
+ main.unwrap_or(own)
+}
+
impl FilmState {
/// Whether frames are being recorded now.
pub(super) fn recording(&self) -> bool {
@@ -456,6 +490,12 @@ impl FilmState {
}
impl Game<'_> {
+ /// The clouds' time for the frame being drawn while a film records, else None (the world's).
+ pub(super) fn film_cloud_clock(&self) -> Option<(i64, f64)> {
+ let film = self.film.film.as_ref().filter(|f| f.phase == Phase::Recording)?;
+ Some(film_cloud_time(film.stepped, film.req.fps))
+ }
+
/// Start of a frame: a mod's start or stop, the writer's end, and the time step to run the
/// frame by: `real` normally, exactly one frame of the film while it records.
pub(super) fn film_step(&mut self, real: Duration) -> Duration {
@@ -479,9 +519,10 @@ impl Game<'_> {
film.stepped += 1;
let at = |k: u64| (k * 1_000_000_000 + fps / 2) / fps;
// Animations on wall time follow the film's frames, not the machine's speed.
- horizon_client::film_clock::set(Some(
- film.origin + Duration::from_nanos(at(n + 1)),
- ));
+ horizon_client::film_clock::set(Some((
+ film.origin,
+ Duration::from_nanos(at(n + 1)),
+ )));
Duration::from_nanos(at(n + 1) - at(n))
}
_ => real,
@@ -523,6 +564,7 @@ impl Game<'_> {
let cancelled = film.cancel;
let before = film.before;
let audio = film.req.audio;
+ let note = film.note.clone();
self.film.film = None;
if audio {
self.audio.film_end();
@@ -533,7 +575,9 @@ impl Game<'_> {
frames,
size,
file: o.file,
- message: if cancelled && !o.ok {
+ message: if !note.is_empty() && (cancelled || !o.ok) {
+ note
+ } else if cancelled && !o.ok {
"cancelled".into()
} else {
o.message
@@ -659,7 +703,7 @@ impl Game<'_> {
film.phase = Phase::Recording;
film.stepped = 0;
film.origin = std::time::Instant::now();
- horizon_client::film_clock::set(Some(film.origin));
+ horizon_client::film_clock::set(Some((film.origin, Duration::ZERO)));
if film.req.audio {
self.audio.film_begin();
}
@@ -779,21 +823,17 @@ impl Game<'_> {
};
}
Phase::Recording => {
+ let mut cancel = cancel;
if let Some(r) = self.renderer.as_mut() {
r.capture_next = false;
- let rest = if cancel {
+ if cancel {
r.capture_discard();
- Ok(Vec::new())
} else {
- r.capture_ready(0).and_then(|_| {
- // Everything in flight, waited for.
- r.capture_ready(0)
- })
- };
- if let (Ok(frames), Some(tx)) = (rest, &film.sink) {
- for f in frames {
- film.sent += 1;
- let _ = tx.send(Msg::Frame(f));
+ // One wait for everything in flight; every frame of it goes on.
+ let rest = r.capture_ready(0).map_err(|e| e.to_string());
+ if let Err(e) = send_rest(rest, film.sink.as_ref(), &mut film.sent) {
+ film.note = e;
+ cancel = true;
}
}
}
@@ -821,3 +861,71 @@ impl Game<'_> {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn frame(n: u8) -> CapturedFrame {
+ CapturedFrame {
+ size: [1, 1],
+ bgra: true,
+ data: vec![n, 0, 0, 255],
+ }
+ }
+
+ fn drain(rx: &mpsc::Receiver<Msg>) -> Vec<u8> {
+ rx.try_iter()
+ .filter_map(|m| match m {
+ Msg::Frame(f) => Some(f.data[0]),
+ _ => None,
+ })
+ .collect()
+ }
+
+ /// #79: whatever is still in flight at the stop (0, 1 or 2 frames, ready or not) reaches the
+ /// writer once, in order; none is thrown away.
+ #[test]
+ fn the_frames_in_flight_at_the_stop_all_reach_the_writer() {
+ for pending in 0..=3u8 {
+ let (tx, rx) = mpsc::sync_channel(8);
+ let mut sent = 7;
+ let rest = (0..pending).map(|i| frame(10 + i)).collect();
+ send_rest(Ok(rest), Some(&tx), &mut sent).unwrap();
+ assert_eq!(sent, 7 + u32::from(pending));
+ assert_eq!(drain(&rx), (0..pending).map(|i| 10 + i).collect::<Vec<_>>());
+ }
+ }
+
+ /// #84: the main view's orientation is used with or without a capture.
+ #[test]
+ fn the_listener_follows_the_main_view_in_every_filming_mode() {
+ assert_eq!(listener_orientation(Some((90.0, 5.0)), (0.0, 0.0)), (90.0, 5.0));
+ assert_eq!(listener_orientation(None, (12.0, -3.0)), (12.0, -3.0));
+ }
+
+ /// #80: the cloud wind of frame N depends on N and the frame rate only.
+ #[test]
+ fn the_cloud_clock_is_the_films_frames() {
+ // One second of film is 20 ticks at any frame rate; the frame in between is a fraction.
+ for fps in [24u32, 30, 60] {
+ assert_eq!(film_cloud_time(u64::from(fps), fps), (20, 0.0));
+ assert_eq!(film_cloud_time(u64::from(fps) * 5, fps).0, 100);
+ }
+ let (t, p) = film_cloud_time(1, 60);
+ assert_eq!(t, 0);
+ assert!((p - 20.0 / 60.0).abs() < 1e-12);
+ // Pure: the same frame, the same sky.
+ assert_eq!(film_cloud_time(777, 30), film_cloud_time(777, 30));
+ }
+
+ #[test]
+ fn a_failed_readback_or_dead_writer_is_an_error_not_a_short_success() {
+ let (tx, rx) = mpsc::sync_channel(8);
+ let mut sent = 0;
+ assert!(send_rest(Err("gpu".into()), Some(&tx), &mut sent).is_err());
+ drop(rx);
+ assert!(send_rest(Ok(vec![frame(1)]), Some(&tx), &mut sent).is_err());
+ assert_eq!(sent, 0);
+ }
+}
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
index 78ee8448b..283acaa8a 100644
--- a/crates/horizon-client/src/game/mod_models.rs
+++ b/crates/horizon-client/src/game/mod_models.rs
@@ -96,6 +96,7 @@ impl Game<'_> {
&mut self,
_camera: glam::DVec3,
_alpha: f64,
+ _drawn: bool,
) -> (Vec<horizon_client::gfx::HdSmoke>, f32) {
(Vec::new(), 0.0)
}
@@ -310,8 +311,11 @@ impl Game<'_> {
&mut self,
camera: DVec3,
alpha: f64,
+ drawn: bool,
) -> (Vec<horizon_client::gfx::HdSmoke>, f32) {
- let on = self.mod_hd().is_some() && horizon_client::gfx::hd_smoke_enabled();
+ // What the mod is told is what is drawn: HD on, the smoke pass possible, and this frame's
+ // fog pass (an Overworld sky, above water) running.
+ let on = self.mod_hd().is_some() && drawn;
self.mods.mods.set_smoke_active(on);
let Some((before, now)) = self.mods.mods.smoke_clock().filter(|_| on) else {
return (Vec::new(), 0.0);
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
index a2a6e78b6..06fa09705 100644
--- a/crates/horizon-client/src/gfx.rs
+++ b/crates/horizon-client/src/gfx.rs
@@ -87,7 +87,7 @@ mod native_post;
pub use hd::set_film_quality;
pub use hd::{
HdLight, HdSettings, HdSmoke, HdSpot, MAX_SPOTS as HD_MAX_SPOTS,
- smoke_enabled as hd_smoke_enabled, spots_enabled as hd_spots_enabled,
+ smoke_enabled as hd_smoke_enabled, smoke_possible as hd_smoke_possible, spots_enabled as hd_spots_enabled,
};
pub use mod_views::ModView;
mod native_sky;
diff --git a/crates/horizon-client/src/gfx/hd/mod.rs b/crates/horizon-client/src/gfx/hd/mod.rs
index d8c015a28..7744784d5 100644
--- a/crates/horizon-client/src/gfx/hd/mod.rs
+++ b/crates/horizon-client/src/gfx/hd/mod.rs
@@ -97,7 +97,7 @@ pub use frame::{HdLight, HdScene, HdSettings, HdSpot, MAX_SPOTS, light_of, vanil
pub use gbuffer::{ALBEDO, LIGHT_LEVELS, NORMAL};
pub use materials::{Table as MaterialTable, table as material_table, wind_enabled, wind_seconds};
pub use shadows::{CASCADES, SHADOW_TINT, cascades, color_shadows_enabled};
-pub use smoke::{HdSmoke, smoke_enabled};
+pub use smoke::{HdSmoke, smoke_enabled, smoke_possible};
pub use taa::{film_quality, jitter, jitter_count, scale_override, set_film_quality, taa_enabled};
pub use volumes::clouds_enabled;
@@ -746,7 +746,7 @@ impl Hd {
lights: &[HdLight],
clock: f32,
) {
- if scene.sky && volumes::fog_enabled() && smoke_enabled() {
+ if scene.sky && smoke::smoke_possible() {
self.smoke.prepare(queue, scene, smoke, lights, clock);
} else {
self.smoke.frame = Default::default();
diff --git a/crates/horizon-client/src/gfx/hd/smoke.rs b/crates/horizon-client/src/gfx/hd/smoke.rs
index 266f5fdef..34f3fd77c 100644
--- a/crates/horizon-client/src/gfx/hd/smoke.rs
+++ b/crates/horizon-client/src/gfx/hd/smoke.rs
@@ -64,6 +64,30 @@ pub fn smoke_enabled() -> bool {
*ON.get_or_init(|| std::env::var("HORIZON_HD_SMOKE").map_or(true, |v| v != "0"))
}
+/// Whether the smoke can be drawn at all: its volume is ray-marched in the fog pass, so it needs
+/// the volumetric fog as well as `HORIZON_HD_SMOKE`. This is what the mods are told.
+pub fn smoke_possible() -> bool {
+ smoke_possible_with(super::volumes::fog_enabled(), smoke_enabled())
+}
+
+fn smoke_possible_with(fog: bool, smoke: bool) -> bool {
+ fog && smoke
+}
+
+#[cfg(test)]
+mod possible_tests {
+ use super::smoke_possible_with;
+
+ /// #81: the answer to the mods is the real one: no fog pass, no smoke promised.
+ #[test]
+ fn smoke_is_promised_only_with_the_fog_pass() {
+ assert!(smoke_possible_with(true, true));
+ assert!(!smoke_possible_with(false, true));
+ assert!(!smoke_possible_with(true, false));
+ assert!(!smoke_possible_with(false, false));
+ }
+}
+
/// A segment of smoke as the game hands it over for a frame, relative to the camera.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
--
2.54.0 (Apple Git-157)
+351 -15
View File
@@ -8,6 +8,7 @@
Запуск: python3 tools/gen_models.py
"""
import json
import math
import os
import sys
import random
@@ -1301,20 +1302,355 @@ ITEMS = [
]
def item_icon(color, seed):
rnd = random.Random(seed)
im = Image.new("RGBA", (16, 16), (0, 0, 0, 0))
for y in range(2, 14):
for x in range(4, 12):
im.putpixel((x, y), tuple(clamp(c + rnd.randint(-10, 10)) for c in color) + (255,))
for x in range(5, 11):
im.putpixel((x, 3), (230, 230, 230, 255))
im.putpixel((7, 6), (255, 60, 60, 255))
im.putpixel((8, 6), (255, 60, 60, 255))
for y in range(13, 16):
im.putpixel((7, y), (40, 40, 40, 255))
im.putpixel((8, y), (40, 40, 40, 255))
return im
# Иконки 16×16 без сглаживания. Каждый предмет рисуется своей функцией: категория задаёт основу
# (машина ПВО, пуск с пламенем, РЛС, склад, прожектор, целеуказатель, стрелки, сворачивание),
# внутри категории — мини-силуэт типа. Цвет предмета — только заливка, форма различает предметы.
class _Icon:
def __init__(self, color, seed):
self.rnd = random.Random(seed)
self.im = Image.new("RGBA", (16, 16), (0, 0, 0, 0))
self.pal = {
"b": tuple(color),
"d": tuple(clamp(v * 0.6) for v in color),
"l": tuple(clamp(v + 45) for v in color),
"k": (30, 30, 34), "r": (255, 60, 60), "o": (255, 150, 40),
"y": (255, 232, 120), "w": (236, 236, 236),
}
def px(self, x, y, key):
if 0 <= x < 16 and 0 <= y < 16:
c = self.pal[key]
if key in ("b", "d", "l"):
c = tuple(clamp(v + self.rnd.randint(-6, 6)) for v in c)
self.im.putpixel((x, y), c + (255,))
def rect(self, x0, y0, x1, y1, key):
for y in range(y0, y1 + 1):
for x in range(x0, x1 + 1):
self.px(x, y, key)
def line(self, x0, y0, x1, y1, key):
dx, dy = abs(x1 - x0), -abs(y1 - y0)
sx, sy = (1 if x0 < x1 else -1), (1 if y0 < y1 else -1)
err = dx + dy
while True:
self.px(x0, y0, key)
if x0 == x1 and y0 == y1:
break
e2 = 2 * err
if e2 >= dy:
err += dy
x0 += sx
if e2 <= dx:
err += dx
y0 += sy
def disc(self, cx, cy, r, key):
for y in range(cy - r - 1, cy + r + 2):
for x in range(cx - r - 1, cx + r + 2):
if (x - cx) ** 2 + (y - cy) ** 2 <= r * r + 0.5 * r:
self.px(x, y, key)
def ring(self, cx, cy, lo, hi, key):
for y in range(16):
for x in range(16):
if lo <= (x - cx) ** 2 + (y - cy) ** 2 <= hi:
self.px(x, y, key)
def poly(self, pts, key):
# Выпуклый многоугольник: заливка по строкам между крайними пересечениями рёбер.
n = len(pts)
for y in range(min(p[1] for p in pts), max(p[1] for p in pts) + 1):
xs = []
for i in range(n):
(x1, y1), (x2, y2) = pts[i], pts[(i + 1) % n]
if y1 == y2:
if y1 == y:
xs += [x1, x2]
elif min(y1, y2) <= y <= max(y1, y2):
xs.append(x1 + (y - y1) * (x2 - x1) / (y2 - y1))
if xs:
for x in range(math.ceil(min(xs) - 1e-9), math.floor(max(xs) + 1e-9) + 1):
self.px(x, y, key)
def _sam_base(c):
c.rect(2, 8, 13, 11, "b")
c.rect(3, 12, 4, 13, "k")
c.rect(11, 12, 12, 13, "k")
# Развёртывание ПВО: машина + пусковая установка.
def _gepard(c): # ствольная турель, два длинных ствола
_sam_base(c)
c.disc(7, 6, 2, "b")
c.line(9, 5, 15, 5, "d")
c.line(9, 7, 15, 7, "d")
def _pantsir(c): # ствольная турель с ракетными контейнерами сверху
_sam_base(c)
c.disc(7, 7, 2, "b")
c.rect(4, 3, 5, 5, "l")
c.rect(9, 3, 10, 5, "l")
c.line(9, 6, 11, 6, "d")
def _s400(c): # три вертикальных контейнера
_sam_base(c)
for x in (3, 6, 9):
c.rect(x, 2, x + 1, 7, "b")
c.rect(x, 2, x + 1, 2, "l")
def _s500(c): # четыре вертикальных контейнера
_sam_base(c)
for x in (2, 5, 8, 11):
c.rect(x, 2, x + 1, 7, "b")
c.rect(x, 2, x + 1, 2, "l")
def _patriot(c): # наклонный пусковой короб
_sam_base(c)
c.poly([(3, 8), (11, 3), (13, 5), (5, 10)], "b")
c.line(7, 7, 9, 5, "d")
def _buk(c): # наклонные направляющие с ракетой
_sam_base(c)
c.line(3, 8, 10, 2, "d")
c.line(5, 9, 12, 3, "d")
c.disc(12, 2, 1, "w")
def _irist(c): # два наклонных контейнера
_sam_base(c)
c.poly([(3, 8), (7, 3), (9, 4), (5, 9)], "b")
c.poly([(7, 9), (11, 3), (13, 4), (9, 10)], "b")
def _nasams(c): # два горизонтальных контейнера
_sam_base(c)
c.rect(3, 2, 12, 3, "b")
c.rect(3, 5, 12, 6, "b")
c.line(3, 4, 12, 4, "d")
def _tor(c): # сетка коротких вертикальных ячеек
_sam_base(c)
for x in (3, 6, 9):
c.rect(x, 2, x + 1, 3, "b")
c.rect(x, 5, x + 1, 6, "b")
def _p1sun(c): # короб с мачтой
_sam_base(c)
c.rect(3, 5, 12, 7, "b")
c.line(7, 1, 7, 4, "d")
c.px(7, 1, "r")
# Радиолокация и РЭБ.
def _radar(c): # параболическая антенна на стойке
c.disc(8, 5, 3, "l")
c.line(8, 8, 8, 12, "d")
c.rect(5, 13, 11, 14, "d")
def _ew(c): # штырь-антенна с сигнальными дугами
c.line(8, 6, 8, 14, "d")
c.disc(8, 4, 1, "b")
c.line(5, 2, 4, 4, "l")
c.line(4, 4, 4, 6, "l")
c.line(4, 6, 5, 8, "l")
c.line(11, 2, 12, 4, "l")
c.line(12, 4, 12, 6, "l")
c.line(12, 6, 11, 8, "l")
# Снабжение.
def _depot(c): # штабель ящиков на поддоне
c.rect(3, 8, 12, 13, "b")
c.line(3, 10, 12, 10, "d")
c.line(3, 12, 12, 12, "d")
c.rect(2, 14, 13, 14, "d")
c.rect(4, 4, 9, 7, "l")
c.line(4, 6, 9, 6, "d")
def _tzm(c): # грузовик с кабиной и кузовом
c.rect(2, 7, 5, 11, "b")
c.rect(3, 8, 4, 9, "w")
c.rect(6, 4, 14, 11, "l")
c.rect(3, 12, 4, 13, "k")
c.rect(10, 12, 11, 13, "k")
c.rect(13, 12, 14, 13, "k")
# Прожектор.
def _searchlight(c):
c.rect(3, 4, 10, 8, "b")
c.poly([(10, 4), (15, 1), (15, 11), (10, 8)], "y")
c.line(6, 9, 6, 13, "d")
c.rect(3, 14, 9, 14, "d")
# Целеуказатель: кольцо с засечками.
def _designator(c):
c.ring(8, 8, 16, 26, "r")
c.rect(7, 1, 8, 2, "r")
c.rect(7, 13, 8, 14, "r")
c.rect(1, 7, 2, 8, "r")
c.rect(13, 7, 14, 8, "r")
c.rect(7, 7, 8, 8, "r")
# Пуски: огонь сзади, силуэт типа спереди.
def _launch_base(c):
c.rect(0, 7, 1, 8, "o")
c.px(0, 7, "y")
def _shahed(c): # треугольное крыло
_launch_base(c)
c.poly([(5, 2), (5, 13), (13, 7)], "b")
def _geran3(c): # реактивный самолёт со стреловидным крылом
_launch_base(c)
c.rect(3, 7, 13, 8, "b")
c.poly([(6, 7), (9, 2), (11, 2), (9, 7)], "d")
c.poly([(6, 8), (9, 13), (11, 13), (9, 8)], "d")
c.poly([(3, 7), (4, 3), (5, 3), (5, 7)], "d")
def _lancet(c): # малый дрон с прямым крылом
_launch_base(c)
c.rect(3, 7, 12, 8, "b")
c.rect(7, 2, 8, 13, "d")
c.rect(3, 6, 4, 9, "d")
def _kh101(c): # крылатая ракета: стреловидные крылья и хвост
_launch_base(c)
c.rect(3, 7, 12, 8, "b")
c.poly([(12, 6), (15, 7), (15, 8), (12, 9)], "b")
c.poly([(6, 7), (9, 3), (10, 3), (8, 7)], "d")
c.poly([(6, 8), (9, 12), (10, 12), (8, 8)], "d")
c.rect(2, 5, 3, 10, "d")
def _kalibr(c): # крылатая ракета: тонкий корпус и четыре хвостовых киля
_launch_base(c)
c.rect(2, 7, 13, 8, "b")
c.poly([(13, 6), (15, 7), (15, 8), (13, 9)], "b")
c.rect(2, 4, 3, 5, "d")
c.rect(2, 10, 3, 11, "d")
def _kh22(c): # толстый корпус с большими дельта-крыльями
_launch_base(c)
c.rect(3, 6, 11, 9, "b")
c.poly([(11, 6), (15, 7), (15, 8), (11, 9)], "b")
c.poly([(4, 6), (8, 2), (9, 2), (7, 6)], "d")
c.poly([(4, 9), (8, 13), (9, 13), (7, 9)], "d")
def _zircon(c): # гиперзвук: корпус с одним передним стрейком
_launch_base(c)
c.rect(2, 7, 12, 8, "b")
c.poly([(12, 7), (15, 7), (12, 8)], "b")
c.poly([(8, 7), (12, 3), (13, 3), (11, 7)], "d")
def _iskander(c): # баллистическая: длинный корпус, конус, сопло
_launch_base(c)
c.rect(3, 7, 11, 8, "b")
c.poly([(11, 6), (15, 7), (15, 8), (11, 9)], "l")
c.rect(2, 6, 2, 9, "d")
def _oreshnik(c): # баллистическая с тремя головными частями
_launch_base(c)
c.rect(2, 6, 10, 9, "b")
c.poly([(10, 5), (13, 6), (10, 7)], "l")
c.poly([(10, 8), (13, 9), (10, 10)], "l")
c.poly([(10, 6), (15, 7), (10, 8)], "b")
def _kinzhal(c): # аэробаллистическая: корпус с треугольными килями
_launch_base(c)
c.rect(3, 7, 11, 8, "b")
c.poly([(11, 6), (15, 7), (11, 9)], "b")
c.poly([(4, 7), (7, 3), (8, 3), (6, 7)], "d")
c.poly([(4, 8), (7, 12), (8, 12), (6, 8)], "d")
def _mover(c): # четыре стрелки перемещения
c.poly([(8, 0), (4, 5), (12, 5)], "b")
c.rect(7, 4, 8, 7, "b")
c.poly([(8, 15), (4, 10), (12, 10)], "b")
c.rect(7, 8, 8, 11, "b")
c.poly([(0, 8), (5, 4), (5, 12)], "b")
c.rect(4, 7, 7, 8, "b")
c.poly([(15, 8), (10, 4), (10, 12)], "b")
c.rect(8, 7, 11, 8, "b")
def _remover(c): # стрелка вниз над лотком: свёртывание
c.poly([(8, 9), (3, 4), (13, 4)], "b")
c.rect(7, 1, 8, 4, "b")
c.rect(2, 11, 13, 13, "b")
c.line(2, 11, 13, 11, "l")
def _flare(c): # звезда осветительной ракеты на стержне
c.disc(8, 7, 1, "y")
c.line(8, 2, 8, 4, "y")
c.line(8, 10, 8, 12, "y")
c.line(3, 7, 5, 7, "y")
c.line(11, 7, 13, 7, "y")
c.line(4, 4, 5, 5, "w")
c.line(11, 4, 10, 5, "w")
c.line(4, 10, 5, 9, "w")
c.line(11, 10, 10, 9, "w")
c.rect(7, 12, 8, 15, "k")
ICON_DRAW = {
"designator": _designator,
"launch_shahed": _shahed, "launch_geran3": _geran3, "launch_lancet": _lancet,
"launch_kh101": _kh101, "launch_kalibr": _kalibr, "launch_kh22": _kh22,
"launch_zircon": _zircon, "launch_iskander": _iskander, "launch_oreshnik": _oreshnik,
"launch_kinzhal": _kinzhal,
"deploy_patriot": _patriot, "deploy_gepard": _gepard, "deploy_pantsir": _pantsir,
"deploy_s400": _s400, "deploy_s500": _s500, "deploy_irist": _irist,
"deploy_nasams": _nasams, "deploy_buk": _buk, "deploy_tor": _tor, "deploy_p1sun": _p1sun,
"deploy_radar": _radar, "deploy_ew": _ew,
"deploy_depot": _depot, "deploy_tzm": _tzm,
"deploy_light": _searchlight,
"mover": _mover, "remover": _remover, "flare_gun": _flare,
}
def item_icon(name, color, seed):
c = _Icon(color, seed)
ICON_DRAW[name](c)
# Контур по краю силуэта: тёмные предметы — светлым, светлые — тёмным, чтобы читались на обоих фонах.
# Альфа-маска не меняется: контур перекрашивает только уже нарисованные пиксели.
lum = 0.3 * color[0] + 0.59 * color[1] + 0.11 * color[2]
edge = (160, 160, 160) if lum < 100 else (70, 70, 70) if lum > 190 else None
if edge:
src = c.im.copy()
def opaque(x, y):
return 0 <= x < 16 and 0 <= y < 16 and src.getpixel((x, y))[3] > 0
for y in range(16):
for x in range(16):
if opaque(x, y) and not all(opaque(x + dx, y + dy) for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))):
c.im.putpixel((x, y), edge + (255,))
return c.im
def write_json(path, obj):
@@ -1350,7 +1686,7 @@ def main():
im = GT.burn(im, zlib.crc32(name.encode()), "glass" if base.startswith("glass") else "paint")
im.save(os.path.join(a, "textures", "block", name + ".png"))
for i, (name, title, color) in enumerate(ITEMS):
item_icon(color, i).save(os.path.join(a, "textures", "item", name + ".png"))
item_icon(name, color, zlib.crc32(name.encode())).save(os.path.join(a, "textures", "item", name + ".png"))
write_json(os.path.join(a, "models", "item", name + ".json"),
{"parent": "minecraft:item/generated", "textures": {"layer0": f"{NS}:item/{name}"}})
write_json(os.path.join(a, "items", name + ".json"),
+36 -6
View File
@@ -20,7 +20,11 @@
Поэтому кадры не сравниваются целиком: по кадрам одного прогона пиксель, у которого размах значений между
кадрами больше --motion-level (16 из 255), считается движущимся (вода, облака, листва). Такие пиксели не
участвуют в сравнении с базой (ни в одном из двух прогонов). Мерцание считается по остальным пикселям:
размах между кадрами больше --flicker-level (6 из 255) и не больше --motion-level. Для движущейся части
размах между кадрами больше --flicker-level (6 из 255) и не больше --motion-level. Сильное мерцание
по кадрам одного прогона от движения не отличить, поэтому «движение» само ограничено: доля непроверенных
пикселей (--max-animated), сумма «неустойчивых» (--max-flicker), пиксели, ставшие движущимися против
БАЗЫ (--max-new-animated: маска берётся из базы, не из проверяемого прогона), и доля проверяемых
пикселей (--min-static: ноль статичных — не «ноль ошибки», а провал). Для движущейся части
выводится отдельная средняя разница, только для информации. Картинка `<сцена>_motion.png` показывает маску.
Коды выхода: 0 — всё в порогах и бюджете; 1 — есть превышение; 2 — ошибка запуска или аргументов.
@@ -337,19 +341,31 @@ def motion_range(rgbs):
def flicker_stats(rng, w, h, n_frames, motion_level, flicker_level):
"""По размаху каждого пикселя между кадрами одного прогона. `pct` — мерцание: размах в
(flicker_level, motion_level]; `animated_pct` — выше motion_level (движение: вода, облака,
листва; ИЛИ очень сильное мерцание — по одному прогону их не различить). `unsteady_pct` — сумма
обоих: все пиксели с размахом выше flicker_level, она не падает, когда мерцание усиливается и
переходит из одной корзины в другую. `unverified_pct` — доля пикселей, которые этот прогон
ничем не проверяет (то же, что animated_pct); `static_pct` — доля проверяемых."""
npx = w * h
flick = sum(1 for r in rng if flicker_level < r <= motion_level)
moving = sum(1 for r in rng if r > motion_level)
return {"pct": 100.0 * flick / npx, "animated_pct": 100.0 * moving / npx,
"unsteady_pct": 100.0 * (flick + moving) / npx,
"unverified_pct": 100.0 * moving / npx, "static_pct": 100.0 * (npx - moving) / npx,
"frames": n_frames, "size": [w, h]}
def compare_runs(cur_rgb, cur_rng, base_rgb, base_rng, w, h, motion_level, diff_out=None, motion_out=None):
"""Первый кадр текущего прогона против первого кадра базы. Движущиеся пиксели (в любом из двух
прогонов) в сравнении не участвуют: mean и pct — только по статичным; для движущихся — отдельная
средняя разница (информация, не порог)."""
"""Первый кадр текущего прогона против первого кадра базы. Из сравнения исключаются только
пиксели, которые двигались уже в БАЗЕ (маска воспроизводимая, не зависит от того, что показал
проверяемый прогон): mean и pct — по остальным; для движущихся — отдельная средняя разница
(информация, не порог). Пиксель, ставший «движущимся» только в текущем прогоне (мерцание,
призраки TAA), из сравнения не исключается и считается в new_animated_pct. Доля проверяемых
пикселей — static_pct: при нуле mean не «0 ошибки», а отсутствие проверки (см. judge)."""
npx = w * h
static = bytes(1 if (a <= motion_level and b <= motion_level) else 0 for a, b in zip(cur_rng, base_rng))
static = bytes(1 if b <= motion_level else 0 for b in base_rng)
new_anim = sum(1 for a, b in zip(cur_rng, base_rng) if a > motion_level and b <= motion_level)
d = bytes(map(abs, map(operator.sub, cur_rgb[0], base_rgb[0])))
dpix = _per_pixel_max(d)
n_static = sum(static)
@@ -358,6 +374,7 @@ def compare_runs(cur_rgb, cur_rng, base_rgb, base_rng, w, h, motion_level, diff_
over = sum(1 for p, s in zip(dpix, static) if s and p > DIFF_LEVEL)
moving_mean = (sum(itertools.compress(d, _expand3(bytes(1 - s for s in static)))) / (3.0 * n_moving)
if n_moving else 0.0)
cur_anim = sum(1 for a in cur_rng if a > motion_level)
if diff_out:
img = bytearray(min(255, v * DIFF_GAIN) for v in d)
for i, s in enumerate(static):
@@ -368,7 +385,9 @@ def compare_runs(cur_rgb, cur_rng, base_rgb, base_rng, w, h, motion_level, diff_
if motion_out:
write_png_rgb(motion_out, w, h, bytes(_expand3(bytes(0 if s else 255 for s in static))))
return {"mean": mean, "pct": 100.0 * over / npx, "animated_pct": 100.0 * n_moving / npx,
"animated_mean": moving_mean, "size": [w, h]}
"animated_mean": moving_mean, "static_pct": 100.0 * n_static / npx,
"new_animated_pct": 100.0 * new_anim / npx, "cur_animated_pct": 100.0 * cur_anim / npx,
"size": [w, h]}
# --- тайминги -----------------------------------------------------------------------------------
@@ -482,11 +501,17 @@ def judge(entry, args):
fl = entry.get("flicker")
if fl and args.max_flicker is not None and fl["pct"] > args.max_flicker:
reasons.append(f"мерцание статичных пикселей {fl['pct']:.2f}% > {args.max_flicker:g}%")
if fl and fl["animated_pct"] > args.max_animated:
reasons.append(f"не проверено (размах > {args.motion_level}/255) {fl['animated_pct']:.1f}% кадра > {args.max_animated:g}%")
base = entry.get("vs_baseline")
if base:
if base.get("error"):
reasons.append(base["error"])
else:
if base["static_pct"] < args.min_static:
reasons.append(f"проверяемых пикселей {base['static_pct']:.1f}% < {args.min_static:g}%: сравнение с базой ничего не доказывает")
if base["new_animated_pct"] > args.max_new_animated:
reasons.append(f"новых движущихся пикселей против базы {base['new_animated_pct']:.2f}% > {args.max_new_animated:g}%")
if base["mean"] > args.max_mean:
reasons.append(f"разница с базой (статика) {base['mean']:.2f} > {args.max_mean:g}")
if base["pct"] > args.max_pct:
@@ -522,6 +547,8 @@ def write_report(out, entries, args, env_extra, size, baseline, meta):
fl = f"{e['flicker']['pct']:.2f}" if e.get("flicker") else "-"
mv = f"{e['flicker']['animated_pct']:.2f}" if e.get("flicker") else "-"
b = e.get("vs_baseline") or {}
if "new_animated_pct" in b: # what the baseline comparison covers and what grew
mv += f" (проверено {b['static_pct']:.0f}%, новых {b['new_animated_pct']:.2f}%)"
bm = f"{b['mean']:.2f}" if "mean" in b else "-"
bp = f"{b['pct']:.2f}" if "pct" in b else "-"
status = "OK" if not e["fail"] else "FAIL: " + "; ".join(e["fail"])
@@ -584,6 +611,9 @@ def parse_args(argv):
p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы статичных пикселей с базой, 0..255")
p.add_argument("--max-pct", type=float, default=5.0, help="порог статичных пикселей >20/255 с базой, %%")
p.add_argument("--max-flicker", type=float, default=None, help="порог мерцания статичных пикселей, %%; без него мерцание только в отчёте (порог пока не откалиброван двумя одинаковыми прогонами)")
p.add_argument("--max-animated", type=float, default=60.0, help="порог доли кадра, которую прогон не проверяет (размах выше --motion-level), %%; не откалиброван")
p.add_argument("--max-new-animated", type=float, default=2.0, help="порог пикселей, статичных в базе и движущихся в прогоне, %%; не откалиброван")
p.add_argument("--min-static", type=float, default=10.0, help="наименьшая доля проверяемых (статичных в базе) пикселей, %%")
p.add_argument("--no-freeze", action="store_true",
help="не замораживать игровое время и погоду (живой замер производительности, не снимки)")
p.add_argument("--no-pass-timing", action="store_true", help="не включать HORIZON_SHADER_PACK_TIMING")
+89
View File
@@ -0,0 +1,89 @@
"""Tests of tools/hd_bench.py's comparison (#82): `python3 -m unittest tools/test_hd_bench.py`."""
import argparse
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import hd_bench as hb # noqa: E402
def frame(w, h, v):
return bytes([v]) * (3 * w * h)
def args(**kw):
a = argparse.Namespace(budget_ms=18.0, max_flicker=1.0, max_animated=60.0, max_mean=3.0,
max_pct=5.0, max_new_animated=2.0, min_static=10.0, motion_level=hb.MOTION_LEVEL)
a.__dict__.update(kw)
return a
def judged(rgbs, base_rgbs, w=2, h=2):
rng = hb.motion_range(rgbs)
brng = hb.motion_range(base_rgbs)
entry = {"gpu_avg_ms": 1.0}
entry["flicker"] = hb.flicker_stats(rng, w, h, len(rgbs), hb.MOTION_LEVEL, hb.FLICKER_LEVEL)
entry["vs_baseline"] = hb.compare_runs(rgbs, rng, base_rgbs, brng, w, h, hb.MOTION_LEVEL)
return entry, hb.judge(entry, args())
class FlickerIsNotAnimation(unittest.TestCase):
def test_the_issues_black_white_strobe_fails(self):
# The issue: a 2x2 frame, the baseline steadily black, the run alternating black and white.
entry, reasons = judged([frame(2, 2, 0), frame(2, 2, 255)] * 2, [frame(2, 2, 0)] * 3)
self.assertEqual(entry["flicker"]["pct"], 0.0) # the old blind spot is still in these two numbers ...
self.assertEqual(entry["flicker"]["animated_pct"], 100.0)
self.assertEqual(entry["flicker"]["unsteady_pct"], 100.0)
self.assertEqual(entry["vs_baseline"]["new_animated_pct"], 100.0)
self.assertTrue(reasons, "... but the verdict is a failure")
def test_stronger_flicker_never_starts_passing(self):
w = h = 4
base = [frame(w, h, 100)] * 3
failed = []
unsteady = []
for amp in (8, 16, 32, 255):
lo, hi = 100 - min(amp // 2, 100), min(255, 100 + (amp + 1) // 2)
run = [frame(w, h, lo), frame(w, h, hi), frame(w, h, lo)]
entry, reasons = judged(run, base, w, h)
failed.append(bool(reasons))
unsteady.append(entry["flicker"]["unsteady_pct"])
self.assertEqual(failed, [True] * 4, failed)
self.assertEqual(unsteady, sorted(unsteady))
def test_a_fully_dynamic_frame_is_not_a_zero_error(self):
w = h = 2
moving = [frame(w, h, 0), frame(w, h, 255)]
entry, reasons = judged(moving, moving)
self.assertEqual(entry["vs_baseline"]["static_pct"], 0.0)
self.assertTrue(any("проверяемых" in r for r in reasons), reasons)
def test_water_and_foliage_that_also_move_in_the_baseline_pass(self):
w, h = 10, 10
n = w * h
def mixed(a, b): # the left 20% animates (water), the rest is still
f = bytearray(frame(w, h, 90))
for px in range(n // 5):
f[3 * px:3 * px + 3] = bytes([a]) * 3
return bytes(f)
run = [mixed(40, 40), mixed(120, 40), mixed(60, 40)]
base = [mixed(30, 40), mixed(130, 40), mixed(70, 40)]
entry, reasons = judged(run, base, w, h)
self.assertEqual(entry["vs_baseline"]["new_animated_pct"], 0.0)
self.assertEqual(reasons, [])
def test_the_mask_comes_from_the_baseline_not_from_the_run(self):
# Growth of the animated area against the baseline is counted and bounded.
w = h = 10
base = [frame(w, h, 90)] * 3
f = bytearray(frame(w, h, 90))
f[0:30] = bytes([250]) * 30 # 10% of the pixels
run = [bytes(f), frame(w, h, 90), bytes(f)]
entry, reasons = judged(run, base, w, h)
self.assertAlmostEqual(entry["vs_baseline"]["new_animated_pct"], 10.0)
self.assertTrue(reasons)
if __name__ == "__main__":
unittest.main()