Searchlights with real spot lights and volumetric beams (#60); replay director: several angles per event, slow-motion kills, /airdef replay film and export to MP4 (#58) (fork patch 32)
build / mod (push) Successful in 3m32s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 09:28:13 +03:00
parent 8a12225ba1
commit 76f680eba6
40 changed files with 3827 additions and 52 deletions
+2
View File
@@ -151,6 +151,8 @@ API мода `render.set-lights` (до 512, плавно интерполиру
Команды:
- `/airdef wave <тип> [кол-во] [дальность]` — налёт на ближайшую цель. Типы: `shahed`, `geran3`, `lancet`, `gerbera`, `kh101`, `kalibr`, `kh22`, `zircon`, `iskander`, `kinzhal`, `oreshnik` и `mix` (комбинированный удар двумя секторами: «Герберы», шахеды, Герани-3, Х-101, «Калибры», Х-22, Циркон, «Искандер», «Кинжал», при 30+ — «Орешник»);
- `/airdef replay [map|full|reel|stop]` — повтор последнего налёта;
- `/airdef replay film [best|all|N]` — фильм режиссёра: для каждого перехвата/прилёта несколько планов (комплекс, пуск, погоня за ракетой, взгляд с цели, замедленный крупный план поражения, общий план после), склейки, плавные движения камеры, камера не уходит под землю; играется в максимальном качестве (HD, мир в полном разрешении, больше шагов облаков и тумана; настройки возвращаются после);
- `/airdef replay export [имя] [best|all|N] [fps=60] [size=1920x1080] [crf=14] [sec=N] [noaudio]` — тот же фильм кадр за кадром с фиксированным шагом (скорость компьютера не важна) в MP4 с звуком: `<HORIZON_HOME>/airdef-films/<имя>.mp4` (нужен `ffmpeg` в PATH или `HORIZON_FFMPEG`; без него — PNG-последовательность и готовая команда ffmpeg). Прогресс в чате, отмена — `/airdef replay stop`. Окно игры держите в фокусе (пауза останавливает съёмку); чат попадает в кадр.
- `/airdef import` — перенести в текущий мир комплексы и цели из старой версии мода (до 0.2.x они были общими для всех миров);
- `/airdef raid` — налёт, настроенный в меню; `/airdef target here` — отметить объект под ногами; `/airdef repair` — ремонт;
- `/airdef roles` — показать, по каким классам целей работает каждый комплекс; `/airdef roles <patriot|gepard|pantsir|s400|s500|all> <классы|default>` — задать (классы через запятую: `drone`, `jet`, `cruise`, `supersonic`, `ballistic` или `all`), например `/airdef roles s400 cruise,ballistic`;
+575
View File
@@ -0,0 +1,575 @@
//! The replay director: a raid's best moments planned as a film. Every event becomes a few shots
//! from different angles (the launcher, behind the interceptor, ahead of the target, a slow-motion
//! close-up at the kill, a wide aftermath), joined by cuts, with eased camera moves and the time
//! slowed round the moment itself.
//!
//! The plan is made once, from the recording alone: nothing random, nothing read from the live
//! raid, so the same film comes out every time. The land is asked for before the film starts (the
//! planner needs the heights under every camera and along every line of sight); each shot has a
//! few candidate placements, and the one the land hides least is kept; the camera is lifted over
//! the ground wherever it would dip into it. Only what the recording knows is shown: a launch is
//! the interceptor's first point, a kill the recorded event; no lock-on or detection is invented.
use crate::replay::{interest, title};
use crate::sim::record::{EventKind, Recording, STEP};
use crate::sim::Ground;
use glam::DVec3;
use std::collections::{HashMap, HashSet};
/// Seconds of film in one game tick.
pub const TICK_S: f64 = 0.05;
/// Seconds between the samples the planner checks a shot at.
const SAMPLE_S: f64 = 0.5;
/// Ticks the planner waits for the land's answers.
const PREP_TICKS: u32 = 160;
/// A land lattice cell, as the ground samples it.
const CELL: f64 = 8.0;
/// Seconds of black at the start and the end.
const FADE_S: f64 = 0.7;
/// Which moments a film is made of.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Which {
/// The three best.
Best,
/// Every worthwhile one.
All,
/// The n best.
Top(usize),
}
#[derive(Clone, Debug)]
enum Aim {
Track(u32, DVec3),
Point(DVec3),
/// Between two tracks (the interceptor and its target): both stay in frame.
Mid(u32, u32, DVec3),
}
#[derive(Clone, Debug)]
enum Eye {
/// From one place to another, eased over the shot.
Dolly(DVec3, DVec3),
/// Behind a track, a little above and to its side.
Behind { track: u32, back: f64, up: f64, side: f64 },
/// In front of a track, looking back at what comes.
Ahead { track: u32, ahead: f64, up: f64, side: f64 },
Static(DVec3),
}
#[derive(Clone, Debug)]
struct Rig {
eye: Eye,
aim: Aim,
fov: (f32, f32),
}
struct Shot {
start: f64,
len: f64,
/// The replay's tick at every `TICK_S` of the shot.
table: Vec<f64>,
variants: Vec<Rig>,
pick: usize,
/// The least height the eye keeps, at the planner's samples.
clear: Vec<f32>,
head: String,
sub: String,
event: usize,
}
/// Where the camera is, and what the replay shows, at a moment of the film.
pub struct Frame {
pub shot: usize,
pub eye: DVec3,
pub look: DVec3,
pub fov: f32,
/// The replay's tick.
pub t: f64,
pub event: usize,
/// 0 to 1 how visible the caption is.
pub caption: f32,
/// 0 to 1 how black the picture is (the film's start and end).
pub black: f32,
}
pub struct Film {
shots: Vec<Shot>,
pub len: f64,
cells: Vec<(i32, i32)>,
heights: HashMap<(i32, i32), Option<f64>>,
waited: u32,
pub ready: bool,
}
fn smooth(x: f64) -> f64 {
let x = x.clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x)
}
fn cell(p: DVec3) -> (i32, i32) {
((p.x / CELL).floor() as i32, (p.z / CELL).floor() as i32)
}
fn cell_centre(c: (i32, i32)) -> DVec3 {
DVec3::new((c.0 as f64 + 0.5) * CELL, 0.0, (c.1 as f64 + 0.5) * CELL)
}
/// Where a track was at `t`, held at its ends.
fn at(rec: &Recording, id: u32, t: f64) -> Option<DVec3> {
let tr = rec.track(id)?;
tr.at(rec.origin, t.clamp(tr.first as f64, tr.last() as f64))
}
/// Its heading, from a window wide enough not to shake with the recording's steps.
fn heading(rec: &Recording, id: u32, t: f64) -> DVec3 {
let w = 3.0 * STEP as f64;
match (at(rec, id, t - w), at(rec, id, t + w)) {
(Some(a), Some(b)) => (b - a).normalize_or(DVec3::NEG_Z),
_ => DVec3::NEG_Z,
}
}
/// Horizontal, across the line from `from` to `at`.
fn across(at: DVec3, from: DVec3) -> DVec3 {
DVec3::new(-(at - from).z, 0.0, (at - from).x).normalize_or(DVec3::X)
}
impl Aim {
fn at(&self, rec: &Recording, t: f64) -> DVec3 {
match self {
Aim::Track(id, fallback) => at(rec, *id, t).unwrap_or(*fallback),
Aim::Point(p) => *p,
Aim::Mid(a, b, fallback) => match (at(rec, *a, t), at(rec, *b, t)) {
(Some(a), Some(b)) => a.lerp(b, 0.5),
(Some(p), None) | (None, Some(p)) => p,
_ => *fallback,
},
}
}
}
impl Rig {
/// The eye, the look-at point and the field of view at replay tick `t`, a fraction `f` into the shot.
fn at(&self, rec: &Recording, t: f64, f: f64) -> (DVec3, DVec3, f32) {
let look = self.aim.at(rec, t);
let eye = match &self.eye {
Eye::Dolly(a, b) => a.lerp(*b, smooth(f)),
Eye::Static(p) => *p,
Eye::Behind { track, back, up, side } => match at(rec, *track, t) {
Some(p) => {
let d = heading(rec, *track, t);
let s = across(p + d, p);
p - d * *back + DVec3::Y * *up + s * *side
}
None => look + DVec3::new(30.0, 10.0, 30.0),
},
Eye::Ahead { track, ahead, up, side } => match at(rec, *track, t) {
Some(p) => {
let d = heading(rec, *track, t);
let s = across(p + d, p);
p + d * *ahead + DVec3::Y * *up + s * *side
}
None => look + DVec3::new(30.0, 10.0, 30.0),
},
};
let fov = self.fov.0 + (self.fov.1 - self.fov.0) * smooth(f) as f32;
(eye, look, fov)
}
}
impl Shot {
fn new(a: f64, b: f64, profile: &[(f64, f64)], variants: Vec<Rig>, head: &str, sub: &str, event: usize) -> Shot {
// Replay ticks a game tick of film plays: the profile's speed at the replay's moment.
let speed = |t: f64| -> f64 {
let mut v = profile.first().map_or(1.0, |p| p.1);
for w in profile.windows(2) {
if t >= w[0].0 && t <= w[1].0 {
let k = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-9);
v = w[0].1 + (w[1].1 - w[0].1) * k;
} else if t > w[1].0 {
v = w[1].1;
}
}
v.max(0.02)
};
let mut table = vec![a];
let mut t = a;
while t < b && table.len() < 4000 {
t = (t + speed(t)).min(b);
table.push(t);
}
if table.len() < 2 {
table.push(b.max(a));
}
let len = (table.len() - 1) as f64 * TICK_S;
Shot { start: 0.0, len, table, variants, pick: 0, clear: Vec::new(), head: head.into(), sub: sub.into(), event }
}
/// The replay's tick `local` seconds into the shot.
fn t_at(&self, local: f64) -> f64 {
let k = (local / TICK_S).clamp(0.0, (self.table.len() - 1) as f64);
let i = (k.floor() as usize).min(self.table.len() - 1);
let j = (i + 1).min(self.table.len() - 1);
self.table[i] + (self.table[j] - self.table[i]) * (k - i as f64)
}
fn samples(&self) -> usize {
((self.len / SAMPLE_S).ceil() as usize + 1).clamp(3, 17)
}
}
/// The events a film of `which` is made of, in the order they happened.
pub fn pick(rec: &Recording, which: Which) -> Vec<usize> {
let (n, floor) = match which {
Which::Best => (3, 2.0),
Which::All => (12, 2.0),
Which::Top(n) => (n.clamp(1, 12), 2.0),
};
let mut best: Vec<(f64, usize)> = (0..rec.events.len()).map(|i| (interest(rec, i), i)).filter(|b| b.0 >= floor).collect();
best.sort_by(|a, b| b.0.total_cmp(&a.0));
let mut chosen: Vec<usize> = Vec::new();
for (_, i) in best {
if chosen.len() >= n {
break;
}
let at = rec.events[i].at as i64;
if chosen.iter().all(|&c| (rec.events[c].at as i64 - at).abs() > 160) {
chosen.push(i);
}
}
chosen.sort_by_key(|&i| rec.events[i].at);
chosen
}
/// The four placements a shot is tried in: either side, low and lifted.
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> {
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);
// 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);
let b = (te + 48.0).min(end).max(a + 4.0);
let variants = placements(|side, lift| {
let s = across(pos, from) * side;
let near = pos + s * 42.0 + DVec3::Y * (6.0 + lift * 0.4);
let closer = pos + s * 26.0 + DVec3::Y * (4.0 + lift * 0.3);
Rig {
eye: Eye::Dolly(near, closer),
aim: aim_id.map_or(Aim::Point(pos), |id| Aim::Track(id, pos)),
fov: (44.0, 30.0),
}
});
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);
if b - a < 20.0 {
return None;
}
let variants = placements(|side, lift| {
let s = across(pos, from) * side;
Rig {
eye: Eye::Dolly(pos + s * 90.0 + DVec3::Y * (34.0 + lift), pos + s * 150.0 + DVec3::Y * (52.0 + lift)),
aim: Aim::Point(pos + DVec3::Y * 6.0),
fov: (58.0, 52.0),
}
});
Some(Shot::new(a, b, &[(a, 1.0), (b, 1.4)], variants, head, "", i))
};
match e.kind {
EventKind::Kill { drone, kind, sam, sam_kind, sam_index, missile } => {
let sp = rec.sam(sam).map_or(pos - DVec3::new(150.0, 0.0, 0.0), |s| s.1);
let name = format!("{} №{}", sam_kind.name(), sam_index + 1);
let sub = format!("Перехват «{}»", kind.spec().name);
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),
});
add(Shot::new(a3, b3, &[(a3, sp3)], 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));
}
}
let high = (pos.y - sp.y).max(0.0);
add(kill_close(sp, Some(drone), "Поражение цели", format!("высота {high:.0} м")));
if let Some(s) = aftermath(sp, "") {
add(s);
}
}
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));
let sub = match e.kind {
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => {
format!("сила взрыва: {}", ["хлопок", "средний", "сильный", "тяжёлый", "мощнейший"][(power as usize).min(4)])
}
_ => String::new(),
};
add(kill_close(from, None, "Прилёт", sub));
if let Some(s) = aftermath(from, "") {
add(s);
}
}
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));
add(kill_close(from, None, "Комплекс уничтожен", String::new()));
if let Some(s) = aftermath(from, "") {
add(s);
}
}
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 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));
}
}
shots
}
impl Film {
/// The film of the best moments of recording `rec`, or None when there is nothing to show.
pub fn build(rec: &Recording, which: Which) -> Option<Film> {
let events = pick(rec, which);
if events.is_empty() {
return None;
}
let mut shots: Vec<Shot> = Vec::new();
for i in events {
shots.extend(plan_event(rec, i));
}
let mut start = 0.0;
for s in &mut shots {
s.start = start;
start += s.len;
}
let len = start;
// Every land cell the planner will look at: under each sample's eye and along its line of sight.
let mut cells: HashSet<(i32, i32)> = HashSet::new();
for s in &shots {
let n = s.samples();
for rig in &s.variants {
for k in 0..n {
let f = k as f64 / (n - 1) as f64;
let (eye, look, _) = rig.at(rec, s.t_at(f * s.len), f);
cells.insert(cell(eye));
let steps = ((eye.distance(look) / CELL) as usize).min(40);
for j in 1..steps {
cells.insert(cell(eye.lerp(look, j as f64 / steps as f64)));
}
}
}
}
let mut cells: Vec<(i32, i32)> = cells.into_iter().collect();
cells.sort_unstable();
Some(Film { shots, len, cells, heights: HashMap::new(), waited: 0, ready: false })
}
/// Asks the game for the land the plan needs; true once the shots are placed. Call every tick.
pub fn prepare(&mut self, rec: &Recording, ground: &mut Ground) -> bool {
if self.ready {
return true;
}
let mut pending = 0;
for c in &self.cells {
if self.heights.contains_key(c) {
continue;
}
match ground.land_state(cell_centre(*c)) {
Some(h) => {
self.heights.insert(*c, h);
}
None => pending += 1,
}
}
self.waited += 1;
if pending == 0 || self.waited > PREP_TICKS {
self.place(rec);
self.ready = true;
}
self.ready
}
/// How far the land's answers are, for a progress line.
pub fn progress(&self) -> f32 {
if self.cells.is_empty() { 1.0 } else { self.heights.len() as f32 / self.cells.len() as f32 }
}
/// Picks each shot's placement: the one with the fewest hidden samples (a ridge across the line of sight).
fn place(&mut self, rec: &Recording) {
let land = |h: &HashMap<(i32, i32), Option<f64>>, p: DVec3| h.get(&cell(p)).copied().flatten();
for s in &mut self.shots {
let n = s.samples();
let mut best = (usize::MAX, 0usize);
for (v, rig) in s.variants.iter().enumerate() {
let mut hidden = 0;
for k in 0..n {
let f = k as f64 / (n - 1) as f64;
let (eye, look, _) = rig.at(rec, s.t_at(f * s.len), f);
let steps = ((eye.distance(look) / CELL) as usize).min(40);
// The camera inside the ground counts a little: it is lifted, but a better place is kept.
if land(&self.heights, eye).is_some_and(|g| eye.y < g + 1.5) {
hidden += 1;
}
for j in 1..steps.saturating_sub(1) {
let u = j as f64 / steps as f64;
let p = eye.lerp(look, u);
if land(&self.heights, p).is_some_and(|g| g > p.y - 1.0) {
hidden += 3;
break;
}
}
}
if hidden < best.0 {
best = (hidden, v);
}
}
s.pick = best.1;
let rig = &s.variants[s.pick];
s.clear = (0..n)
.map(|k| {
let f = k as f64 / (n - 1) as f64;
let (eye, _, _) = rig.at(rec, s.t_at(f * s.len), f);
land(&self.heights, eye).map_or(f32::MIN, |g| g as f32 + 3.0)
})
.collect();
}
}
/// The shot at video time `tau`, with its local time.
fn locate(&self, tau: f64) -> (usize, f64) {
let i = self.shots.iter().rposition(|s| s.start <= tau).unwrap_or(0);
(i, (tau - self.shots[i].start).clamp(0.0, self.shots[i].len))
}
/// The camera and the replay's moment at video time `tau` (seconds from the film's start).
pub fn frame(&self, rec: &Recording, tau: f64) -> Frame {
let (i, local) = self.locate(tau);
let s = &self.shots[i];
let f = if s.len > 0.0 { local / s.len } else { 0.0 };
let t = s.t_at(local);
let (mut eye, look, fov) = s.variants[s.pick.min(s.variants.len() - 1)].at(rec, t, f);
if !s.clear.is_empty() {
let x = f * (s.clear.len() - 1) as f64;
let k = (x.floor() as usize).min(s.clear.len() - 1);
let l = (k + 1).min(s.clear.len() - 1);
// The least of the two neighbours that is known, so an unknown never lowers the floor.
let (a, b) = (s.clear[k], s.clear[l]);
let floor = if a == f32::MIN || b == f32::MIN { a.max(b) } else { a + (b - a) * (x - k as f64) as f32 };
if floor > f32::MIN {
eye.y = eye.y.max(floor as f64);
}
}
// Captions come in after a beat and go out before the cut.
let caption = if s.head.is_empty() && s.sub.is_empty() {
0.0
} else {
(smooth((local - 0.25) / 0.4).min(smooth((s.len - 0.2 - local) / 0.4))) as f32
};
let black = smooth(1.0 - tau / FADE_S).max(smooth(1.0 - (self.len - tau) / FADE_S)) as f32;
Frame { shot: i, eye, look, fov, t, event: s.event, caption, black }
}
/// The caption of a shot: (head, sub).
pub fn caption(&self, shot: usize) -> (&str, &str) {
self.shots.get(shot).map_or(("", ""), |s| (s.head.as_str(), s.sub.as_str()))
}
pub fn shots(&self) -> usize {
self.shots.len()
}
/// The video time a shot starts at.
pub fn start_of(&self, shot: usize) -> f64 {
self.shots.get(shot).map_or(0.0, |s| s.start)
}
/// A one-line plan, for the chat.
pub fn summary(&self) -> String {
let events: HashSet<usize> = self.shots.iter().map(|s| s.event).collect();
format!("{} моментов, {} планов, {:.0} с", events.len(), self.shots.len(), self.len)
}
}
+215 -5
View File
@@ -7,12 +7,13 @@ use horizon_mod_sdk::{
Mod, Registration, chat, command, game,
hud::{self, Corner},
input::{self, Event},
render::{self, Lighting, ModelInstanceId, ParticleSpawn, PointLight},
render::{self, Lighting, ModelInstanceId, ParticleSpawn, PointLight, SpotLight},
storage,
ui::{self, ButtonWidget, ItemWidget, ListWidget, Screen, Widget},
};
mod cinema;
mod film;
mod monitor;
mod panel;
mod radar;
@@ -75,6 +76,10 @@ struct AirDef {
wave: wave::WaveConfig,
/// A replay playing (it takes the screen and the mouse wheel).
replay: Option<replay::Player>,
/// An MP4 export in progress (`/airdef replay export`).
export: Option<Export>,
/// The effects preset a film replaced: (shader, hd).
film_prev: Option<(bool, bool)>,
/// The world drawn beyond the render distance (replays and cameras see far off).
far: bool,
/// The unit the move remote picked (its id), waiting for where to go.
@@ -101,6 +106,29 @@ struct AirDef {
by_id: Vec<ModelInstanceId>,
}
/// What `/airdef replay export` was asked for.
struct ExportOpts {
name: String,
which: String,
fps: u32,
size: (u32, u32),
crf: u32,
audio: bool,
seconds: Option<f64>,
}
#[derive(PartialEq)]
enum Stage {
Planning,
Arming,
Recording,
Finishing,
}
struct Export {
opts: ExportOpts,
stage: Stage,
ticks: u32,
}
/// A sound heard later: when, what, where, and how loud and high it was.
struct PendingSound {
due: u64,
@@ -377,8 +405,9 @@ impl AirDef {
if !s.alive() && s.burning() {
add(s.id + 15, s.pos + DVec3::Y * 1.5, (1.0, 0.5, 0.18), 80.0 * flicker(s.id), 30.0);
}
// A searchlight's spot where its beam meets the target.
if s.kind == SamKind::Light
// Without the renderer's spot lights, a searchlight's spot where its beam meets the target.
if !self.sim.spot_hd
&& s.kind == SamKind::Light
&& let Some(t) = s.target.and_then(|t| self.sim.drones.iter().find(|d| d.id == t))
&& self.sim.lit.contains(&t.id)
{
@@ -388,6 +417,35 @@ impl AirDef {
lights
}
/// 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())
.take(16)
.map(|b| SpotLight {
id: b.id,
x: b.origin.x,
y: b.origin.y,
z: b.origin.z,
dx: b.dir.x as f32,
dy: b.dir.y as f32,
dz: b.dir.z as f32,
color: (0.95, 0.97, 1.0),
intensity: 90_000.0,
range: (b.free + 3.5).min(1024.0) as f32,
inner_angle: sim::lights::BEAM_INNER as f32,
outer_angle: sim::lights::BEAM_OUTER as f32,
beam: 1.0,
})
.collect()
}
/// The effects preset: none, the post pass, or the post pass under the HD lighting.
fn set_effects(&mut self, shader: bool, hd: bool) {
self.sim.shader = shader;
@@ -398,6 +456,9 @@ impl AirDef {
}
self.hd = shader && hd;
self.apply_hd();
if !self.hd {
self.sim.spot_hd = false;
}
self.dirty = true;
}
@@ -407,6 +468,7 @@ impl AirDef {
// (The punch of the next blast sets its own.)
if !self.hd {
render::set_lights(&[]);
let _ = render::set_spot_lights(&[]);
}
}
@@ -545,6 +607,7 @@ impl AirDef {
render::set_lighting(Lighting { hd: true, exposure: 1.0 + 0.4 * p, bloom: 0.55 + 0.9 * p });
}
render::set_lights(&self.lights(&out));
self.sim.spot_hd = render::set_spot_lights(&self.spots());
let (clock, segments) = self.shown_smoke();
self.smoke.hd = render::set_smoke(clock, &segments);
} else {
@@ -893,6 +956,14 @@ impl AirDef {
}
fn stop_replay(&mut self) {
if self.export.take().is_some() {
render::capture_stop(true);
say("Экспорт отменён");
}
render::set_film_quality(false);
if let Some((shader, hd)) = self.film_prev.take() {
self.set_effects(shader, hd);
}
self.replay = None;
render::set_time_override(None);
render::set_main_view(None);
@@ -902,6 +973,140 @@ impl AirDef {
self.monitor_shown = false;
}
/// 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> {
let n = self.sim.replays.len();
if n == 0 {
return Err("Повторов пока нет: запишется первый же налёт (когда небо очистится)".into());
}
let which = match which {
"best" => film::Which::Best,
"all" => film::Which::All,
n => film::Which::Top(n.parse().map_err(|_| "/airdef replay film [best|all|N]")?),
};
let rec = &self.sim.replays[n - 1];
let f = film::Film::build(rec, which).ok_or("В этом налёте нечего показать")?;
let mut player = replay::Player::new(rec.number, replay::Mode::Film(Box::new(f)), &self.sim);
if let Some(o) = export {
player.wait = true;
player.clean = true;
player.limit = o.seconds;
}
self.replay = Some(player);
self.grid = false;
input::claim_wheel(true);
// The best picture: HD on, the renderer's film quality (full-size world, more cloud and fog steps).
if self.film_prev.is_none() {
self.film_prev = Some((self.sim.shader, self.hd));
}
self.set_effects(true, true);
render::set_film_quality(true);
Ok(())
}
/// `/airdef replay export [имя] [best|all|N] [fps=60] [size=1920x1080] [crf=14] [sec=N] [noaudio]`.
fn start_export(&mut self, args: &[&str]) -> Result<(), String> {
let mut o = ExportOpts { name: String::new(), which: "best".into(), fps: 60, size: (1920, 1080), crf: 14, audio: true, seconds: None };
for a in args {
if let Some(v) = a.strip_prefix("fps=") {
o.fps = v.parse().map_err(|_| "fps=число")?;
} else if let Some(v) = a.strip_prefix("size=") {
let (w, h) = v.split_once('x').ok_or("size=1920x1080")?;
o.size = (w.parse().map_err(|_| "size=1920x1080")?, h.parse().map_err(|_| "size=1920x1080")?);
} else if let Some(v) = a.strip_prefix("crf=") {
o.crf = v.parse().map_err(|_| "crf=число")?;
} else if let Some(v) = a.strip_prefix("sec=") {
o.seconds = Some(v.parse().map_err(|_| "sec=число")?);
} else if *a == "noaudio" {
o.audio = false;
} else if matches!(*a, "best" | "all") || a.parse::<usize>().is_ok() {
o.which = a.to_string();
} else {
o.name = a.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')).collect();
}
}
if self.export.is_some() {
return Err("Экспорт уже идёт (/airdef replay stop — отмена)".into());
}
if o.name.is_empty() {
let n = self.sim.replays.last().map_or(0, |r| r.number);
o.name = format!("raid{n}-{}", o.which);
}
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 });
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 };
ex.ticks += 1;
let st = render::capture_status();
let failed = |s: &render::CaptureState| s.phase == render::CapturePhase::Failed;
let mut finish: Option<Result<String, String>> = None;
match ex.stage {
Stage::Planning if r.film_ready() => {
let o = &ex.opts;
let res = render::capture_start(&render::CaptureOptions {
folder: "airdef-films".into(),
name: o.name.clone(),
fps: o.fps,
width: o.size.0,
height: o.size.1,
crf: o.crf,
audio: o.audio,
warmup: 8,
});
match res {
Ok(()) => {
say(&format!("Кадры готовы ({}); запись…", r.film_summary()));
ex.stage = Stage::Arming;
}
Err(e) => finish = Some(Err(e)),
}
}
Stage::Arming => {
if st.phase == render::CapturePhase::Recording {
r.wait = false;
ex.stage = Stage::Recording;
ex.ticks = 0;
} else if failed(&st) {
finish = Some(Err(st.message.clone()));
}
}
Stage::Recording => {
if failed(&st) {
finish = Some(Err(st.message.clone()));
} else if r.ended {
render::capture_stop(false);
ex.stage = Stage::Finishing;
} else if ex.ticks % 100 == 0 {
let len = r.film_len().unwrap_or(1.0).max(0.1);
say(&format!("Экспорт: {:.0}% ({} кадров)", (r.film_time() / len * 100.0).min(100.0), st.frames));
}
}
Stage::Finishing => match st.phase {
render::CapturePhase::Done => {
let note = if st.message.is_empty() { String::new() } else { format!(" ({})", st.message) };
finish = Some(Ok(format!("{}x{}, {} кадров: {}{note}", st.width, st.height, st.frames, st.file)));
}
render::CapturePhase::Failed => finish = Some(Err(st.message.clone())),
_ => {}
},
_ => {}
}
if let Some(res) = finish {
self.export = None;
match res {
Ok(m) => say(&format!("Экспорт готов: {m}")),
Err(e) => say(&format!("Экспорт не удался: {e}")),
}
r.done = true;
}
}
/// What a raid strikes: the marked objects, else where the player stands.
fn objects(&self, player: DVec3) -> Vec<DVec3> {
let live: Vec<DVec3> = self
@@ -1090,7 +1295,7 @@ impl AirDef {
}
}
const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|stop] | scenario save|load|list|delete | why [номер] | raid | repair | import | ew on|off | flare | move <номер> | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | truth on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats";
const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|film [best|all|N]|export [имя] [best|all|N] [fps=60 size=1920x1080 crf=14 sec=N noaudio]|stop] | scenario save|load|list|delete | why [номер] | raid | repair | import | ew on|off | flare | move <номер> | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | truth on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats";
impl Mod for AirDef {
fn init() -> Result<(Self, Registration), String> {
@@ -1107,6 +1312,8 @@ impl Mod for AirDef {
grid_ids: Vec::new(),
wave: wave::WaveConfig::default(),
replay: None,
export: None,
film_prev: None,
smoke: Default::default(),
far: true,
moving: None,
@@ -1258,7 +1465,8 @@ impl Mod for AirDef {
out.sounds.clear();
out.shake = None;
}
r.tick(&self.sim, self.listener, &mut out);
r.tick(&self.sim, &mut self.ground, self.listener, &mut out);
self.export_step(&mut r);
if r.done {
self.replay = Some(r);
self.stop_replay();
@@ -1527,6 +1735,8 @@ impl Mod for AirDef {
["replay", rest @ ..] => {
match rest.first().copied().unwrap_or("map") {
"stop" => self.stop_replay(),
"film" => self.start_film(rest.get(1).copied().unwrap_or("best"), None)?,
"export" => self.start_export(&rest[1..])?,
what => self.start_replay(what, None)?,
}
None
+174 -8
View File
@@ -4,7 +4,9 @@
//! world and a director's camera (a full-screen camera view) follows them —
//! behind the drone, then behind the interceptor or from the side — slowing
//! time round the moment itself.
use crate::film::{self, Film};
use crate::panel::distance;
use crate::sim::Ground;
use crate::sim::record::{EventKind, Recording, STEP, What};
use std::cell::Cell;
use crate::sim::{Class, DroneKind, Out, Sim};
@@ -23,6 +25,8 @@ pub enum Mode {
Full,
/// Clips one after another (the wheel skips): one event, or the highlight reel.
Clips { list: Vec<Clip>, at: usize, reel: bool },
/// The director's film: planned shots from several angles with cuts and slow motion.
Film(Box<Film>),
}
pub struct Clip {
@@ -55,6 +59,20 @@ pub struct Player {
heard: f64,
/// Time stands still (Space, or the end reached).
paused: bool,
/// The film's own clock in seconds (the replay's moment follows from the plan).
tau: f64,
/// The film waits for the recorder to start (an export).
pub wait: bool,
/// Titles and bars only: no keys, clock or help on the screen (an export).
pub clean: bool,
/// The film stops at this many seconds (a short test export).
pub limit: Option<f64>,
/// The film reached its end.
pub ended: bool,
/// Which of the two sets of ghost ids is in use (a cut changes it, so nothing glides across it).
alt: bool,
/// The film's shot, caption and blackness this tick.
cut: Option<(usize, f32, f32)>,
/// Ticks the controls stay on screen.
help: u32,
pub done: bool,
@@ -131,7 +149,7 @@ pub fn row(rec: &Recording, i: usize) -> String {
}
/// How worth watching an event is: the reel takes the best.
fn interest(rec: &Recording, i: usize) -> f64 {
pub fn interest(rec: &Recording, i: usize) -> f64 {
let e = &rec.events[i];
match e.kind {
EventKind::Kill { kind, missile, .. } => {
@@ -206,7 +224,7 @@ impl Player {
let speed = match mode {
Mode::Map => 8.0,
Mode::Full => 2.0,
Mode::Clips { .. } => 1.0,
Mode::Clips { .. } | Mode::Film(_) => 1.0,
};
let _ = sim;
Player {
@@ -226,6 +244,13 @@ impl Player {
fov: 50.0,
heard: start,
paused: false,
tau: 0.0,
wait: false,
clean: false,
limit: None,
ended: false,
alt: false,
cut: None,
help: 100,
done: false,
}
@@ -264,6 +289,13 @@ impl Player {
self.heard = self.t;
self.shot = (usize::MAX, 0);
}
Mode::Film(f) => {
// To the next or the previous plan.
let now = self.cut.map_or(0, |c| c.0);
let to = (now as i32 + dir).clamp(0, f.shots() as i32 - 1) as usize;
self.tau = f.start_of(to);
self.paused = false;
}
Mode::Clips { list, at, reel } => {
let next = (*at as i32 + dir).clamp(0, list.len() as i32 - 1) as usize;
// Back on the first moment: from its start again.
@@ -283,6 +315,7 @@ impl Player {
match &self.mode {
Mode::Map | Mode::Full => self.t >= rec.len as f64,
Mode::Clips { list, at, .. } => *at + 1 >= list.len() && self.t >= list[*at].to,
Mode::Film(_) => self.ended,
}
}
@@ -295,6 +328,8 @@ impl Player {
}
_ => 0.0,
};
self.tau = 0.0;
self.ended = false;
self.heard = self.t;
self.card = if reel { 50 } else { 0 };
self.fade = 6;
@@ -326,11 +361,15 @@ impl Player {
}
/// One tick: time runs, the ghosts fly, the director films. Models and sounds go to `out`.
pub fn tick(&mut self, sim: &Sim, listener: DVec3, out: &mut Out) {
pub fn tick(&mut self, sim: &Sim, ground: &mut Ground, listener: DVec3, out: &mut Out) {
let Some(rec) = sim.replay(self.rec) else {
self.done = true;
return;
};
if matches!(self.mode, Mode::Film(_)) {
self.tick_film(sim, ground, listener, out, rec);
return;
}
self.fade = self.fade.saturating_sub(1);
if self.card > 0 {
self.card -= 1;
@@ -360,6 +399,7 @@ impl Player {
self.paused = true;
}
}
Mode::Film(_) => {}
Mode::Clips { list, at, reel } => {
if self.t >= list[*at].to {
if *at + 1 < list.len() {
@@ -380,7 +420,11 @@ impl Player {
}
sim.draw_ghosts(rec, self.t, GHOSTS, out);
self.direct(rec);
// Sounds as the camera hears them: moved so the player stands where the camera is.
self.sounds(rec, listener, out);
}
/// Sounds as the camera hears them: moved so the player stands where the camera is.
fn sounds(&mut self, rec: &Recording, listener: DVec3, out: &mut Out) {
let heard = |p: DVec3| listener + (p - self.eye);
for e in rec.events.iter().filter(|e| (self.heard..self.t).contains(&(e.at as f64))) {
let event = match e.kind {
@@ -402,7 +446,125 @@ impl Player {
self.heard = self.t;
}
/// The event the camera films now, and how it shoots it: (eye, look, fov, shot).
/// One tick of the film: its clock runs, the plan gives the camera and the replay's moment.
fn tick_film(&mut self, sim: &Sim, ground: &mut Ground, listener: DVec3, out: &mut Out, rec: &Recording) {
let Mode::Film(mut film) = std::mem::replace(&mut self.mode, Mode::Map) else { return };
self.fade = self.fade.saturating_sub(1);
self.help = self.help.saturating_sub(1);
if !film.prepare(rec, ground) {
self.rate = 0.0;
self.mode = Mode::Film(film);
return;
}
let end = self.limit.map_or(film.len, |l| l.min(film.len));
if !self.paused && !self.wait && !self.ended {
self.tau += film::TICK_S;
}
if self.tau >= end {
self.tau = end;
self.ended = true;
// A live film stops on its last frame (Space plays it again); an export is ended by the caller.
if !self.clean {
self.paused = true;
}
}
let f = film.frame(rec, self.tau);
let changed = self.cut.is_none_or(|c| c.0 != f.shot);
if changed {
self.view = self.view.wrapping_add(1).max(VIEW);
self.alt = !self.alt;
self.heard = f.t;
self.fade = if self.cut.is_some() { 2 } else { 0 };
}
self.rate = if changed { 1.0 } else { (f.t - self.t).clamp(0.0, 1.0) };
self.t = f.t;
self.eye = f.eye;
self.look = f.look;
self.fov = f.fov;
self.shot = (f.event, f.shot.min(255) as u8);
self.cut = Some((f.shot, f.caption, f.black));
let base = GHOSTS + if self.alt { 0x0100_0000 } else { 0 };
sim.draw_ghosts(rec, self.t, base, out);
if !self.wait {
self.sounds(rec, listener, out);
} else {
self.heard = self.t;
}
self.mode = Mode::Film(film);
}
/// The film's length in seconds (what an export records), after the limit.
pub fn film_len(&self) -> Option<f64> {
match &self.mode {
Mode::Film(f) => Some(self.limit.map_or(f.len, |l| l.min(f.len))),
_ => None,
}
}
/// Whether the film's shots are placed (an export waits for it).
pub fn film_ready(&self) -> bool {
matches!(&self.mode, Mode::Film(f) if f.ready)
}
/// The film's clock, in seconds.
pub fn film_time(&self) -> f64 {
self.tau
}
/// A line about the film, for the chat.
pub fn film_summary(&self) -> String {
match &self.mode {
Mode::Film(f) => f.summary(),
_ => String::new(),
}
}
/// The film's screen: bars, captions, the black of its start and end.
fn film_hud(&self, rec: &Recording, film: &Film, sim: &Sim, gw: f32, gh: f32) -> Vec<Shape> {
let mut s: Vec<Shape> = Vec::new();
let bar = (gh * 0.09).round();
if !film.ready {
s.push(fill(0.0, 0.0, gw, gh, 0xFF000000));
s.push(text(format!("Готовлю кадры… {:.0}%", film.progress() * 100.0), gw / 2.0, gh / 2.0 - 4.0, 0xFFC0C0C0, true));
return s;
}
s.push(fill(0.0, 0.0, gw, bar, 0xFF000000));
s.push(fill(0.0, gh - bar, gw, bar, 0xFF000000));
let (shot, caption, black) = self.cut.unwrap_or((0, 0.0, 1.0));
if !self.clean {
let blink = (sim.tick / 10) % 2 == 0;
s.push(fill(8.0, bar / 2.0 - 3.0, 6.0, 6.0, if blink { 0xFFFF2020 } else { 0x60FF2020 }));
s.push(text(format!("ФИЛЬМ · налёт №{} · план {}/{}", rec.number, shot + 1, film.shots()), 18.0, bar / 2.0 - 4.0, 0xFFFFFFFF, false));
let time = format!("{} / {}{}", clock(self.tau * 20.0), clock(film.len * 20.0), if self.paused { " ПАУЗА" } else { "" });
let tw = horizon_mod_sdk::render::text_width(&time);
s.push(text(time, gw - 8.0 - tw, bar / 2.0 - 4.0, 0xFFC0C0C0, false));
// Progress.
s.push(fill(0.0, gh - bar - 2.0, gw * (self.tau / film.len.max(0.01)) as f32, 2.0, 0xC0FFD040));
}
// The caption: a lower third that comes in after the cut and goes before the next.
let (head, sub) = film.caption(shot);
if caption > 0.01 {
let a = (caption * 255.0) as u32;
let x = (gw * 0.07).round();
let y = gh - bar - 52.0;
let w = horizon_mod_sdk::render::text_width(head).max(horizon_mod_sdk::render::text_width(sub)) + 22.0;
s.push(fill(x - 8.0, y, 3.0, if sub.is_empty() { 18.0 } else { 30.0 }, (a << 24) | 0xFFD040));
s.push(fill(x - 5.0, y, w, if sub.is_empty() { 18.0 } else { 30.0 }, ((a * 140 / 255) << 24) | 0x000000));
s.push(text(head, x + 4.0, y + 5.0, (a << 24) | 0xFFFFFF, false));
if !sub.is_empty() {
s.push(text(sub, x + 4.0, y + 17.0, (a << 24) | 0xC0C0C0, false));
}
}
if self.fade > 0 {
s.push(fill(0.0, 0.0, gw, gh, ((self.fade.min(6) * 42) as u32) << 24));
}
if black > 0.003 {
s.push(fill(0.0, 0.0, gw, gh, (((black * 255.0) as u32).min(255)) << 24));
}
if !self.clean {
self.controls(&mut s, gw, gh, bar);
}
s
}
/// The event the camera films now: (eye, look, fov, shot).
fn shoot(&mut self, rec: &Recording, i: usize) -> (DVec3, DVec3, f32, u8) {
let e = rec.events[i];
let t = self.t;
@@ -478,7 +640,7 @@ impl Player {
.iter()
.position(|e| (self.t - 30.0..self.t + 120.0).contains(&(e.at as f64)))
.or(if self.shot.0 != usize::MAX && (rec.events[self.shot.0].at as f64) > self.t - 60.0 { Some(self.shot.0) } else { None }),
Mode::Map => None,
Mode::Map | Mode::Film(_) => None,
};
let (eye, look, fov, shot) = match focus {
Some(i) => {
@@ -522,9 +684,10 @@ impl Player {
let d = (self.look - self.eye).normalize_or(DVec3::NEG_Z);
let yaw = (-d.x).atan2(d.z).to_degrees() as f32;
let pitch = (-d.y).asin().to_degrees() as f32;
let width = 1280u32;
// The main view takes the window's size; the picture size only matters to the small views.
let width = 1920u32;
let height = ((width as f32) * gh / gw.max(1.0)) as u32;
vec![CameraView { id: self.view, x: self.eye.x, y: self.eye.y, z: self.eye.z, yaw, pitch, fov: self.fov, width, height: height.clamp(240, 1080) }]
vec![CameraView { id: self.view, x: self.eye.x, y: self.eye.y, z: self.eye.z, yaw, pitch, fov: self.fov, width, height: height.clamp(240, 2160) }]
}
/// The replay's screen: the film with its titles, or the map.
@@ -533,6 +696,9 @@ impl Player {
if matches!(self.mode, Mode::Map) {
return self.map(rec, gw, gh);
}
if let Mode::Film(f) = &self.mode {
return self.film_hud(rec, f, sim, gw, gh);
}
// The world itself is the camera's (`set-main-view`): the HUD only frames it.
let mut s: Vec<Shape> = Vec::new();
// Letterbox, the recording's marks.
+53 -5
View File
@@ -10,6 +10,13 @@ const FLARE_LIGHT: f64 = 90.0;
const FLARE_CLIMB: u32 = 28;
/// How far a searchlight's beam reaches.
pub const BEAM: f64 = 450.0;
/// A searchlight's cone: full strength inside the inner half angle, none past the outer (radians).
pub const BEAM_INNER: f64 = 0.03;
pub const BEAM_OUTER: f64 = 0.075;
/// A target is lit once the axis is this close to it (inside the cone's soft edge).
const LIT_ANGLE: f64 = 0.05;
/// Ticks between casts of the beam's axis against the land.
const CAST_EVERY: u64 = 4;
/// Guns' hits at night on an unlit target.
pub const DARK_HITS: f64 = 0.4;
/// Ticks between a gun's own flares.
@@ -35,7 +42,7 @@ impl Sim {
}
/// The flares and the searchlights, and what they light; the guns' own flares.
pub(super) fn step_lights(&mut self, out: &mut Out) {
pub(super) fn step_lights(&mut self, ground: &mut Ground, out: &mut Out) {
self.lit.clear();
let now = self.tick;
// Flares: up, a burst, then down slowly, swaying, lighting all round.
@@ -71,12 +78,14 @@ impl Sim {
let gun_targets: Vec<u32> = self.sams.iter().filter_map(|s| s.gun_target).collect();
for i in 0..self.sams.len() {
if self.sams[i].kind != SamKind::Light || !self.sams[i].alive() || self.sams[i].moving() {
self.sams[i].beam_on = false;
continue;
}
let (pos, id) = (self.sams[i].pos, self.sams[i].id);
if !self.night {
let sam = &mut self.sams[i];
sam.target = None;
sam.beam_on = false;
sam.pitch += (0.0 - sam.pitch) * 0.05;
continue;
}
@@ -103,15 +112,34 @@ impl Sim {
sam.yaw = slew(sam.yaw, want_yaw, 0.12);
sam.pitch = slew(sam.pitch, want_pitch.clamp(0.05, 1.5), 0.08);
sam.target = target.map(|t| t.0);
// The beam: segments widening with distance, out to the target.
// The beam: a spot light from the head (see `Sam::beam`); the axis is cast against the land
// now and then, and the light and the lit mark stop where the land does.
let (head, rot) = self.sams[i].mount();
let dir = rot * DVec3::NEG_Z;
// The target is lit once the beam is on it, not while it still swings round.
let sam = &mut self.sams[i];
sam.beam_on = true;
if now.saturating_sub(sam.beam_cast) >= CAST_EVERY || sam.beam_free <= 0.0 {
sam.beam_cast = now;
sam.beam_free = match ground.world.probe(head + dir, head + dir * BEAM, now) {
world::Sweep::Hit { point, .. } => point.distance(head).max(1.0),
_ => BEAM,
};
}
let free = sam.beam_free;
// The target is lit once the beam is on it and nothing of the land is between, not while
// it still swings round.
if let Some((t, at)) = target
&& dir.angle_between(at - head) < 0.08
&& dir.angle_between(at - head) < LIT_ANGLE
&& (at - head).length() <= free + 2.0
{
self.lit.push(t);
}
// Without the renderer's spot light: segments widening with distance, thin; none with
// the effects off.
if self.spot_hd || !self.shader {
continue;
}
let reach = reach.min(free);
let mut along = 1.0;
// Up to 14 segments (the unit's part ids 2..15), each longer than the last: one
// reaches about 550 blocks, past the beam's reach, about 9 blocks wide out there.
@@ -119,7 +147,7 @@ impl Sim {
if along >= reach {
break;
}
let size = 0.8 + along * 0.12;
let size = 0.5 + along * 0.1;
let len = 3.0 * size;
out.model(id + 2 + k, "airdef:beam", head + dir * (along + len / 2.0), rot, size, true);
along += len;
@@ -147,3 +175,23 @@ impl Sim {
}
}
}
/// A searchlight's beam for the renderer: where it starts, which way it points and how far it runs free.
pub struct Beam {
pub id: u32,
pub origin: DVec3,
pub dir: DVec3,
pub free: f64,
}
impl Sam {
/// The beam of a lit searchlight, from the lens of its head.
pub fn spot_beam(&self) -> Option<Beam> {
if self.kind != SamKind::Light || !self.alive() || !self.beam_on {
return None;
}
let (head, rot) = self.mount();
let dir = rot * DVec3::NEG_Z;
Some(Beam { id: self.id, origin: head + dir * 1.0, dir, free: self.beam_free })
}
}
+13 -2
View File
@@ -16,7 +16,7 @@ pub mod record;
mod sight;
mod terrain;
mod ew;
mod lights;
pub mod lights;
mod logistics;
mod orders;
mod units;
@@ -1231,6 +1231,11 @@ pub struct Sam {
pub ew_lock: bool,
/// Ticks till a gun fires its next illumination flare.
flare_cd: u32,
/// A searchlight: lit up (night), how far its beam runs free along the axis (the land stops it),
/// and the tick that was last cast.
beam_on: bool,
beam_free: f64,
beam_cast: u64,
/// Shots the land ruled out, by target: when it was judged and why (`terrain::MASKED`, `terrain::PATH_BLOCKED`).
pub(super) terrain: HashMap<u32, (u64, u8)>,
/// Targets whose land hasn't been answered yet, and since when it has been waited for.
@@ -1286,6 +1291,9 @@ impl Sam {
heat: 0.0,
ew_lock: false,
flare_cd: 0,
beam_on: false,
beam_free: 0.0,
beam_cast: 0,
terrain: HashMap::new(),
waits: HashMap::new(),
}
@@ -1431,6 +1439,8 @@ pub struct Sim {
/// Illumination flares in the sky, and the drones lit this tick.
pub flares: Vec<lights::Flare>,
pub lit: Vec<u32>,
/// The renderer draws the searchlights' spot lights and beams (HD); else the beam models do.
pub spot_hd: bool,
}
/// Ids: objects get multiples of 16, their parts the low bits.
@@ -1475,6 +1485,7 @@ impl Sim {
day_time: None,
flares: Vec::new(),
lit: Vec::new(),
spot_hd: false,
}
}
pub fn id(&mut self) -> u32 {
@@ -1579,7 +1590,7 @@ impl Sim {
self.step_supply(out);
self.step_ew(out);
self.update_sight(ground);
self.step_lights(out);
self.step_lights(ground, out);
self.step_sams(ground, out);
self.step_missiles(ground, out);
self.step_fx(ground, out);
+10
View File
@@ -35,6 +35,16 @@ impl Ground {
}
}
/// For a planner: None while the game has not answered, Some(None) where it has no land to
/// tell of, else Some(Some(height)).
pub fn land_state(&mut self, p: DVec3) -> Option<Option<f64>> {
match self.rise(p) {
Rise::Pending => None,
Rise::Void => Some(None),
Rise::At(g) => Some(Some(g)),
}
}
/// The same, saying whether the game has not answered yet or has no land there to tell of.
pub(super) fn rise(&mut self, p: DVec3) -> Rise {
let key = (
+18 -1
View File
@@ -32,7 +32,7 @@ Horizon действует правило clean-room. Исключение — P
| Материалы (классы спрайтов: листва, растения, металл, полированное), ветер, мокрые поверхности | `gfx/hd/materials.rs`; `terrain.wgsl` (`terrain_vertex`/`wind_offset`, `material_params`), `entity.wgsl`, `gfx/hd/light.wgsl` (`shade_surface`) |
| Пост-проход мода (взрывы, дым, вспышки, свечение) и его прелюдия | `airdef-mod/shaders/effects.wgsl`, прелюдия в `horizon/.../gfx/mod_post.rs` |
| Источники света мода (пламя, ракеты, взрывы, прожекторы) | `airdef-mod/src/lib.rs`, `fn lights` |
| API мода: `render.set-lights`, `render.set-lighting` (уровень API 10) | `horizon/crates/horizon-mod-sdk/wit/horizon.wit`, `horizon/crates/horizon-mods/src/runtime.rs` |
| API мода: `render.set-lights`, `render.set-lighting` (уровень API 10), `render.set-spot-lights` (16) | `horizon/crates/horizon-mod-sdk/wit/horizon.wit`, `horizon/crates/horizon-mods/src/runtime.rs` |
Форк ведётся в `horizon/` (ветка `airdef`), в репозиторий мода попадает **патчами**:
@@ -339,3 +339,20 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home
- **Малые виды** (мониторы, `gfx/mod_views.rs`) остаются экономными: небо, твёрдая земля, сущности и вторым проходом пост-эффект мода с камерой этого вида (`ModPost::write_to/run_view`, режим `params[0].w = 0`: пост сам освещает землю и рисует дым). Бюджет по площади картинки (`post_budget`: <4096 px без пост-прохода, до 150k px — 4 взрыва/12 огней, до 600k — 12/32, больше — всё). HD-освещения, облаков и TAA у малых видов нет.
- Мод в повторе берёт взрывы из записи (`record::replay_blasts`, чистая функция времени), время идёт по часам повтора (`params[255].x` — доля частичного тика, которую эффекты не используют при паузе/замедлении).
- #51: на перехвате слои звука (`cinema.rs`), свет-«удар» (белая вспышка в HD-освещении, радиус до 256), пик экспозиции/bloom (`set-lighting`), в `effects.wgsl` широкое гало, подсветка неба/облаков и яркое кольцо ударной волны на экране.
## Прожекторы: направленный свет и объёмный луч (#60)
- **API 16** `render.set-spot-lights(list<spot-light>) -> bool`: апекс, направление, цвет, интенсивность, `range` (до 1024), внутренний/внешний полуугол (рад), `beam` (видимость луча 0..1). До 32 от мода, рисуются ближайшие 16 (`MAX_SPOTS`). Возвращает, рисует ли HD (`HORIZON_HD_SPOTS=0` выключает), иначе мод держит свой запасной луч. Игра интерполирует по id между двумя вызовами (`mod_models.rs: mod_spots`) и переводит в `HdSpot` (`gfx/hd/frame.rs`): координаты камеры, косинусы углов.
- **Данные в шейдере**: `HdFrame.spots` (4 vec4 на источник: апекс+range, ось+cos внешнего, цвет+интенсивность, cos внутреннего+beam), число в `vfog.w`. Новых привязок нет.
- **Тайлы**: заголовок тайла 5 слов (`TILE_WORDS`), пятое: маска конусов, бит j стоит, если выпуклая оболочка конуса (апекс и квадрат вокруг дальнего круга, `tiles.rs: spot_hull`, `cover_points`) на экране накрывает тайл. Маска служит и поверхностям, и лучу. Индекс тайла в WGSL: `* 5u`.
- **Поверхности и цели**: `spot_light` (`spot.wgsl`, вставляется маркером `//SPOT` в `assemble`): конус с мягкой кромкой (smoothstep по косинусам), `I/(d²+1)`, окно в последние 3 блока до `range`, угол падения как у точечных; в `shade_surface` (земля, стены, дроны: они в G-буфере). Теней нет: свет обрывается по `range`, который мод ставит по броску оси на землю (`ground.world.probe`, раз в 4 тика).
- **Луч**: `spot_beams` (`spot.wgsl`): пересечение луча взгляда с конусом (`spot_span`: квадратное уравнение конуса, обрезка по оси и range); внутри 8 шагов с джиттером IGN; яркое ядро, мягкая периферия; затухание `1/(1+d/70)`; плотность воздуха `max(spot_air, 0.0009)` (в чистом воздухе слабый луч, в дымке/тумане ярче и короче); фаза Henyey-Greenstein вперёд (на лампу ярче). Складывается (прозрачный), ограничен глубиной сцены: за стеной не продолжается; камера внутри конуса работает. **Туман вкл**: считается в `fs_fog` (`spot_air = fog_sigma`, длина до 1100 блоков, в небо тоже, не 192 как у тумана); **туман выкл или нет атмосферы**: тот же код в `fs_light` с постоянной плотностью `ambient.z`.
- **Мод**: `sim/lights.rs`: голова прожектора шлёт один `SpotLight` (`Sam::spot_beam`, `lib.rs: spots`; конус 0.03/0.075 рад, range = свободная длина оси + 3.5). Цель «освещена» (`lit`) только в угле 0.05 от оси и если она не дальше свободной длины (за горой бонуса нет). Старые `airdef:beam` рисуются, только когда HD не рисует споты и эффекты включены (тоньше), при эффектах off луча нет. Точечный «шар» на цели остаётся лишь без спотов. В реплеях споты не шлются (реплей не пишет голову прожектора; #56/#58 отдельно).
- **Ограничения**: тень рельефа вне оси (боковая часть конуса за гребнем светит), запасной луч непрозрачен (текстура модели без альфы), GPU-цена на массовой сцене не снята.
## Кинорежиссёр и экспорт MP4 (#58, API 17)
- **Качество фильма** (`render.set-film-quality`, включается и на время захвата): мир HD в полном размере окна (`hd::scale_override` → 1.0), шаги облаков ×2 (шаг 7 блоков, 16–52 шага), шаги тумана ×2; флаг идёт в `HdFrame.surface.z`. Смена `set-main-view` id (склейка) сбрасывает историю TAA и облаков (`Renderer::cut`).
- **Захват** (`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`, ускорение/замедление времени).
@@ -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/31] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 01/32] 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/31] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 02/32] 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/31] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 03/32] 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/31] Mod models: draw far models as scaled impostors past
Subject: [PATCH 04/32] 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/31] Mod cameras: render.set-views draws the world from
Subject: [PATCH 05/32] 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/31] Mods: more fuel per tick and per event (AirDef plans
Subject: [PATCH 06/32] 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/31] Mod cameras up to 1920x1080 (full-screen replays)
Subject: [PATCH 07/32] 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/31] Mod cameras: distant terrain tiles, fog measured from
Subject: [PATCH 08/32] 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/31] Mods: game.world-id, so mods keep state per world (API
Subject: [PATCH 09/32] 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/31] cargo-horizon-mod: trial runs behind feature full, so a
Subject: [PATCH 10/32] 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/31] HD lighting: G-buffer pass, per-pixel mod point lights
Subject: [PATCH 11/32] 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/31] HD lighting stage 2: sun and moon shadow cascades, the
Subject: [PATCH 12/32] 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/31] HD: bind groups cached until a resize or a new source,
Subject: [PATCH 13/32] 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/31] HD: lights culled per 16x16 tile (surface and halo
Subject: [PATCH 14/32] 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/31] HD: GPU time per pass in the F3 timing list,
Subject: [PATCH 15/32] 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/31] HD: shadows draw only sun-facing quads and skip buried
Subject: [PATCH 16/32] 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/31] Mods: model handles (render.model-id, model-generation,
Subject: [PATCH 17/32] 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/31] HD: temporal upscaling (HORIZON_HD_SCALE: world,
Subject: [PATCH 18/32] 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/31] HD water: its own pass (colour, normal, depth), the
Subject: [PATCH 19/32] 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/31] HD water: no vanilla texture on the surface (the biome
Subject: [PATCH 20/32] 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/31] HD sky and light: a physical atmosphere (Rayleigh, Mie,
Subject: [PATCH 21/32] 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/31] HD water: a darker blue, a little clearer, ripples a
Subject: [PATCH 22/32] 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/31] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
Subject: [PATCH 23/32] 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/31] Mods: world queries for simulations (render.query-rays,
Subject: [PATCH 24/32] 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/31] HD fixes: rain level from the world not from rain
Subject: [PATCH 25/32] 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/31] HD split into modules (gfx/hd/: one Rust file and one
Subject: [PATCH 26/32] 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/31] HD clouds: noise scales that divide the camera's
Subject: [PATCH 27/32] 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/31] HD: volumetric smoke and missile trails with clear
Subject: [PATCH 28/32] 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/31] HD clouds composited by depth so terrain and vehicles
Subject: [PATCH 29/32] 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/31] HD smoke: young trails drawn at least as wide as a grid
Subject: [PATCH 30/32] 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/31] Mods: render.set-main-view (API level 15) renders the
Subject: [PATCH 31/32] 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
File diff suppressed because it is too large Load Diff