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
build / mod (push) Successful in 3m32s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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`;
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user