Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76f680eba6 | |||
| 8a12225ba1 | |||
| 4bab572293 | |||
| f43fa5ee66 | |||
| 336ae9f7cc | |||
| 5f8bff31b9 | |||
| 3ced60e84f |
@@ -6,3 +6,4 @@ __pycache__/
|
||||
/out/
|
||||
.DS_Store
|
||||
run-home-bench/
|
||||
run-home-smoke/
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// [0] now (mod ticks), blasts, lights, -
|
||||
// [1 + 2i], [2 + 2i] blast i: x, y, z, size | start tick, life ticks, kind, -
|
||||
// [97 + 2j], [98 + 2j] light j: x, y, z, radius | r, g, b, intensity
|
||||
// [255].x hold: 0 live; a replay's share of the frame's partial tick it does not advance with
|
||||
|
||||
const BLAST_BASE: i32 = 1;
|
||||
const LIGHT_BASE: i32 = 97;
|
||||
@@ -80,7 +81,7 @@ fn screen_radius_clip(c0: vec4<f32>, radius: f32) -> f32 {
|
||||
}
|
||||
|
||||
fn now() -> f32 {
|
||||
return frame.params[0].x + frame.time.y;
|
||||
return frame.params[0].x + frame.time.y * (1.0 - frame.params[255].x);
|
||||
}
|
||||
|
||||
// Fire's colour from its heat (0 cold soot .. 1 white-hot).
|
||||
@@ -201,7 +202,9 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
|
||||
let t1 = min(span.y, wall);
|
||||
if t1 <= t0 { continue; }
|
||||
let heat = 1.0 - smoothstep(0.05, 0.55, k);
|
||||
let fade = 1.0 - smoothstep(0.65, 1.0, k);
|
||||
// With the HD renderer's volumetric smoke (params[0].w = 2) only the fireball is drawn here.
|
||||
var fade = 1.0 - smoothstep(0.65, 1.0, k);
|
||||
if frame.params[0].w > 1.5 { fade = 1.0 - smoothstep(0.08, 0.28, k); }
|
||||
// Fewer steps for a fireball that is small on screen.
|
||||
let on_screen = radius / max(length(centre), 1.0);
|
||||
let steps = i32(clamp(on_screen * 40.0, 6.0, 14.0));
|
||||
@@ -229,12 +232,15 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
|
||||
}
|
||||
color = color * trans + lit;
|
||||
|
||||
// 3. Flashes: a bright bloom at the blast and the whole view lit up for a moment.
|
||||
// 3. Flashes (#51): a huge bright bloom at the blast, a wide halo that lifts the sky and the
|
||||
// clouds, a white shock ring racing out, and the view lit up for a moment. The sum is
|
||||
// squashed, so a crowded sky never whites the screen out for long.
|
||||
var flare = vec3<f32>(0.0);
|
||||
for (var i = 0; i < blasts; i++) {
|
||||
let a = frame.params[BLAST_BASE + 2 * i];
|
||||
let b = frame.params[BLAST_BASE + 2 * i + 1];
|
||||
let age = t_now - b.x;
|
||||
if age < 0.0 || age > 10.0 { continue; }
|
||||
if age < 0.0 || age > 24.0 { continue; }
|
||||
let c0 = clip_of(a.xyz);
|
||||
if c0.w <= 0.5 { continue; }
|
||||
let p = vec3<f32>(clip_uv(c0), c0.w);
|
||||
@@ -242,15 +248,37 @@ fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
|
||||
// than the distance along the ray towards the screen edge.
|
||||
let dist = length(a.xyz - frame.camera.xyz);
|
||||
let visible = select(0.25, 1.0, scene_distance(p.xy) > dist - a.w);
|
||||
let f = exp(-age * 0.7) * min(a.w / 4.0, 2.5) * visible;
|
||||
let r = screen_radius_clip(c0, a.w * 2.5) + 20.0;
|
||||
let dd = length(px - p.xy * frame.resolution.xy) / r;
|
||||
// The bloom's tail is below 5e-4 (under 8 bits) past nine radii.
|
||||
if dd < 9.0 {
|
||||
color += vec3<f32>(1.0, 0.85, 0.6) * f * (exp(-dd * dd * 3.0) * 2.5 + exp(-dd * 0.8) * 0.25);
|
||||
let mass = min(a.w / 4.0, 2.5);
|
||||
// How close it is, in its own sizes: a burst overhead is felt, a far one only seen.
|
||||
let near = clamp(1.0 - dist / (a.w * 40.0), 0.0, 1.0);
|
||||
let centre = p.xy * frame.resolution.xy;
|
||||
if age <= 10.0 {
|
||||
let f = exp(-age * 0.7) * mass * visible;
|
||||
let r = screen_radius_clip(c0, a.w * 2.5) + 20.0;
|
||||
let dd = length(px - centre) / r;
|
||||
// The bloom's tail is below 5e-4 (under 8 bits) past nine radii.
|
||||
if dd < 9.0 {
|
||||
flare += vec3<f32>(1.0, 0.85, 0.6) * f * (exp(-dd * dd * 3.0) * 3.2 + exp(-dd * 0.8) * 0.3);
|
||||
}
|
||||
// The halo: a third of the screen wide for a close blast, lifting the sky and clouds most.
|
||||
let wide = (0.12 + 0.3 * near) * frame.resolution.y;
|
||||
let dw = length(px - centre) / wide;
|
||||
if dw < 4.0 {
|
||||
let sky = select(1.0, 1.7, wall > 1.0e5);
|
||||
flare += vec3<f32>(1.0, 0.82, 0.58) * f * near * sky * exp(-dw * dw) * 0.55;
|
||||
}
|
||||
flare += vec3<f32>(1.0, 0.8, 0.55) * f * (0.12 + 0.2 * near) / (1.0 + p.z * 0.01);
|
||||
}
|
||||
// The shock ring: the same radius as the wave that bends the picture.
|
||||
let ring = screen_radius_clip(c0, age * 3.2 * (0.6 + a.w * 0.1));
|
||||
let width = 3.0 + ring * 0.025;
|
||||
let x = (length(px - centre) - ring) / width;
|
||||
if abs(x) < 4.0 {
|
||||
let fade = 1.0 - age / 24.0;
|
||||
flare += vec3<f32>(1.0, 0.9, 0.72) * exp(-x * x) * fade * fade * mass * (0.25 + 0.5 * near) * visible;
|
||||
}
|
||||
color += vec3<f32>(1.0, 0.8, 0.55) * f * 0.12 / (1.0 + p.z * 0.01);
|
||||
}
|
||||
color += flare / (1.0 + flare * 0.2);
|
||||
|
||||
// 4. Glows: engine flames and muzzle flashes.
|
||||
for (var j = 0; j < lights; j++) {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! The cinematic look of a blast (#51): a layered sound (a close crack, a low thump, a rolling
|
||||
//! tail; from afar the muffled report and a late boom), and the camera's reaction (a brief
|
||||
//! exposure and bloom punch). Everything is a function of the blast, its distance and its age, so
|
||||
//! a replay hears and sees the same as the live raid did, from the replay's camera.
|
||||
|
||||
/// The power (0 air burst .. 4 ballistic) of a blast sound event, if it is one.
|
||||
pub fn blast_power(event: &str) -> Option<usize> {
|
||||
Some(match event {
|
||||
"airdef:blast.air" => 0,
|
||||
"airdef:blast.medium" => 1,
|
||||
"airdef:blast.big" => 2,
|
||||
"airdef:blast.heavy" => 3,
|
||||
"airdef:blast.huge" => 4,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// One voice of a blast: the sound event (with `_far` where the listener is far), its loudness
|
||||
/// and pitch, and the ticks it follows the first after the sound has travelled.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Layer {
|
||||
pub name: String,
|
||||
pub volume: f32,
|
||||
pub pitch: f32,
|
||||
pub delay: u64,
|
||||
}
|
||||
|
||||
/// Blasts of one tick that get all their layers, the nearest first: the rest of a mass raid
|
||||
/// plays its one voice, so the key events are not buried and the mix does not clip.
|
||||
pub const FULL: usize = 3;
|
||||
|
||||
/// The voices of a blast of `power`, `dist` blocks from the listener (`far`: where the muffled
|
||||
/// variants take over), `gain` its loudness (1 normal; bigger warheads more), the `rank`-th
|
||||
/// nearest of its tick.
|
||||
pub fn layers(power: usize, dist: f64, far: f64, gain: f32, pitch: f32, rank: usize) -> Vec<Layer> {
|
||||
let g = gain.clamp(0.3, 2.0);
|
||||
let own = ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][power.min(4)];
|
||||
let layer = |name: &str, volume: f32, pitch: f32, delay: u64| Layer { name: name.to_string(), volume, pitch, delay };
|
||||
let far_away = dist > far;
|
||||
let suffix = if far_away { "_far" } else { "" };
|
||||
if rank >= FULL {
|
||||
return vec![layer(&format!("{own}{suffix}"), g, pitch, 0)];
|
||||
}
|
||||
if far_away {
|
||||
// The muffled report, then a deep boom that arrives late.
|
||||
return vec![
|
||||
layer(&format!("{own}_far"), g, pitch, 0),
|
||||
layer("airdef:blast.big_far", 0.7 * g, pitch * 0.6, 10 + 2 * power as u64),
|
||||
];
|
||||
}
|
||||
let tail = (0.45 + 0.08 * power as f32) * g;
|
||||
if power == 0 {
|
||||
// An air burst has no body of its own: the crack, the thump, the roll.
|
||||
vec![
|
||||
layer("airdef:blast.air", g, pitch * 1.12, 0),
|
||||
layer("airdef:blast.big", 0.85 * g, pitch * 0.66, 2),
|
||||
layer("airdef:blast.huge", tail, pitch * 0.5, 7),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
layer(own, g, pitch, 0),
|
||||
layer("airdef:blast.air", 0.7 * g, pitch * 1.15, 0),
|
||||
layer("airdef:blast.huge", tail, pitch * 0.5, 7),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// How hard the picture is punched now (0 to 1) by a blast of fireball radius `size` that went
|
||||
/// off `age` ticks ago `dist` blocks from the camera: bright and brief, near and big blasts most.
|
||||
pub fn punch(size: f32, age: f32, dist: f64) -> f32 {
|
||||
if age < 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let reach = (size as f64 * 45.0).max(30.0);
|
||||
let near = (1.0 - dist / reach).clamp(0.0, 1.0) as f32;
|
||||
let big = (size / 8.0).clamp(0.35, 1.0);
|
||||
// A rise over a tick, then gone in about half a second.
|
||||
(near * big * (-age / 3.0).exp() * (age / 0.8).min(1.0)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_near_intercept_is_layered_and_a_mass_raid_is_bounded() {
|
||||
let l = layers(0, 40.0, 140.0, 1.2, 1.0, 0);
|
||||
assert_eq!(l.len(), 3);
|
||||
assert!(l.iter().all(|v| !v.name.ends_with("_far")));
|
||||
assert!(l[0].delay == 0 && l[2].delay > l[1].delay, "crack, then thump, then the roll");
|
||||
// A far one is the muffled report and a late boom.
|
||||
let f = layers(0, 400.0, 140.0, 1.0, 1.0, 0);
|
||||
assert!(f.iter().all(|v| v.name.ends_with("_far")) && f[1].delay > 0);
|
||||
// The rest of a tick's blasts play one voice, and the whole mix stays bounded.
|
||||
let total: f32 = (0..12).flat_map(|r| layers(2, 60.0, 140.0, 1.0, 1.0, r)).map(|v| v.volume).sum();
|
||||
let voices: usize = (0..12).map(|r| layers(2, 60.0, 140.0, 1.0, 1.0, r).len()).sum();
|
||||
assert_eq!(voices, FULL * 3 + (12 - FULL));
|
||||
assert!(total < 18.0, "{total}");
|
||||
assert_eq!(blast_power("airdef:blast.huge"), Some(4));
|
||||
assert_eq!(blast_power("airdef:boom.sonic"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_punch_is_brief_near_and_bounded() {
|
||||
assert_eq!(punch(5.0, -1.0, 10.0), 0.0);
|
||||
assert!(punch(5.0, 1.5, 20.0) > punch(5.0, 1.5, 150.0));
|
||||
assert!(punch(5.0, 1.5, 20.0) > punch(2.0, 1.5, 20.0));
|
||||
assert!(punch(13.0, 1.0, 0.0) <= 1.0);
|
||||
assert!(punch(5.0, 30.0, 20.0) < 0.01, "gone in a second and a half");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+377
-22
@@ -7,15 +7,18 @@ 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;
|
||||
mod replay;
|
||||
mod smoke;
|
||||
pub mod sim;
|
||||
mod scenario;
|
||||
mod wave;
|
||||
@@ -54,6 +57,7 @@ const ITEMS: [&str; 29] = [
|
||||
];
|
||||
|
||||
struct AirDef {
|
||||
smoke: smoke::Smoke,
|
||||
sim: Sim,
|
||||
ground: Ground,
|
||||
dirty: bool,
|
||||
@@ -72,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.
|
||||
@@ -86,6 +94,8 @@ struct AirDef {
|
||||
menu: Option<Page>,
|
||||
/// Where the player is, for the sounds' delay and far variants.
|
||||
listener: DVec3,
|
||||
/// The picture punch (#51) the HD exposure and bloom were last set for.
|
||||
punch: f32,
|
||||
/// Sounds on their way to the player at the speed of sound.
|
||||
pending: Vec<PendingSound>,
|
||||
/// The handles `render.model-id` gave the model names (valid under `model_generation`); none:
|
||||
@@ -96,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,
|
||||
@@ -161,6 +194,16 @@ fn say(text: &str) {
|
||||
chat::show(text);
|
||||
}
|
||||
|
||||
/// What the effects draw this tick (`Mod::shown`).
|
||||
struct Shown {
|
||||
blasts: Vec<sim::Blast>,
|
||||
/// The clock the blasts' `start` is on: the simulation's ticks, or the recording's.
|
||||
now: f32,
|
||||
/// 1 minus the share of the frame's partial tick the effects advance with.
|
||||
hold: f32,
|
||||
replay: bool,
|
||||
}
|
||||
|
||||
fn player() -> Option<game::Player> {
|
||||
game::get_player()
|
||||
}
|
||||
@@ -301,14 +344,37 @@ impl AirDef {
|
||||
/// blasts (a flash, then the fireball's glow dying down), burning wrecks and the
|
||||
/// searchlights' spots.
|
||||
fn lights(&self, out: &Out) -> Vec<PointLight> {
|
||||
let shown = self.shown();
|
||||
let mut lights: Vec<PointLight> = Vec::new();
|
||||
let mut add = |id: u32, at: DVec3, rgb: (f32, f32, f32), intensity: f32, radius: f32| {
|
||||
if lights.len() < 500 && intensity > 0.05 {
|
||||
lights.push(PointLight { id, x: at.x, y: at.y, z: at.z, color: rgb, intensity, radius: radius.clamp(1.0, 256.0) });
|
||||
}
|
||||
};
|
||||
let tick = self.sim.tick;
|
||||
let tick = shown.now as u64;
|
||||
let flicker = |id: u32| 0.85 + 0.15 * (((tick as u32).wrapping_mul(2654435761) ^ id.wrapping_mul(97)) % 100) as f32 / 100.0;
|
||||
// Blasts first, the nearest first: a crowded sky never costs the key intercept its light.
|
||||
let viewer = self.viewer(&shown);
|
||||
let mut near: Vec<&sim::Blast> = shown.blasts.iter().collect();
|
||||
if let Some(e) = viewer {
|
||||
near.sort_by(|a, b| a.at.distance_squared(e).total_cmp(&b.at.distance_squared(e)));
|
||||
}
|
||||
for (rank, b) in near.into_iter().enumerate() {
|
||||
let age = (shown.now - b.start as f32).max(0.0);
|
||||
let size = b.size;
|
||||
let glow = (-age / (b.life as f32 * 0.15).max(1.0)).exp();
|
||||
let flash = (-age * 0.35).exp() * 4.0;
|
||||
// Its own id range: the models' and the systems' ids are small numbers.
|
||||
let id = 0x6100_0000 | (b.id & 0x00ff_ffff);
|
||||
let centre = b.at + DVec3::Y * size as f64 * 0.5;
|
||||
add(id, centre, (1.0, 0.58, 0.25), size * size * 18.0 * (glow + flash), size * 14.0);
|
||||
// The punch (#51): a white burst that lights the land, the smoke and the wrecks far round
|
||||
// for a moment; for the nearest few, scaled by the blast.
|
||||
if rank < 6 {
|
||||
let burst = (-age / 2.0).exp();
|
||||
add(0x6300_0000 | (b.id & 0x00ff_ffff), centre, (1.0, 0.9, 0.74), size * size * 95.0 * burst, (size * 30.0).min(256.0));
|
||||
}
|
||||
}
|
||||
for m in out.models.iter().filter(|m| m.glow) {
|
||||
let s = m.scale.max(0.1);
|
||||
match m.name {
|
||||
@@ -321,17 +387,12 @@ impl AirDef {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for b in &self.sim.blasts {
|
||||
let age = tick.saturating_sub(b.start) as f32;
|
||||
let size = b.size;
|
||||
let glow = (-age / (b.life as f32 * 0.15).max(1.0)).exp();
|
||||
let flash = (-age * 0.35).exp() * 4.0;
|
||||
// Its own id range: the models' and the systems' ids are small numbers.
|
||||
let id = 0x6100_0000 | (b.id & 0x00ff_ffff);
|
||||
add(id, b.at + DVec3::Y * size as f64 * 0.5, (1.0, 0.58, 0.25), size * size * 18.0 * (glow + flash), size * 14.0);
|
||||
// A replay shows its own flames (the models of the ghosts) and none of the live world's.
|
||||
if shown.replay {
|
||||
return lights;
|
||||
}
|
||||
// Fires burn on for minutes: a flickering, wide glow that dies down with the flames.
|
||||
for f in &self.sim.fires.list {
|
||||
for f in self.sim.fires.list.iter().filter(|f| f.shown) {
|
||||
let (fl, sm) = f.level(tick);
|
||||
if f.quiet {
|
||||
continue;
|
||||
@@ -344,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)
|
||||
{
|
||||
@@ -355,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;
|
||||
@@ -365,29 +456,34 @@ impl AirDef {
|
||||
}
|
||||
self.hd = shader && hd;
|
||||
self.apply_hd();
|
||||
if !self.hd {
|
||||
self.sim.spot_hd = false;
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Switches the HD lighting.
|
||||
fn apply_hd(&self) {
|
||||
render::set_lighting(Lighting { hd: self.hd, exposure: 1.0, bloom: 0.55 });
|
||||
// (The punch of the next blast sets its own.)
|
||||
if !self.hd {
|
||||
render::set_lights(&[]);
|
||||
let _ = render::set_spot_lights(&[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands the simulation's output to the game.
|
||||
/// The post pass's parameters: blasts and glowing parts (shaders/effects.wgsl).
|
||||
fn shader_params(&self, out: &Out) -> Vec<f32> {
|
||||
let shown = self.shown();
|
||||
let mut p = vec![0.0f32; 4];
|
||||
p[0] = self.sim.tick as f32;
|
||||
p[3] = if self.hd { 1.0 } else { 0.0 };
|
||||
let mut blasts: Vec<&sim::Blast> = self.sim.blasts.iter().rev().take(48).collect();
|
||||
p[0] = shown.now;
|
||||
p[3] = if self.smoke.hd { 2.0 } else if self.hd { 1.0 } else { 0.0 };
|
||||
let mut blasts: Vec<&sim::Blast> = shown.blasts.iter().rev().take(48).collect();
|
||||
// The shader composites the volumes front to back with one shared transmittance, so
|
||||
// the nearest to the player goes first. This is exact for volumes that do not
|
||||
// overlap; where two intersect, no order by centre is right along every ray.
|
||||
if let Some(pl) = player() {
|
||||
let e = eye(&pl);
|
||||
if let Some(e) = self.viewer(&shown) {
|
||||
blasts.sort_by(|a, b| a.at.distance_squared(e).total_cmp(&b.at.distance_squared(e)));
|
||||
}
|
||||
p[1] = blasts.len() as f32;
|
||||
@@ -418,9 +514,52 @@ impl AirDef {
|
||||
lights += 1;
|
||||
}
|
||||
p[2] = lights as f32;
|
||||
// The last vec4: how much of the frame's partial tick the effects run on (0 live; a
|
||||
// slowed or paused replay runs on its own time, not the frame's).
|
||||
p.resize(1024, 0.0);
|
||||
p[1020] = shown.hold;
|
||||
p
|
||||
}
|
||||
|
||||
/// The smoke volumes the renderer draws and the clock they are aged by: the live ones, or (in a
|
||||
/// replay) the blasts' billows at the replay's moment, never the live raid's trails and fires.
|
||||
/// The billows are the live ones' own function (`Smoke::segments`), run on the recording's
|
||||
/// blasts and time with the live state set aside for the call.
|
||||
fn shown_smoke(&mut self) -> (f64, Vec<render::SmokeSegment>) {
|
||||
let shown = self.shown();
|
||||
if !shown.replay {
|
||||
return (self.sim.tick as f64, self.smoke.segments(&self.sim));
|
||||
}
|
||||
let blasts = std::mem::replace(&mut self.sim.blasts, shown.blasts);
|
||||
let fires = std::mem::take(&mut self.sim.fires.list);
|
||||
let tick = std::mem::replace(&mut self.sim.tick, shown.now as u64);
|
||||
let segments = smoke::Smoke::default().segments(&self.sim);
|
||||
self.sim.blasts = blasts;
|
||||
self.sim.fires.list = fires;
|
||||
self.sim.tick = tick;
|
||||
(f64::from(shown.now), segments)
|
||||
}
|
||||
|
||||
/// Where the picture is seen from: the replay's camera, or the player's eyes.
|
||||
fn viewer(&self, shown: &Shown) -> Option<DVec3> {
|
||||
match &self.replay {
|
||||
Some(r) if shown.replay => Some(r.eye()),
|
||||
_ => player().map(|pl| eye(&pl)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The blasts the effects show and the clock they run on: the live ones, or (in a replay) the
|
||||
/// recording's at the replay's moment, never the live world's.
|
||||
fn shown(&self) -> Shown {
|
||||
if let Some(r) = &self.replay
|
||||
&& r.filming()
|
||||
&& let Some(rec) = self.sim.replay(r.rec)
|
||||
{
|
||||
return Shown { blasts: sim::record::replay_blasts(rec, r.time()), now: r.time() as f32, hold: 1.0 - r.rate().clamp(0.0, 1.0), replay: true };
|
||||
}
|
||||
Shown { blasts: self.sim.blasts.clone(), now: self.sim.tick as f32, hold: 0.0, replay: false }
|
||||
}
|
||||
|
||||
/// The models to the game by handle: each name crosses once, the buffer stays allocated.
|
||||
fn send_models(&mut self, models: &[sim::Model]) {
|
||||
let generation = render::model_generation();
|
||||
@@ -458,15 +597,32 @@ impl AirDef {
|
||||
render::set_params(&self.shader_params(&out));
|
||||
}
|
||||
if self.hd {
|
||||
// The camera's reaction to a near blast: a brief exposure and bloom punch.
|
||||
let shown = self.shown();
|
||||
let p = self.viewer(&shown).map_or(0.0, |e| {
|
||||
shown.blasts.iter().map(|b| cinema::punch(b.size, shown.now - b.start as f32, b.at.distance(e))).fold(0.0, f32::max)
|
||||
});
|
||||
if (p - self.punch).abs() > 0.01 || (p == 0.0 && self.punch != 0.0) {
|
||||
self.punch = p;
|
||||
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 {
|
||||
self.smoke.hd = false;
|
||||
}
|
||||
if !out.models.is_empty() || self.sim.tick % 20 == 0 {
|
||||
self.send_models(&out.models);
|
||||
}
|
||||
if !out.particles.is_empty() {
|
||||
let hd_smoke = self.smoke.hd && !self.replay.as_ref().is_some_and(|r| r.filming());
|
||||
let spawns: Vec<ParticleSpawn> = out
|
||||
.particles
|
||||
.into_iter()
|
||||
// The long smoke is the renderer's volume then.
|
||||
.filter(|p| !(hd_smoke && matches!(p.kind, "minecraft:campfire_cosy_smoke" | "minecraft:campfire_signal_smoke" | "minecraft:large_smoke")))
|
||||
.take(512)
|
||||
.map(|p| ParticleSpawn {
|
||||
particle: p.kind.to_string(),
|
||||
@@ -484,9 +640,13 @@ impl AirDef {
|
||||
render::spawn_particles(&spawns);
|
||||
}
|
||||
let now = self.sim.tick;
|
||||
// Blasts play layered (cinema.rs), the nearest ones in full.
|
||||
let mut booms: Vec<(f64, usize, DVec3, f32, f32)> = Vec::new();
|
||||
for (event, at, volume, pitch) in out.sounds {
|
||||
let dist = at.distance(self.listener);
|
||||
if travels(event) {
|
||||
if let Some(power) = cinema::blast_power(event) {
|
||||
booms.push((dist, power, at, volume, pitch));
|
||||
} else if travels(event) {
|
||||
// Seen first, heard later; from afar muffled.
|
||||
let name = if dist > FAR { format!("{event}_far") } else { event.to_string() };
|
||||
let delay = (dist / SOUND_SPEED) as u64;
|
||||
@@ -499,6 +659,18 @@ impl AirDef {
|
||||
render::play_sound(event, at.x, at.y, at.z, volume, pitch);
|
||||
}
|
||||
}
|
||||
booms.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
for (rank, (dist, power, at, volume, pitch)) in booms.into_iter().enumerate() {
|
||||
for l in cinema::layers(power, dist, FAR, volume, pitch, rank) {
|
||||
// Seen first, heard later: each voice after the sound has travelled.
|
||||
let delay = (dist / SOUND_SPEED) as u64 + l.delay;
|
||||
if delay == 0 {
|
||||
render::play_sound(&l.name, at.x, at.y, at.z, l.volume, l.pitch);
|
||||
} else if self.pending.len() < 256 {
|
||||
self.pending.push(PendingSound { due: now + delay, name: l.name, at, volume: l.volume, pitch: l.pitch });
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < self.pending.len() {
|
||||
if self.pending[i].due <= now {
|
||||
@@ -784,14 +956,157 @@ 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);
|
||||
input::claim_wheel(false);
|
||||
render::set_hud(&[]);
|
||||
render::set_views(&[]);
|
||||
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
|
||||
@@ -980,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> {
|
||||
@@ -997,6 +1312,9 @@ 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,
|
||||
hd: false,
|
||||
@@ -1004,6 +1322,7 @@ impl Mod for AirDef {
|
||||
health_mark: 0,
|
||||
menu: None,
|
||||
listener: DVec3::ZERO,
|
||||
punch: 0.0,
|
||||
pending: Vec::new(),
|
||||
model_ids: std::collections::HashMap::new(),
|
||||
model_generation: u32::MAX,
|
||||
@@ -1084,7 +1403,7 @@ impl Mod for AirDef {
|
||||
self.ground.answer(render::ground(), now);
|
||||
// The blocks themselves (issues #42, #43): what changed, and the sections asked for last tick.
|
||||
let changes = render::world_changes();
|
||||
self.ground.world.changed(changes.epoch, changes.overflow, &changes.sections);
|
||||
self.ground.world.changed_rev(changes.epoch, changes.revision, changes.overflow, &changes.sections);
|
||||
let sections = render::sections()
|
||||
.into_iter()
|
||||
.map(|s| sim::world::SectionAnswer {
|
||||
@@ -1101,21 +1420,53 @@ impl Mod for AirDef {
|
||||
})
|
||||
.collect();
|
||||
self.ground.world.answer(sections, now);
|
||||
// Exact collision shapes of blocks a flight met (#63): the answers to last tick's rays.
|
||||
let replies: Vec<sim::world::RayReply> = render::rays()
|
||||
.into_iter()
|
||||
.map(|r| sim::world::RayReply {
|
||||
id: r.id,
|
||||
clear: matches!(r.verdict, render::Verdict::Clear),
|
||||
hit: matches!(r.verdict, render::Verdict::Hit),
|
||||
fraction: r.fraction,
|
||||
normal: [r.normal.0, r.normal.1, r.normal.2],
|
||||
block: [r.block.0, r.block.1, r.block.2],
|
||||
})
|
||||
.collect();
|
||||
self.ground.world.answer_rays(&replies);
|
||||
let mut out = Out::default();
|
||||
self.listener = DVec3::new(p.x, p.y + 1.6, p.z);
|
||||
self.sim.night = game::get_clock().is_some_and(|c| (13000..23000).contains(&(c.day_time % 24000)));
|
||||
self.sim.day_time = game::get_clock().map(|c| c.day_time);
|
||||
self.sim.step(&mut self.ground, Some(DVec3::new(p.x, p.y, p.z)), &mut out);
|
||||
self.smoke.update(&self.sim);
|
||||
let wanted = self.ground.take_wanted();
|
||||
if !wanted.is_empty() {
|
||||
render::query_ground(&wanted);
|
||||
}
|
||||
let rays: Vec<render::Ray> = self
|
||||
.ground
|
||||
.world
|
||||
.take_rays(now)
|
||||
.into_iter()
|
||||
.map(|(id, a, b)| render::Ray { id, start: (a.x, a.y, a.z), end: (b.x, b.y, b.z), policy: render::Policy::Collision })
|
||||
.collect();
|
||||
if !rays.is_empty() {
|
||||
render::query_rays(&rays);
|
||||
}
|
||||
let sections = self.ground.world.take_wanted(now);
|
||||
if !sections.is_empty() {
|
||||
render::query_sections(render::Policy::Collision, §ions);
|
||||
}
|
||||
if let Some(mut r) = self.replay.take() {
|
||||
r.tick(&self.sim, self.listener, &mut out);
|
||||
// A replay shows the recording alone: not the live raid's models, particles, sounds or jolts.
|
||||
if r.filming() {
|
||||
out.models.clear();
|
||||
out.particles.clear();
|
||||
out.sounds.clear();
|
||||
out.shake = None;
|
||||
}
|
||||
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();
|
||||
@@ -1130,6 +1481,8 @@ impl Mod for AirDef {
|
||||
let size = render::gui_size();
|
||||
render::set_hud(&r.hud(&self.sim, size));
|
||||
render::set_views(&r.views(size));
|
||||
// The world through the director's camera, with the full pipeline (HD, the post pass).
|
||||
render::set_main_view(r.filming().then(|| r.view_id()));
|
||||
// The sky of the raid, not of now (None for a raid whose clock was unknown).
|
||||
render::set_time_override(r.day_time(&self.sim));
|
||||
self.monitor_shown = true;
|
||||
@@ -1382,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
|
||||
|
||||
+203
-12
@@ -4,12 +4,14 @@
|
||||
//! 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};
|
||||
use glam::DVec3;
|
||||
use horizon_mod_sdk::render::{CameraView, FillShape, Point, QuadShape, Rect, Shape, TextShape, ViewShape};
|
||||
use horizon_mod_sdk::render::{CameraView, FillShape, Point, QuadShape, Rect, Shape, TextShape};
|
||||
|
||||
/// Ghost models' ids, clear of the live ones.
|
||||
const GHOSTS: u32 = 0x5000_0000;
|
||||
@@ -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 {
|
||||
@@ -37,6 +41,8 @@ pub struct Player {
|
||||
pub mode: Mode,
|
||||
t: f64,
|
||||
speed: f64,
|
||||
/// How fast the replay's time ran this tick (0 paused or held on a card; slowed round a moment).
|
||||
rate: f64,
|
||||
/// The director's current shot (event, shot) and the view id it films with.
|
||||
shot: (usize, u8),
|
||||
view: u32,
|
||||
@@ -53,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,
|
||||
@@ -129,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, .. } => {
|
||||
@@ -204,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 {
|
||||
@@ -213,6 +233,7 @@ impl Player {
|
||||
mode,
|
||||
t: start,
|
||||
speed,
|
||||
rate: 0.0,
|
||||
shot: (usize::MAX, 0),
|
||||
view: VIEW,
|
||||
fade: 0,
|
||||
@@ -223,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,
|
||||
}
|
||||
@@ -261,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.
|
||||
@@ -280,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,22 +328,48 @@ 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;
|
||||
}
|
||||
|
||||
/// Where the camera is this tick.
|
||||
pub fn eye(&self) -> DVec3 {
|
||||
self.eye
|
||||
}
|
||||
/// The replay's moment, in the recording's ticks.
|
||||
pub fn time(&self) -> f64 {
|
||||
self.t
|
||||
}
|
||||
/// How fast the replay's time runs now, as a share of real time (1 normal, 0 held).
|
||||
pub fn rate(&self) -> f32 {
|
||||
self.rate as f32
|
||||
}
|
||||
/// Whether the world is filmed (the map is drawn on the HUD instead).
|
||||
pub fn filming(&self) -> bool {
|
||||
!matches!(self.mode, Mode::Map)
|
||||
}
|
||||
/// The camera the world is rendered from (`set-main-view`).
|
||||
pub fn view_id(&self) -> u32 {
|
||||
self.view
|
||||
}
|
||||
/// The time of day the raid was flown in, as of the replay's moment now.
|
||||
pub fn day_time(&self, sim: &Sim) -> Option<u64> {
|
||||
sim.replay(self.rec)?.day_time(self.t)
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -325,6 +387,7 @@ impl Player {
|
||||
speed = 0.0;
|
||||
}
|
||||
}
|
||||
self.rate = if self.paused { 0.0 } else { speed };
|
||||
if !self.paused {
|
||||
self.t += speed;
|
||||
}
|
||||
@@ -336,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() {
|
||||
@@ -356,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 {
|
||||
@@ -365,7 +433,8 @@ impl Player {
|
||||
EventKind::SamLost { .. } => "airdef:blast.big",
|
||||
EventKind::Split { .. } => "airdef:boom.sonic",
|
||||
};
|
||||
out.sound(event, heard(e.pos), 1.0, 1.0);
|
||||
let gain = if let EventKind::Kill { kind, .. } = e.kind { crate::sim::record::kill_look(kind).2 } else { 1.0 };
|
||||
out.sound(event, heard(e.pos), gain, 1.0);
|
||||
}
|
||||
for tr in rec.tracks.iter().filter(|tr| (self.heard..self.t).contains(&(tr.first as f64))) {
|
||||
if let What::Missile(kind, _) = tr.what
|
||||
@@ -377,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;
|
||||
@@ -453,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) => {
|
||||
@@ -497,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.
|
||||
@@ -508,8 +696,11 @@ impl Player {
|
||||
if matches!(self.mode, Mode::Map) {
|
||||
return self.map(rec, gw, gh);
|
||||
}
|
||||
let mut s = vec![fill(0.0, 0.0, gw, gh, 0xFF000000)];
|
||||
s.push(Shape::View(ViewShape { view: self.view, rect: Rect { x: 0.0, y: 0.0, w: gw, h: gh }, mode: 0 }));
|
||||
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.
|
||||
let bar = (gh * 0.09).round();
|
||||
s.push(fill(0.0, 0.0, gw, bar, 0xFF000000));
|
||||
|
||||
@@ -169,22 +169,22 @@ impl Sim {
|
||||
continue;
|
||||
}
|
||||
// Only those not yet sent at a unit.
|
||||
if self.sams.iter().any(|s| s.pos.distance(d.target) < 6.0) {
|
||||
if self.sams.iter().any(|s| s.pos.distance(d.target - d.err) < 6.0) {
|
||||
continue;
|
||||
}
|
||||
let to = if !ew.is_empty() { pick(n, &ew) } else { pick(n, &known) };
|
||||
d.target = to;
|
||||
d.target = to + d.err;
|
||||
d.route.clear();
|
||||
retargeted.push((true, qi));
|
||||
n += 1;
|
||||
}
|
||||
// In flight: a target system destroyed — the next known one, or keep going.
|
||||
for (di, d) in self.drones.iter_mut().enumerate().filter(|(_, d)| d.alive() && d.smart) {
|
||||
let aimed_at_wreck = self.sams.iter().any(|s| !s.alive() && s.pos.distance(d.target) < 6.0);
|
||||
let aimed_at_wreck = self.sams.iter().any(|s| !s.alive() && s.pos.distance(d.target - d.err) < 6.0);
|
||||
if aimed_at_wreck {
|
||||
let next = known.iter().min_by(|a, b| a.1.distance(d.pos).total_cmp(&b.1.distance(d.pos)));
|
||||
if let Some((_, at)) = next {
|
||||
d.target = *at;
|
||||
d.target = *at + d.err;
|
||||
d.route.clear();
|
||||
retargeted.push((false, di));
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
//! oldest dying down first. A replay draws the same fires from its events (`replay_fire`).
|
||||
use super::*;
|
||||
|
||||
/// Fires burning at once.
|
||||
/// Fires drawn at full strength at once (flames, smoke columns, lights, fire blocks): the strongest `MAX`.
|
||||
pub const MAX: usize = 12;
|
||||
/// Fires kept at all, burning places the player may come back to: past it the weakest are put out (#69).
|
||||
/// The rest of them, beyond `MAX`, only smoulder (a thin smoke, now and then), with no light.
|
||||
pub const LIST_MAX: usize = 32;
|
||||
/// A fire put out by the water it fell in lasts this long (steam only).
|
||||
const STEAM: u32 = 160;
|
||||
/// Ticks the oldest fire has left when a newer one pushes it out.
|
||||
@@ -32,6 +35,8 @@ pub struct Fire {
|
||||
blocks: Vec<(i32, i32, i32)>,
|
||||
/// The host's wreck is flaming: this fire leaves the flames to it.
|
||||
pub quiet: bool,
|
||||
/// Among the `MAX` strongest this tick: the only ones drawn in full and lit (#69).
|
||||
pub shown: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -117,7 +122,20 @@ impl Sim {
|
||||
asked: 0,
|
||||
blocks: Vec::new(),
|
||||
quiet: false,
|
||||
shown: true,
|
||||
});
|
||||
// A hard bound on the places kept (#69): the weakest, soonest out, goes now and its fire blocks with it.
|
||||
while self.fires.list.len() > LIST_MAX {
|
||||
let weakest = (0..self.fires.list.len())
|
||||
.min_by(|&a, &b| {
|
||||
let (fa, fb) = (&self.fires.list[a], &self.fires.list[b]);
|
||||
let w = |f: &Fire| f.size as f64 * (f.life as f64 - now.saturating_sub(f.start) as f64).max(0.0);
|
||||
w(fa).total_cmp(&w(fb)).then(fa.id.cmp(&fb.id))
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let gone = self.fires.list.swap_remove(weakest);
|
||||
self.fires.cleanup.extend(gone.take_blocks());
|
||||
}
|
||||
if self.fires.list.len() > MAX {
|
||||
// The oldest dies down (and is gone) instead of burning on.
|
||||
let old = self.fires.list.iter_mut().filter(|f| now.saturating_sub(f.start) as u32 + FADE < f.life).min_by_key(|f| f.start);
|
||||
@@ -133,6 +151,19 @@ impl Sim {
|
||||
out.commands.append(&mut self.fires.cleanup);
|
||||
let damage = self.damage;
|
||||
let mut fires = std::mem::take(&mut self.fires.list);
|
||||
// The strongest `MAX` are drawn in full and lit; the rest smoulder (#69).
|
||||
let mut rank: Vec<(f32, u32, usize)> = fires
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
let (fl, sm) = f.level(now);
|
||||
(f.size * (fl + 0.3 * sm), f.id, i)
|
||||
})
|
||||
.collect();
|
||||
rank.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1)));
|
||||
for (k, &(_, _, i)) in rank.iter().enumerate() {
|
||||
fires[i].shown = k < MAX;
|
||||
}
|
||||
for f in &mut fires {
|
||||
let age = now.saturating_sub(f.start);
|
||||
// Where the land is under it: it burns on the surface, not at the height of what fell.
|
||||
@@ -159,7 +190,12 @@ impl Sim {
|
||||
f.quiet = f.host.is_some_and(|id| self.sams.iter().any(|s| s.id == id && !s.alive() && s.burn > 0));
|
||||
let (fl, sm) = f.level(now);
|
||||
let s = f.size;
|
||||
if !f.quiet {
|
||||
if !f.quiet && !f.shown {
|
||||
// Beyond the budget: a thin smoke now and then, no flames, no light, no new fire blocks.
|
||||
if sm > 0.04 && now % 24 == (f.id % 24) as u64 {
|
||||
out.particles("minecraft:large_smoke", f.at + DVec3::Y, 0.4 + 0.3 * f.size, DVec3::new(0.0, 0.1, 0.0), 0.03, 1);
|
||||
}
|
||||
} else if !f.quiet {
|
||||
let top = f.at + DVec3::Y * 0.4;
|
||||
let spread = 0.4 + 0.5 * s;
|
||||
if fl > 0.04 && now % 2 == 0 {
|
||||
@@ -180,7 +216,7 @@ impl Sim {
|
||||
}
|
||||
}
|
||||
// Real fire, only where the damage is real: a few blocks on the solid ground.
|
||||
if damage && f.land == Some(true) && fl > 0.3 && now % REFRESH == (f.id as u64) % REFRESH && age >= 20 {
|
||||
if damage && f.shown && f.land == Some(true) && fl > 0.3 && now % REFRESH == (f.id as u64) % REFRESH && age >= 20 {
|
||||
let (x, y, z) = (f.at.x.floor() as i32, f.at.y.floor() as i32, f.at.z.floor() as i32);
|
||||
let n = if s >= 2.5 { 5 } else if s >= 1.5 { 3 } else { 1 };
|
||||
for k in 0..n {
|
||||
@@ -215,9 +251,15 @@ impl Sim {
|
||||
/// A fire for a replay: the event's power at age `age` ticks, as a glowing flame model's scale
|
||||
/// (zero when it is out or not started) and a smoke strength.
|
||||
pub fn replay_fire(power: u32, age: f64) -> (f32, f32) {
|
||||
if age < 0.0 {
|
||||
replay_fire_of(size_for(power), life_for(power), age)
|
||||
}
|
||||
|
||||
/// The same for a fire of a given `size` and `life` (a merged fire has grown): out (zero) before
|
||||
/// it starts and from the end of its life on, as the live fire is removed then.
|
||||
pub fn replay_fire_of(size: f32, life: u32, age: f64) -> (f32, f32) {
|
||||
if !(0.0..life as f64).contains(&age) {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let (fl, sm) = level(age, life_for(power) as f64);
|
||||
(fl * size_for(power), sm)
|
||||
let (fl, sm) = level(age, life as f64);
|
||||
(fl * size, sm)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
+249
-52
@@ -16,7 +16,7 @@ pub mod record;
|
||||
mod sight;
|
||||
mod terrain;
|
||||
mod ew;
|
||||
mod lights;
|
||||
pub mod lights;
|
||||
mod logistics;
|
||||
mod orders;
|
||||
mod units;
|
||||
@@ -219,10 +219,19 @@ pub struct Ground {
|
||||
asked: std::collections::HashSet<(i32, i32)>,
|
||||
/// The tick of the last full clean-up (a big cache would otherwise be scanned on every answer).
|
||||
swept: u64,
|
||||
/// The tick of the last answer round: what the heights' ages are measured against.
|
||||
now: u64,
|
||||
/// The blocks themselves (issues #42, #43): what stops a flight.
|
||||
pub world: world::WorldCache,
|
||||
}
|
||||
|
||||
/// The `when` of a height reported changed: due to be asked again.
|
||||
const STALE: u64 = u64::MAX;
|
||||
/// Ticks after which a land height used by the flights' look-ahead is asked again (#64), changes told of or not.
|
||||
const HEIGHT_TTL: u64 = 1200;
|
||||
/// Ticks a flight waits for an unanswered section, and for a chunk the game doesn't have while low over the land (#61).
|
||||
pub const HOLD_PENDING: u16 = 45;
|
||||
pub const HOLD_NO_CHUNK: u16 = 240;
|
||||
/// Ticks between full clean-ups of a big ground cache.
|
||||
const SWEEP_EVERY: u64 = 200;
|
||||
|
||||
@@ -235,7 +244,7 @@ impl Ground {
|
||||
Some(&(y, when)) => {
|
||||
// Refreshes of known columns take at most a quarter of the questions: the rest
|
||||
// stay free for columns never seen, or those would starve.
|
||||
if now.saturating_sub(when) > 200 && self.wanted.len() < 64 && self.asked.insert(key) {
|
||||
if (when == STALE || now.saturating_sub(when) > 200) && self.wanted.len() < 64 && self.asked.insert(key) {
|
||||
self.wanted.push(key);
|
||||
}
|
||||
(y > -64).then_some(f64::from(y))
|
||||
@@ -249,14 +258,54 @@ impl Ground {
|
||||
}
|
||||
}
|
||||
pub fn answer(&mut self, answers: Vec<(i32, i32, i32)>, now: u64) {
|
||||
self.now = now;
|
||||
for (x, z, y) in answers {
|
||||
self.known.insert((x, z), (y, now));
|
||||
}
|
||||
self.age_heights();
|
||||
if self.known.len() > 300_000 && now.saturating_sub(self.swept) >= SWEEP_EVERY {
|
||||
self.swept = now;
|
||||
self.known.retain(|_, (_, when)| now.saturating_sub(*when) < 2000);
|
||||
}
|
||||
}
|
||||
/// Heights over blocks that changed (the block cache's notes of changes, chunk replacements and a new world)
|
||||
/// are marked old (`when` = STALE): still used, asked again within the budget. #64.
|
||||
fn age_heights(&mut self) {
|
||||
let all = std::mem::take(&mut self.world.changed_all);
|
||||
let cols = std::mem::take(&mut self.world.changed_cols);
|
||||
if !all && cols.is_empty() {
|
||||
return;
|
||||
}
|
||||
if all || cols.len() > 24 {
|
||||
for (&(x, z), v) in self.known.iter_mut() {
|
||||
if all || cols.contains(&(x >> 4, z >> 4)) {
|
||||
v.1 = STALE;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for &(sx, sz) in &cols {
|
||||
for x in sx * 16..sx * 16 + 16 {
|
||||
for z in sz * 16..sz * 16 + 16 {
|
||||
if let Some(v) = self.known.get_mut(&(x, z)) {
|
||||
v.1 = STALE;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The lattice columns of the flights' look-ahead (see `terrain`).
|
||||
for x in (sx * 16..sx * 16 + 16).step_by(8) {
|
||||
for z in (sz * 16..sz * 16 + 16).step_by(8) {
|
||||
if let Some(v) = self.known.get_mut(&(x + 4, z + 4)) {
|
||||
v.1 = STALE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether a known height is due to be asked again: reported old, or too old to trust.
|
||||
fn due(&self, when: u64) -> bool {
|
||||
when == STALE || self.now.saturating_sub(when) > HEIGHT_TTL
|
||||
}
|
||||
pub fn take_wanted(&mut self) -> Vec<(i32, i32)> {
|
||||
self.asked.clear();
|
||||
std::mem::take(&mut self.wanted)
|
||||
@@ -265,6 +314,48 @@ impl Ground {
|
||||
pub fn sweep(&mut self, a: DVec3, b: DVec3, skip_start: bool, now: u64) -> world::Sweep {
|
||||
self.world.sweep(a, b, skip_start, now)
|
||||
}
|
||||
/// The step of flight `key` from `a` to `b`: the first solid block by the occupancy, then (#63) by the
|
||||
/// block's exact collision shape, which the game answers a tick or two later (see `WorldCache::narrow`).
|
||||
pub fn trace(&mut self, key: u32, a: DVec3, b: DVec3, skip_start: bool, now: u64) -> world::Sweep {
|
||||
let s = self.world.sweep(a, b, skip_start, now);
|
||||
self.world.narrow(key, s, a, b, now)
|
||||
}
|
||||
/// What to do with a step that ended in blocks the game hasn't told of (#61): `Some(t)` cuts the step at `t`
|
||||
/// of the way (it holds there, the section is asked for urgently), `None` lets it go on. Unread is never
|
||||
/// taken for clear: a section not answered yet holds a flight for up to `HOLD_PENDING` ticks, a chunk the
|
||||
/// game doesn't have holds one that is low over the land (or inside it) for `HOLD_NO_CHUNK`; after that, and
|
||||
/// high over the land, it goes on (the game has nothing there to hit) until a step is clear again.
|
||||
pub fn unread(&mut self, sweep: &world::Sweep, hold: &mut u16, low: bool) -> Option<f64> {
|
||||
match *sweep {
|
||||
world::Sweep::Unknown { t, pending } => {
|
||||
let limit = if pending {
|
||||
HOLD_PENDING
|
||||
} else if low {
|
||||
HOLD_NO_CHUNK
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if *hold < limit {
|
||||
*hold += 1;
|
||||
self.world.stats.held += 1;
|
||||
Some((t - 1e-6).max(0.0))
|
||||
} else {
|
||||
self.world.stats.passed_unknown += 1;
|
||||
None
|
||||
}
|
||||
}
|
||||
world::Sweep::Clear => {
|
||||
*hold = 0;
|
||||
None
|
||||
}
|
||||
world::Sweep::Hit { .. } => None,
|
||||
// The exact shape is being asked for: the flight holds at the face (a bounded wait, see `narrow`).
|
||||
world::Sweep::Wait { t } => {
|
||||
self.world.stats.held += 1;
|
||||
Some((t - 1e-6).max(0.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Has the sections along a flight read before it gets there.
|
||||
pub fn ahead(&mut self, pos: DVec3, vel: DVec3, ticks: f64, now: u64) {
|
||||
self.world.look_ahead(pos, vel, ticks, now);
|
||||
@@ -748,6 +839,11 @@ pub struct Drone {
|
||||
smart: bool,
|
||||
/// What the land ahead asks of its flight (see `terrain`).
|
||||
nav: Nav,
|
||||
/// Its navigation error (#65): `target` is the nominal aim plus this, kept apart so a new aim (a retarget)
|
||||
/// keeps the error this airframe was born with instead of becoming exact.
|
||||
err: DVec3,
|
||||
/// Ticks held at the edge of blocks the game has not told of (#61); reset by a clear step.
|
||||
hold: u16,
|
||||
}
|
||||
|
||||
/// A drone's terrain following: refreshed every few ticks, used every one.
|
||||
@@ -970,6 +1066,50 @@ pub struct Missile {
|
||||
/// The system that fired it.
|
||||
sam: u32,
|
||||
age: u32,
|
||||
/// Ticks held at the edge of blocks the game has not told of (#61).
|
||||
hold: u16,
|
||||
/// What it knows of its target (#66): the last fix, from the firing system's track (the data link)
|
||||
/// or from its own seeker. It steers on this, never on the target's true position.
|
||||
fix: Option<Fix>,
|
||||
/// The tick its seeker last held the target (0: not yet).
|
||||
seen: u64,
|
||||
}
|
||||
|
||||
/// A missile's knowledge of its target: where it was and how it went when measured, and when.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Fix {
|
||||
pub pos: DVec3,
|
||||
pub vel: DVec3,
|
||||
pub tick: u64,
|
||||
/// Measured by its own seeker (else by the firing system's radar, passed on by the link).
|
||||
pub seeker: bool,
|
||||
}
|
||||
|
||||
impl Fix {
|
||||
/// Where the target should be by now, carrying the measurement on.
|
||||
pub fn est(&self, now: u64) -> DVec3 {
|
||||
self.pos + self.vel * now.saturating_sub(self.tick).min(MAX_COAST) as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticks a fix is carried on by its velocity; a missile with nothing newer than this has lost its target.
|
||||
pub const MAX_COAST: u64 = 240;
|
||||
/// Ticks the seeker keeps a target it lost sight of (it looks again round the last place).
|
||||
const REACQUIRE: u64 = 12;
|
||||
|
||||
impl MissileKind {
|
||||
/// How far its own seeker holds a target of average size (blocks); 0: command guidance only.
|
||||
pub fn seeker(self) -> f64 {
|
||||
match self {
|
||||
MissileKind::M57e6 => 0.0,
|
||||
MissileKind::Pac3 => 110.0,
|
||||
MissileKind::M48n6 | MissileKind::M77n6 => 120.0,
|
||||
MissileKind::IrisT | MissileKind::M9m317 => 90.0,
|
||||
MissileKind::Amraam => 130.0,
|
||||
MissileKind::M9m338 => 70.0,
|
||||
MissileKind::P1sun => 60.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- air defence
|
||||
@@ -1091,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.
|
||||
@@ -1146,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(),
|
||||
}
|
||||
@@ -1291,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.
|
||||
@@ -1335,6 +1485,7 @@ impl Sim {
|
||||
day_time: None,
|
||||
flares: Vec::new(),
|
||||
lit: Vec::new(),
|
||||
spot_hd: false,
|
||||
}
|
||||
}
|
||||
pub fn id(&mut self) -> u32 {
|
||||
@@ -1379,37 +1530,9 @@ impl Sim {
|
||||
}
|
||||
|
||||
fn add_drone(&mut self, kind: DroneKind, from: DVec3, dir: DVec3, target: DVec3) {
|
||||
let id = self.id();
|
||||
let spec = kind.spec();
|
||||
// Its navigation error: it flies to, and strikes at, a point off the aim it was given.
|
||||
let target = target + self.aim_error(kind);
|
||||
let (vel, flight) = if spec.ballistic.is_some() {
|
||||
// An arc from `from` to `target` at the kind's horizontal speed.
|
||||
let flat = horizontal(target - from);
|
||||
let time = (flat.length() / spec.speed).max(30.0);
|
||||
let up = (target.y - from.y + 0.5 * GRAVITY * time * time) / time;
|
||||
(flat / time + DVec3::Y * up, Flight::Ballistic)
|
||||
} else {
|
||||
(dir.normalize_or(DVec3::NEG_Z) * spec.speed, Flight::Cruise)
|
||||
};
|
||||
self.drones.push(Drone {
|
||||
id,
|
||||
kind,
|
||||
pos: from,
|
||||
vel,
|
||||
target,
|
||||
flight,
|
||||
roll: 0.0,
|
||||
spin: 0.0,
|
||||
age: 0,
|
||||
engaged: 0,
|
||||
route: Vec::new(),
|
||||
low: false,
|
||||
jammed: false,
|
||||
sector: 0,
|
||||
smart: false,
|
||||
nav: Nav::default(),
|
||||
});
|
||||
// Its navigation error is drawn in `make_drone`, the one path of manual and raid launches (#65).
|
||||
let d = self.make_drone(kind, from, dir, target);
|
||||
self.drones.push(d);
|
||||
self.launched += 1;
|
||||
}
|
||||
|
||||
@@ -1467,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);
|
||||
@@ -1513,9 +1636,11 @@ impl Sim {
|
||||
d.vel *= 0.985;
|
||||
d.spin += 0.35;
|
||||
d.pos += d.vel;
|
||||
let sweep = ground.sweep(from, d.pos, d.age <= 3, now);
|
||||
let sweep = ground.trace(d.id, from, d.pos, d.age <= 3, now);
|
||||
if let Some((_, p)) = sweep.hit() {
|
||||
d.pos = p;
|
||||
} else if let Some(t) = ground.unread(&sweep, &mut d.hold, below.is_some_and(|g| from.y < g + 12.0)) {
|
||||
d.pos = from.lerp(d.pos, t);
|
||||
}
|
||||
if self.rng.chance(0.5) {
|
||||
out.particles("minecraft:large_smoke", d.pos, 0.2, DVec3::new(0.0, 0.05, 0.0), 0.02, 1);
|
||||
@@ -1544,9 +1669,11 @@ impl Sim {
|
||||
d.vel = turn(d.vel, d.target - d.pos, spec.turn) * sp;
|
||||
}
|
||||
d.pos += d.vel;
|
||||
let sweep = ground.sweep(from, d.pos, d.age <= 3, now);
|
||||
let sweep = ground.trace(d.id, from, d.pos, d.age <= 3, now);
|
||||
if let Some((_, p)) = sweep.hit() {
|
||||
d.pos = p;
|
||||
} else if let Some(t) = ground.unread(&sweep, &mut d.hold, below.is_some_and(|g| from.y < g + 12.0)) {
|
||||
d.pos = from.lerp(d.pos, t);
|
||||
}
|
||||
let speed = d.vel.length();
|
||||
let landed = sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.2, &sweep, now);
|
||||
@@ -1721,9 +1848,12 @@ impl Sim {
|
||||
let speed = if d.flight == Flight::Dive { spec.speed * 1.35 } else { spec.speed };
|
||||
d.vel = new * speed;
|
||||
d.pos += d.vel;
|
||||
let sweep = ground.sweep(from, d.pos, d.age <= 3, now);
|
||||
let sweep = ground.trace(d.id, from, d.pos, d.age <= 3, now);
|
||||
if let Some((_, p)) = sweep.hit() {
|
||||
d.pos = p;
|
||||
} else if let Some(t) = ground.unread(&sweep, &mut d.hold, below.is_some_and(|g| from.y < g + 12.0)) {
|
||||
// Not told of what lies ahead: it doesn't fly into it unseen (a held flight waits, a tick or two).
|
||||
d.pos = from.lerp(d.pos, t);
|
||||
}
|
||||
let at_target = d.pos.distance(d.target) < speed.max(1.5);
|
||||
let in_ground = sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.2, &sweep, now);
|
||||
@@ -1818,6 +1948,8 @@ impl Sim {
|
||||
sector,
|
||||
smart: false,
|
||||
nav: Nav::default(),
|
||||
err: DVec3::ZERO,
|
||||
hold: 0,
|
||||
});
|
||||
}
|
||||
for d in &self.drones {
|
||||
@@ -1894,10 +2026,10 @@ impl Sim {
|
||||
}
|
||||
if let Some(p) = player {
|
||||
let d = p.distance(pos);
|
||||
let reach = [45.0, 45.0, 100.0, 160.0, 240.0][power];
|
||||
let reach = [70.0, 45.0, 100.0, 160.0, 240.0][power];
|
||||
if d < reach {
|
||||
let strength = [0.4, 0.4, 0.9, 1.3, 1.8][power];
|
||||
out.jolt((1.0 - d / reach) as f32 * strength, [0.8, 0.8, 1.6, 2.2, 3.0][power]);
|
||||
let strength = [0.55, 0.4, 0.9, 1.3, 1.8][power];
|
||||
out.jolt((1.0 - d / reach) as f32 * strength, [1.0, 0.8, 1.6, 2.2, 3.0][power]);
|
||||
}
|
||||
}
|
||||
self.blast_damage(pos, power, out);
|
||||
@@ -1954,11 +2086,13 @@ impl Sim {
|
||||
if let Some(s) = self.sams.iter_mut().find(|s| s.id == by) {
|
||||
s.kills += 1;
|
||||
}
|
||||
self.fireball(pos, 3.5, 8);
|
||||
// The look of the kill goes by the warhead it blew up (replays use the same).
|
||||
let (size, life, gain) = record::kill_look(self.drones[idx].kind);
|
||||
self.fireball(pos, size as f64, life / 9);
|
||||
out.particles("minecraft:explosion", pos, 0.6, DVec3::ZERO, 0.0, 4);
|
||||
out.particles("minecraft:large_smoke", pos, 1.0, DVec3::ZERO, 0.06, 25);
|
||||
out.particles("minecraft:firework", pos, 0.3, DVec3::ZERO, 0.35, 30);
|
||||
out.sound("airdef:blast.air", pos, 1.0, 1.0 + self.rng.f() as f32 * 0.2);
|
||||
out.sound("airdef:blast.air", pos, gain, 1.0 + self.rng.f() as f32 * 0.2);
|
||||
for _ in 0..5 {
|
||||
let id = self.id();
|
||||
let v = vel + DVec3::new(self.rng.s(), self.rng.f() * 0.8, self.rng.s()) * 0.35;
|
||||
@@ -2290,10 +2424,17 @@ impl Sim {
|
||||
if let Some(s) = self.sams.iter().find(|s| s.id == sam) {
|
||||
self.intel.revealed.push((sam, s.pos, self.tick));
|
||||
}
|
||||
// It leaves with the track the system fired on (#66).
|
||||
let fix = self
|
||||
.sams
|
||||
.iter()
|
||||
.find(|s| s.id == sam)
|
||||
.and_then(|s| s.track(target))
|
||||
.map(|tr| Fix { pos: tr.pos, vel: tr.vel, tick: tr.tick, seeker: false });
|
||||
let spec = kind.spec();
|
||||
let id = self.id();
|
||||
let v0 = if spec.cold > 0 { 0.7 } else { 0.8 };
|
||||
self.missiles.push(Missile { id, kind, pos: muzzle, vel: dir * v0, target, sam, age: 0 });
|
||||
self.missiles.push(Missile { id, kind, pos: muzzle, vel: dir * v0, target, sam, age: 0, hold: 0, fix, seen: 0 });
|
||||
if spec.cold > 0 {
|
||||
// Thrown out by gas: a puff, a thump; the motor lights later.
|
||||
out.particles("minecraft:cloud", muzzle, 0.6, dir * 0.05, 0.05, 30);
|
||||
@@ -2328,6 +2469,16 @@ impl Sim {
|
||||
let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32);
|
||||
let (mut blocked, mut scrubbed) = (0u32, 0u32);
|
||||
let sams_fit: HashMap<u32, f64> = self.sams.iter().map(|s| (s.id, 1.0 - 0.3 * (1.0 - s.health.clamp(0.0, 1.0)))).collect();
|
||||
// The data link (#66): the track the firing system holds of each missile's target, if it is up.
|
||||
let links: HashMap<(u32, u32), Track> = self
|
||||
.missiles
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let s = self.sams.iter().find(|s| s.id == m.sam)?;
|
||||
(s.alive() && !s.moving()).then_some(())?;
|
||||
Some(((m.sam, m.target), *s.track(m.target)?))
|
||||
})
|
||||
.collect();
|
||||
for m in &mut self.missiles {
|
||||
m.age += 1;
|
||||
let spec = m.kind.spec();
|
||||
@@ -2339,23 +2490,52 @@ impl Sim {
|
||||
(m.vel.length() + spec.accel).min(spec.speed)
|
||||
};
|
||||
let dir = m.vel.normalize_or(DVec3::Y);
|
||||
let new = match target {
|
||||
Some(t) if m.age >= spec.boost => {
|
||||
// What it knows: the link's newest track, and its own seeker once the target is in its reach and in view.
|
||||
if let Some(tr) = links.get(&(m.sam, m.target))
|
||||
&& m.fix.is_none_or(|f| tr.tick > f.tick && !(f.seeker && now.saturating_sub(f.tick) <= REACQUIRE))
|
||||
{
|
||||
m.fix = Some(Fix { pos: tr.pos, vel: tr.vel, tick: tr.tick, seeker: false });
|
||||
}
|
||||
if let Some(t) = target
|
||||
&& lit
|
||||
&& m.age >= spec.boost
|
||||
&& m.kind.seeker() > 0.0
|
||||
{
|
||||
let range = m.kind.seeker() * t.kind.rcs().powf(0.25).clamp(0.6, 1.6);
|
||||
let to = t.pos - m.pos;
|
||||
let in_view = to.length() < range && to.normalize_or_zero().dot(dir) > 0.5;
|
||||
if in_view && !matches!(ground.world.probe(m.pos, t.pos, now), world::Sweep::Hit { .. }) {
|
||||
m.seen = now;
|
||||
}
|
||||
if m.seen > 0 && now.saturating_sub(m.seen) <= REACQUIRE {
|
||||
// The seeker measures the target (the track of its own, newer than any link's).
|
||||
m.fix = Some(Fix { pos: if m.seen == now { t.pos } else { m.fix.map_or(t.pos, |f| f.pos) }, vel: t.vel, tick: if m.seen == now { now } else { m.fix.map_or(now, |f| f.tick) }, seeker: true });
|
||||
}
|
||||
}
|
||||
// Nothing new for too long and no seeker on it: the interceptor has lost its target.
|
||||
if target.is_some() && m.fix.is_none_or(|f| now.saturating_sub(f.tick) > MAX_COAST) && m.age > spec.boost + 20 {
|
||||
expired += 1;
|
||||
done.push((m.id, None, false, m.sam));
|
||||
continue;
|
||||
}
|
||||
let aim = m.fix.map(|f| (f.est(now), f.vel));
|
||||
let new = match aim {
|
||||
Some((at, vel)) if m.age >= spec.boost => {
|
||||
// Time to go by the closing speed: head-on targets come to meet it.
|
||||
let los = (t.pos - m.pos).normalize_or_zero();
|
||||
let closing = (speed - t.vel.dot(los)).max(1.0);
|
||||
let tgo = t.pos.distance(m.pos) / closing;
|
||||
let los = (at - m.pos).normalize_or_zero();
|
||||
let closing = (speed - vel.dot(los)).max(1.0);
|
||||
let tgo = at.distance(m.pos) / closing;
|
||||
// Over the land in the way: lofted as far as the missile can, else it blows up before the ridge.
|
||||
let (want, fail) = terrain::steer(ground, m.kind, m.pos, speed, t.pos + t.vel * tgo);
|
||||
let (want, fail) = terrain::steer(ground, m.kind, m.pos, speed, at + vel * tgo);
|
||||
if fail {
|
||||
scrubbed += 1;
|
||||
done.push((m.id, Some(t.id), false, m.sam));
|
||||
done.push((m.id, target.map(|t| t.id), false, m.sam));
|
||||
continue;
|
||||
}
|
||||
turn(dir, want, spec.turn)
|
||||
}
|
||||
// After a cold launch it tips over towards the target while the motor burns.
|
||||
Some(t) if lit && spec.cold > 0 => turn(dir, t.pos - m.pos, spec.turn * 0.5),
|
||||
Some((at, _)) if lit && spec.cold > 0 => turn(dir, at - m.pos, spec.turn * 0.5),
|
||||
_ => dir,
|
||||
};
|
||||
let before = m.pos;
|
||||
@@ -2363,7 +2543,24 @@ impl Sim {
|
||||
m.pos += m.vel;
|
||||
// The first block on the way this tick; a fuse that goes off earlier along the way still counts.
|
||||
ground.ahead(m.pos, m.vel, 10.0, now);
|
||||
let world = ground.sweep(before, m.pos, m.age <= 12, now).hit();
|
||||
let sweep = ground.trace(m.id | 0x8000_0000, before, m.pos, m.age <= 12, now);
|
||||
let mut world = sweep.hit();
|
||||
// Blocks not told of (#61): the interceptor holds at the edge of what is read, and no fuse is
|
||||
// counted beyond it; low over a chunk the game doesn't have, it is lost there as against a wall.
|
||||
if world.is_none()
|
||||
&& let world::Sweep::Unknown { t, pending } = sweep
|
||||
{
|
||||
let low = ground.at(m.pos, now).is_some_and(|g| before.y < g + 12.0);
|
||||
if !pending && low {
|
||||
world = Some((t, before.lerp(m.pos, t)));
|
||||
} else if let Some(t) = ground.unread(&sweep, &mut m.hold, low || pending) {
|
||||
m.pos = before.lerp(m.pos, t);
|
||||
}
|
||||
} else if let world::Sweep::Wait { t } = sweep {
|
||||
m.pos = before.lerp(m.pos, (t - 1e-6).max(0.0));
|
||||
} else if matches!(sweep, world::Sweep::Clear) {
|
||||
m.hold = 0;
|
||||
}
|
||||
if let Some(t) = target {
|
||||
// Closest approach this tick, both moving (fast targets cross a fuse in a tick).
|
||||
let rel0 = (t.pos - t.vel) - before;
|
||||
|
||||
@@ -350,9 +350,10 @@ impl Sim {
|
||||
from.y = floor;
|
||||
d.pos.y = floor;
|
||||
if spec.ballistic.is_some() {
|
||||
let flat = horizontal(aim - from);
|
||||
let aimed = aim + d.err;
|
||||
let flat = horizontal(aimed - from);
|
||||
let time = (flat.length() / spec.speed).max(30.0);
|
||||
d.vel = flat / time + DVec3::Y * ((aim.y - from.y + 0.5 * GRAVITY * time * time) / time);
|
||||
d.vel = flat / time + DVec3::Y * ((aimed.y - from.y + 0.5 * GRAVITY * time * time) / time);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,6 +428,10 @@ impl Sim {
|
||||
pub(super) fn make_drone(&mut self, kind: DroneKind, from: DVec3, dir: DVec3, target: DVec3) -> Drone {
|
||||
let id = self.id();
|
||||
let spec = kind.spec();
|
||||
// Its navigation error (CEP and a rare gross fault, #59/#65): the same for a manual launch and a raid.
|
||||
// The raid's own ±4 spread of aim points is the tactical dispersal and stays on top of it.
|
||||
let err = self.aim_error(kind);
|
||||
let target = target + err;
|
||||
let (vel, flight) = if spec.ballistic.is_some() {
|
||||
let flat = horizontal(target - from);
|
||||
let time = (flat.length() / spec.speed).max(30.0);
|
||||
@@ -452,6 +457,8 @@ impl Sim {
|
||||
sector: 0,
|
||||
smart: false,
|
||||
nav: Nav::default(),
|
||||
err,
|
||||
hold: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+111
-37
@@ -114,9 +114,11 @@ pub struct Recording {
|
||||
/// The systems and objects as they stood: (id, kind, where).
|
||||
pub sams: Vec<(u32, SamKind, DVec3)>,
|
||||
pub objects: Vec<DVec3>,
|
||||
/// The world clock's time of day (ticks) every `TIME_STEP` ticks of the recording, from its
|
||||
/// start: replays show the sky of the raid, not the current one. Empty when the clock was unknown.
|
||||
times: Vec<u64>,
|
||||
/// The world clock's time of day as (recording tick, day ticks), sampled on every
|
||||
/// `TIME_STEP`-th tick the clock was known: replays show the sky of the raid, not the current
|
||||
/// one. A tick the clock was unknown has no sample (a gap), so a late start or a lost clock
|
||||
/// never shifts later samples; empty when the clock was never known.
|
||||
times: Vec<(u32, u64)>,
|
||||
/// Ticks the sky has been clear.
|
||||
quiet: u32,
|
||||
}
|
||||
@@ -166,6 +168,86 @@ impl Track {
|
||||
}
|
||||
}
|
||||
|
||||
/// A fire as a replay shows it: what the events lit and kept alight, merged as the live fires are.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ReplayFire {
|
||||
pub at: DVec3,
|
||||
/// The recording's tick it was lit.
|
||||
pub start: u32,
|
||||
pub life: u32,
|
||||
pub size: f32,
|
||||
/// The event that lit it (the models' ids).
|
||||
pub key: usize,
|
||||
}
|
||||
|
||||
/// The look of an interception by the drone's warhead: (fireball radius, ticks it burns, loudness).
|
||||
/// A bare decoy pops; a big warhead lights the sky. The same in the live raid and its replay.
|
||||
pub fn kill_look(kind: DroneKind) -> (f32, u32, f32) {
|
||||
let w = kind.spec().warhead.min(8) as f32;
|
||||
(3.5 * (0.85 + 0.12 * w), (8 + (w as u32) / 2) * 9, 1.0 + 0.06 * w)
|
||||
}
|
||||
|
||||
/// A blast's look for an event: (fireball radius, ticks it burns). The live blasts use the same.
|
||||
pub fn blast_spec(kind: EventKind) -> (f32, u32) {
|
||||
const SIZE: [f32; 5] = [2.5, 4.5, 7.0, 10.0, 13.0];
|
||||
const LIFE: [u32; 5] = [11, 11, 18, 22, 26];
|
||||
match kind {
|
||||
EventKind::Kill { kind, .. } => {
|
||||
let (size, life, _) = kill_look(kind);
|
||||
(size, life)
|
||||
}
|
||||
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => {
|
||||
let p = (power as usize).min(4);
|
||||
(SIZE[p], LIFE[p] * 9)
|
||||
}
|
||||
EventKind::SamLost { .. } => (8.0, 18 * 9),
|
||||
EventKind::Split { .. } => (2.0, 7 * 9),
|
||||
}
|
||||
}
|
||||
|
||||
/// The blasts of the recording's events burning at `t` (its ticks, fractional): the same
|
||||
/// `Blast`s the live simulation keeps, with `start` in the recording's ticks and the event's
|
||||
/// own id range. A pure function of `t`, so a seek or a pause shows the same frame.
|
||||
pub fn replay_blasts(rec: &Recording, t: f64) -> Vec<Blast> {
|
||||
rec.events
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(k, e)| {
|
||||
let (size, life) = blast_spec(e.kind);
|
||||
let age = t - e.at as f64;
|
||||
(0.0..life as f64).contains(&age).then_some(Blast { id: 0x4000_0000 | k as u32, at: e.pos, size, start: e.at as u64, life })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The fires burning at `t` in the recording: events within 6 blocks of a fire still burning
|
||||
/// feed it (the greater size, the longer life), as `Sim::ignite` does; those after it died light
|
||||
/// a new one. A pure function of `t`.
|
||||
pub fn replay_fires(rec: &Recording, t: f64) -> Vec<ReplayFire> {
|
||||
let mut fires: Vec<ReplayFire> = Vec::new();
|
||||
for (k, e) in rec.events.iter().enumerate() {
|
||||
if e.at as f64 > t {
|
||||
break;
|
||||
}
|
||||
let power = match e.kind {
|
||||
EventKind::Crash { power, .. } => power,
|
||||
EventKind::Hit { power, .. } => power.max(1),
|
||||
EventKind::SamLost { .. } => 2,
|
||||
_ => continue,
|
||||
};
|
||||
let (size, life) = (fires::size_for(power), fires::life_for(power));
|
||||
let alive = |f: &ReplayFire| e.at < f.start + f.life;
|
||||
if let Some(f) = fires.iter_mut().find(|f| alive(f) && f.at.distance(e.pos) < 6.0) {
|
||||
f.size = f.size.max(size);
|
||||
f.life = f.life.max(e.at - f.start + life);
|
||||
} else {
|
||||
fires.push(ReplayFire { at: e.pos, start: e.at, life, size, key: k });
|
||||
}
|
||||
}
|
||||
fires.retain(|f| t < (f.start + f.life) as f64);
|
||||
fires
|
||||
}
|
||||
|
||||
impl Recording {
|
||||
fn new(number: u32, sim: &Sim) -> Self {
|
||||
let origin = sim.targets.first().copied().or_else(|| sim.sams.first().map(|s| s.pos)).unwrap_or(DVec3::ZERO);
|
||||
@@ -199,12 +281,16 @@ impl Recording {
|
||||
/// clock as sampled, run on between samples at the pace it ran (a jump is a step), or None
|
||||
/// when the clock was not known (the replay then shows the world's own time).
|
||||
pub fn day_time(&self, t: f64) -> Option<u64> {
|
||||
let k = (t.max(0.0) / TIME_STEP as f64).floor() as usize;
|
||||
let i = k.min(self.times.len().checked_sub(1)?);
|
||||
let a = self.times[i];
|
||||
let into = if i == k { t.max(0.0) - (i as u32 * TIME_STEP) as f64 } else { 0.0 };
|
||||
let t = t.max(0.0);
|
||||
// The last sample at or before `t`; none before the first one means unknown.
|
||||
let i = self.times.partition_point(|s| s.0 as f64 <= t).checked_sub(1)?;
|
||||
let (ta, a) = self.times[i];
|
||||
match self.times.get(i + 1) {
|
||||
Some(&b) if b >= a && b - a <= 2 * TIME_STEP as u64 => Some(a + ((b - a) as f64 * into / TIME_STEP as f64) as u64),
|
||||
// Neighbouring samples with no gap between: run on at the pace between them.
|
||||
Some(&(tb, b)) if tb - ta <= TIME_STEP && b >= a && b - a <= 2 * TIME_STEP as u64 => {
|
||||
let f = ((t - ta as f64) / (tb - ta) as f64).clamp(0.0, 1.0);
|
||||
Some(a + ((b - a) as f64 * f) as u64)
|
||||
}
|
||||
_ => Some(a),
|
||||
}
|
||||
}
|
||||
@@ -271,9 +357,8 @@ impl Sim {
|
||||
let Some(r) = &mut self.rec else { return };
|
||||
if r.len % TIME_STEP == 0
|
||||
&& let Some(dt) = self.day_time
|
||||
&& r.times.len() == (r.len / TIME_STEP) as usize
|
||||
{
|
||||
r.times.push(dt);
|
||||
r.times.push((r.len, dt));
|
||||
}
|
||||
if r.len % STEP == 0 {
|
||||
for d in &self.drones {
|
||||
@@ -349,43 +434,32 @@ impl Sim {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fires: what the events left burning (the flames dim and the smoke thickens with time).
|
||||
let mut lit: Vec<(DVec3, f64)> = Vec::new();
|
||||
for (k, e) in rec.events.iter().enumerate() {
|
||||
let power = match e.kind {
|
||||
EventKind::Crash { power, .. } => power,
|
||||
EventKind::Hit { power, .. } => power.max(1),
|
||||
EventKind::SamLost { .. } => 2,
|
||||
_ => continue,
|
||||
};
|
||||
let age = t - e.at as f64;
|
||||
// One fire where several events met.
|
||||
if lit.iter().any(|(p, at)| p.distance(e.pos) < 6.0 && *at <= e.at as f64) {
|
||||
continue;
|
||||
}
|
||||
lit.push((e.pos, e.at as f64));
|
||||
let (flame, smoke) = fires::replay_fire(power, age);
|
||||
let id = base + 0x00c0_0000 + k as u32 * PARTS;
|
||||
// Fires: what the events left burning (the flames dim and the smoke thickens with time),
|
||||
// out when their life ends.
|
||||
for f in replay_fires(rec, t) {
|
||||
let age = t - f.start as f64;
|
||||
let (flame, smoke) = fires::replay_fire_of(f.size, f.life, age);
|
||||
let id = base + 0x00c0_0000 + f.key as u32 * PARTS;
|
||||
if flame > 0.05 {
|
||||
let wob = 1.0 + 0.15 * (age * 0.9 + k as f64).sin();
|
||||
out.model(id, "airdef:flame", e.pos + DVec3::Y * 0.5, DQuat::from_rotation_y(age * 0.07), (flame as f64 * 0.8 * wob).max(0.05), true);
|
||||
let wob = 1.0 + 0.15 * (age * 0.9 + f.key as f64).sin();
|
||||
out.model(id, "airdef:flame", f.at + DVec3::Y * 0.5, DQuat::from_rotation_y(age * 0.07), (flame as f64 * 0.8 * wob).max(0.05), true);
|
||||
}
|
||||
if smoke > 0.1 && (age as u64) % 3 == 0 {
|
||||
out.particles("minecraft:large_smoke", e.pos + DVec3::Y, 0.8, DVec3::new(0.0, 0.1, 0.0), 0.03, 1 + (smoke * 2.0) as u32);
|
||||
out.particles("minecraft:large_smoke", f.at + DVec3::Y, 0.8, DVec3::new(0.0, 0.1, 0.0), 0.03, 1 + (smoke * 2.0) as u32);
|
||||
}
|
||||
}
|
||||
// Blasts: a fireball growing and fading for 1.5 s after each.
|
||||
// Blasts: with the shader effects on, the post pass and the HD lights draw them from
|
||||
// `replay_blasts` (the same as the live ones); the models are the fallback without them.
|
||||
if self.shader {
|
||||
return;
|
||||
}
|
||||
for (k, e) in rec.events.iter().enumerate() {
|
||||
let age = t - e.at as f64;
|
||||
if !(0.0..30.0).contains(&age) {
|
||||
continue;
|
||||
}
|
||||
let size = match e.kind {
|
||||
EventKind::Kill { .. } => 3.5,
|
||||
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => [2.5, 4.5, 7.0, 10.0, 13.0][(power as usize).min(4)],
|
||||
EventKind::SamLost { .. } => 8.0,
|
||||
EventKind::Split { .. } => 2.0,
|
||||
};
|
||||
let (size, _) = blast_spec(e.kind);
|
||||
let size = size as f64;
|
||||
let f = age / 30.0;
|
||||
let s = size * (f * 4.0).min(1.0) * (1.0 - f * f);
|
||||
let id = base + 0x0080_0000 + k as u32 * PARTS;
|
||||
|
||||
+498
-13
@@ -137,7 +137,7 @@ fn retargeted_smart_drone_gets_a_route_round_the_defences() {
|
||||
sim.tick = 40;
|
||||
sim.adapt();
|
||||
let d = &sim.drones[0];
|
||||
assert!(d.target.distance(DVec3::new(0.0, 70.0, 1000.0)) < 1.0, "not sent at the unit");
|
||||
assert!((d.target - d.err).distance(DVec3::new(0.0, 70.0, 1000.0)) < 1.0, "not sent at the unit");
|
||||
assert!(d.route.len() > 1, "no route after the new target: it flies straight through the defences");
|
||||
}
|
||||
|
||||
@@ -375,8 +375,12 @@ fn fast_drone_must_not_cross_a_one_block_wall() {
|
||||
sim.add_drone(DroneKind::Zircon, DVec3::new(0.0, 100.0, 0.0), DVec3::X, DVec3::new(1000.0, 100.0, 0.0));
|
||||
sim.drones[0].flight = Flight::Dive;
|
||||
warm(&mut ground, &src, DVec3::new(0.0, 100.0, 0.0), DVec3::new(30.0, 100.0, 0.0));
|
||||
fly(&mut sim, &mut ground, &src);
|
||||
assert!(sim.drones.is_empty(), "crossed the wall in one tick");
|
||||
// The wall's exact shape is asked of the game (#63): a tick or two, the drone holds at its face meanwhile.
|
||||
for _ in 0..4 {
|
||||
fly(&mut sim, &mut ground, &src);
|
||||
assert!(sim.drones.first().is_none_or(|d| d.pos.x < 3.5), "crossed the wall");
|
||||
}
|
||||
assert!(sim.drones.is_empty(), "stuck at the wall");
|
||||
let at = sim.blasts.first().expect("no blast").at;
|
||||
assert!((at.x - 3.0).abs() < 0.05 && (at.y - 100.0).abs() < 1.5, "burst at {at:?}, not at the wall face x=3");
|
||||
assert_eq!((sim.crashes, sim.hits), (1, 0), "a wall in the way is a crash, not a hit on the aim");
|
||||
@@ -422,10 +426,14 @@ fn missile_cannot_pass_through_a_wall() {
|
||||
sim.drones[0].vel = DVec3::ZERO;
|
||||
sim.drones[0].engaged = 1;
|
||||
let (target, id) = (sim.drones[0].id, sim.id());
|
||||
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::X * 3.0, target, sam: 0, age: 20 });
|
||||
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::X * 3.0, target, sam: 0, age: 20, hold: 0, fix: None, seen: 0 });
|
||||
warm(&mut ground, &src, DVec3::new(0.0, 100.0, 0.0), DVec3::new(30.0, 100.0, 0.0));
|
||||
sim.tick = 1;
|
||||
sim.step_missiles(&mut ground, &mut Out::default());
|
||||
for _ in 0..4 {
|
||||
sim.tick += 1;
|
||||
sim.step_missiles(&mut ground, &mut Out::default());
|
||||
let wanted = ground.world.take_wanted(sim.tick);
|
||||
ground.world.serve(&src, wanted, sim.tick);
|
||||
}
|
||||
assert!(sim.missiles.is_empty(), "the missile is still flying");
|
||||
assert_eq!(sim.kills, 0, "a kill through the wall");
|
||||
assert!(sim.drones[0].alive(), "the target died behind the wall");
|
||||
@@ -526,6 +534,81 @@ fn recordings_keep_the_time_of_day() {
|
||||
}
|
||||
}
|
||||
|
||||
// #57: a clock missing at the start, in the middle, or later recovering never loses or shifts the samples.
|
||||
#[test]
|
||||
fn recorded_clock_survives_gaps() {
|
||||
let mut sim = Sim::new(1);
|
||||
sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 500.0));
|
||||
let mut g = Ground::default();
|
||||
let mut out = Out::default();
|
||||
for i in 0..100u64 {
|
||||
// Unknown at the start (ticks 0-19) and for a stretch in the middle (50-69).
|
||||
sim.day_time = if i < 20 || (50..70).contains(&i) { None } else { Some(1000 + i) };
|
||||
sim.step(&mut g, None, &mut out);
|
||||
}
|
||||
let r = sim.rec.as_ref().unwrap();
|
||||
assert_eq!(r.day_time(5.0), None, "before the first sample: unknown, not the current time");
|
||||
let v = r.day_time(30.0).expect("recovered after a missing start");
|
||||
assert!((1020..1040).contains(&v), "{v}");
|
||||
// Inside the gap the last known time holds; nothing is invented.
|
||||
let held = r.day_time(60.0).unwrap();
|
||||
assert!((1030..1050).contains(&held), "{held}");
|
||||
// After the gap the new samples land at their own ticks (seek forward and back alike).
|
||||
let after = r.day_time(85.0).unwrap();
|
||||
assert!((1080..1100).contains(&after), "{after}");
|
||||
assert!(r.day_time(30.0).unwrap() < r.day_time(85.0).unwrap());
|
||||
assert!(r.day_time(85.0).unwrap() > r.day_time(60.0).unwrap());
|
||||
assert!(r.day_time(25.0).unwrap() <= r.day_time(35.0).unwrap());
|
||||
}
|
||||
|
||||
// #56: a replay's fires and blasts are pure functions of its time: they end with their life,
|
||||
// merge as the live ones do, and a seek back shows the same frame.
|
||||
#[test]
|
||||
fn replay_fires_and_blasts_follow_the_recording() {
|
||||
use record::{Event, EventKind};
|
||||
let mut sim = Sim::new(1);
|
||||
sim.day_time = Some(1000);
|
||||
sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 500.0));
|
||||
let mut g = Ground::default();
|
||||
let mut out = Out::default();
|
||||
sim.step(&mut g, None, &mut out);
|
||||
let mut rec = sim.rec.take().unwrap();
|
||||
let at = DVec3::new(5.0, 70.0, 5.0);
|
||||
let crash = |power| EventKind::Crash { drone: 1, kind: DroneKind::Geran3, power };
|
||||
let life = fires::life_for(1);
|
||||
rec.events = vec![
|
||||
Event { at: 10, pos: at, kind: crash(1) },
|
||||
// The same spot while it burns: one fire, burning longer.
|
||||
Event { at: 500, pos: at + DVec3::X, kind: crash(3) },
|
||||
// The same spot long after it died: a new fire.
|
||||
Event { at: 500 + 4000 + fires::life_for(3), pos: at, kind: crash(1) },
|
||||
];
|
||||
assert!(record::replay_fires(&rec, 0.0).is_empty(), "before the first event");
|
||||
let f = record::replay_fires(&rec, 600.0);
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].start, 10);
|
||||
assert!(f[0].life >= 490 + fires::life_for(3) && f[0].size >= fires::size_for(3), "fed: {:?}", f[0]);
|
||||
// Past its life it is gone: no flames, no smoke.
|
||||
let end = f[0].start + f[0].life;
|
||||
assert!(record::replay_fires(&rec, end as f64 - 1.0).len() == 1);
|
||||
assert!(record::replay_fires(&rec, end as f64 + 1.0).is_empty());
|
||||
assert_eq!(fires::replay_fire(1, life as f64 + 10.0), (0.0, 0.0));
|
||||
assert_eq!(fires::replay_fire(1, 1.0e5), (0.0, 0.0));
|
||||
assert!(fires::replay_fire(1, 100.0).1 > 0.0);
|
||||
// The later event lights a new one.
|
||||
let f = record::replay_fires(&rec, (500 + 4000 + fires::life_for(3) + 5) as f64);
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].start, 500 + 4000 + fires::life_for(3));
|
||||
// Seeking back and forth gives the same blasts.
|
||||
let a = record::replay_blasts(&rec, 20.0);
|
||||
let _ = record::replay_blasts(&rec, 510.0);
|
||||
let b = record::replay_blasts(&rec, 20.0);
|
||||
assert_eq!(a.len(), 1);
|
||||
assert_eq!((a[0].id, a[0].start, a[0].life), (b[0].id, b[0].start, b[0].life));
|
||||
assert!(record::replay_blasts(&rec, 5.0).is_empty());
|
||||
assert!(record::replay_blasts(&rec, 10.0 + a[0].life as f64).is_empty(), "burnt out");
|
||||
}
|
||||
|
||||
fn sim_first(r: &record::Recording) -> u64 {
|
||||
r.tracks.first().map_or(0, |t| t.first as u64)
|
||||
}
|
||||
@@ -667,7 +750,7 @@ fn behind_ridge(land: &Land<impl Fn(i32, i32) -> i32>, crest: i32, kind: SamKind
|
||||
}
|
||||
// It crawls along the far side of the ridge, 20 blocks over the land.
|
||||
let there = (land.0)(x as i32, 0) as f64;
|
||||
sim.add_drone(DroneKind::Gerbera, DVec3::new(x, there + 20.0, -100.0), DVec3::Z, DVec3::new(x, there, 3000.0));
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(x, there + 20.0, -100.0), DVec3::Z, DVec3::new(x, there, 3000.0));
|
||||
for _ in 0..ticks {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
answer_land(&mut ground, land, sim.tick);
|
||||
@@ -727,10 +810,15 @@ fn a_long_range_system_lofts_over_a_ridge_or_waits() {
|
||||
assert!(sim.sams[0].fired > 0, "an IRIS-T SLM must loft over a 25-block ridge");
|
||||
assert_eq!(sim.tally.blocked + sim.tally.scrubbed, 0, "its interceptors hit the ridge");
|
||||
let tall = ridge_at(150, 400, 100);
|
||||
let sim = behind_ridge(&tall, 150, SamKind::IrisT, 230.0, 300);
|
||||
assert_eq!(sim.sams[0].fired, 0, "no missile can climb a 400-block mountain at this range");
|
||||
assert_eq!(sim.tally.blocked + sim.tally.scrubbed, 0);
|
||||
assert!(sim.why(0).iter().any(|c| !c.ok && c.text.contains("рельеф")), "{:?}", sim.why(0).iter().map(|c| &c.text).collect::<Vec<_>>());
|
||||
// The track is fresh only for a while (the target creeps out of reach): the reason is read at several moments.
|
||||
let mut told = false;
|
||||
for ticks in (150..=300).step_by(10) {
|
||||
let sim = behind_ridge(&tall, 150, SamKind::IrisT, 230.0, ticks);
|
||||
assert_eq!(sim.sams[0].fired, 0, "no missile can climb a 400-block mountain at this range");
|
||||
assert_eq!(sim.tally.blocked + sim.tally.scrubbed, 0);
|
||||
told |= sim.why(0).iter().any(|c| !c.ok && c.text.contains("рельеф"));
|
||||
}
|
||||
assert!(told, "`why` never named the land as the reason");
|
||||
}
|
||||
|
||||
// Interceptors fly over rolling land at a target low over it, and none ends in the ground.
|
||||
@@ -753,7 +841,7 @@ fn an_interceptor_over_rolling_land_does_not_hit_the_ground() {
|
||||
let (target, id) = (sim.drones[0].id, sim.id());
|
||||
let from = DVec3::new(0.0, (rolling.0)(0, 0) as f64 + 3.0, 0.0);
|
||||
sim.drones[0].engaged = 1;
|
||||
sim.missiles.push(Missile { id, kind, pos: from, vel: DVec3::new(0.5, 0.8, 0.0).normalize() * 0.8, target, sam: 0, age: 0 });
|
||||
sim.missiles.push(Missile { id, kind, pos: from, vel: DVec3::new(0.5, 0.8, 0.0).normalize() * 0.8, target, sam: 0, age: 0, hold: 0, fix: Some(fix_of(&sim.drones[0], sim.tick)), seen: 0 });
|
||||
for _ in 0..500 {
|
||||
if sim.missiles.is_empty() {
|
||||
break;
|
||||
@@ -768,6 +856,11 @@ fn an_interceptor_over_rolling_land_does_not_hit_the_ground() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fix a link would have given on a target (a test stand-in for the firing system's track; these tests keep their targets still).
|
||||
fn fix_of(d: &Drone, tick: u64) -> Fix {
|
||||
Fix { pos: d.pos, vel: DVec3::ZERO, tick, seeker: false }
|
||||
}
|
||||
|
||||
// An interceptor with no way over the land blows up in the air, and that is counted.
|
||||
#[test]
|
||||
fn an_interceptor_without_a_way_over_blows_up_high() {
|
||||
@@ -783,7 +876,7 @@ fn an_interceptor_without_a_way_over_blows_up_high() {
|
||||
sim.add_drone(DroneKind::Gerbera, DVec3::new(420.0, 90.0, 0.0), DVec3::X, DVec3::new(5000.0, 64.0, 0.0));
|
||||
let (target, id) = (sim.drones[0].id, sim.id());
|
||||
sim.drones[0].engaged = 1;
|
||||
sim.missiles.push(Missile { id, kind: MissileKind::M57e6, pos: DVec3::new(230.0, 70.0, 0.0), vel: DVec3::X * 3.0, target, sam: 0, age: 20 });
|
||||
sim.missiles.push(Missile { id, kind: MissileKind::M57e6, pos: DVec3::new(230.0, 70.0, 0.0), vel: DVec3::X * 3.0, target, sam: 0, age: 20, hold: 0, fix: Some(fix_of(&sim.drones[0], sim.tick)), seen: 0 });
|
||||
for _ in 0..200 {
|
||||
if sim.missiles.is_empty() {
|
||||
break;
|
||||
@@ -798,3 +891,395 @@ fn an_interceptor_without_a_way_over_blows_up_high() {
|
||||
let at = sim.blasts.first().expect("no blast").at;
|
||||
assert!(at.y > (wall.0)(at.x as i32, 0) as f64 + 3.0, "it burst in the ground at {at:?}");
|
||||
}
|
||||
|
||||
// ---- #61-#66, #69 (review of 6f3fc64): unread blocks, cache lifetime, exact shapes, heights, accuracy, guidance, fires
|
||||
|
||||
/// Blocks with finer shapes: `shape` gives the boxes of a block (none: from `solid`).
|
||||
struct Shaped<S: Fn(i32, i32, i32) -> Option<Vec<[f64; 6]>>>(S);
|
||||
impl<S: Fn(i32, i32, i32) -> Option<Vec<[f64; 6]>>> world::WorldSource for Shaped<S> {
|
||||
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
|
||||
(self.0)(x, y, z).map(|b| !b.is_empty())
|
||||
}
|
||||
fn boxes(&self, x: i32, y: i32, z: i32) -> Vec<[f64; 6]> {
|
||||
(self.0)(x, y, z).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
// #61: a flight on a cold cache does not cross the wall before the game has answered
|
||||
#[test]
|
||||
fn cold_cache_never_means_clear_for_a_drone() {
|
||||
let src = Blocks(|x, y, z| Some(y < 64 || (x == 3 && (64..141).contains(&y) && z.abs() < 50)));
|
||||
let mut sim = Sim::new(1);
|
||||
sim.damage = false;
|
||||
let mut ground = Ground::default();
|
||||
sim.add_drone(DroneKind::Zircon, DVec3::new(0.0, 100.0, 0.0), DVec3::X, DVec3::new(1000.0, 100.0, 0.0));
|
||||
sim.drones[0].flight = Flight::Dive;
|
||||
// No warm-up: nothing is read. One tick: the drone has not gone through, and asks for the sections.
|
||||
sim.tick += 1;
|
||||
sim.step_drones(&mut ground, None, &mut Out::default());
|
||||
assert!(sim.drones[0].pos.x < 3.5, "crossed the wall unread: x = {}", sim.drones[0].pos.x);
|
||||
assert!(ground.world.stats.held > 0);
|
||||
let mut hit_wall = false;
|
||||
for _ in 0..8 {
|
||||
let wanted = ground.world.take_wanted(sim.tick);
|
||||
ground.world.serve(&src, wanted, sim.tick);
|
||||
sim.tick += 1;
|
||||
sim.step_drones(&mut ground, None, &mut Out::default());
|
||||
assert!(sim.drones.first().is_none_or(|d| d.pos.x < 3.5), "crossed the wall");
|
||||
hit_wall |= sim.drones.is_empty();
|
||||
}
|
||||
assert!(hit_wall && sim.crashes == 1, "the wall was not found once read: crashes {}", sim.crashes);
|
||||
}
|
||||
|
||||
// #61: nor does an interceptor fuse past the unread part of its way
|
||||
#[test]
|
||||
fn cold_cache_does_not_fuse_through_a_wall() {
|
||||
let src = Blocks(|x, y, z| Some(y < 64 || (x == 3 && (64..141).contains(&y) && z.abs() < 50)));
|
||||
let mut sim = Sim::new(1);
|
||||
let mut ground = Ground::default();
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(8.5, 100.0, 0.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 900.0));
|
||||
sim.drones[0].vel = DVec3::ZERO;
|
||||
sim.drones[0].engaged = 1;
|
||||
let (target, id) = (sim.drones[0].id, sim.id());
|
||||
let fix = Some(fix_of(&sim.drones[0], 0));
|
||||
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::X * 3.0, target, sam: 0, age: 20, hold: 0, fix, seen: 0 });
|
||||
sim.tick = 1;
|
||||
sim.step_missiles(&mut ground, &mut Out::default());
|
||||
assert_eq!(sim.missiles.len(), 1, "the missile went on (held) at the edge of what is read");
|
||||
assert!(sim.missiles[0].pos.x < 3.5 && sim.kills == 0 && sim.tally.missed == 0, "fused unread");
|
||||
for _ in 0..6 {
|
||||
let wanted = ground.world.take_wanted(sim.tick);
|
||||
ground.world.serve(&src, wanted, sim.tick);
|
||||
sim.tick += 1;
|
||||
sim.step_missiles(&mut ground, &mut Out::default());
|
||||
}
|
||||
assert!(sim.missiles.is_empty() && sim.kills == 0 && sim.tally.blocked == 1, "{:?}", sim.tally);
|
||||
}
|
||||
|
||||
// #61: a chunk the game does not have holds a low flight for a bounded time, then it goes on; a section
|
||||
// that was queued as a look-ahead moves up when a step needs it, even with the queue full.
|
||||
#[test]
|
||||
fn unknown_policy_is_bounded_and_urgent_sections_jump_the_queue() {
|
||||
let none = Blocks(|_, _, _| None);
|
||||
let mut ground = Ground::default();
|
||||
// Ground known: the flight is 5 blocks over it, so it is low.
|
||||
ground.answer((-40..400).map(|x| (x, 0, 64)).collect(), 0);
|
||||
let mut sim = Sim::new(1);
|
||||
sim.damage = false;
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 69.0, 0.0), DVec3::X, DVec3::new(5000.0, 69.0, 0.0));
|
||||
let mut held = 0;
|
||||
for _ in 0..900 {
|
||||
let before = sim.drones[0].pos;
|
||||
sim.tick += 1;
|
||||
sim.step_drones(&mut ground, None, &mut Out::default());
|
||||
let wanted = ground.world.take_wanted(sim.tick);
|
||||
ground.world.serve(&none, wanted, sim.tick);
|
||||
held += (sim.drones[0].pos.distance(before) < 1e-3) as u32;
|
||||
}
|
||||
assert!(held > 20, "an unloaded chunk was no obstacle at all ({held})");
|
||||
assert!(sim.drones[0].pos.x > 100.0, "held for ever at x = {}", sim.drones[0].pos.x);
|
||||
// Urgent over ahead.
|
||||
let mut w = world::WorldCache::default();
|
||||
for i in 0..600 {
|
||||
w.look_ahead(DVec3::new(f64::from(i) * 16.0, 70.0, 0.0), DVec3::X, 1.0, 1);
|
||||
}
|
||||
w.sweep(DVec3::new(500.5, 70.5, 0.5), DVec3::new(501.5, 70.5, 0.5), false, 1);
|
||||
let first = w.take_wanted(1);
|
||||
assert!(first.contains(&(31, 4, 0)), "the section a step needs waited behind look-aheads");
|
||||
}
|
||||
|
||||
// #62: a chunk's unload or replacement voids what was known; light-only changes cost nothing
|
||||
#[test]
|
||||
fn chunk_replacement_and_age_invalidate_known_sections() {
|
||||
let mut w = world::WorldCache::default();
|
||||
let (a, b) = (DVec3::new(0.5, 70.5, 0.5), DVec3::new(30.5, 70.5, 0.5));
|
||||
let empty = Blocks(|_, _, _| Some(false));
|
||||
let wall = Blocks(|x, _, _| Some(x == 20));
|
||||
w.changed_rev(1, 10, false, &[]);
|
||||
for t in 1..4 {
|
||||
w.sweep(a, b, false, t);
|
||||
let q = w.take_wanted(t);
|
||||
w.serve(&empty, q, t);
|
||||
}
|
||||
assert_eq!(w.sweep(a, b, false, 5), world::Sweep::Clear);
|
||||
// Light only: the counter does not move, nothing happens.
|
||||
let n = w.stats.invalidated;
|
||||
w.changed_rev(1, 10, false, &[]);
|
||||
assert_eq!(w.stats.invalidated, n, "a light update invalidated the cache");
|
||||
// The chunk is replaced by a packet with a wall: the game says which column (unload/replace note).
|
||||
w.columns_changed(&[(0, 0), (1, 0)]);
|
||||
assert!(matches!(w.sweep(a, b, false, 6), world::Sweep::Unknown { pending: true, .. }), "an unloaded chunk still answers from memory");
|
||||
for t in 7..10 {
|
||||
w.sweep(a, b, false, t);
|
||||
let q = w.take_wanted(t);
|
||||
w.serve(&wall, q, t);
|
||||
}
|
||||
assert!(matches!(w.sweep(a, b, false, 10), world::Sweep::Hit { block: [20, 70, 0], .. }));
|
||||
// A replacement nobody listed: the counter moved with no section named; everything read is read again.
|
||||
w.changed_rev(1, 11, false, &[]);
|
||||
assert!(matches!(w.sweep(a, b, false, 11), world::Sweep::Hit { .. }), "old data is used until the answer");
|
||||
let q = w.take_wanted(11);
|
||||
assert!(!q.is_empty());
|
||||
w.serve(&empty, q, 12);
|
||||
assert_eq!(w.sweep(a, b, false, 13), world::Sweep::Clear);
|
||||
// And a section in use is read again after a while, told of or not.
|
||||
for t in (14..14 + 1300).step_by(100) {
|
||||
w.sweep(a, b, false, t);
|
||||
}
|
||||
assert!(w.stats.aged > 0 && !w.take_wanted(1400).is_empty(), "never read again");
|
||||
// Another world, as ever.
|
||||
w.changed_rev(2, 0, false, &[]);
|
||||
assert!(w.is_empty());
|
||||
}
|
||||
|
||||
// #63: a lower slab does not stop what flies over its free half; a full wall and the slab's own half do
|
||||
#[test]
|
||||
fn partial_blocks_use_exact_shapes() {
|
||||
let slab = Shaped(|x, y, z| {
|
||||
if y < 64 {
|
||||
Some(vec![[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]])
|
||||
} else if (x, y, z) == (10, 70, 0) {
|
||||
Some(vec![[0.0, 0.0, 0.0, 1.0, 0.5, 1.0]])
|
||||
} else if x == 20 && z == 0 {
|
||||
Some(vec![[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]])
|
||||
} else {
|
||||
Some(vec![])
|
||||
}
|
||||
});
|
||||
let mut g = Ground::default();
|
||||
let run = |g: &mut Ground, a: DVec3, b: DVec3| {
|
||||
let mut r = g.trace(7, a, b, false, 1);
|
||||
for t in 1..8 {
|
||||
let q = g.world.take_wanted(t);
|
||||
g.world.serve(&slab, q, t);
|
||||
if !matches!(r, world::Sweep::Wait { .. } | world::Sweep::Unknown { .. }) {
|
||||
break;
|
||||
}
|
||||
r = g.trace(7, a, b, false, t + 1);
|
||||
}
|
||||
r
|
||||
};
|
||||
// The free upper half: clear (and the wall at x=20 is further on than the segment).
|
||||
let r = run(&mut g, DVec3::new(9.5, 70.75, 0.5), DVec3::new(11.5, 70.75, 0.5));
|
||||
assert_eq!(r, world::Sweep::Clear, "the free half of a slab was an obstacle");
|
||||
// Through the slab's own half: a hit where its shape starts.
|
||||
let mut g = Ground::default();
|
||||
let r = run(&mut g, DVec3::new(9.5, 70.25, 0.5), DVec3::new(11.5, 70.25, 0.5));
|
||||
let world::Sweep::Hit { point, normal, block, .. } = r else { panic!("{r:?}") };
|
||||
assert!((point.x - 10.0).abs() < 1e-9 && normal == [-1, 0, 0] && block == [10, 70, 0], "{r:?}");
|
||||
// A full-size wall is a hit at its face, like the section's cube.
|
||||
let mut g = Ground::default();
|
||||
let r = run(&mut g, DVec3::new(18.5, 70.5, 0.5), DVec3::new(22.5, 70.5, 0.5));
|
||||
assert!(matches!(r, world::Sweep::Hit { block: [20, 70, 0], normal: [-1, 0, 0], .. }), "{r:?}");
|
||||
// The flight holds at the face meanwhile (a pending answer is a wait, not a pass).
|
||||
let mut g = Ground::default();
|
||||
let w = world::WorldCache::default();
|
||||
drop(w);
|
||||
for t in 0..4 {
|
||||
let q = g.world.take_wanted(t);
|
||||
g.world.serve(&slab, q, t);
|
||||
g.world.sweep(DVec3::new(18.5, 70.5, 0.5), DVec3::new(22.5, 70.5, 0.5), false, t);
|
||||
}
|
||||
let first = g.trace(9, DVec3::new(18.5, 70.5, 0.5), DVec3::new(22.5, 70.5, 0.5), false, 5);
|
||||
assert!(matches!(first, world::Sweep::Wait { .. }), "{first:?}");
|
||||
// Never answered: after the wait the solid cube of the sections stands.
|
||||
let last = g.trace(9, DVec3::new(18.5, 70.5, 0.5), DVec3::new(22.5, 70.5, 0.5), false, 5 + 31);
|
||||
assert!(matches!(last, world::Sweep::Hit { .. }), "{last:?}");
|
||||
}
|
||||
|
||||
// #64: heights known are old when blocks change, a chunk is replaced, or time passes; they are asked again
|
||||
#[test]
|
||||
fn land_heights_follow_world_changes_and_age() {
|
||||
let mut g = Ground::default();
|
||||
g.answer(vec![(4, 4, 150)], 0);
|
||||
assert_eq!(g.land(DVec3::new(4.0, 70.0, 4.0)), Some(150.0));
|
||||
assert!(g.take_wanted().is_empty(), "a fresh height was asked again");
|
||||
// The mountain is blown away: the game says which section changed.
|
||||
g.world.changed(1, false, &[(0, 9, 0)]);
|
||||
g.answer(vec![], 10);
|
||||
assert_eq!(g.land(DVec3::new(4.0, 70.0, 4.0)), Some(150.0), "the old height is used until the new one comes");
|
||||
assert!(g.take_wanted().contains(&(4, 4)), "a changed column was not asked again");
|
||||
g.answer(vec![(4, 4, 70)], 11);
|
||||
assert_eq!(g.land(DVec3::new(4.0, 70.0, 4.0)), Some(70.0));
|
||||
// Another column is not touched by that.
|
||||
g.answer(vec![(100, 100, 90)], 12);
|
||||
g.world.changed(1, false, &[(0, 9, 0)]);
|
||||
g.answer(vec![], 13);
|
||||
assert_eq!(g.land(DVec3::new(100.0, 70.0, 100.0)), Some(90.0));
|
||||
assert!(!g.take_wanted().contains(&(100, 100)));
|
||||
// Time alone: the issue's case, 500 seconds on.
|
||||
g.answer(vec![], 10_000);
|
||||
assert_eq!(g.land(DVec3::new(4.0, 70.0, 4.0)), Some(70.0));
|
||||
assert!(g.take_wanted().contains(&(4, 4)), "never asked again after 500 s");
|
||||
// A planner column too.
|
||||
let mut g = Ground::default();
|
||||
g.answer(vec![(20, 20, 80)], 0);
|
||||
g.answer(vec![], 5000);
|
||||
assert_eq!(g.column(20, 20, 1000), Some(Some(80.0)));
|
||||
assert!(g.take_wanted().contains(&(20, 20)));
|
||||
// A chunk replaced.
|
||||
let mut g = Ground::default();
|
||||
g.answer(vec![(4, 4, 150), (8, 8, 150)], 0);
|
||||
g.world.columns_changed(&[(0, 0)]);
|
||||
g.answer(vec![], 1);
|
||||
g.land(DVec3::new(4.0, 70.0, 4.0));
|
||||
assert!(g.take_wanted().contains(&(4, 4)));
|
||||
}
|
||||
|
||||
// #65: manual launches and raids draw their navigation error the same way, and a retarget keeps it
|
||||
#[test]
|
||||
fn raids_have_the_same_strike_accuracy_as_manual_launches() {
|
||||
for kind in [DroneKind::Kh101, DroneKind::Shahed, DroneKind::Zircon] {
|
||||
let (cep, gross) = kind.accuracy();
|
||||
let aim = DVec3::new(0.0, 70.0, 1000.0);
|
||||
let mut sim = Sim::new(1);
|
||||
let (mut manual, mut raid) = (Vec::new(), Vec::new());
|
||||
for _ in 0..600 {
|
||||
sim.add_drone(kind, DVec3::new(0.0, 120.0, 0.0), DVec3::Z, aim);
|
||||
let d = sim.drones.pop().unwrap();
|
||||
manual.push(horizontal(d.target - aim).length());
|
||||
let d = sim.make_drone(kind, DVec3::new(0.0, 120.0, 0.0), DVec3::Z, aim);
|
||||
raid.push(horizontal(d.target - aim).length());
|
||||
assert!(d.err != DVec3::ZERO && (d.target - aim - d.err).length() < 1e-9);
|
||||
}
|
||||
for (name, v) in [("manual", &mut manual), ("raid", &mut raid)] {
|
||||
v.sort_by(f64::total_cmp);
|
||||
let median = v[v.len() / 2];
|
||||
assert!((median - cep).abs() < cep * 0.25, "{kind:?} {name}: median {median} for a CEP of {cep}");
|
||||
let far = v.iter().filter(|r| **r > MISS_RADIUS).count() as f64 / v.len() as f64;
|
||||
assert!(far > gross * 0.4 && far < gross * 2.5 + 0.02, "{kind:?} {name}: {far} beyond the blast for a fault rate of {gross}");
|
||||
}
|
||||
}
|
||||
// A planned raid's drones all carry their error, and a smart retarget gives the new aim on top of it.
|
||||
let mut sim = Sim::new(3);
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Ew, DVec3::new(0.0, 70.0, 900.0), 0.0));
|
||||
sim.intel.revealed.push((id, DVec3::new(0.0, 70.0, 900.0), 0));
|
||||
let order = RaidOrder { kinds: vec![(DroneKind::Zircon, 20)], approach: Approach::Sector, dist: 400.0, aim: Aim::Objects };
|
||||
sim.order_raid(&order, &[DVec3::new(0.0, 70.0, 0.0)], &mut Out::default());
|
||||
for _ in 0..40 {
|
||||
if !sim.planning() {
|
||||
break;
|
||||
}
|
||||
sim.step(&mut Ground::default(), None, &mut Out::default());
|
||||
}
|
||||
assert!(!sim.queue.is_empty() && sim.queue.iter().all(|(_, d)| d.err != DVec3::ZERO), "raid drones without a navigation error");
|
||||
let mut d = sim.make_drone(DroneKind::Zircon, DVec3::new(0.0, 100.0, -500.0), DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
|
||||
let err = d.err;
|
||||
d.smart = true;
|
||||
sim.queue.clear();
|
||||
sim.queue.push((0, d));
|
||||
sim.tick = 40;
|
||||
sim.adapt();
|
||||
let d = &sim.queue[0].1;
|
||||
assert!((d.target - d.err - DVec3::new(0.0, 70.0, 900.0)).length() < 1e-9 && d.err == err, "the retarget lost or doubled the error");
|
||||
}
|
||||
|
||||
// #66: an interceptor steers on what it was told or what its seeker holds, not on the true position
|
||||
#[test]
|
||||
fn interceptors_guide_on_tracks_and_seekers_not_on_truth() {
|
||||
let fly = |hidden: DVec3, fix: Option<Fix>| {
|
||||
let mut sim = Sim::new(1);
|
||||
sim.add_drone(DroneKind::Shahed, hidden, DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0));
|
||||
sim.drones[0].vel = DVec3::ZERO;
|
||||
let (target, id) = (sim.drones[0].id, sim.id());
|
||||
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix, seen: 0 });
|
||||
sim.tick = 100;
|
||||
sim.step_missiles(&mut Ground::default(), &mut Out::default());
|
||||
sim.missiles.first().map(|m| m.vel)
|
||||
};
|
||||
// The issue's case: no system, no track, a target 600 away that is somewhere else in the second copy.
|
||||
let a = fly(DVec3::new(0.0, 100.0, 600.0), None);
|
||||
let b = fly(DVec3::new(300.0, 100.0, 600.0), None);
|
||||
assert_eq!(a, b, "the missile knows where the target is without any measurement");
|
||||
// The data link's fix is what it steers on, wherever the target went since.
|
||||
let told = Some(Fix { pos: DVec3::new(300.0, 100.0, 600.0), vel: DVec3::ZERO, tick: 100, seeker: false });
|
||||
let (c, d) = (fly(DVec3::new(0.0, 100.0, 600.0), told), fly(DVec3::new(-300.0, 100.0, 600.0), told));
|
||||
assert_eq!(c, d);
|
||||
assert!(c.unwrap().x > 0.05, "it did not turn to the fix: {c:?}");
|
||||
// The seeker takes over once the target is in its reach: now the truth steers it.
|
||||
let near = |at: DVec3| {
|
||||
let mut sim = Sim::new(1);
|
||||
sim.add_drone(DroneKind::Shahed, at, DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0));
|
||||
sim.drones[0].vel = DVec3::ZERO;
|
||||
let (target, id) = (sim.drones[0].id, sim.id());
|
||||
let stale = Some(Fix { pos: DVec3::new(-60.0, 100.0, 80.0), vel: DVec3::ZERO, tick: 90, seeker: false });
|
||||
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix: stale, seen: 0 });
|
||||
sim.tick = 100;
|
||||
sim.step_missiles(&mut Ground::default(), &mut Out::default());
|
||||
(sim.missiles[0].vel, sim.missiles[0].seen)
|
||||
};
|
||||
let (v, seen) = near(DVec3::new(40.0, 100.0, 70.0));
|
||||
assert!(seen == 100 && v.x > 0.05, "the seeker did not hold a target in its reach: {v:?}");
|
||||
let (v, seen) = near(DVec3::new(300.0, 100.0, 70.0));
|
||||
assert!(seen == 0 && v.x < -0.05, "a target outside its reach steered it: {v:?}");
|
||||
// Behind a mountain the seeker sees nothing.
|
||||
let mut sim = Sim::new(1);
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(40.0, 100.0, 70.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0));
|
||||
sim.drones[0].vel = DVec3::ZERO;
|
||||
let (target, id) = (sim.drones[0].id, sim.id());
|
||||
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix: Some(Fix { pos: DVec3::new(0.0, 100.0, 300.0), vel: DVec3::ZERO, tick: 90, seeker: false }), seen: 0 });
|
||||
let hill = Blocks(|x, y, z| Some(y < 64 || ((10..14).contains(&x) && (0..40).contains(&z) && y < 300)));
|
||||
let mut ground = Ground::default();
|
||||
for t in 0..6 {
|
||||
ground.world.probe(DVec3::new(0.0, 100.0, 0.0), DVec3::new(40.0, 100.0, 70.0), t);
|
||||
let q = ground.world.take_wanted(t);
|
||||
ground.world.serve(&hill, q, t);
|
||||
}
|
||||
sim.tick = 100;
|
||||
sim.step_missiles(&mut ground, &mut Out::default());
|
||||
assert_eq!(sim.missiles[0].seen, 0, "the seeker saw through a mountain");
|
||||
// No fix for too long and no seeker on it: the interceptor is lost, and counted so.
|
||||
let mut sim = Sim::new(1);
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 100.0, 3000.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0));
|
||||
sim.drones[0].vel = DVec3::ZERO;
|
||||
let (target, id) = (sim.drones[0].id, sim.id());
|
||||
let old = Some(Fix { pos: DVec3::new(0.0, 100.0, 300.0), vel: DVec3::ZERO, tick: 0, seeker: false });
|
||||
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix: old, seen: 0 });
|
||||
sim.tick = MAX_COAST + 50;
|
||||
sim.step_missiles(&mut Ground::default(), &mut Out::default());
|
||||
assert!(sim.missiles.is_empty() && sim.tally.expired == 1, "{:?}", sim.tally);
|
||||
}
|
||||
|
||||
// #69: many separate strikes: the list, what is drawn and the lights are bounded; the rest end properly
|
||||
#[test]
|
||||
fn mass_strikes_keep_fires_effects_and_lights_bounded() {
|
||||
let mut sim = Sim::new(1);
|
||||
sim.damage = true;
|
||||
let mut ground = Ground::default();
|
||||
ground.answer((-100..2000).flat_map(|x| (-10..10).map(move |z| (x, z, 70))).collect(), 0);
|
||||
for i in 0..120 {
|
||||
sim.ignite(DVec3::new(f64::from(i) * 10.0, 70.0, 0.0), (i % 5) as u32, None);
|
||||
}
|
||||
assert!(sim.fires.len() <= fires::LIST_MAX, "{} fires kept", sim.fires.len());
|
||||
let mut peak_particles = 0;
|
||||
for _ in 0..200 {
|
||||
sim.tick += 1;
|
||||
let mut out = Out::default();
|
||||
sim.step_fires(&mut ground, &mut out);
|
||||
peak_particles = peak_particles.max(out.particles.iter().map(|p| p.count as usize).sum::<usize>());
|
||||
assert!(sim.fires.list.iter().filter(|f| f.shown).count() <= fires::MAX);
|
||||
assert!(sim.fires.len() <= fires::LIST_MAX);
|
||||
}
|
||||
// Twelve fires at full strength: well under what 120 would have been.
|
||||
assert!(peak_particles < 12 * 24 + fires::LIST_MAX, "{peak_particles} particles in a tick");
|
||||
// A long stream of strikes, one every few ticks, stays bounded; at the end all go out and the blocks go with them.
|
||||
for i in 0..400 {
|
||||
sim.tick += 5;
|
||||
sim.ignite(DVec3::new(f64::from(i % 150) * 12.0, 70.0, 0.0), 2, None);
|
||||
sim.step_fires(&mut ground, &mut Out::default());
|
||||
assert!(sim.fires.len() <= fires::LIST_MAX);
|
||||
}
|
||||
let mut commands = 0;
|
||||
for _ in 0..8000 {
|
||||
sim.tick += 1;
|
||||
let mut out = Out::default();
|
||||
sim.step_fires(&mut ground, &mut out);
|
||||
commands += out.commands.len();
|
||||
if sim.fires.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(sim.fires.is_empty(), "fires burn for ever");
|
||||
let _ = commands;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub(super) enum Rise {
|
||||
|
||||
impl Ground {
|
||||
/// One above the highest solid block at the lattice cell holding `p`, if known; asked for when it isn't.
|
||||
/// Unlike `at` it never asks again for a column it has, and it leaves room for the questions that matter more.
|
||||
/// Like `at` it asks again for a column that is old or was reported changed (#64), and it leaves room for the questions that matter more.
|
||||
pub fn land(&mut self, p: DVec3) -> Option<f64> {
|
||||
match self.rise(p) {
|
||||
Rise::At(g) => Some(g),
|
||||
@@ -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 = (
|
||||
@@ -42,8 +52,13 @@ impl Ground {
|
||||
(p.z.floor() as i32).div_euclid(LATTICE) * LATTICE + LATTICE / 2,
|
||||
);
|
||||
match self.known.get(&key) {
|
||||
Some(&(y, _)) if y > -64 => Rise::At(f64::from(y)),
|
||||
Some(_) => Rise::Void,
|
||||
Some(&(y, when)) => {
|
||||
// Old or reported changed: still used, asked again with the questions to spare.
|
||||
if self.due(when) && self.wanted.len() < ASK_MAX / 2 && self.asked.insert(key) {
|
||||
self.wanted.push(key);
|
||||
}
|
||||
if y > -64 { Rise::At(f64::from(y)) } else { Rise::Void }
|
||||
}
|
||||
None => {
|
||||
if self.wanted.len() < ASK_MAX && self.asked.insert(key) {
|
||||
self.wanted.push(key);
|
||||
@@ -57,7 +72,12 @@ impl Ground {
|
||||
/// where it has no land to tell of. Asks, up to `cap` questions waiting.
|
||||
pub(super) fn column(&mut self, x: i32, z: i32, cap: usize) -> Option<Option<f64>> {
|
||||
match self.known.get(&(x, z)) {
|
||||
Some(&(y, _)) => Some((y > -64).then_some(f64::from(y))),
|
||||
Some(&(y, when)) => {
|
||||
if self.due(when) && self.wanted.len() < cap / 2 && self.asked.insert((x, z)) {
|
||||
self.wanted.push((x, z));
|
||||
}
|
||||
Some((y > -64).then_some(f64::from(y)))
|
||||
}
|
||||
None => {
|
||||
if self.wanted.len() < cap && self.asked.insert((x, z)) {
|
||||
self.wanted.push((x, z));
|
||||
|
||||
+291
-9
@@ -18,6 +18,8 @@ pub const PER_TICK: usize = 64;
|
||||
const MAX_SECTIONS: usize = 12_000;
|
||||
/// Ticks before an unanswered or unknown section is asked again.
|
||||
const RETRY: u64 = 60;
|
||||
/// Ticks after which a section in use is read again, whether or not a change was reported (#62).
|
||||
const RECHECK: u64 = 1200;
|
||||
/// Longest stretch of a flight asked for ahead, in blocks.
|
||||
const AHEAD_MAX: f64 = 192.0;
|
||||
/// Questions waiting: more are dropped (they are asked again when needed).
|
||||
@@ -57,8 +59,11 @@ pub enum Sweep {
|
||||
Clear,
|
||||
/// The first solid block: where the segment meets it, which way the face looks, and the block.
|
||||
Hit { t: f64, point: DVec3, normal: [i32; 3], block: [i32; 3] },
|
||||
/// The walk reached blocks the game doesn't know (before any solid one), `t` of the way.
|
||||
Unknown { t: f64 },
|
||||
/// The walk reached blocks the game doesn't know (before any solid one), `t` of the way. `pending`: the
|
||||
/// section hasn't been answered yet (it is asked for, urgently); else the game said it has no chunk there.
|
||||
Unknown { t: f64, pending: bool },
|
||||
/// A solid block was met at `t` and its exact shape is being asked for (#63): the flight holds at its face.
|
||||
Wait { t: f64 },
|
||||
}
|
||||
|
||||
impl Sweep {
|
||||
@@ -83,10 +88,60 @@ pub struct WorldStats {
|
||||
pub invalidated: u64,
|
||||
pub evicted: u64,
|
||||
pub world_changes: u64,
|
||||
/// Steps cut short at unread blocks (#61), and steps let through after waiting for too long.
|
||||
pub held: u64,
|
||||
pub passed_unknown: u64,
|
||||
/// Sections dropped or re-read for a chunk's unload or replacement, or for age (#62).
|
||||
pub aged: u64,
|
||||
pub columns_changed: u64,
|
||||
}
|
||||
|
||||
/// What the game's exact ray said of a flight's step, once it answered (#63).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Exact {
|
||||
Pending,
|
||||
/// The collision shapes leave the way open.
|
||||
Clear,
|
||||
/// Where the shapes stop it: the point, the face and the block.
|
||||
Hit { point: DVec3, normal: [i32; 3], block: [i32; 3] },
|
||||
}
|
||||
|
||||
struct RayEntry {
|
||||
id: u32,
|
||||
a: DVec3,
|
||||
b: DVec3,
|
||||
state: Exact,
|
||||
asked: u64,
|
||||
}
|
||||
|
||||
/// The game's verdict on an exact ray, as `render.rays` gives it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct RayReply {
|
||||
pub id: u32,
|
||||
pub clear: bool,
|
||||
pub hit: bool,
|
||||
pub fraction: f64,
|
||||
pub normal: [i32; 3],
|
||||
pub block: [i32; 3],
|
||||
}
|
||||
|
||||
/// Ticks an exact ray may take to be answered before the block counts as the solid cube the sections say.
|
||||
const RAY_WAIT: u64 = 30;
|
||||
/// Exact rays asked in one tick (the game's budget is 256).
|
||||
const RAYS_PER_TICK: usize = 128;
|
||||
|
||||
#[derive(Default)]
|
||||
struct RayDesk {
|
||||
next: u32,
|
||||
/// By flight and block: a flight that meets the same block again finds its answer.
|
||||
entries: HashMap<(u32, [i32; 3]), RayEntry>,
|
||||
by_id: HashMap<u32, (u32, [i32; 3])>,
|
||||
out: Vec<(u32, DVec3, DVec3)>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WorldCache {
|
||||
rays: RayDesk,
|
||||
secs: HashMap<Sec, Entry>,
|
||||
urgent: Vec<Sec>,
|
||||
ahead: Vec<Sec>,
|
||||
@@ -94,8 +149,15 @@ pub struct WorldCache {
|
||||
/// Sections asked, and when.
|
||||
asked: HashMap<Sec, u64>,
|
||||
epoch: u32,
|
||||
/// The game's count of block changes, as of the last note, and the one the section entries were read at.
|
||||
revision: u64,
|
||||
/// The last `solid` that could not tell: the section was not answered yet.
|
||||
unread_pending: bool,
|
||||
/// Sweeps made as a look ahead (flight planning) ask for sections after the urgent ones.
|
||||
quiet: bool,
|
||||
/// Columns (section x, z) where blocks changed since the land heights last looked (#64); `all`: too many to say.
|
||||
pub(super) changed_cols: HashSet<(i32, i32)>,
|
||||
pub(super) changed_all: bool,
|
||||
pub stats: WorldStats,
|
||||
}
|
||||
|
||||
@@ -136,6 +198,22 @@ impl WorldCache {
|
||||
/// The game's note of changes: another world voids everything; changed
|
||||
/// sections (or too many to say) are read again when next used.
|
||||
pub fn changed(&mut self, epoch: u32, overflow: bool, sections: &[Sec]) {
|
||||
self.changed_rev(epoch, self.revision, overflow, sections);
|
||||
}
|
||||
|
||||
/// The same with the game's change counter: a jump in it that the listed sections don't explain
|
||||
/// (changes the game didn't list, a chunk replaced behind the subscription's back) makes every section
|
||||
/// read before it due for a new read when it is next used (#62).
|
||||
pub fn changed_rev(&mut self, epoch: u32, revision: u64, overflow: bool, sections: &[Sec]) {
|
||||
let unexplained = self.epoch == epoch && revision != self.revision && sections.is_empty() && !overflow;
|
||||
self.revision = revision;
|
||||
if unexplained {
|
||||
// Not named, so not known which: a light update alone doesn't move the counter, a block change does.
|
||||
for e in self.secs.values_mut() {
|
||||
e.stale = true;
|
||||
}
|
||||
self.stats.invalidated += self.secs.len() as u64;
|
||||
}
|
||||
if epoch != self.epoch {
|
||||
if self.epoch != 0 {
|
||||
self.secs.clear();
|
||||
@@ -143,9 +221,13 @@ impl WorldCache {
|
||||
self.ahead.clear();
|
||||
self.queued.clear();
|
||||
self.asked.clear();
|
||||
self.changed_all = true;
|
||||
}
|
||||
self.epoch = epoch;
|
||||
}
|
||||
if overflow || unexplained {
|
||||
self.changed_all = true;
|
||||
}
|
||||
if overflow {
|
||||
for e in self.secs.values_mut() {
|
||||
e.stale = true;
|
||||
@@ -153,6 +235,7 @@ impl WorldCache {
|
||||
self.stats.invalidated += self.secs.len() as u64;
|
||||
}
|
||||
for s in sections {
|
||||
self.note_column(s.0, s.2);
|
||||
if let Some(e) = self.secs.get_mut(s) {
|
||||
e.stale = true;
|
||||
self.stats.invalidated += 1;
|
||||
@@ -161,6 +244,38 @@ impl WorldCache {
|
||||
self.stats.world_changes += sections.len() as u64;
|
||||
}
|
||||
|
||||
fn note_column(&mut self, sx: i32, sz: i32) {
|
||||
if self.changed_cols.len() >= 4096 {
|
||||
self.changed_all = true;
|
||||
} else {
|
||||
self.changed_cols.insert((sx, sz));
|
||||
}
|
||||
}
|
||||
|
||||
/// Chunk columns (x, z in chunks) the game unloaded or replaced (#62): what is known of their sections is
|
||||
/// void (`Unknown`) until they are read again. A new chunk packet replaces the blocks wholesale, which no
|
||||
/// per-block note says.
|
||||
pub fn columns_changed(&mut self, columns: &[(i32, i32)]) {
|
||||
if columns.is_empty() {
|
||||
return;
|
||||
}
|
||||
let set: HashSet<(i32, i32)> = columns.iter().copied().collect();
|
||||
let mut n = 0u64;
|
||||
for &(cx, cz) in columns {
|
||||
self.note_column(cx, cz);
|
||||
}
|
||||
for (s, e) in self.secs.iter_mut() {
|
||||
if set.contains(&(s.0, s.2)) {
|
||||
e.fill = Fill::Unknown;
|
||||
e.stale = true;
|
||||
e.since = 0;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
self.stats.invalidated += n;
|
||||
self.stats.columns_changed += columns.len() as u64;
|
||||
}
|
||||
|
||||
/// An explosion is about to change the land: the sections round it are read again.
|
||||
pub fn touched(&mut self, at: DVec3, radius: f64) {
|
||||
let lo = sec_of((at.x - radius).floor() as i32, (at.y - radius).floor() as i32, (at.z - radius).floor() as i32);
|
||||
@@ -220,11 +335,24 @@ impl WorldCache {
|
||||
}
|
||||
|
||||
fn want(&mut self, s: Sec, urgent: bool, now: u64) {
|
||||
if self.queued.contains(&s) || self.asked.get(&s).is_some_and(|&t| now.saturating_sub(t) < RETRY) {
|
||||
if self.queued.contains(&s) {
|
||||
// Already waiting as a look-ahead, and a step needs it now: it moves up (#61).
|
||||
if urgent && let Some(i) = self.ahead.iter().position(|a| *a == s) {
|
||||
self.ahead.swap_remove(i);
|
||||
self.urgent.push(s);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.asked.get(&s).is_some_and(|&t| now.saturating_sub(t) < RETRY) {
|
||||
return;
|
||||
}
|
||||
if self.urgent.len() + self.ahead.len() >= QUEUE_MAX {
|
||||
return;
|
||||
// Full: an urgent need pushes a look-ahead out, a look-ahead waits.
|
||||
if !urgent || self.ahead.is_empty() {
|
||||
return;
|
||||
}
|
||||
let gone = self.ahead.swap_remove(0);
|
||||
self.queued.remove(&gone);
|
||||
}
|
||||
if urgent {
|
||||
self.urgent.push(s);
|
||||
@@ -266,7 +394,12 @@ impl WorldCache {
|
||||
return false;
|
||||
};
|
||||
e.used = now;
|
||||
let refresh = e.stale || (e.fill == Fill::Unknown && now.saturating_sub(e.since) >= RETRY);
|
||||
// Whatever is in use is read again now and then, bounded: a change nobody told of ends up seen (#62).
|
||||
let aged = e.fill != Fill::Unknown && now.saturating_sub(e.since) >= RECHECK;
|
||||
if aged {
|
||||
self.stats.aged += 1;
|
||||
}
|
||||
let refresh = e.stale || aged || (e.fill == Fill::Unknown && now.saturating_sub(e.since) >= RETRY);
|
||||
if refresh {
|
||||
self.want(s, urgent, now);
|
||||
}
|
||||
@@ -281,8 +414,17 @@ impl WorldCache {
|
||||
*last = Some(s);
|
||||
self.touch(s, !self.quiet, now);
|
||||
}
|
||||
match &self.secs.get(&s)?.fill {
|
||||
Fill::Unknown => None,
|
||||
self.unread_pending = false;
|
||||
let Some(entry) = self.secs.get(&s) else {
|
||||
self.unread_pending = true;
|
||||
return None;
|
||||
};
|
||||
match &entry.fill {
|
||||
Fill::Unknown => {
|
||||
// Asked and answered unknown: no chunk there. Asked again (or not at all yet): waiting.
|
||||
self.unread_pending = entry.stale && entry.since == 0;
|
||||
None
|
||||
}
|
||||
Fill::Empty => Some(false),
|
||||
Fill::Full => Some(true),
|
||||
Fill::Mixed(bits) => {
|
||||
@@ -312,6 +454,75 @@ impl WorldCache {
|
||||
r
|
||||
}
|
||||
|
||||
/// A step of flight `key` from `a` to `b` that met a solid block (`hit`): the occupancy says "stops
|
||||
/// something", the shape may leave room (slabs, stairs, fences, panes). The block's exact shapes are asked
|
||||
/// of the game (a ray over the step) and the flight holds at the face meanwhile; a `Hit` comes back as the
|
||||
/// exact contact, a `Clear` lets this flight through that block. An answer that doesn't come in
|
||||
/// [`RAY_WAIT`] ticks, or `unknown`, leaves the hit as the solid cube.
|
||||
pub fn narrow(&mut self, key: u32, hit: Sweep, a: DVec3, b: DVec3, now: u64) -> Sweep {
|
||||
let Sweep::Hit { t, block, .. } = hit else { return hit };
|
||||
let k = (key, block);
|
||||
let Some(e) = self.rays.entries.get_mut(&k) else {
|
||||
if self.rays.entries.len() >= 4096 {
|
||||
return hit;
|
||||
}
|
||||
self.rays.next = self.rays.next.wrapping_add(1);
|
||||
let id = self.rays.next;
|
||||
self.rays.entries.insert(k, RayEntry { id, a, b, state: Exact::Pending, asked: now });
|
||||
self.rays.by_id.insert(id, k);
|
||||
self.rays.out.push((id, a, b));
|
||||
return Sweep::Wait { t };
|
||||
};
|
||||
if e.state == Exact::Pending && now.saturating_sub(e.asked) > RAY_WAIT {
|
||||
e.state = Exact::Hit { point: a + (b - a) * t, normal: [0; 3], block };
|
||||
}
|
||||
match e.state {
|
||||
Exact::Pending => Sweep::Wait { t },
|
||||
Exact::Clear => Sweep::Clear,
|
||||
Exact::Hit { point, normal, block } => {
|
||||
let d = b - a;
|
||||
let f = ((point - a).dot(d) / d.length_squared().max(1e-12)).max(0.0);
|
||||
if f > 1.0 {
|
||||
// The shape's face is beyond this step: nothing is met yet.
|
||||
Sweep::Clear
|
||||
} else {
|
||||
Sweep::Hit { t: f, point, normal, block }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The exact rays to ask this tick: id, start, end.
|
||||
pub fn take_rays(&mut self, now: u64) -> Vec<(u32, DVec3, DVec3)> {
|
||||
let n = RAYS_PER_TICK.min(self.rays.out.len());
|
||||
let out: Vec<_> = self.rays.out.drain(..n).collect();
|
||||
if now % 128 == 0 {
|
||||
let dead: Vec<_> = self.rays.entries.iter().filter(|(_, e)| now.saturating_sub(e.asked) > 600).map(|(k, _)| *k).collect();
|
||||
for k in dead {
|
||||
if let Some(e) = self.rays.entries.remove(&k) {
|
||||
self.rays.by_id.remove(&e.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The game's answers to the exact rays.
|
||||
pub fn answer_rays(&mut self, replies: &[RayReply]) {
|
||||
for r in replies {
|
||||
let Some(k) = self.rays.by_id.get(&r.id).copied() else { continue };
|
||||
let Some(e) = self.rays.entries.get_mut(&k) else { continue };
|
||||
e.state = if r.clear {
|
||||
Exact::Clear
|
||||
} else if r.hit {
|
||||
Exact::Hit { point: e.a + (e.b - e.a) * r.fraction, normal: r.normal, block: r.block }
|
||||
} else {
|
||||
// No verdict: the solid cube the sections said.
|
||||
Exact::Hit { point: e.a, normal: [0; 3], block: k.1 }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// The first solid block along `a` to `b`, by walking the blocks it
|
||||
/// crosses; `skip_start` ignores the block it starts in (a launch from a
|
||||
/// pad that is part of the land).
|
||||
@@ -340,7 +551,7 @@ impl WorldCache {
|
||||
match self.solid(&mut last, pos[0], pos[1], pos[2], now) {
|
||||
None => {
|
||||
self.stats.unknown_sweeps += 1;
|
||||
return Sweep::Unknown { t };
|
||||
return Sweep::Unknown { t, pending: self.unread_pending };
|
||||
}
|
||||
Some(true) if !(first && skip_start) => {
|
||||
self.stats.hits += 1;
|
||||
@@ -366,7 +577,7 @@ impl WorldCache {
|
||||
face[axis] = -step[axis];
|
||||
}
|
||||
self.stats.unknown_sweeps += 1;
|
||||
Sweep::Unknown { t }
|
||||
Sweep::Unknown { t, pending: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,6 +586,38 @@ impl WorldCache {
|
||||
pub trait WorldSource {
|
||||
/// Whether the block stops a projectile; `None` where there is no chunk.
|
||||
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool>;
|
||||
/// The collision boxes of the block (`[x0, y0, z0, x1, y1, z1]` inside the block): a full cube where
|
||||
/// it is solid, unless the world has finer shapes.
|
||||
fn boxes(&self, x: i32, y: i32, z: i32) -> Vec<[f64; 6]> {
|
||||
if self.solid(x, y, z) == Some(true) { vec![[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]] } else { Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The first entry of segment `a` to `b` into the box (slab test): the fraction and the axis.
|
||||
fn enter_box(a: DVec3, d: DVec3, lo: DVec3, hi: DVec3) -> Option<(f64, usize)> {
|
||||
let (mut t0, mut t1, mut axis) = (0.0f64, 1.0f64, usize::MAX);
|
||||
for i in 0..3 {
|
||||
let (o, v) = (a[i], d[i]);
|
||||
if v.abs() < 1e-12 {
|
||||
if o < lo[i] || o > hi[i] {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
let (mut n, mut f) = ((lo[i] - o) / v, (hi[i] - o) / v);
|
||||
if n > f {
|
||||
std::mem::swap(&mut n, &mut f);
|
||||
}
|
||||
if n > t0 {
|
||||
t0 = n;
|
||||
axis = i;
|
||||
}
|
||||
t1 = t1.min(f);
|
||||
if t0 > t1 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((t0, axis))
|
||||
}
|
||||
|
||||
/// A [`WorldSource`] as the game would answer `render.query-sections` with it.
|
||||
@@ -402,9 +645,48 @@ pub fn section_of(source: &dyn WorldSource, s: Sec) -> Fill {
|
||||
}
|
||||
|
||||
impl WorldCache {
|
||||
/// Answers the exact rays of the last tick from a synthetic world, as the game does one tick later.
|
||||
pub fn serve_rays(&mut self, source: &dyn WorldSource, rays: Vec<(u32, DVec3, DVec3)>) {
|
||||
let replies: Vec<RayReply> = rays
|
||||
.into_iter()
|
||||
.map(|(id, a, b)| {
|
||||
let d = b - a;
|
||||
let mut best: Option<(f64, usize, [i32; 3])> = None;
|
||||
let (lo, hi) = (a.min(b).floor(), a.max(b).floor());
|
||||
for x in lo.x as i32..=hi.x as i32 {
|
||||
for y in lo.y as i32..=hi.y as i32 {
|
||||
for z in lo.z as i32..=hi.z as i32 {
|
||||
for bx in source.boxes(x, y, z) {
|
||||
let o = DVec3::new(f64::from(x), f64::from(y), f64::from(z));
|
||||
if let Some((t, axis)) = enter_box(a, d, o + DVec3::new(bx[0], bx[1], bx[2]), o + DVec3::new(bx[3], bx[4], bx[5]))
|
||||
&& best.is_none_or(|b| t < b.0)
|
||||
{
|
||||
best = Some((t, axis, [x, y, z]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match best {
|
||||
None => RayReply { id, clear: true, hit: false, fraction: 0.0, normal: [0; 3], block: [0; 3] },
|
||||
Some((t, axis, block)) => {
|
||||
let mut normal = [0; 3];
|
||||
if axis < 3 {
|
||||
normal[axis] = if d[axis] > 0.0 { -1 } else { 1 };
|
||||
}
|
||||
RayReply { id, clear: false, hit: true, fraction: t, normal, block }
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.answer_rays(&replies);
|
||||
}
|
||||
|
||||
/// Answers the questions of the last tick from a synthetic world, as the game does one tick later.
|
||||
pub fn serve(&mut self, source: &dyn WorldSource, wanted: Vec<Sec>, now: u64) {
|
||||
let answers = wanted.into_iter().map(|sec| SectionAnswer { sec, fill: section_of(source, sec) }).collect();
|
||||
self.answer(answers, now);
|
||||
let rays = self.take_rays(now);
|
||||
self.serve_rays(source, rays);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Smoke for the HD renderer (`render.set-smoke`): exhaust trails of drones and missiles that leave
|
||||
//! a clear tunnel in fog and cloud, smoke columns over fires, and the smoke of blasts. The game
|
||||
//! derives everything from a segment's age, so the columns and blasts are a pure function of the
|
||||
//! tick (nothing grows twice when a frame is shown again); trails are the only state, and they are
|
||||
//! cut back when the tick goes backwards.
|
||||
use crate::sim::{Class, Sim};
|
||||
use glam::DVec3;
|
||||
use horizon_mod_sdk::render::SmokeSegment;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const MAX_TRAIL: usize = 2400;
|
||||
const MAX_TOTAL: usize = 4000;
|
||||
|
||||
struct Trail {
|
||||
seg: SmokeSegment,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Smoke {
|
||||
trails: Vec<Trail>,
|
||||
/// Where each flying thing last left a segment: (kind, id) -> (position, tick).
|
||||
last: HashMap<(u8, u32), (DVec3, u64)>,
|
||||
last_tick: u64,
|
||||
next: u32,
|
||||
/// The renderer draws the smoke (the answer of the last call).
|
||||
pub hd: bool,
|
||||
}
|
||||
|
||||
fn seg(id: u32, a: DVec3, b: DVec3, born: u64, life: u32) -> SmokeSegment {
|
||||
SmokeSegment {
|
||||
id,
|
||||
ax: a.x,
|
||||
ay: a.y,
|
||||
az: a.z,
|
||||
bx: b.x,
|
||||
by: b.y,
|
||||
bz: b.z,
|
||||
born: born as f64,
|
||||
life: life as f32,
|
||||
radius_start: 1.0,
|
||||
radius_end: 6.0,
|
||||
density: 0.5,
|
||||
heat: 0.0,
|
||||
soot: 0.4,
|
||||
rise: 0.2,
|
||||
clear: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
impl Smoke {
|
||||
fn trail(&mut self, key: (u8, u32), at: DVec3, tick: u64, style: impl Fn(&mut SmokeSegment)) {
|
||||
let Some(&(from, when)) = self.last.get(&key) else {
|
||||
self.last.insert(key, (at, tick));
|
||||
return;
|
||||
};
|
||||
if at.distance(from) < 5.0 && tick - when < 10 {
|
||||
return;
|
||||
}
|
||||
// A jump (a teleport, a respawn) is no trail.
|
||||
if at.distance(from) < 400.0 {
|
||||
self.next = self.next.wrapping_add(1);
|
||||
let mut s = seg(self.next, from, at, tick, 1200);
|
||||
style(&mut s);
|
||||
self.trails.push(Trail { seg: s });
|
||||
}
|
||||
self.last.insert(key, (at, tick));
|
||||
}
|
||||
|
||||
/// Follows the simulation one tick: new trail segments for what flies, old ones dropped.
|
||||
pub fn update(&mut self, sim: &Sim) {
|
||||
let tick = sim.tick;
|
||||
if tick < self.last_tick {
|
||||
self.trails.retain(|t| t.seg.born as u64 <= tick);
|
||||
self.last.clear();
|
||||
}
|
||||
self.last_tick = tick;
|
||||
for d in &sim.drones {
|
||||
let class = d.kind.spec().class;
|
||||
let (r0, r1, dens, heat, soot, clear, life): (f32, f32, f32, f32, f32, f32, f32) = match class {
|
||||
Class::Drone => (0.9, 5.0, 2.0, 0.0, 0.45, 3.0, 1200.0),
|
||||
Class::Jet | Class::Cruise => (1.1, 7.0, 2.5, 0.15, 0.35, 4.0, 1300.0),
|
||||
Class::Supersonic => (1.4, 9.0, 3.2, 0.5, 0.2, 5.0, 1300.0),
|
||||
Class::Ballistic => (2.0, 13.0, 4.0, 0.7, 0.15, 7.0, 1500.0),
|
||||
};
|
||||
self.trail((0, d.id), d.pos, tick, |s| {
|
||||
s.radius_start = r0;
|
||||
s.radius_end = r1;
|
||||
s.density = dens;
|
||||
s.heat = heat;
|
||||
s.soot = soot;
|
||||
s.clear = clear;
|
||||
s.life = life;
|
||||
});
|
||||
}
|
||||
for m in &sim.missiles {
|
||||
self.trail((1, m.id), m.pos, tick, |s| {
|
||||
s.radius_start = 0.6;
|
||||
s.radius_end = 5.0;
|
||||
s.density = 3.0;
|
||||
s.heat = 0.8;
|
||||
s.soot = 0.05;
|
||||
s.clear = 3.0;
|
||||
s.life = 1000.0;
|
||||
});
|
||||
}
|
||||
// Forget what is gone.
|
||||
if tick % 100 == 0 {
|
||||
let alive: std::collections::HashSet<(u8, u32)> = sim
|
||||
.drones
|
||||
.iter()
|
||||
.map(|d| (0, d.id))
|
||||
.chain(sim.missiles.iter().map(|m| (1, m.id)))
|
||||
.collect();
|
||||
self.last.retain(|k, _| alive.contains(k));
|
||||
}
|
||||
self.trails.retain(|t| tick < t.seg.born as u64 + t.seg.life as u64);
|
||||
if self.trails.len() > MAX_TRAIL {
|
||||
let cut = self.trails.len() - MAX_TRAIL;
|
||||
self.trails.drain(..cut);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every segment alive at the simulation's tick: trails, fire columns, blast smoke.
|
||||
pub fn segments(&self, sim: &Sim) -> Vec<SmokeSegment> {
|
||||
let tick = sim.tick;
|
||||
let mut out: Vec<SmokeSegment> = self.trails.iter().map(|t| t.seg.clone()).collect();
|
||||
// A column over each fire: a puff every 15 ticks that rises, spreads and thins.
|
||||
for f in &sim.fires.list {
|
||||
if f.quiet && f.size < 1.0 {
|
||||
continue;
|
||||
}
|
||||
// Only the strongest fires get a full column; the rest a thin puff now and then.
|
||||
let step = if f.shown { 15 } else { 90 };
|
||||
let first = f.start.max(tick.saturating_sub(520));
|
||||
let mut k = first.div_ceil(step) * step;
|
||||
while k <= tick && out.len() < MAX_TOTAL {
|
||||
let (flame, smoke) = f.level(k);
|
||||
if smoke > 0.05 && k >= f.start {
|
||||
let id = 0x4000_0000 | (f.id.wrapping_mul(131) ^ (k / step) as u32) & 0x3fff_ffff;
|
||||
let at = f.at + DVec3::Y * 0.6;
|
||||
let mut s = seg(id, at, at, k, 540);
|
||||
s.radius_start = 1.0 * f.size;
|
||||
s.radius_end = 8.0 * f.size + 2.0;
|
||||
s.density = 2.6 * smoke * f.size.sqrt() * if f.shown { 1.0 } else { 0.5 };
|
||||
s.heat = flame * 0.6;
|
||||
s.soot = 0.85;
|
||||
s.rise = 2.6;
|
||||
out.push(s);
|
||||
}
|
||||
k += step;
|
||||
}
|
||||
}
|
||||
// Blasts: a billow of puffs going up.
|
||||
for b in &sim.blasts {
|
||||
let age = tick.saturating_sub(b.start);
|
||||
if age > 900 {
|
||||
continue;
|
||||
}
|
||||
for j in 0..6u64 {
|
||||
let born = b.start + j * 3;
|
||||
if born > tick {
|
||||
break;
|
||||
}
|
||||
let h = b.size as f64 * (0.2 + 0.5 * j as f64);
|
||||
let at = b.at + DVec3::Y * h;
|
||||
let mut s = seg(0x5000_0000 | (b.id.wrapping_mul(7) + j as u32) & 0x0fff_ffff, at, at, born, 800);
|
||||
s.radius_start = b.size * 0.6;
|
||||
s.radius_end = b.size * 3.2 + 4.0;
|
||||
s.density = 2.4 / (1.0 + j as f32 * 0.25);
|
||||
s.heat = if j < 3 { 1.0 } else { 0.4 };
|
||||
s.soot = 0.55;
|
||||
s.rise = 4.0 - 0.3 * j as f32;
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
out.truncate(MAX_TOTAL);
|
||||
out
|
||||
}
|
||||
}
|
||||
+56
-4
@@ -29,9 +29,10 @@ Horizon действует правило clean-room. Исключение — P
|
||||
| Встраивание в кадр: G-буфер-проход, каскады теней, вызовы `hd.light` / `hd.finish`, джиттер, тайминги | `horizon/crates/horizon-client/src/gfx.rs` (`Renderer::render`, ищи `hd_on`, `AirDef fork:`) |
|
||||
| G-буфер террейна: `fs_gbuffer`, `fs_shadow` | `horizon/crates/horizon-client/src/terrain.wgsl` |
|
||||
| G-буфер сущностей и моделей мода: `fs_entity_gbuffer`, `fs_entity_shadow` | `horizon/crates/horizon-client/src/entity.wgsl` |
|
||||
| Материалы (классы спрайтов: листва, растения, металл, полированное), ветер, мокрые поверхности | `gfx/hd/materials.rs`; `terrain.wgsl` (`terrain_vertex`/`wind_offset`, `material_params`), `entity.wgsl`, `gfx/hd/light.wgsl` (`shade_surface`) |
|
||||
| Пост-проход мода (взрывы, дым, вспышки, свечение) и его прелюдия | `airdef-mod/shaders/effects.wgsl`, прелюдия в `horizon/.../gfx/mod_post.rs` |
|
||||
| Источники света мода (пламя, ракеты, взрывы, прожекторы) | `airdef-mod/src/lib.rs`, `fn lights` |
|
||||
| API мода: `render.set-lights`, `render.set-lighting` (уровень API 10) | `horizon/crates/horizon-mod-sdk/wit/horizon.wit`, `horizon/crates/horizon-mods/src/runtime.rs` |
|
||||
| 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`), в репозиторий мода попадает **патчами**:
|
||||
|
||||
@@ -117,6 +118,7 @@ Bind groups кэшируются и пересоздаются только пр
|
||||
| 15 | текстура неба (`sky_lut`) |
|
||||
| 16, 17 | объёмный туман (свет+пропускание) и длина его луча |
|
||||
| 18, 19 | объёмные облака, карта теней облаков |
|
||||
| 20 | длина луча каждого текселя облаков (`cloud_dist_tex`, для апсэмплинга по глубине) |
|
||||
|
||||
`HdFrame` (Rust и WGSL держать в синхроне!): `inverse_view_proj`, `resolution` (w, h, 1/w, 1/h), `params` (число
|
||||
источников, экспозиция, bloom, счётчик кадров), `cascades[2]`, `sun` (направление, сила), `sun_color` (rgb, ночь),
|
||||
@@ -186,7 +188,10 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс
|
||||
Графический полигон: `python3 tools/hd_bench.py` (`--list` — сцены; `--only polygon_noon` — одна сцена; `--env
|
||||
HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home-bench/out/before` — diff ×6 и
|
||||
пороги; `--budget-ms 18` — GPU-бюджет). Сам копирует `Polygon`/`Hills` в `run-home-bench/home`, сокет
|
||||
`run-home-bench/c.sock`, одна игра за раз; отчёт `report.md` с таблицей, проходами GPU и кадрами.
|
||||
`run-home-bench/c.sock`, одна игра за раз; отчёт `report.md` с таблицей, проходами GPU и кадрами. Время и погода
|
||||
заморожены (`gamerule advance_time/advance_weather false`); вода и листва идут по часам стены, поэтому кадры сравниваются
|
||||
по статичным пикселям: 3 кадра подряд, пиксели с размахом > 16/255 (вода, облака, листва) не участвуют в сравнении с базой
|
||||
и в мерцании (маска `<сцена>_motion.png`); `--no-freeze` — живой замер производительности; `--help` — все пороги.
|
||||
|
||||
Отладка: временно выводи промежуточные величины цветом в `fs_light` (так нашли пустую карту теней и неверное
|
||||
направление солнца). Помни: солнце в мире идёт по оси X (`(-sin a, cos a, 0)`), а `Globals.sun` неба — в своей
|
||||
@@ -221,6 +226,13 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home
|
||||
| #45: `gfx/hd.rs` разделён на модуль `gfx/hd/` (по файлу на проход: `shadows`, `gbuffer`, `ao`, `sky`, `volumes`, `lighting`+`tiles`, `water`, `taa`, `bloom`, `tonemap`, `frame`), WGSL вынесен в `.wgsl` (`include_str!`, общая прелюдия), у каждого прохода своя структура ресурсов/пайплайнов; контракты цвета, глубины, альф и времени записаны в `hd/mod.rs` и в начале файлов проходов; тесты привязок и размера `HdFrame`. Вид не менялся (сверка `tools/hd_bench.py`) | готово | в рабочем дереве форка |
|
||||
| #39: веер восхода `vanilla_sunrise_weight` отсекается до цикла по 16 треугольникам выше горизонта (знак x стороны солнца и наклон ≤ α/3; проверено перебором 200 тыс. лучей без пропусков); `hd lighting` на рассвете 5.3 → 3.3 мс (M-серия, 2560×1440) | готово | в рабочем дереве форка |
|
||||
|
||||
| Материалы и блики (план 5): G-буфер `levels.zw` = шероховатость, металличность; GGX-блик солнца с Френелем и отражение неба (`sky_along`/`atmosphere_sky`) в `shade_surface`; мокрые поверхности и лужи в дождь (`HdFrame.surface`); `HORIZON_HD_SPECULAR=0` — выкл. | готово | в рабочем дереве форка |
|
||||
| Ветер (план 7): листва и растения качаются в вершинной стадии террейна (`Globals.hd_wind`, `hd_materials`), те же смещения в тенях; `HORIZON_HD_WIND=0` — выкл. | готово | в рабочем дереве форка |
|
||||
| #67: облака марш идут до ближайшей поверхности (`fs_cloud` читает глубину мира, вторая цель — длина луча, история отбрасывается при другой длине); `fs_light` кладёт облака на все пиксели (`cloud_at`: 4 текселя с весом по близости длины луча), под туман и над поверхностью, для воды — в `air`; луч внутри слоя вдоль горизонта больше не пропускается | готово; проверено только пустой полигон (нет гор/техники в слое, снимки `scratchpad/c*.png`), бюджет GPU на Mac не мерился | в рабочем дереве форка |
|
||||
| #68: шум облаков по Y — от низа слоя, а не от камеры; шкалы тумана 170→128, дыма 22→32, луж 0.45/1.3→0.5/1.25 делят период обёртки 65536/256; тест `noise_scales_divide_the_camera_wrap` | готово | в рабочем дереве форка |
|
||||
| #70: `Game::sky_darken` — всегда реальное время мира (ИИ, спавн, блоки, статистика); `shown_sky_darken` (время реплея) — только тени сущностей и свет глаза; небо/луна/цвета остаются по показанному времени | готово (собрано, игровой проверки день/ночь реплея нет) | в рабочем дереве форка |
|
||||
| Объёмный дым и следы ракет (#54, п. 8 плана): API уровня 14 `render.set-smoke`, сетка дыма 128×64×128 по 3 блока (`gfx/hd/smoke.rs`, `smoke_grid.wgsl`), марш в проходе тумана, «туннель» в тумане и облаках, `HORIZON_HD_SMOKE=0`; мод: `airdef-mod/src/smoke.rs` (следы дронов и ракет, колонны над пожарами, дым взрывов) | готово, в рабочем дереве форка; вид не настраивался (см. «Дым и следы») | не закоммичено |
|
||||
|
||||
## План (по порядку) и ТЗ
|
||||
|
||||
Каждый пункт — самостоятельная задача. Перед началом прочитай `gfx/hd/mod.rs` (контракты) и файл нужного прохода.
|
||||
@@ -238,10 +250,10 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home
|
||||
текстуре), солнце/луна дисками, звёзды из ванили; туман и лучи берут цвет из той же модели. Учесть дождь
|
||||
(`weather`), закат, Незер/Энд (там — ванильное небо).
|
||||
4. **Объёмные облака** — СДЕЛАНО (патч 24, см. «Закат, облака и туман»). Осталось: перистые/высококучевые слои Photon, облака в отражении воды.
|
||||
5. **Блики и материалы**: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из
|
||||
5. **Блики и материалы** — СДЕЛАНО (см. «Материалы, мокрое, ветер»). Исходное ТЗ: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из
|
||||
мода), мокрые поверхности и лужи в дождь.
|
||||
6. **PCSS** (мягкость тени по расстоянию до тенеотбрасывателя) и цветные тени через стекло/листву.
|
||||
7. **Ветер** листвы/травы (вершинный шейдер террейна по типу блока), рябь от дождя.
|
||||
7. **Ветер** — СДЕЛАНО (рябь от дождя на воде осталась). Исходное ТЗ: листвы/травы (вершинный шейдер террейна по типу блока), рябь от дождя.
|
||||
8. **Объёмный дым** пожаров и взрывов, принимающий свет и тени (сейчас дым — в `effects.wgsl`, без теней).
|
||||
9. Опционально: motion blur, DoF (для кинокамеры повторов).
|
||||
10. **RT-пресет**: `wgpu` `EXPERIMENTAL_RAY_QUERY` (Vulkan/DX12/Metal M3+), BLAS по секциям, ray-query тени и
|
||||
@@ -304,3 +316,43 @@ HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off` — A/B; `--baseline run-home
|
||||
- **Ванильная вода в HD.** Пока `hd.water_active()`, `fs_translucent` не рисует цвет воды (альфа 0), но пишет её глубину; флаг — `Globals.hd_flags.x` (поле добавлено в Rust-`Globals`, `terrain.wgsl`, `entity.wgsl`; пишется после `hd.write` и в `hd.main_globals`). Вода без дна (`g = 0`) считается водой толщиной 48.
|
||||
- **Не сделано.** Полупрозрачное перед водой берёт вес 0.7 на «вклад воды» (приближение: настоящий порядок «вода → прозрачное» потребовал бы разделить проход сцены). Частицы перед водой, `HORIZON_HD_SCALE=0.67`, поиск мерцания на рябь после этих правок не прогонялись.
|
||||
- **`HORIZON_HD_TAA=0`.** На Polygon в полдень (`tp @s 40 76 40 135 15`) HD с выключенным TAA выглядит так же (дымка, небо, тени на месте), GPU 16.5 мс против 18.1 мс с TAA (минус ≈ проход TAA); потери вида и падения до 4.8 мс не воспроизвелись. В коде `HORIZON_HD_TAA` читают только `taa_enabled()` (джиттер, проход `taa`, история), остальные проходы от него не зависят. Если проявится снова — проверь, что до шота прошёл `/airdef shader hd` (при неготовой команде кадр без HD ~5 мс).
|
||||
|
||||
## Материалы, мокрое, ветер: что знать преемнику
|
||||
|
||||
- **Класс спрайта.** `gfx/hd/materials.rs` по имени спрайта (`minecraft:block/<имя>`) даёт класс 0..8 (обычный, листва, растение, нижняя/верхняя половина двойного растения, металл, гладкое, глянец, дерево). Таблица — по 4 бита на слой, первые 2048 слоёв (`Globals.hd_materials`, 64 × `vec4<u32>`, считается в `game.rs` и кладётся в `Renderer`); спрайты модов дальше 2048 — обычные. Числа `MAT_*` в `terrain.wgsl` держать в синхроне. Добавить блок в класс — список имён в `classify`.
|
||||
- **G-буфер.** `levels.zw` (было не занято) = шероховатость и металличность (`material_params` в `terrain.wgsl`; для сущностей — по оверлею в `fs_entity_gbuffer`: обычные 0.9/0; модели мода `41` — крашеный металл 0.42/0.3 (по умолчанию для всех моделей мода, `game/mod_models.rs`), `42` — голый металл 0.28/1 (мод пока не умеет выбирать), `40` — свечение 0.5/0). Вода своим проходом, без материала.
|
||||
- **Блик.** В `shade_surface` (`light.wgsl`): GGX (α = rough², не ниже 0.03) × Смит × Френель Шлика, у шероховатых Френель почти не растёт (`max(gloss, F0)`); цвет — солнце × тень × `sky_reach`; потолок 6. Отражение неба: `atmosphere_sky(reflect)` × Френель × gloss² × доступ к небесному свету × AO, без SSR на террейне. Металл сохраняет 35% диффузного (карты окружения нет). Дно под водой блеск не получает.
|
||||
- **Мокро.** `HdFrame.surface.x` — влажность 0..1 (CPU, `Hd::wetness`): растёт за ~15 с дождя к уровню дождя мира, сохнет ~150 с. Под открытым небом (`lm.x` > 0.8) поверхности темнеют до ×0.7 (боковые грани слабее) и их шероховатость падает к 0.12; лужи — на плоских гранях вверх, по 2-октавному value-шуму в мировых координатах (`wet_noise`, период 256 блоков), с влажности ~0.3, альбедо ×0.6 и шероховатость 0.03, отражают небо.
|
||||
- **Ветер.** `terrain_vertex` смещает вершины листвы (±0.04 блока, непрерывно по мировой позиции, чтобы блоки не расходились) и растений (до 0.14 блока вверху спрайта; низ — по `uv.y`; двойные растения стыкуются на середине). Волны — целые циклы на 256 блоков и 600 секунд (как у воды). Данные ветра — в `Globals.hd_wind` (сила 1 + 1.2·дождь, секунды, камера mod 256); они же в глобалах каскадов теней, поэтому смещение одно и то же во всех проходах (ванильный, G-буфер, тени, глубина совпадают). Вне HD сила 0 — вершинный шейдер не меняется. TAA не знает о движении листвы (малые амплитуды).
|
||||
- **Переключатели.** `HORIZON_HD_WIND=0`, `HORIZON_HD_SPECULAR=0` (блик, отражения и мокрое).
|
||||
|
||||
## Проверка материалов/мокрого/ветра (что видно и что нет)
|
||||
|
||||
- Увидено в игре (Hills, `run-home-bench/out/mat1`, `out/rain`): ветер работает, листва и трава двигаются, артефактов швов нет; в дождь сцена заметно темнее (мокрое), небо серое. Не проверено глазами: блики на технике (в Polygon сцена бенча сейчас без техники, не сопоставима с базой `before-mat`), отдельные лужи (кадр дождя смотрит в небо), высыхание (`out/rain/drying_30s.png` снят, не разобран), ночь.
|
||||
- Мерцание: `hills_river_noon` 9% без ветра (`out/nowind`) против 31% с ветром — это движение листвы вблизи камеры, не шум; амплитуда листвы снижена с 0.04 до 0.025 после этого замера (повторно не мерилось).
|
||||
- Тайминги **не сняты**: параллельно шли игры других агентов (числа 25–155 мс недействительны). Перед замером брать `run-home/.gpu-lock`.
|
||||
- Известно: `shaders_translate_to_hlsl_for_directx_12` падает на `cloud.wgsl` (не мой файл).
|
||||
|
||||
## Камеры мода: полный вид и мониторы (#56, #51)
|
||||
|
||||
- **Главный вид** (`render.set-main-view`, API 15): мир экрана рисуется из камеры мода (`set-views`) полным конвейером — HD, небо, облака, туман, объёмный дым, TAA, пост-проход мода, разрешение окна. Руку, контур блока и ванильный HUD не рисуем (`game.rs`: `mod_main_view`, `show_hud`). Так показываются повторы Full/Reel/моменты; чанки остаются вокруг игрока, слушатель — игрок.
|
||||
- **Малые виды** (мониторы, `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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,603 @@
|
||||
From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:26:09 +0300
|
||||
Subject: [PATCH 29/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
|
||||
only, gameplay sky darkening uses the world's time (#70)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/game.rs | 37 +++--
|
||||
crates/horizon-client/src/game/render_tick.rs | 2 +-
|
||||
crates/horizon-client/src/gfx/hd/cloud.wgsl | 54 ++++++-
|
||||
.../src/gfx/hd/cloud_common.wgsl | 3 +-
|
||||
crates/horizon-client/src/gfx/hd/fog.wgsl | 6 +-
|
||||
crates/horizon-client/src/gfx/hd/light.wgsl | 42 +++++-
|
||||
crates/horizon-client/src/gfx/hd/lighting.rs | 4 +-
|
||||
crates/horizon-client/src/gfx/hd/volumes.rs | 135 +++++++++++++++---
|
||||
8 files changed, 239 insertions(+), 44 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
|
||||
index 2130d8d88..f21b3dcdd 100644
|
||||
--- a/crates/horizon-client/src/game.rs
|
||||
+++ b/crates/horizon-client/src/game.rs
|
||||
@@ -1838,6 +1838,8 @@ struct Game<'a> {
|
||||
events: events::Events,
|
||||
/// The overworld's last `sky_darken` and its inputs (time, rain, thunder).
|
||||
sky_darken_memo: std::cell::Cell<Option<((i64, u32, u32), i32)>>,
|
||||
+ /// The same for the time a mod shows (a replay's): render only.
|
||||
+ shown_darken_memo: std::cell::Cell<Option<((i64, u32, u32), i32)>>,
|
||||
persist: persist::EntityMemory,
|
||||
/// Connected to a server: the world comes from the network (game/remote.rs).
|
||||
remote: Option<remote::Remote>,
|
||||
@@ -8020,7 +8022,7 @@ impl Game<'_> {
|
||||
.flatten();
|
||||
if let Some(sprite) = shadow_sprite {
|
||||
let ambient = if self.dim == Dim::Nether { 0.1 } else { 0.0 };
|
||||
- let darken = self.sky_darken();
|
||||
+ let darken = self.shown_sky_darken();
|
||||
let a = alpha;
|
||||
let lerp = |p: Vec3, q: Vec3| {
|
||||
glam::DVec3::new(
|
||||
@@ -8378,33 +8380,43 @@ impl Game<'_> {
|
||||
.map_or(self.world.day_time, |t| t as i64)
|
||||
}
|
||||
|
||||
+ /// The sky's darkening for the game's rules (mob AI, spawning, block behaviour, stats): always
|
||||
+ /// the world's real time, never a mod's render-only override.
|
||||
fn sky_darken(&self) -> i32 {
|
||||
+ self.sky_darken_at(self.world.day_time, &self.sky_darken_memo)
|
||||
+ }
|
||||
+
|
||||
+ /// The sky's darkening as the player sees it (entity shadows, particle light): follows the mod's
|
||||
+ /// time override, so a replay's night looks dark without touching the rules.
|
||||
+ fn shown_sky_darken(&self) -> i32 {
|
||||
+ self.sky_darken_at(self.shown_day_time(), &self.shown_darken_memo)
|
||||
+ }
|
||||
+
|
||||
+ fn sky_darken_at(
|
||||
+ &self,
|
||||
+ time: i64,
|
||||
+ memo: &std::cell::Cell<Option<((i64, u32, u32), i32)>>,
|
||||
+ ) -> i32 {
|
||||
if self.dim == Dim::Nether {
|
||||
return 11;
|
||||
}
|
||||
if self.dim == Dim::End {
|
||||
- let sky = self
|
||||
- .res
|
||||
- .end_environment()
|
||||
- .sample(self.shown_day_time(), None, None);
|
||||
+ let sky = self.res.end_environment().sample(time, None, None);
|
||||
return (15.0 - 15.0 * sky.sky_light_level).round() as i32;
|
||||
}
|
||||
let (rain, thunder) = self.world.weather.levels(1.0);
|
||||
// Sampled once per (time, weather): every mob asks each tick.
|
||||
- let key = (self.shown_day_time(), rain.to_bits(), thunder.to_bits());
|
||||
- if let Some((k, darken)) = self.sky_darken_memo.get()
|
||||
+ let key = (time, rain.to_bits(), thunder.to_bits());
|
||||
+ if let Some((k, darken)) = memo.get()
|
||||
&& k == key
|
||||
{
|
||||
return darken;
|
||||
}
|
||||
- let mut sky = self
|
||||
- .res
|
||||
- .environment()
|
||||
- .sample(self.shown_day_time(), None, None);
|
||||
+ let mut sky = self.res.environment().sample(time, None, None);
|
||||
horizon_client::environment::apply_weather(&mut sky, rain, thunder);
|
||||
// Level.updateSkyBrightness: darkening from the sky light level.
|
||||
let darken = (15.0 - 15.0 * sky.sky_light_level).round() as i32;
|
||||
- self.sky_darken_memo.set(Some((key, darken)));
|
||||
+ memo.set(Some((key, darken)));
|
||||
darken
|
||||
}
|
||||
fn mob_hit(&self) -> Option<(usize, f64)> {
|
||||
@@ -13954,6 +13966,7 @@ impl<'a> Game<'a> {
|
||||
mods: Default::default(),
|
||||
native_shaders: Default::default(),
|
||||
sky_darken_memo: Default::default(),
|
||||
+ shown_darken_memo: Default::default(),
|
||||
persist: Default::default(),
|
||||
remote: None,
|
||||
lan: None,
|
||||
diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs
|
||||
index 6807d7718..9912970a5 100644
|
||||
--- a/crates/horizon-client/src/game/render_tick.rs
|
||||
+++ b/crates/horizon-client/src/game/render_tick.rs
|
||||
@@ -241,7 +241,7 @@ impl Game<'_> {
|
||||
eye[2].floor() as i32,
|
||||
];
|
||||
let packed = self.world.light(b[0], b[1], b[2]);
|
||||
- let sky = i32::from(packed >> 4) - self.sky_darken();
|
||||
+ let sky = i32::from(packed >> 4) - self.shown_sky_darken();
|
||||
let raw = sky.max(i32::from(packed & 15)).clamp(0, 15) as u8;
|
||||
lightmap_brightness(if self.dim == Dim::Nether { 0.1 } else { 0.0 }, raw)
|
||||
}
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/cloud.wgsl b/crates/horizon-client/src/gfx/hd/cloud.wgsl
|
||||
index fad234a46..39a239f4d 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/cloud.wgsl
|
||||
+++ b/crates/horizon-client/src/gfx/hd/cloud.wgsl
|
||||
@@ -1,6 +1,17 @@
|
||||
|
||||
@group(0) @binding(6) var history: texture_2d<f32>;
|
||||
@group(0) @binding(7) var smoke_tex: texture_2d<f32>;
|
||||
+@group(0) @binding(8) var scene_depth: texture_depth_2d;
|
||||
+@group(0) @binding(9) var history_dist: texture_2d<f32>;
|
||||
+
|
||||
+// What the sky pixels report as the length of their ray (nothing stops them).
|
||||
+const SKY_LEN: f32 = 20000.0;
|
||||
+
|
||||
+struct CloudOut {
|
||||
+ @location(0) cloud: vec4<f32>,
|
||||
+ // How far the ray was marched: the surface behind this texel, or SKY_LEN for sky.
|
||||
+ @location(1) dist: vec4<f32>,
|
||||
+}
|
||||
|
||||
fn ign(px: vec2<f32>) -> f32 {
|
||||
let p = px + vec2<f32>(5.588238 * frame.params.w);
|
||||
@@ -65,7 +76,26 @@ fn cloud_scatter(
|
||||
}
|
||||
|
||||
@fragment
|
||||
-fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
|
||||
+fn fs_cloud(in: FullOut) -> CloudOut {
|
||||
+ // The nearest of the four world pixels this texel stands for: the march ends at the surface
|
||||
+ // there (the sky counts as infinitely far), so what stands in front of a cloud stays in front
|
||||
+ // and what stands behind it is hidden by it.
|
||||
+ let size = vec2<i32>(frame.resolution.xy);
|
||||
+ let base = vec2<i32>(in.position.xy) * 2;
|
||||
+ var zmax = 0.0;
|
||||
+ var best = base;
|
||||
+ for (var k = 0; k < 4; k++) {
|
||||
+ let q = min(base + vec2<i32>(k & 1, k >> 1), size - 1);
|
||||
+ let z = textureLoad(scene_depth, q, 0);
|
||||
+ if z > zmax {
|
||||
+ zmax = z;
|
||||
+ best = q;
|
||||
+ }
|
||||
+ }
|
||||
+ let suv = (vec2<f32>(best) + 0.5) * frame.resolution.zw;
|
||||
+ let sndc = vec4<f32>(suv.x * 2.0 - 1.0, 1.0 - suv.y * 2.0, max(zmax, 0.000001), 1.0);
|
||||
+ let sh = frame.inverse_view_proj * sndc;
|
||||
+ let scene_len = select(SKY_LEN, length(sh.xyz / sh.w), zmax > 0.0);
|
||||
let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, 0.5, 1.0);
|
||||
let hh = frame.inverse_view_proj * ndc;
|
||||
let dir = normalize(hh.xyz / hh.w);
|
||||
@@ -73,11 +103,13 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
|
||||
var hit_t = 900.0;
|
||||
let y0 = frame.clouds.x;
|
||||
let y1 = y0 + frame.clouds.y;
|
||||
- if abs(dir.y) > 0.004 {
|
||||
- let ta = y0 / dir.y;
|
||||
- let tb = y1 / dir.y;
|
||||
+ // A level ray stays in the slab if the camera is in it (it never leaves), else misses it.
|
||||
+ let dy = select(dir.y, select(-1.0e-4, 1.0e-4, dir.y >= 0.0), abs(dir.y) < 1.0e-4);
|
||||
+ {
|
||||
+ let ta = y0 / dy;
|
||||
+ let tb = y1 / dy;
|
||||
let lo = max(min(ta, tb), 0.0);
|
||||
- let hi = min(max(ta, tb), 4200.0);
|
||||
+ let hi = min(max(ta, tb), min(4200.0, scene_len));
|
||||
if hi > lo {
|
||||
let dither = ign(in.position.xy);
|
||||
let n = clamp(i32(ceil((hi - lo) / 14.0)), 8, 26);
|
||||
@@ -131,6 +163,8 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
|
||||
cur = vec4<f32>(colour, trans);
|
||||
}
|
||||
}
|
||||
+ var out: CloudOut;
|
||||
+ out.dist = vec4<f32>(scene_len, 0.0, 0.0, 0.0);
|
||||
// Reproject the history by where the clouds are (rotation and the camera's step) and blend.
|
||||
if frame.cloud_cam.w > 0.5 {
|
||||
let hp = frame.prev_view_proj * vec4<f32>(dir * hit_t + frame.taa.xyz, 1.0);
|
||||
@@ -138,12 +172,18 @@ fn fs_cloud(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let huv = vec2<f32>(hp.x / hp.w * 0.5 + 0.5, 0.5 - hp.y / hp.w * 0.5);
|
||||
if all(huv > vec2<f32>(0.0)) && all(huv < vec2<f32>(1.0)) {
|
||||
let old = textureSampleLevel(history, linear, huv, 0.0);
|
||||
+ // A history that was marched to another surface (an edge, something that moved in
|
||||
+ // front) does not describe this ray.
|
||||
+ let hs = vec2<f32>(textureDimensions(history_dist));
|
||||
+ let old_len = textureLoad(history_dist, vec2<i32>(huv * hs), 0).x;
|
||||
+ let same_len = 1.0 - smoothstep(0.5, 1.0, abs(old_len - scene_len) / (0.1 * min(scene_len, old_len) + 2.0));
|
||||
// Not too much of a history that disagrees by a lot (a gust, a fast turn).
|
||||
let diff = length(old.rgb - cur.rgb) + abs(old.a - cur.a);
|
||||
- let keep = 0.9 * (1.0 - smoothstep(0.35, 1.2, diff));
|
||||
+ let keep = 0.9 * (1.0 - smoothstep(0.35, 1.2, diff)) * same_len;
|
||||
cur = mix(cur, old, keep);
|
||||
}
|
||||
}
|
||||
}
|
||||
- return cur;
|
||||
+ out.cloud = cur;
|
||||
+ return out;
|
||||
}
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
|
||||
index e2352f008..8ee014420 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
|
||||
+++ b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
|
||||
@@ -44,7 +44,8 @@ fn cloud_density(pos: vec3<f32>, detail: f32) -> f32 {
|
||||
d *= smoothstep(0.0, 0.2, h);
|
||||
if d < 0.002 { return 0.0; }
|
||||
if detail > 0.0 {
|
||||
- let p3 = vec3<f32>(w.x, pos.y, w.y);
|
||||
+ // Height above the layer's base, not the camera's height: the noise stays put in the world.
|
||||
+ let p3 = vec3<f32>(w.x, pos.y - frame.clouds.x, w.y);
|
||||
let warp = (textureSampleLevel(noise3, repeat_s, p3 / 1024.0, 0.0).b - 0.5) * 0.4;
|
||||
let w0 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 512.0 + vec3<f32>(warp), 0.0).r;
|
||||
let w1 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 64.0 + vec3<f32>(warp * 3.0), 0.0).g;
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/fog.wgsl b/crates/horizon-client/src/gfx/hd/fog.wgsl
|
||||
index 38b9098e3..d035bbf0d 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/fog.wgsl
|
||||
+++ b/crates/horizon-client/src/gfx/hd/fog.wgsl
|
||||
@@ -40,7 +40,7 @@ fn sun_visible(p: vec3<f32>) -> f32 {
|
||||
}
|
||||
|
||||
// The air's extinction per block at a camera-relative point: a haze that thins with height above
|
||||
-// the sea, a morning mist hugging the ground, rain, patches from a 3D noise that drifts; none
|
||||
+// the sea, a morning mist hugging the ground, rain, patches from a 3D noise that drifts (its scale divides 65536, the wrap of the camera's x, z, so it does not jump at x or z = 0); none
|
||||
// deep underground.
|
||||
fn fog_sigma(p: vec3<f32>) -> f32 {
|
||||
let wy = p.y + frame.vfog.y;
|
||||
@@ -50,7 +50,7 @@ fn fog_sigma(p: vec3<f32>) -> f32 {
|
||||
s += frame.sky_extra.z * 0.003;
|
||||
let w = vec3<f32>(p.x + frame.cloud_cam.x, wy, p.z + frame.cloud_cam.y)
|
||||
+ vec3<f32>(0.5, 0.0, 0.35) * frame.clouds.w;
|
||||
- let n = textureSampleLevel(noise3, repeat_s, w / 170.0, 0.0).b;
|
||||
+ let n = textureSampleLevel(noise3, repeat_s, w / 128.0, 0.0).b;
|
||||
let patchy = 0.35 + 1.3 * smoothstep(0.25, 0.75, n);
|
||||
// Fade out far below the sea's height: caves keep their own dark air.
|
||||
return s * patchy * smoothstep(28.0, 58.0, wy);
|
||||
@@ -70,7 +70,7 @@ fn smoke_detail(pos: vec3<f32>, core: f32) -> f32 {
|
||||
let w = vec3<f32>(pos.x + frame.cloud_cam.x, pos.y + frame.vfog.y, pos.z + frame.cloud_cam.y)
|
||||
- vec3<f32>(SMOKE_WIND.x, 0.0, SMOKE_WIND.y) * frame.smoke.z;
|
||||
let n1 = textureSampleLevel(noise3, repeat_s, w / 64.0, 0.0).r;
|
||||
- let n2 = textureSampleLevel(noise3, repeat_s, w / 22.0, 0.0).g;
|
||||
+ let n2 = textureSampleLevel(noise3, repeat_s, w / 32.0, 0.0).g;
|
||||
let n = 0.55 * n1 + 0.45 * n2;
|
||||
// Thin smoke breaks up into wisps, thick smoke stays whole.
|
||||
let keep = smoothstep(0.18, 0.62, n + 0.45 * min(core, 1.5) - 0.12);
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/light.wgsl b/crates/horizon-client/src/gfx/hd/light.wgsl
|
||||
index 2534a3db8..870ae0d7d 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/light.wgsl
|
||||
+++ b/crates/horizon-client/src/gfx/hd/light.wgsl
|
||||
@@ -30,6 +30,9 @@ struct Light {
|
||||
@group(0) @binding(17) var fog_dist_tex: texture_2d<f32>;
|
||||
@group(0) @binding(18) var cloud_tex: texture_2d<f32>;
|
||||
@group(0) @binding(19) var cloud_shadow_tex: texture_2d<f32>;
|
||||
+// How far each cloud texel's ray was marched (the surface behind it, or SKY_RAY for sky).
|
||||
+@group(0) @binding(20) var cloud_dist_tex: texture_2d<f32>;
|
||||
+const SKY_RAY: f32 = 20000.0;
|
||||
const CLOUD_SHADOW_R: f32 = 1200.0;
|
||||
// The pixel being lit, for the shadow filter's noise.
|
||||
var<private> frag_px: vec2<f32>;
|
||||
@@ -194,6 +197,34 @@ fn fog_at(px: vec2<i32>, dist: f32) -> vec4<f32> {
|
||||
return sum / total;
|
||||
}
|
||||
|
||||
+// The volumetric clouds between the camera and a surface `dist` away (SKY_RAY for sky): the four
|
||||
+// nearest half-resolution texels weighted by closeness and by how near the length they were
|
||||
+// marched to is, so a cloud in front of a mountain does not bleed onto the sky beside it.
|
||||
+fn cloud_at(px: vec2<i32>, dist: f32) -> vec4<f32> {
|
||||
+ let size = vec2<i32>(textureDimensions(cloud_tex));
|
||||
+ let f = vec2<f32>(px) * 0.5 - vec2<f32>(0.25);
|
||||
+ let base = vec2<i32>(floor(f));
|
||||
+ let t = f - floor(f);
|
||||
+ var sum = vec4<f32>(0.0);
|
||||
+ var total = 0.0;
|
||||
+ for (var j = 0; j < 2; j++) {
|
||||
+ for (var i = 0; i < 2; i++) {
|
||||
+ let q = clamp(base + vec2<i32>(i, j), vec2<i32>(0), size - 1);
|
||||
+ let s = textureLoad(cloud_tex, q, 0);
|
||||
+ let sd = textureLoad(cloud_dist_tex, q, 0).x;
|
||||
+ let bw = select(1.0 - t.x, t.x, i == 1) * select(1.0 - t.y, t.y, j == 1);
|
||||
+ let dz = (sd - dist) / (0.06 * min(sd, dist) + 0.4);
|
||||
+ let w = bw / (1.0 + dz * dz * dz * dz);
|
||||
+ sum += s * w;
|
||||
+ total += w;
|
||||
+ }
|
||||
+ }
|
||||
+ if total < 0.0001 {
|
||||
+ return textureLoad(cloud_tex, clamp(px / 2, vec2<i32>(0), size - 1), 0);
|
||||
+ }
|
||||
+ return sum / total;
|
||||
+}
|
||||
+
|
||||
struct LightOut {
|
||||
// The lit colour; alpha 0.5 marks water (the floor under it, lit without any air), 0.6 an
|
||||
// object in front of the water (the scene as drawn).
|
||||
@@ -246,7 +277,7 @@ fn shade_surface(px: vec2<i32>, p: vec3<f32>, water: bool) -> vec3<f32> {
|
||||
let flat_up = smoothstep(0.9, 0.98, n.y);
|
||||
let soak = wet * open_sky * (0.35 + 0.65 * flat_up);
|
||||
let wp = frame.cloud_cam.xy + p.xz;
|
||||
- let nz = 0.6 * wet_noise(wp * 0.45) + 0.4 * wet_noise(wp * 1.3 + vec2<f32>(17.0, 5.0));
|
||||
+ let nz = 0.6 * wet_noise(wp * 0.5) + 0.4 * wet_noise(wp * 1.25 + vec2<f32>(17.0, 5.0));
|
||||
puddle = smoothstep(0.66 - 0.16 * wet, 0.74 - 0.16 * wet, nz) * smoothstep(0.3, 0.7, wet) * open_sky * flat_up;
|
||||
surface_rgb *= mix(1.0, 0.7, soak) * mix(1.0, 0.6, puddle);
|
||||
rough = mix(rough, 0.12, soak * (1.0 - 0.5 * metal));
|
||||
@@ -377,7 +408,7 @@ fn fs_light(in: FullOut) -> LightOut {
|
||||
c = sky + kept;
|
||||
// The volumetric clouds over the sky and the sun.
|
||||
if frame.clouds.z > 0.5 {
|
||||
- let cl = textureSampleLevel(cloud_tex, linear, in.uv, 0.0);
|
||||
+ let cl = cloud_at(px, SKY_RAY);
|
||||
c = c * cl.a + cl.rgb;
|
||||
}
|
||||
}
|
||||
@@ -421,6 +452,13 @@ fn fs_light(in: FullOut) -> LightOut {
|
||||
air_t = fg.a;
|
||||
air_s = fg.rgb;
|
||||
}
|
||||
+ // The clouds between the camera and the surface (inside the layer, or in front of a mountain
|
||||
+ // or a flying thing): laid over the surface and under the fog, as the sky's are.
|
||||
+ if d > 0.0 && atmosphere && frame.clouds.z > 0.5 {
|
||||
+ let cl = cloud_at(px, length(ps));
|
||||
+ air_s += air_t * cl.rgb;
|
||||
+ air_t *= cl.a;
|
||||
+ }
|
||||
if !volumetric && sun_on > 0.0 && density > 0.0 {
|
||||
let steps = 14;
|
||||
let jitter = ign(in.position.xy);
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/lighting.rs b/crates/horizon-client/src/gfx/hd/lighting.rs
|
||||
index da56d00ba..d26f1e513 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/lighting.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd/lighting.rs
|
||||
@@ -34,7 +34,7 @@ pub const MAX_LIGHTS: usize = 512;
|
||||
|
||||
pub(super) const LIGHT: &str = include_str!("light.wgsl");
|
||||
/// The lighting's group 0, binding by binding (see the contract above).
|
||||
-pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 20] = [
|
||||
+pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 21] = [
|
||||
UNIFORM,
|
||||
UNFILTERED,
|
||||
DEPTH,
|
||||
@@ -55,6 +55,7 @@ pub(super) const LIGHT_BINDINGS: [wgpu::BindingType; 20] = [
|
||||
UNFILTERED,
|
||||
FLOAT,
|
||||
FLOAT,
|
||||
+ UNFILTERED,
|
||||
];
|
||||
|
||||
/// The outside views and sampler the lighting group reads.
|
||||
@@ -219,6 +220,7 @@ impl Hd {
|
||||
wgpu::BindingResource::TextureView(&self.volumes.fog_dist),
|
||||
wgpu::BindingResource::TextureView(self.volumes.cloud_view()),
|
||||
wgpu::BindingResource::TextureView(&self.volumes.cloud_shadow),
|
||||
+ wgpu::BindingResource::TextureView(self.volumes.cloud_dist_view()),
|
||||
];
|
||||
let entries: Vec<_> = resources
|
||||
.into_iter()
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/volumes.rs b/crates/horizon-client/src/gfx/hd/volumes.rs
|
||||
index a338d756c..d3b55e2f2 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/volumes.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd/volumes.rs
|
||||
@@ -26,8 +26,8 @@
|
||||
|
||||
use super::noise as hd_noise;
|
||||
use super::{
|
||||
- DEPTH, FLOAT, Hd, SAMPLER, SHADOW_ARRAY, SHADOW_SAMPLER, UNIFORM, entries, fullscreen, module,
|
||||
- stamp, stamped_pass, target,
|
||||
+ DEPTH, FLOAT, Hd, SAMPLER, SHADOW_ARRAY, SHADOW_SAMPLER, UNFILTERED, UNIFORM, entries,
|
||||
+ fullscreen, module, stamp, stamped_pass, target,
|
||||
};
|
||||
|
||||
pub const CLOUD_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
@@ -86,6 +86,9 @@ pub struct HdVolumes {
|
||||
shadow_sampler: wgpu::Sampler,
|
||||
/// The clouds' half-resolution target and its history (read one, write the other).
|
||||
pub clouds: [wgpu::TextureView; 2],
|
||||
+ /// How far each cloud texel's ray was marched (ping-pong with `clouds`).
|
||||
+ cloud_dist: [wgpu::TextureView; 2],
|
||||
+ cloud_depth: Option<wgpu::TextureView>,
|
||||
flip: usize,
|
||||
history_ready: bool,
|
||||
pub cloud_shadow: wgpu::TextureView,
|
||||
@@ -111,9 +114,10 @@ const D3: wgpu::BindingType = wgpu::BindingType::Texture {
|
||||
};
|
||||
|
||||
/// `fs_cloud`'s group 0: frame, 3D noise, 2D noise, clamped and repeating samplers, the sky
|
||||
-/// texture, the previous cloud frame.
|
||||
-pub(super) const CLOUD_BINDINGS: [wgpu::BindingType; 8] =
|
||||
- [UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT, FLOAT, FLOAT];
|
||||
+/// texture, the previous cloud frame, the smoke atlas, the world's depth, the previous march lengths.
|
||||
+pub(super) const CLOUD_BINDINGS: [wgpu::BindingType; 10] = [
|
||||
+ UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT, FLOAT, FLOAT, DEPTH, UNFILTERED,
|
||||
+];
|
||||
/// `fs_cloud_shadow`'s group 0: as the clouds' without the history.
|
||||
pub(super) const CLOUD_SHADOW_BINDINGS: [wgpu::BindingType; 6] =
|
||||
[UNIFORM, D3, FLOAT, SAMPLER, SAMPLER, FLOAT];
|
||||
@@ -218,15 +222,40 @@ impl HdVolumes {
|
||||
let cloud_module = module(device, "HD clouds", &cloud_src);
|
||||
let shadow_module = module(device, "HD cloud shadows", &shadow_src);
|
||||
let fog_module = module(device, "HD fog", &fog_body());
|
||||
- let cloud_pipeline = fullscreen(
|
||||
- device,
|
||||
- "HD clouds",
|
||||
- &cloud_layout,
|
||||
- &cloud_module,
|
||||
- "fs_cloud",
|
||||
- CLOUD_FORMAT,
|
||||
- None,
|
||||
- );
|
||||
+ let cloud_pipeline = {
|
||||
+ let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
+ label: Some("HD clouds"),
|
||||
+ bind_group_layouts: &[Some(&cloud_layout)],
|
||||
+ ..Default::default()
|
||||
+ });
|
||||
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
+ label: Some("HD clouds"),
|
||||
+ layout: Some(&layout),
|
||||
+ vertex: wgpu::VertexState {
|
||||
+ module: &cloud_module,
|
||||
+ entry_point: Some("vs_full"),
|
||||
+ compilation_options: Default::default(),
|
||||
+ buffers: &[],
|
||||
+ },
|
||||
+ fragment: Some(wgpu::FragmentState {
|
||||
+ module: &cloud_module,
|
||||
+ entry_point: Some("fs_cloud"),
|
||||
+ compilation_options: Default::default(),
|
||||
+ targets: &[CLOUD_FORMAT, FOG_DIST_FORMAT].map(|format| {
|
||||
+ Some(wgpu::ColorTargetState {
|
||||
+ format,
|
||||
+ blend: None,
|
||||
+ write_mask: wgpu::ColorWrites::ALL,
|
||||
+ })
|
||||
+ }),
|
||||
+ }),
|
||||
+ primitive: Default::default(),
|
||||
+ depth_stencil: None,
|
||||
+ multisample: Default::default(),
|
||||
+ multiview_mask: None,
|
||||
+ cache: None,
|
||||
+ })
|
||||
+ };
|
||||
let shadow_pipeline = fullscreen(
|
||||
device,
|
||||
"HD cloud shadows",
|
||||
@@ -286,6 +315,11 @@ impl HdVolumes {
|
||||
one("HD clouds", [1, 1], CLOUD_FORMAT),
|
||||
one("HD clouds", [1, 1], CLOUD_FORMAT),
|
||||
],
|
||||
+ cloud_dist: [
|
||||
+ one("HD cloud distance", [1, 1], FOG_DIST_FORMAT),
|
||||
+ one("HD cloud distance", [1, 1], FOG_DIST_FORMAT),
|
||||
+ ],
|
||||
+ cloud_depth: None,
|
||||
flip: 0,
|
||||
history_ready: false,
|
||||
cloud_shadow: one(
|
||||
@@ -316,6 +350,10 @@ impl HdVolumes {
|
||||
one("HD clouds", CLOUD_FORMAT),
|
||||
one("HD clouds", CLOUD_FORMAT),
|
||||
];
|
||||
+ self.cloud_dist = [
|
||||
+ one("HD cloud distance", FOG_DIST_FORMAT),
|
||||
+ one("HD cloud distance", FOG_DIST_FORMAT),
|
||||
+ ];
|
||||
self.fog = one("HD fog", FOG_FORMAT);
|
||||
self.fog_dist = one("HD fog distance", FOG_DIST_FORMAT);
|
||||
self.history_ready = false;
|
||||
@@ -342,6 +380,11 @@ impl HdVolumes {
|
||||
&self.clouds[self.flip]
|
||||
}
|
||||
|
||||
+ /// The march lengths that go with `cloud_view`.
|
||||
+ pub fn cloud_dist_view(&self) -> &wgpu::TextureView {
|
||||
+ &self.cloud_dist[self.flip]
|
||||
+ }
|
||||
+
|
||||
fn group(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
@@ -372,7 +415,12 @@ impl HdVolumes {
|
||||
frame: &wgpu::Buffer,
|
||||
sky_lut: &wgpu::TextureView,
|
||||
atlas: &wgpu::TextureView,
|
||||
+ depth: &wgpu::TextureView,
|
||||
) {
|
||||
+ if self.cloud_depth.as_ref() != Some(depth) {
|
||||
+ self.cloud_groups = None;
|
||||
+ self.cloud_depth = Some(depth.clone());
|
||||
+ }
|
||||
if self.shadow_group.is_none() {
|
||||
let layout = self.shadow_layout.clone();
|
||||
let (n3, n2, clamp, repeat) = (
|
||||
@@ -405,6 +453,7 @@ impl HdVolumes {
|
||||
self.repeat.clone(),
|
||||
);
|
||||
let hist = self.clouds.clone();
|
||||
+ let hist_dist = self.cloud_dist.clone();
|
||||
let groups = [0, 1].map(|read| {
|
||||
self.group(
|
||||
device,
|
||||
@@ -419,6 +468,8 @@ impl HdVolumes {
|
||||
wgpu::BindingResource::TextureView(sky_lut),
|
||||
wgpu::BindingResource::TextureView(&hist[read]),
|
||||
wgpu::BindingResource::TextureView(atlas),
|
||||
+ wgpu::BindingResource::TextureView(depth),
|
||||
+ wgpu::BindingResource::TextureView(&hist_dist[read]),
|
||||
],
|
||||
)
|
||||
});
|
||||
@@ -455,7 +506,7 @@ impl HdVolumes {
|
||||
stamped_pass(
|
||||
encoder,
|
||||
"HD clouds",
|
||||
- &[&self.clouds[write]],
|
||||
+ &[&self.clouds[write], &self.cloud_dist[write]],
|
||||
&self.cloud_pipeline,
|
||||
&groups[read],
|
||||
stamp,
|
||||
@@ -525,8 +576,13 @@ impl Hd {
|
||||
depth: &wgpu::TextureView,
|
||||
) {
|
||||
if self.clouds_active {
|
||||
- self.volumes
|
||||
- .prepare_clouds(device, &self.frame, &self.sky.lut, &self.smoke.atlas);
|
||||
+ self.volumes.prepare_clouds(
|
||||
+ device,
|
||||
+ &self.frame,
|
||||
+ &self.sky.lut,
|
||||
+ &self.smoke.atlas,
|
||||
+ depth,
|
||||
+ );
|
||||
self.volumes
|
||||
.draw_cloud_shadow(encoder, stamp(&mut self.timing, "hd cloud shadows"));
|
||||
self.volumes
|
||||
@@ -570,3 +626,48 @@ pub fn shader_sources() -> Vec<(&'static str, String, &'static [wgpu::BindingTyp
|
||||
("fog", fog_body(), &FOG_BINDINGS),
|
||||
]
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+
|
||||
+ /// Every world-anchored noise lookup divides the world coordinate (the camera's x, z wrapped to
|
||||
+ /// 65536) by a scale that divides 65536, or the pattern jumps where the wrap happens (x or z = 0).
|
||||
+ #[test]
|
||||
+ fn noise_scales_divide_the_camera_wrap() {
|
||||
+ let mut found = 0;
|
||||
+ for (name, src) in [("cloud_common", CLOUD_COMMON), ("fog", FOG)] {
|
||||
+ for key in ["w / ", "p3 / "] {
|
||||
+ for part in src.split(key).skip(1) {
|
||||
+ let num: String = part
|
||||
+ .chars()
|
||||
+ .take_while(|c| c.is_ascii_digit() || *c == '.')
|
||||
+ .collect();
|
||||
+ let Ok(scale) = num.parse::<f64>() else {
|
||||
+ continue;
|
||||
+ };
|
||||
+ found += 1;
|
||||
+ assert!(
|
||||
+ scale >= 1.0 && scale.fract() == 0.0 && 65536 % (scale as u64) == 0,
|
||||
+ "{name}: the scale {scale} does not divide 65536"
|
||||
+ );
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ assert!(found >= 8, "found only {found} noise lookups");
|
||||
+ }
|
||||
+
|
||||
+ /// The cloud noise's height is measured from the layer's base, not from the camera: the same
|
||||
+ /// world point gives the same noise coordinate at any camera height.
|
||||
+ #[test]
|
||||
+ fn cloud_noise_height_is_anchored_to_the_layer() {
|
||||
+ assert!(CLOUD_COMMON.contains("pos.y - frame.clouds.x"));
|
||||
+ // frame.clouds.x = CLOUD_BASE - camera height: y_world - CLOUD_BASE however high the camera.
|
||||
+ for cam in [64.0f32, 210.0, 1000.0] {
|
||||
+ let world_y = 230.0f32;
|
||||
+ let rel = world_y - cam;
|
||||
+ let base_rel = CLOUD_BASE - cam;
|
||||
+ assert!((rel - base_rel - (world_y - CLOUD_BASE)).abs() < 1e-3);
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:44:45 +0300
|
||||
Subject: [PATCH 30/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
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx/hd/smoke.rs | 12 ++++++++++++
|
||||
crates/horizon-client/src/gfx/hd/smoke_grid.wgsl | 2 +-
|
||||
2 files changed, 13 insertions(+), 1 deletion(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/smoke.rs b/crates/horizon-client/src/gfx/hd/smoke.rs
|
||||
index dee0932e8..c751df4f4 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/smoke.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd/smoke.rs
|
||||
@@ -46,6 +46,8 @@ const MAX_INDICES: usize = 120_000;
|
||||
pub const MAX_LIGHTS: usize = 16;
|
||||
/// How fast the wind carries smoke (blocks per second, x and z).
|
||||
const WIND: [f32; 2] = [1.6, 1.2];
|
||||
+/// The thinnest a trail is drawn, in blocks of radius (a cell is `CELL`).
|
||||
+const MIN_TUBE: f32 = 3.6;
|
||||
|
||||
/// Smoke is on unless `HORIZON_HD_SMOKE=0`.
|
||||
pub fn smoke_enabled() -> bool {
|
||||
@@ -402,6 +404,16 @@ fn bake(s: &HdSmoke) -> Option<Baked> {
|
||||
// Spreading thins it (the core's mass over a wider tube), and it fades out towards its end.
|
||||
let thin = (r0 / radius).powf(0.5);
|
||||
let density = s.shape[2] * (1.0 - u).powi(2) * thin * (t / 0.25).min(1.0).max(0.2);
|
||||
+ // A tube thinner than the grid's cells is sampled unevenly (cells near its axis get it, their
|
||||
+ // neighbours miss it): a chain of blobs. Trails are kept at least a cell and a bit wide, with
|
||||
+ // the same mass per length, so a young trail is a continuous ribbon.
|
||||
+ let d = [s.b[0] - s.a[0], s.b[1] - s.a[1], s.b[2] - s.a[2]];
|
||||
+ let tube = d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 0.25;
|
||||
+ let (radius, density) = if tube && radius < MIN_TUBE {
|
||||
+ (MIN_TUBE, density * radius / MIN_TUBE)
|
||||
+ } else {
|
||||
+ (radius, density)
|
||||
+ };
|
||||
let heat = s.shape[3] * (-t / 1.5).exp();
|
||||
let clear_amount = (1.0 - u).powf(0.8);
|
||||
let clear = s.motion[1] * (1.0 + 0.4 * u);
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl b/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
|
||||
index 01b6e082a..0adcd55f6 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
|
||||
+++ b/crates/horizon-client/src/gfx/hd/smoke_grid.wgsl
|
||||
@@ -40,7 +40,7 @@ fn fs_smoke_grid(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let seed = segs[i + 3u].x;
|
||||
let len = sqrt(len2);
|
||||
// Bulges along the tube, so it is not a pipe.
|
||||
- let r = pa.w * (1.0 + 0.22 * sin(along * len * 0.6 + seed * 40.0));
|
||||
+ let r = pa.w * (1.0 + 0.07 * sin(along * len * 0.35 + seed * 40.0));
|
||||
if d < r && pb.w > 0.0 {
|
||||
let q = d / r;
|
||||
var w = (1.0 - q * q);
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
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/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
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/game.rs | 31 +++-
|
||||
crates/horizon-client/src/game/mod_models.rs | 9 ++
|
||||
crates/horizon-client/src/gfx.rs | 39 ++++-
|
||||
crates/horizon-client/src/gfx/mod_post.rs | 152 +++++++++++++++----
|
||||
crates/horizon-client/src/gfx/mod_views.rs | 69 ++++++---
|
||||
crates/horizon-mod-sdk/wit/horizon.wit | 10 ++
|
||||
crates/horizon-mods/src/api_level.rs | 6 +-
|
||||
crates/horizon-mods/src/runtime.rs | 12 ++
|
||||
docs/api/mods-reference.md | 1 +
|
||||
9 files changed, 269 insertions(+), 60 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
|
||||
index f21b3dcdd..0e779aaa0 100644
|
||||
--- a/crates/horizon-client/src/game.rs
|
||||
+++ b/crates/horizon-client/src/game.rs
|
||||
@@ -5950,7 +5950,7 @@ impl Game<'_> {
|
||||
// the sleep overlay, debug screen and open screens still draw.
|
||||
let spectator = self.spectator();
|
||||
let (wi, hi) = (w as i32, h as i32);
|
||||
- let show_hud = !self.ui.hide_gui;
|
||||
+ let show_hud = !self.ui.hide_gui && self.mod_main_view(self.clock.fraction()).is_none();
|
||||
// max(Hud.leftHeight, Hud.rightHeight) after the bars: the item name and
|
||||
// the action bar sit above it.
|
||||
let mut bar_height = 39;
|
||||
@@ -11572,6 +11572,13 @@ impl Game<'_> {
|
||||
let (view_yaw, view_pitch) = self.shaken_view(view_yaw, view_pitch);
|
||||
self.audio
|
||||
.set_listener([camera.x, camera.y, camera.z], view_yaw, view_pitch);
|
||||
+ // AirDef fork: a mod's camera as the screen's own view (replays): the listener stays the
|
||||
+ // player's, everything seen is drawn from the camera with the full pipeline.
|
||||
+ let main_view = self.mod_main_view(alpha);
|
||||
+ let (camera, underwater, view_yaw, view_pitch) = match &main_view {
|
||||
+ Some(v) => (v.camera, false, v.yaw, v.pitch),
|
||||
+ None => (camera, underwater, view_yaw, view_pitch),
|
||||
+ };
|
||||
let block = [
|
||||
camera.x.floor() as i32,
|
||||
camera.y.floor() as i32,
|
||||
@@ -11579,6 +11586,7 @@ impl Game<'_> {
|
||||
];
|
||||
// The camera inside an opaque block sees nothing through it: disable.
|
||||
let cave_culling = self.settings.horizon.cave_culling
|
||||
+ && main_view.is_none()
|
||||
&& !self
|
||||
.res
|
||||
.facts()
|
||||
@@ -11670,7 +11678,11 @@ impl Game<'_> {
|
||||
// player who may build. Adventure players only get one with a CAN_BREAK or
|
||||
// CAN_PLACE_ON item and spectators with a menu block; Horizon reads neither the
|
||||
// components nor opens menus for spectators, so those never show it.
|
||||
- let outline = if self.screen == Screen::Playing && !self.ui.hide_gui && self.may_build() {
|
||||
+ let outline = if self.screen == Screen::Playing
|
||||
+ && !self.ui.hide_gui
|
||||
+ && main_view.is_none()
|
||||
+ && self.may_build()
|
||||
+ {
|
||||
self.pick()
|
||||
.map(|h| {
|
||||
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
|
||||
@@ -11733,17 +11745,19 @@ impl Game<'_> {
|
||||
};
|
||||
let fov_degrees = if title {
|
||||
85.0
|
||||
+ } else if let Some(v) = &main_view {
|
||||
+ v.fov
|
||||
} else {
|
||||
self.fov_degrees(camera_alpha as f32, camera.to_array())
|
||||
};
|
||||
self.locator_camera =
|
||||
(!title).then_some((camera.to_array(), view_yaw, view_pitch, fov_degrees));
|
||||
- let bob = if title {
|
||||
+ let bob = if title || main_view.is_some() {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
self.bob_matrix(camera_alpha as f32)
|
||||
};
|
||||
- let spin = if title {
|
||||
+ let spin = if title || main_view.is_some() {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
self.spinning_matrix(camera_alpha as f32)
|
||||
@@ -11791,7 +11805,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
(Vec::new(), None)
|
||||
};
|
||||
- let hand = if title || self.scoping() {
|
||||
+ let hand = if title || self.scoping() || main_view.is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.hand_geometry(camera_alpha)
|
||||
@@ -11829,7 +11843,12 @@ impl Game<'_> {
|
||||
hud.patches.append(&mut self.map_patches);
|
||||
let entity_spans = std::mem::take(self.entity_spans.get_mut());
|
||||
let glowing = std::mem::take(self.glowing_vertices.get_mut());
|
||||
- let mod_views = self.mod_views(alpha);
|
||||
+ // The main view is the frame itself, not a picture of its own.
|
||||
+ let mod_views: Vec<_> = self
|
||||
+ .mod_views(alpha)
|
||||
+ .into_iter()
|
||||
+ .filter(|v| main_view.as_ref().is_none_or(|m| m.id != v.id))
|
||||
+ .collect();
|
||||
let hd_lights = self.mod_lights(camera, alpha);
|
||||
let (hd_smoke, hd_smoke_clock) = self.mod_smoke(camera, alpha);
|
||||
let frame = Frame {
|
||||
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
|
||||
index f5e68566a..7a4d742df 100644
|
||||
--- a/crates/horizon-client/src/game/mod_models.rs
|
||||
+++ b/crates/horizon-client/src/game/mod_models.rs
|
||||
@@ -71,6 +71,9 @@ impl Game<'_> {
|
||||
pub(super) fn mod_views(&self, _alpha: f64) -> Vec<horizon_client::gfx::ModView> {
|
||||
Vec::new()
|
||||
}
|
||||
+ pub(super) fn mod_main_view(&self, _alpha: f64) -> Option<horizon_client::gfx::ModView> {
|
||||
+ None
|
||||
+ }
|
||||
pub(super) fn mod_hd(&self) -> Option<horizon_client::gfx::HdSettings> {
|
||||
None
|
||||
}
|
||||
@@ -300,6 +303,12 @@ impl Game<'_> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
+ /// The camera a mod asked the screen's world from (`render.set-main-view`), for this frame.
|
||||
+ pub(super) fn mod_main_view(&self, alpha: f64) -> Option<horizon_client::gfx::ModView> {
|
||||
+ let id = self.mods.mods.main_view()?;
|
||||
+ self.mod_views(alpha).into_iter().find(|v| v.id == id)
|
||||
+ }
|
||||
+
|
||||
/// Draws every mod model between its last two poses, lit by the world
|
||||
/// (or at full brightness), its faces shaded by its own rotation.
|
||||
pub(super) fn mod_model_geometry(&self, camera: DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||||
index 93a2f7c63..1a4e68b88 100644
|
||||
--- a/crates/horizon-client/src/gfx.rs
|
||||
+++ b/crates/horizon-client/src/gfx.rs
|
||||
@@ -3363,7 +3363,8 @@ impl Renderer {
|
||||
view: &t.depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
- store: wgpu::StoreOp::Discard,
|
||||
+ // The mod's post pass reads it.
|
||||
+ store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
@@ -3411,6 +3412,33 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
}
|
||||
+ drop(pass);
|
||||
+ // The mod's post pass with this camera: its blasts, flashes and glows in the picture,
|
||||
+ // as much as the picture's size is worth. The picture's own camera and world are
|
||||
+ // lit by the pass itself (no HD lighting here: it is the world's, not a picture's).
|
||||
+ t.post = false;
|
||||
+ if let Some(post) = &self.mod_post
|
||||
+ && let Some((blasts, lights)) = mod_views::post_budget(t.size)
|
||||
+ {
|
||||
+ let mut params = self.mod_params.0.clone();
|
||||
+ if params.len() >= 4 {
|
||||
+ // Mode 0: the pass lights the land and draws the smoke itself.
|
||||
+ params[3] = 0.0;
|
||||
+ params[1] = params[1].min(blasts as f32);
|
||||
+ params[2] = params[2].min(lights as f32);
|
||||
+ }
|
||||
+ post.write_to(
|
||||
+ &self.queue,
|
||||
+ &t.post_buffer,
|
||||
+ (d.view, d.projection, d.camera),
|
||||
+ frame.sky.sun_angle,
|
||||
+ t.size,
|
||||
+ ¶ms,
|
||||
+ self.mod_params.1,
|
||||
+ );
|
||||
+ post.run_view(&self.device, encoder, &t.post_buffer, (&t.color, &t.depth), &t.fx);
|
||||
+ t.post = true;
|
||||
+ }
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(&views.main, 0, &self.globals, 0, size);
|
||||
}
|
||||
@@ -3703,7 +3731,12 @@ impl Renderer {
|
||||
} else {
|
||||
self.config.format.remove_srgb_suffix()
|
||||
};
|
||||
- self.mod_post = Some(mod_post::ModPost::new(&self.device, source, format)?);
|
||||
+ self.mod_post = Some(mod_post::ModPost::new(
|
||||
+ &self.device,
|
||||
+ source,
|
||||
+ format,
|
||||
+ self.config.format.remove_srgb_suffix(),
|
||||
+ )?);
|
||||
self.mod_post_source = Some(source.to_owned());
|
||||
}
|
||||
}
|
||||
@@ -4424,6 +4457,8 @@ impl Renderer {
|
||||
id: v.id,
|
||||
view_proj,
|
||||
view,
|
||||
+ projection: world_projection(v.fov, aspect, frame.depth_far),
|
||||
+ camera: v.camera,
|
||||
aspect,
|
||||
fov: v.fov,
|
||||
visible: seen,
|
||||
diff --git a/crates/horizon-client/src/gfx/mod_post.rs b/crates/horizon-client/src/gfx/mod_post.rs
|
||||
index d1f54a4bb..3d67ff25b 100644
|
||||
--- a/crates/horizon-client/src/gfx/mod_post.rs
|
||||
+++ b/crates/horizon-client/src/gfx/mod_post.rs
|
||||
@@ -106,6 +106,8 @@ fn horizon_mod_post_fs(in: HorizonModPostOut) -> @location(0) vec4<f32> {
|
||||
|
||||
pub struct ModPost {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
+ /// The same pass onto the mod cameras' pictures (the surface's format).
|
||||
+ view_pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
buffer: wgpu::Buffer,
|
||||
sampler: wgpu::Sampler,
|
||||
@@ -144,6 +146,7 @@ impl ModPost {
|
||||
device: &wgpu::Device,
|
||||
source: &str,
|
||||
format: wgpu::TextureFormat,
|
||||
+ view_format: wgpu::TextureFormat,
|
||||
) -> Result<Self, String> {
|
||||
let module = compile(source)?;
|
||||
let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry {
|
||||
@@ -189,31 +192,35 @@ impl ModPost {
|
||||
label: Some("Mod post"),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
- let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
- label: Some("Mod post"),
|
||||
- layout: Some(&pipeline_layout),
|
||||
- vertex: wgpu::VertexState {
|
||||
- module: &shader,
|
||||
- entry_point: Some("horizon_mod_post_vs"),
|
||||
- compilation_options: Default::default(),
|
||||
- buffers: &[],
|
||||
- },
|
||||
- primitive: Default::default(),
|
||||
- depth_stencil: None,
|
||||
- multisample: Default::default(),
|
||||
- fragment: Some(wgpu::FragmentState {
|
||||
- module: &shader,
|
||||
- entry_point: Some("horizon_mod_post_fs"),
|
||||
- compilation_options: Default::default(),
|
||||
- targets: &[Some(wgpu::ColorTargetState {
|
||||
- format,
|
||||
- blend: None,
|
||||
- write_mask: wgpu::ColorWrites::ALL,
|
||||
- })],
|
||||
- }),
|
||||
- multiview_mask: None,
|
||||
- cache: None,
|
||||
- });
|
||||
+ let make = |format: wgpu::TextureFormat| {
|
||||
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
+ label: Some("Mod post"),
|
||||
+ layout: Some(&pipeline_layout),
|
||||
+ vertex: wgpu::VertexState {
|
||||
+ module: &shader,
|
||||
+ entry_point: Some("horizon_mod_post_vs"),
|
||||
+ compilation_options: Default::default(),
|
||||
+ buffers: &[],
|
||||
+ },
|
||||
+ primitive: Default::default(),
|
||||
+ depth_stencil: None,
|
||||
+ multisample: Default::default(),
|
||||
+ fragment: Some(wgpu::FragmentState {
|
||||
+ module: &shader,
|
||||
+ entry_point: Some("horizon_mod_post_fs"),
|
||||
+ compilation_options: Default::default(),
|
||||
+ targets: &[Some(wgpu::ColorTargetState {
|
||||
+ format,
|
||||
+ blend: None,
|
||||
+ write_mask: wgpu::ColorWrites::ALL,
|
||||
+ })],
|
||||
+ }),
|
||||
+ multiview_mask: None,
|
||||
+ cache: None,
|
||||
+ })
|
||||
+ };
|
||||
+ let pipeline = make(format);
|
||||
+ let view_pipeline = make(view_format);
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
return Err(e.to_string());
|
||||
}
|
||||
@@ -231,6 +238,7 @@ impl ModPost {
|
||||
});
|
||||
Ok(Self {
|
||||
pipeline,
|
||||
+ view_pipeline,
|
||||
layout,
|
||||
buffer,
|
||||
sampler,
|
||||
@@ -253,7 +261,30 @@ impl ModPost {
|
||||
let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far)
|
||||
* frame.bob
|
||||
* frame.spin;
|
||||
- let a = frame.sky.sun_angle.to_radians();
|
||||
+ self.write_to(
|
||||
+ queue,
|
||||
+ &self.buffer,
|
||||
+ (view, projection, frame.camera),
|
||||
+ frame.sky.sun_angle,
|
||||
+ size,
|
||||
+ params,
|
||||
+ partial,
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ /// The same for another camera (a mod view's picture) into its own `buffer`.
|
||||
+ #[allow(clippy::too_many_arguments)]
|
||||
+ pub fn write_to(
|
||||
+ &self,
|
||||
+ queue: &wgpu::Queue,
|
||||
+ buffer: &wgpu::Buffer,
|
||||
+ (view, projection, camera): (Mat4, Mat4, glam::DVec3),
|
||||
+ sun_angle: f32,
|
||||
+ size: [u32; 2],
|
||||
+ params: &[f32],
|
||||
+ partial: f32,
|
||||
+ ) {
|
||||
+ let a = sun_angle.to_radians();
|
||||
let sun = [-a.sin(), a.cos(), 0.0, 0.0];
|
||||
let mut p = [[0.0f32; 4]; PARAMS / 4];
|
||||
for (i, v) in params.iter().take(PARAMS).enumerate() {
|
||||
@@ -264,12 +295,7 @@ impl ModPost {
|
||||
view_proj: m(projection * view),
|
||||
view_inverse: m(view.inverse()),
|
||||
projection_inverse: m(projection.inverse()),
|
||||
- camera: [
|
||||
- frame.camera.x as f32,
|
||||
- frame.camera.y as f32,
|
||||
- frame.camera.z as f32,
|
||||
- 0.0,
|
||||
- ],
|
||||
+ camera: [camera.x as f32, camera.y as f32, camera.z as f32, 0.0],
|
||||
resolution: [
|
||||
size[0] as f32,
|
||||
size[1] as f32,
|
||||
@@ -285,7 +311,67 @@ impl ModPost {
|
||||
sun,
|
||||
params: p,
|
||||
};
|
||||
- queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
|
||||
+ queue.write_buffer(buffer, 0, bytemuck::bytes_of(&inputs));
|
||||
+ }
|
||||
+
|
||||
+ /// A buffer for one more camera's `write_to`.
|
||||
+ pub fn new_buffer(device: &wgpu::Device) -> wgpu::Buffer {
|
||||
+ device.create_buffer(&wgpu::BufferDescriptor {
|
||||
+ label: Some("Mod post frame (view)"),
|
||||
+ size: size_of::<ModFrame>() as u64,
|
||||
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
+ mapped_at_creation: false,
|
||||
+ })
|
||||
+ }
|
||||
+
|
||||
+ /// Draws the pass over a mod camera's picture (`world`: colour and depth, `buffer`: what
|
||||
+ /// `write_to` filled) onto `target`, in the surface's format.
|
||||
+ pub fn run_view(
|
||||
+ &self,
|
||||
+ device: &wgpu::Device,
|
||||
+ encoder: &mut wgpu::CommandEncoder,
|
||||
+ buffer: &wgpu::Buffer,
|
||||
+ world: (&wgpu::TextureView, &wgpu::TextureView),
|
||||
+ target: &wgpu::TextureView,
|
||||
+ ) {
|
||||
+ let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
+ label: Some("Mod post (view)"),
|
||||
+ layout: &self.layout,
|
||||
+ entries: &[
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 0,
|
||||
+ resource: buffer.as_entire_binding(),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 1,
|
||||
+ resource: wgpu::BindingResource::TextureView(world.0),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 2,
|
||||
+ resource: wgpu::BindingResource::TextureView(world.1),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 3,
|
||||
+ resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
+ },
|
||||
+ ],
|
||||
+ });
|
||||
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
+ label: Some("Mod post (view)"),
|
||||
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
+ view: target,
|
||||
+ resolve_target: None,
|
||||
+ depth_slice: None,
|
||||
+ ops: wgpu::Operations {
|
||||
+ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
+ store: wgpu::StoreOp::Store,
|
||||
+ },
|
||||
+ })],
|
||||
+ ..Default::default()
|
||||
+ });
|
||||
+ pass.set_pipeline(&self.view_pipeline);
|
||||
+ pass.set_bind_group(0, &group, &[]);
|
||||
+ pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// Draws the pass over the world target `world` (colour, depth) onto `screen`.
|
||||
diff --git a/crates/horizon-client/src/gfx/mod_views.rs b/crates/horizon-client/src/gfx/mod_views.rs
|
||||
index aa2c5b971..bb65480ea 100644
|
||||
--- a/crates/horizon-client/src/gfx/mod_views.rs
|
||||
+++ b/crates/horizon-client/src/gfx/mod_views.rs
|
||||
@@ -26,15 +26,34 @@ pub(super) const PER_FRAME: usize = 3;
|
||||
pub(super) const RADIUS: i32 = 12;
|
||||
|
||||
pub(super) struct Target {
|
||||
- size: [u32; 2],
|
||||
+ pub size: [u32; 2],
|
||||
pub color: wgpu::TextureView,
|
||||
pub depth: wgpu::TextureView,
|
||||
pub globals: wgpu::Buffer,
|
||||
blit: wgpu::BindGroup,
|
||||
+ /// The picture after the mod's post pass (`post`), and what shows it.
|
||||
+ pub fx: wgpu::TextureView,
|
||||
+ blit_fx: wgpu::BindGroup,
|
||||
+ /// The post pass's uniform for this camera.
|
||||
+ pub post_buffer: wgpu::Buffer,
|
||||
+ /// The picture shown went through the post pass this time.
|
||||
+ pub post: bool,
|
||||
/// Drawn at least once: the HUD shows nothing before.
|
||||
pub drawn: bool,
|
||||
}
|
||||
|
||||
+/// How much of the mod's post pass a picture of `size` pays for: (blasts, lights) the pass
|
||||
+/// marches, or None for a picture too small to see them in. Full-screen pictures get all.
|
||||
+pub(super) fn post_budget(size: [u32; 2]) -> Option<(usize, usize)> {
|
||||
+ let area = size[0] as u64 * size[1] as u64;
|
||||
+ match area {
|
||||
+ 0..4096 => None,
|
||||
+ 4096..150_000 => Some((4, 12)),
|
||||
+ 150_000..600_000 => Some((12, 32)),
|
||||
+ _ => Some((48, 79)),
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
/// A camera drawn this frame.
|
||||
pub(super) struct Due {
|
||||
pub id: u32,
|
||||
@@ -42,6 +61,9 @@ pub(super) struct Due {
|
||||
/// world's other geometry are.
|
||||
pub view_proj: Mat4,
|
||||
pub view: Mat4,
|
||||
+ /// Its own projection and place, for the mod's post pass.
|
||||
+ pub projection: Mat4,
|
||||
+ pub camera: DVec3,
|
||||
pub aspect: f32,
|
||||
pub fov: f32,
|
||||
pub visible: Vec<super::resident::Visible>,
|
||||
@@ -239,22 +261,31 @@ impl Views {
|
||||
let depth = texture(
|
||||
"Mod view depth",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
- wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
+ wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
);
|
||||
- let blit = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
- label: Some("Mod view picture"),
|
||||
- layout: &self.layout,
|
||||
- entries: &[
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 0,
|
||||
- resource: wgpu::BindingResource::TextureView(&color),
|
||||
- },
|
||||
- wgpu::BindGroupEntry {
|
||||
- binding: 1,
|
||||
- resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
- },
|
||||
- ],
|
||||
- });
|
||||
+ let fx = texture(
|
||||
+ "Mod view post",
|
||||
+ self.format,
|
||||
+ wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
+ );
|
||||
+ let group = |view: &wgpu::TextureView| {
|
||||
+ device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
+ label: Some("Mod view picture"),
|
||||
+ layout: &self.layout,
|
||||
+ entries: &[
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 0,
|
||||
+ resource: wgpu::BindingResource::TextureView(view),
|
||||
+ },
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 1,
|
||||
+ resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
+ },
|
||||
+ ],
|
||||
+ })
|
||||
+ };
|
||||
+ let blit = group(&color);
|
||||
+ let blit_fx = group(&fx);
|
||||
let globals = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Mod view globals"),
|
||||
size: globals_size,
|
||||
@@ -267,6 +298,10 @@ impl Views {
|
||||
depth,
|
||||
globals,
|
||||
blit,
|
||||
+ fx,
|
||||
+ blit_fx,
|
||||
+ post_buffer: super::mod_post::ModPost::new_buffer(device),
|
||||
+ post: false,
|
||||
drawn: false,
|
||||
}
|
||||
}
|
||||
@@ -306,7 +341,7 @@ impl Views {
|
||||
pass.set_viewport(x, y, w, h, 0.0, 1.0);
|
||||
pass.set_scissor_rect(x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32);
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
- pass.set_bind_group(0, &t.blit, &[]);
|
||||
+ pass.set_bind_group(0, if t.post { &t.blit_fx } else { &t.blit }, &[]);
|
||||
let mode = view.mode.min(2);
|
||||
pass.draw(0..3, mode..mode + 1);
|
||||
}
|
||||
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
index fba8f162d..bae0d2134 100644
|
||||
--- a/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
@@ -664,6 +664,16 @@ interface render {
|
||||
/// Replaces the mod's cameras: up to 8. A `view` shape shows one; the
|
||||
/// game renders up to three a frame, in turn.
|
||||
set-views: func(views: list<camera-view>);
|
||||
+ /// Renders the screen's world from one of the `set-views` cameras
|
||||
+ /// (AirDef fork), with everything the player's own view has: the HD
|
||||
+ /// lighting, sky, clouds, fog, smoke, TAA and the post passes, at the
|
||||
+ /// window's resolution. The picture size of that camera is ignored, the
|
||||
+ /// player's hand, block outline and the game's HUD are not drawn (the mod's
|
||||
+ /// own `set-hud` shapes are); a `view` shape of that camera draws nothing.
|
||||
+ /// For a full-screen replay or a director's camera; the small pictures of
|
||||
+ /// monitors stay `view` shapes. None (or an unknown id) is the player's
|
||||
+ /// own view. The chunks stay those round the player.
|
||||
+ set-main-view: func(view: option<u32>);
|
||||
/// A light the mod's things give off (AirDef fork): rocket flames,
|
||||
/// flares, blasts. Drawn by the HD lighting (`set-lighting`).
|
||||
record point-light {
|
||||
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
|
||||
index f438eadbd..e578de688 100644
|
||||
--- a/crates/horizon-mods/src/api_level.rs
|
||||
+++ b/crates/horizon-mods/src/api_level.rs
|
||||
@@ -7,13 +7,13 @@
|
||||
//! version's level so the launcher can tell before it installs.
|
||||
|
||||
/// The newest level this Horizon serves.
|
||||
-pub const LEVEL: u32 = 14;
|
||||
+pub const LEVEL: u32 = 15;
|
||||
|
||||
/// Every function the game offers mods, as `interface.function`, with its
|
||||
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
|
||||
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
|
||||
/// test fails until it is listed here).
|
||||
-pub const FUNCTIONS: [(&str, u32); 68] = [
|
||||
+pub const FUNCTIONS: [(&str, u32); 69] = [
|
||||
("log.write", 0),
|
||||
("game.get-player", 0),
|
||||
("game.get-clock", 0),
|
||||
@@ -86,6 +86,8 @@ pub const FUNCTIONS: [(&str, u32); 68] = [
|
||||
("render.set-time-override", 13),
|
||||
// AirDef fork: volumetric smoke and exhaust trails.
|
||||
("render.set-smoke", 14),
|
||||
+ // AirDef fork: a mod camera as the screen's own view.
|
||||
+ ("render.set-main-view", 15),
|
||||
];
|
||||
|
||||
/// What the game's program carries so the launcher can read its level
|
||||
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
|
||||
index 495ef6cae..74da34673 100644
|
||||
--- a/crates/horizon-mods/src/runtime.rs
|
||||
+++ b/crates/horizon-mods/src/runtime.rs
|
||||
@@ -506,6 +506,8 @@ struct State {
|
||||
post_params: Vec<f32>,
|
||||
/// `render.set-views`' cameras (AirDef fork).
|
||||
views: Vec<CameraView>,
|
||||
+ /// `render.set-main-view`: the camera the screen's world is rendered from.
|
||||
+ main_view: Option<u32>,
|
||||
/// `render.set-lights` and `render.set-lighting` (AirDef fork).
|
||||
lights: Vec<PointLight>,
|
||||
lighting: Option<ModLighting>,
|
||||
@@ -754,6 +756,7 @@ impl Mods {
|
||||
post: None,
|
||||
post_params: Vec::new(),
|
||||
views: Vec::new(),
|
||||
+ main_view: None,
|
||||
lights: Vec::new(),
|
||||
lighting: None,
|
||||
time_override: None,
|
||||
@@ -1363,6 +1366,12 @@ impl Mods {
|
||||
t.smoke_active = on;
|
||||
}
|
||||
}
|
||||
+ /// The camera the first running mod asked the screen's world from, if any.
|
||||
+ pub fn main_view(&self) -> Option<u32> {
|
||||
+ self.entries
|
||||
+ .iter()
|
||||
+ .find_map(|e| e.run.as_ref()?.store.data().main_view)
|
||||
+ }
|
||||
/// The time of day the first running mod asked to show, if any.
|
||||
pub fn time_override(&self) -> Option<u64> {
|
||||
self.entries
|
||||
@@ -2230,6 +2239,9 @@ impl render::Host for State {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
+ fn set_main_view(&mut self, view: Option<u32>) {
|
||||
+ self.main_view = view;
|
||||
+ }
|
||||
fn set_lights(&mut self, lights: Vec<PointLight>) {
|
||||
let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
|
||||
let unit = |v: f32| {
|
||||
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
|
||||
index fefc4903f..1bd2c7eae 100644
|
||||
--- a/docs/api/mods-reference.md
|
||||
+++ b/docs/api/mods-reference.md
|
||||
@@ -667,6 +667,7 @@ A piece of smoke or exhaust trail (AirDef fork): a tube from `a` to `b` (equal e
|
||||
| `clear-post` | `func()` | Removes this mod's post pass: the world is shown as the game draws it. |
|
||||
| `set-params` | `func(params: list<f32>)` | The pass's `frame.params` (up to 1024 floats, as 256 vec4), kept until replaced; call it once a tick (the pass gets the partial tick in `frame.time.y`). |
|
||||
| `set-views` | `func(views: list<camera-view>)` | Replaces the mod's cameras: up to 8. A `view` shape shows one; the game renders up to three a frame, in turn. |
|
||||
+| `set-main-view` | `func(view: option<u32>)` | Renders the screen's world from one of the `set-views` cameras (AirDef fork), with everything the player's own view has: the HD lighting, sky, clouds, fog, smoke, TAA and the post passes, at the window's resolution. The picture size of that camera is ignored, the player's hand, block outline and the game's HUD are not drawn (the mod's own `set-hud` shapes are); a `view` shape of that camera draws nothing. For a full-screen replay or a director's camera; the small pictures of monitors stay `view` shapes. None (or an unknown id) is the player's own view. The chunks stay those round the player. |
|
||||
| `set-lights` | `func(lights: list<point-light>)` | Replaces the mod's lights: up to 512, interpolated per id between the last two calls. |
|
||||
| `set-lighting` | `func(lighting: lighting)` | Switches the HD lighting on or off for this mod's world, with its exposure and bloom; off, the world is drawn as the game draws it. |
|
||||
| `set-time-override` | `func(day-time: option<u64>)` | Shows the world at this time of day (AirDef fork): the sky, the sun and moon, the sky light and the fog, as `day-time` ticks (24000 a day, as `clock`). Only what the player sees changes: the world's own clock, its saves and its rules do not. None shows the world's own time. |
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+209
-87
@@ -1,24 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Графический полигон HD (#44): фиксированные сцены, сравнение кадров, мерцание и GPU-бюджет.
|
||||
"""Графический полигон HD (#44, #71): фиксированные сцены, сравнение кадров, мерцание и GPU-бюджет.
|
||||
|
||||
python3 tools/hd_bench.py --list # сцены и их параметры
|
||||
python3 tools/hd_bench.py # все сцены -> run-home-bench/out/<время>/
|
||||
python3 tools/hd_bench.py --only polygon_noon # одна сцена (годится и при занятой игре)
|
||||
python3 tools/hd_bench.py --env HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off
|
||||
python3 tools/hd_bench.py --baseline run-home-bench/out/before --out run-home-bench/out/after
|
||||
python3 tools/hd_bench.py --no-freeze # живой замер производительности: время и погода идут
|
||||
|
||||
Каждый прогон: свежая копия миров Polygon и Hills из run-home/saves в run-home-bench/home, мод airdef,
|
||||
игра офскрин на сокете run-home-bench/c.sock (одна за раз; чужой игре run-home/ctl.sock не мешаем).
|
||||
Для каждой сцены: время суток, ясная погода, телепорт, пауза на устаканивание, кадр, ещё один кадр
|
||||
(мерцание), `bench 4` (GPU avg, мс) и средние проходы из run-home-bench/c.log при HORIZON_SHADER_PACK_TIMING=1.
|
||||
Для каждой сцены: время и погода заморожены (`gamerule advance_time/advance_weather false`), время суток,
|
||||
ясная погода, телепорт, пауза на устаканивание, подряд --stills кадров (по умолчанию 3), прогрев, `bench 4`
|
||||
(GPU avg, мс) и средние проходы из run-home-bench/c.log при HORIZON_SHADER_PACK_TIMING=1.
|
||||
Итог: report.md и results.json в папке вывода.
|
||||
|
||||
Анимация (#71). Игровое время и погоду мы замораживаем, но вода (волны, HD `water.y`) и листва (ветер,
|
||||
`wind_seconds`) идут по часам стены, поэтому два прогона одного бинарника разные и без изменений кода.
|
||||
Поэтому кадры не сравниваются целиком: по кадрам одного прогона пиксель, у которого размах значений между
|
||||
кадрами больше --motion-level (16 из 255), считается движущимся (вода, облака, листва). Такие пиксели не
|
||||
участвуют в сравнении с базой (ни в одном из двух прогонов). Мерцание считается по остальным пикселям:
|
||||
размах между кадрами больше --flicker-level (6 из 255) и не больше --motion-level. Для движущейся части
|
||||
выводится отдельная средняя разница, только для информации. Картинка `<сцена>_motion.png` показывает маску.
|
||||
|
||||
Коды выхода: 0 — всё в порогах и бюджете; 1 — есть превышение; 2 — ошибка запуска или аргументов.
|
||||
Только стандартная библиотека; при установленном Pillow сравнение быстрее.
|
||||
Только стандартная библиотека; при установленном Pillow декодирование PNG быстрее.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import operator
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
@@ -38,9 +51,12 @@ HOME = BENCH / "home" # HORIZON_HOME одного прогона, пересо
|
||||
CTL = ROOT / "horizon" / "target" / "fast" / "horizon-ctl"
|
||||
SAVES = ROOT / "run-home" / "saves"
|
||||
OTHER_SOCK = "run-home/ctl.sock" # чужая игра (другой агент): только читаем, не трогаем
|
||||
DIFF_LEVEL = 20 # порог «заметной» разницы по каналу, из 255
|
||||
DIFF_LEVEL = 20 # порог «заметной» разницы по каналу, из 255 (сравнение с базой)
|
||||
DIFF_GAIN = 6 # усиление разницы в diff-картинках
|
||||
MOTION_LEVEL = 16 # размах значения между кадрами одного прогона выше этого — движение (вода, облака, листва)
|
||||
FLICKER_LEVEL = 6 # размах выше этого на статичных пикселях — мерцание
|
||||
TIMING_RE = re.compile(r"shader pack GPU time per pass .*?: (.*) = ([\d.]+)\s*$")
|
||||
FREEZE_RULES = ("advance_time", "advance_weather") # имена игровых правил в horizon-client-core/gamerules.rs
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -173,6 +189,14 @@ def stop_game():
|
||||
SOCK.unlink()
|
||||
|
||||
|
||||
def file_sha256(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
# --- кадры: PNG без Pillow и с ним -------------------------------------------------------------
|
||||
|
||||
def _paeth(a, b, c):
|
||||
@@ -248,51 +272,103 @@ def write_png_rgb(path, w, h, rgb):
|
||||
+ chunk(b"IEND", b""))
|
||||
|
||||
|
||||
try: # необязательно: быстрее декодирование и сравнение
|
||||
from PIL import Image, ImageChops, ImageStat
|
||||
try: # необязательно: быстрее декодирование
|
||||
from PIL import Image
|
||||
except ImportError: # pragma: no cover
|
||||
Image = None
|
||||
|
||||
|
||||
def compare_frames(a, b, diff_out=None):
|
||||
"""Средняя разница по каналам (0..255) и доля пикселей с разницей > DIFF_LEVEL, в %."""
|
||||
def load_rgb(path):
|
||||
if Image is not None:
|
||||
A = Image.open(a).convert("RGB")
|
||||
B = Image.open(b).convert("RGB")
|
||||
if A.size != B.size:
|
||||
raise BenchError(f"размеры кадров разные: {A.size} и {B.size}")
|
||||
d = ImageChops.difference(A, B)
|
||||
mean = sum(ImageStat.Stat(d).mean) / 3
|
||||
r, g, bl = d.split()
|
||||
mx = ImageChops.lighter(ImageChops.lighter(r, g), bl)
|
||||
hist = mx.histogram()
|
||||
total = A.size[0] * A.size[1]
|
||||
pct = 100.0 * sum(hist[DIFF_LEVEL + 1:]) / total
|
||||
if diff_out:
|
||||
d.point(lambda v: min(255, v * DIFF_GAIN)).save(diff_out)
|
||||
return {"mean": mean, "pct": pct, "size": list(A.size)}
|
||||
wa, ha, pa = read_png_rgb(a)
|
||||
wb, hb, pb = read_png_rgb(b)
|
||||
if (wa, ha) != (wb, hb):
|
||||
raise BenchError(f"размеры кадров разные: {wa}x{ha} и {wb}x{hb}")
|
||||
total_abs = 0
|
||||
over = 0
|
||||
diff = bytearray(len(pa)) if diff_out else None
|
||||
for i in range(0, len(pa), 3):
|
||||
dr = abs(pa[i] - pb[i])
|
||||
dg = abs(pa[i + 1] - pb[i + 1])
|
||||
db = abs(pa[i + 2] - pb[i + 2])
|
||||
total_abs += dr + dg + db
|
||||
if max(dr, dg, db) > DIFF_LEVEL:
|
||||
over += 1
|
||||
if diff is not None:
|
||||
diff[i] = min(255, dr * DIFF_GAIN)
|
||||
diff[i + 1] = min(255, dg * DIFF_GAIN)
|
||||
diff[i + 2] = min(255, db * DIFF_GAIN)
|
||||
im = Image.open(path).convert("RGB")
|
||||
return im.size[0], im.size[1], im.tobytes()
|
||||
return read_png_rgb(path)
|
||||
|
||||
|
||||
def load_frames(paths):
|
||||
"""(w, h, [bytes RGB каждого кадра]); размеры должны совпадать."""
|
||||
w = h = None
|
||||
rgbs = []
|
||||
for p in paths:
|
||||
pw, ph, px = load_rgb(p)
|
||||
if w is None:
|
||||
w, h = pw, ph
|
||||
elif (pw, ph) != (w, h):
|
||||
raise BenchError(f"размеры кадров разные: {w}x{h} и {pw}x{ph} ({Path(p).name})")
|
||||
rgbs.append(px)
|
||||
return w, h, rgbs
|
||||
|
||||
|
||||
def shot_frames(folder, name, n=None):
|
||||
"""Кадры сцены по порядку: <имя>.png, <имя>_still2.png, ... до n или пока файлы есть."""
|
||||
paths = []
|
||||
while n is None or len(paths) < n:
|
||||
k = len(paths) + 1
|
||||
p = Path(folder) / (f"{name}.png" if k == 1 else f"{name}_still{k}.png")
|
||||
if not p.exists():
|
||||
break
|
||||
paths.append(p)
|
||||
return paths
|
||||
|
||||
|
||||
# --- сравнение кадров с учётом анимации (#71) ---------------------------------------------------
|
||||
|
||||
def _per_pixel_max(ch):
|
||||
"""Из байтов по каналам (RGB, по 3 на пиксель) — максимум по каналам на пиксель."""
|
||||
return bytes(map(max, ch[0::3], ch[1::3], ch[2::3]))
|
||||
|
||||
|
||||
def _expand3(mask):
|
||||
"""Маска на пиксель -> маска на байт (по 3 на пиксель)."""
|
||||
m = bytearray(3 * len(mask))
|
||||
m[0::3] = mask
|
||||
m[1::3] = mask
|
||||
m[2::3] = mask
|
||||
return m
|
||||
|
||||
|
||||
def motion_range(rgbs):
|
||||
"""Размах значения каждого пикселя между кадрами одного прогона (максимум по каналам, 0..255)."""
|
||||
lo = hi = rgbs[0]
|
||||
for f in rgbs[1:]:
|
||||
lo = bytes(map(min, lo, f))
|
||||
hi = bytes(map(max, hi, f))
|
||||
return _per_pixel_max(bytes(map(operator.sub, hi, lo)))
|
||||
|
||||
|
||||
def flicker_stats(rng, w, h, n_frames, motion_level, flicker_level):
|
||||
npx = w * h
|
||||
flick = sum(1 for r in rng if flicker_level < r <= motion_level)
|
||||
moving = sum(1 for r in rng if r > motion_level)
|
||||
return {"pct": 100.0 * flick / npx, "animated_pct": 100.0 * moving / npx,
|
||||
"frames": n_frames, "size": [w, h]}
|
||||
|
||||
|
||||
def compare_runs(cur_rgb, cur_rng, base_rgb, base_rng, w, h, motion_level, diff_out=None, motion_out=None):
|
||||
"""Первый кадр текущего прогона против первого кадра базы. Движущиеся пиксели (в любом из двух
|
||||
прогонов) в сравнении не участвуют: mean и pct — только по статичным; для движущихся — отдельная
|
||||
средняя разница (информация, не порог)."""
|
||||
npx = w * h
|
||||
static = bytes(1 if (a <= motion_level and b <= motion_level) else 0 for a, b in zip(cur_rng, base_rng))
|
||||
d = bytes(map(abs, map(operator.sub, cur_rgb[0], base_rgb[0])))
|
||||
dpix = _per_pixel_max(d)
|
||||
n_static = sum(static)
|
||||
n_moving = npx - n_static
|
||||
mean = sum(itertools.compress(d, _expand3(static))) / (3.0 * n_static) if n_static else 0.0
|
||||
over = sum(1 for p, s in zip(dpix, static) if s and p > DIFF_LEVEL)
|
||||
moving_mean = (sum(itertools.compress(d, _expand3(bytes(1 - s for s in static)))) / (3.0 * n_moving)
|
||||
if n_moving else 0.0)
|
||||
if diff_out:
|
||||
write_png_rgb(diff_out, wa, ha, bytes(diff))
|
||||
npx = wa * ha
|
||||
return {"mean": total_abs / (3.0 * npx), "pct": 100.0 * over / npx, "size": [wa, ha]}
|
||||
img = bytearray(min(255, v * DIFF_GAIN) for v in d)
|
||||
for i, s in enumerate(static):
|
||||
if not s: # движущиеся пиксели — синим, чтобы не путать с регрессией
|
||||
j = 3 * i
|
||||
img[j], img[j + 1], img[j + 2] = 0, 0, 80
|
||||
write_png_rgb(diff_out, w, h, bytes(img))
|
||||
if motion_out:
|
||||
write_png_rgb(motion_out, w, h, bytes(_expand3(bytes(0 if s else 255 for s in static))))
|
||||
return {"mean": mean, "pct": 100.0 * over / npx, "animated_pct": 100.0 * n_moving / npx,
|
||||
"animated_mean": moving_mean, "size": [w, h]}
|
||||
|
||||
|
||||
# --- тайминги -----------------------------------------------------------------------------------
|
||||
@@ -345,19 +421,26 @@ def bench_gpu_avg(reply):
|
||||
|
||||
# --- сцена ------------------------------------------------------------------------------------
|
||||
|
||||
def run_shot(shot, out, settle, bench_secs, timing):
|
||||
def run_shot(shot, out, settle, bench_secs, timing, stills, gap, warmup, freeze):
|
||||
x, y, z, yaw, pitch = shot.pose
|
||||
if freeze: # игровое время и погода стоят: облачные часы и погода не уходят между снимками
|
||||
for rule in FREEZE_RULES:
|
||||
ctl("command", f"gamerule {rule} false")
|
||||
ctl("command", f"time set {shot.time}")
|
||||
ctl("command", "weather clear")
|
||||
ctl("command", f"tp @s {x} {y} {z} {yaw} {pitch}")
|
||||
ctl("wait-ready", 120, timeout=150)
|
||||
time.sleep(settle)
|
||||
first = out / f"{shot.name}.png"
|
||||
ctl("screenshot", str(first.resolve()))
|
||||
time.sleep(0.5)
|
||||
second = out / f"{shot.name}_still2.png"
|
||||
ctl("screenshot", str(second.resolve()))
|
||||
result = {"frame": first.name, "still2": second.name, "gpu_avg_ms": None, "passes": {},
|
||||
frames = []
|
||||
for k in range(stills):
|
||||
if k:
|
||||
time.sleep(gap)
|
||||
path = out / (f"{shot.name}.png" if k == 0 else f"{shot.name}_still{k + 1}.png")
|
||||
ctl("screenshot", str(path.resolve()))
|
||||
frames.append(path.name)
|
||||
if warmup > 0: # прогрев перед замером, не попадает в отчёт
|
||||
ctl("bench", warmup, timeout=warmup + 60)
|
||||
result = {"frame": frames[0], "frames": frames, "gpu_avg_ms": None, "passes": {},
|
||||
"pass_total_ms": None, "bench_json": None}
|
||||
offset = LOG.stat().st_size if LOG.exists() else 0
|
||||
reply = ctl("bench", bench_secs, timeout=bench_secs + 60)
|
||||
@@ -397,43 +480,53 @@ def judge(entry, args):
|
||||
if gpu is None and not entry.get("error"):
|
||||
reasons.append("нет GPU-таймингов (bench)")
|
||||
fl = entry.get("flicker")
|
||||
if fl and fl["pct"] > args.max_flicker:
|
||||
reasons.append(f"мерцание {fl['pct']:.2f}% > {args.max_flicker:g}%")
|
||||
if fl and args.max_flicker is not None and fl["pct"] > args.max_flicker:
|
||||
reasons.append(f"мерцание статичных пикселей {fl['pct']:.2f}% > {args.max_flicker:g}%")
|
||||
base = entry.get("vs_baseline")
|
||||
if base:
|
||||
if base.get("error"):
|
||||
reasons.append(base["error"])
|
||||
else:
|
||||
if base["mean"] > args.max_mean:
|
||||
reasons.append(f"разница с базой {base['mean']:.2f} > {args.max_mean:g}")
|
||||
reasons.append(f"разница с базой (статика) {base['mean']:.2f} > {args.max_mean:g}")
|
||||
if base["pct"] > args.max_pct:
|
||||
reasons.append(f"пикселей >20/255 {base['pct']:.2f}% > {args.max_pct:g}%")
|
||||
reasons.append(f"статичных пикселей >20/255 {base['pct']:.2f}% > {args.max_pct:g}%")
|
||||
return reasons
|
||||
|
||||
|
||||
def write_report(out, entries, args, env_extra, size, baseline):
|
||||
def write_report(out, entries, args, env_extra, size, baseline, meta):
|
||||
lines = []
|
||||
add = lines.append
|
||||
add(f"# HD-полигон: {out.name}")
|
||||
add("")
|
||||
add(f"- коммит: `{git_rev()}`, дата: {datetime.datetime.now():%Y-%m-%d %H:%M}")
|
||||
add(f"- движок: `{meta['game']}`, sha256 `{meta['game_sha256'][:16]}`; коммит мода (не движка): `{git_rev()}`")
|
||||
add(f"- ресурсы: `{meta['resources']}`")
|
||||
add(f"- дата: {datetime.datetime.now():%Y-%m-%d %H:%M}")
|
||||
add(f"- разрешение кадра: {size[0]}x{size[1]}" if size else "- разрешение кадра: ?")
|
||||
add(f"- окружение игры: " + (", ".join(f"`{k}={v}`" for k, v in sorted(env_extra.items())) or "по умолчанию"))
|
||||
add(f"- бюджет GPU: {args.budget_ms:g} мс (bench {args.bench_secs:g} с), мерцание <= {args.max_flicker:g}%")
|
||||
add("- время и погода: " + ("заморожены (`gamerule advance_time/advance_weather false`)" if meta["freeze"]
|
||||
else "идут (`--no-freeze`, живой замер)"))
|
||||
add(f"- кадров на сцену: {meta['stills']} через {meta['still_gap']:g} с; прогрев {meta['warmup_secs']:g} с")
|
||||
add(f"- движение: размах между кадрами > {meta['motion_level']}/255 (вода, облака, листва) не сравнивается с базой; "
|
||||
f"мерцание: статичные пиксели с размахом > {meta['flicker_level']}/255")
|
||||
add("- окружение игры: " + (", ".join(f"`{k}={v}`" for k, v in sorted(env_extra.items())) or "по умолчанию"))
|
||||
add(f"- бюджет GPU: {args.budget_ms:g} мс (bench {args.bench_secs:g} с), мерцание: "
|
||||
+ (f"<= {args.max_flicker:g}%" if args.max_flicker is not None else "только в отчёте (порог не задан)"))
|
||||
if baseline:
|
||||
add(f"- сравнение с: `{baseline}` (средняя разница <= {args.max_mean:g}, пикселей >20/255 <= {args.max_pct:g}%)")
|
||||
add(f"- сравнение с: `{baseline}` (статичные пиксели: средняя разница <= {args.max_mean:g}, "
|
||||
f"пикселей >20/255 <= {args.max_pct:g}%)")
|
||||
add("")
|
||||
add("| сцена | мир | время | GPU avg, мс | бюджет | мерцание %>20 | к базе: mean | к базе: %>20 | итог |")
|
||||
add("|---|---|---|---|---|---|---|---|---|")
|
||||
add("| сцена | мир | время | GPU avg, мс | бюджет | мерцание % | движение % | к базе (статика): mean | %>20 | итог |")
|
||||
add("|---|---|---|---|---|---|---|---|---|---|")
|
||||
for e in entries:
|
||||
gpu = f"{e['gpu_avg_ms']:.2f}" if e.get("gpu_avg_ms") is not None else "-"
|
||||
fl = f"{e['flicker']['pct']:.2f}" if e.get("flicker") else "-"
|
||||
mv = f"{e['flicker']['animated_pct']:.2f}" if e.get("flicker") else "-"
|
||||
b = e.get("vs_baseline") or {}
|
||||
bm = f"{b['mean']:.2f}" if "mean" in b else "-"
|
||||
bp = f"{b['pct']:.2f}" if "pct" in b else "-"
|
||||
status = "OK" if not e["fail"] else "FAIL: " + "; ".join(e["fail"])
|
||||
budget = "да" if e.get("gpu_avg_ms") is not None and e["gpu_avg_ms"] <= args.budget_ms else ("нет" if e.get("gpu_avg_ms") is not None else "-")
|
||||
add(f"| {e['name']} | {e['world']} | {e['time']} | {gpu} | {budget} | {fl} | {bm} | {bp} | {status} |")
|
||||
add(f"| {e['name']} | {e['world']} | {e['time']} | {gpu} | {budget} | {fl} | {mv} | {bm} | {bp} | {status} |")
|
||||
passes = []
|
||||
for e in entries:
|
||||
for p in e.get("passes", {}):
|
||||
@@ -458,7 +551,10 @@ def write_report(out, entries, args, env_extra, size, baseline):
|
||||
add(f"![{e['name']}]({e['frame']})")
|
||||
if e.get("diff_image"):
|
||||
add("")
|
||||
add(f"Разница с базой (x{DIFF_GAIN}): ")
|
||||
add(f"Разница с базой (x{DIFF_GAIN}, синим — движущиеся пиксели, не сравниваются): ")
|
||||
if e.get("motion_image"):
|
||||
add("")
|
||||
add(f"Движущиеся пиксели (белым): ")
|
||||
if e.get("warning"):
|
||||
add("")
|
||||
add(f"> {e['warning']}")
|
||||
@@ -477,14 +573,26 @@ def parse_args(argv):
|
||||
p.add_argument("--env", action="append", default=[], metavar="КЛЮЧ=ЗНАЧЕНИЕ", help="переменная для игры, например HORIZON_HD_TAA=0")
|
||||
p.add_argument("--budget-ms", type=float, default=18.0, help="бюджет GPU на кадр, мс (по умолчанию 18)")
|
||||
p.add_argument("--bench-secs", type=float, default=4.0, help="сколько секунд bench (по умолчанию 4)")
|
||||
p.add_argument("--settle", type=float, default=4.0, help="пауза после телепорта, с (по умолчанию 4)")
|
||||
p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы с базой, 0..255")
|
||||
p.add_argument("--max-pct", type=float, default=5.0, help="порог пикселей >20/255 с базой, %%")
|
||||
p.add_argument("--max-flicker", type=float, default=5.0, help="порог мерцания (два кадра подряд), %%")
|
||||
p.add_argument("--settle", type=float, default=12.0, help="пауза после телепорта, с (по умолчанию 12: за 4 с TAA и экспозиция ещё сходятся, мерцание 20%% -> 6%%)")
|
||||
p.add_argument("--stills", type=int, default=3, help="кадров подряд на сцену, не меньше 2 (по умолчанию 3)")
|
||||
p.add_argument("--still-gap", type=float, default=0.5, help="пауза между кадрами, с (по умолчанию 0.5)")
|
||||
p.add_argument("--warmup-secs", type=float, default=1.0, help="прогрев перед замером GPU, с; не в отчёте (по умолчанию 1)")
|
||||
p.add_argument("--motion-level", type=int, default=MOTION_LEVEL,
|
||||
help=f"размах между кадрами выше этого (из 255) — движение, не сравнивается с базой (по умолчанию {MOTION_LEVEL})")
|
||||
p.add_argument("--flicker-level", type=int, default=FLICKER_LEVEL,
|
||||
help=f"размах статичного пикселя выше этого (из 255) — мерцание (по умолчанию {FLICKER_LEVEL})")
|
||||
p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы статичных пикселей с базой, 0..255")
|
||||
p.add_argument("--max-pct", type=float, default=5.0, help="порог статичных пикселей >20/255 с базой, %%")
|
||||
p.add_argument("--max-flicker", type=float, default=None, help="порог мерцания статичных пикселей, %%; без него мерцание только в отчёте (порог пока не откалиброван двумя одинаковыми прогонами)")
|
||||
p.add_argument("--no-freeze", action="store_true",
|
||||
help="не замораживать игровое время и погоду (живой замер производительности, не снимки)")
|
||||
p.add_argument("--no-pass-timing", action="store_true", help="не включать HORIZON_SHADER_PACK_TIMING")
|
||||
p.add_argument("--game", help="horizon-client (по умолчанию как в tools/airdef.py)")
|
||||
p.add_argument("--resources", help="подготовленные ресурсы Minecraft 26.3")
|
||||
return p.parse_args(argv)
|
||||
args = p.parse_args(argv)
|
||||
if args.stills < 2:
|
||||
p.error("--stills: нужно не меньше 2 кадров, иначе нет ни мерцания, ни движения")
|
||||
return args
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
@@ -518,6 +626,14 @@ def main(argv=None):
|
||||
|
||||
game = Path(args.game) if args.game else airdef.built_game()
|
||||
resources = Path(args.resources) if args.resources else airdef.resources()
|
||||
try:
|
||||
game_sha = file_sha256(game)
|
||||
except OSError:
|
||||
game_sha = "?" * 64
|
||||
meta = {"game": str(game), "game_sha256": game_sha, "resources": str(resources),
|
||||
"freeze": not args.no_freeze, "stills": args.stills, "still_gap": args.still_gap,
|
||||
"warmup_secs": args.warmup_secs, "motion_level": args.motion_level,
|
||||
"flicker_level": args.flicker_level}
|
||||
timing = not args.no_pass_timing
|
||||
env = game_env(env_extra, timing)
|
||||
prepare_home()
|
||||
@@ -537,23 +653,28 @@ def main(argv=None):
|
||||
for s in group:
|
||||
entry = {**asdict(s), "frame": f"{s.name}.png"}
|
||||
try:
|
||||
entry.update(run_shot(s, out, args.settle, args.bench_secs, timing))
|
||||
fl = compare_frames(out / f"{s.name}.png", out / f"{s.name}_still2.png")
|
||||
entry["flicker"] = fl
|
||||
entry.update(run_shot(s, out, args.settle, args.bench_secs, timing, args.stills,
|
||||
args.still_gap, args.warmup_secs, not args.no_freeze))
|
||||
w, h, rgbs = load_frames(shot_frames(out, s.name, args.stills))
|
||||
rng = motion_range(rgbs)
|
||||
entry["flicker"] = flicker_stats(rng, w, h, len(rgbs), args.motion_level, args.flicker_level)
|
||||
if baseline and not entry.get("error"):
|
||||
base_paths = shot_frames(baseline, s.name) # все кадры базы, сколько есть
|
||||
if not base_paths:
|
||||
entry["vs_baseline"] = {"error": f"в базе нет {s.name}.png"}
|
||||
else:
|
||||
bw, bh, brgbs = load_frames(base_paths)
|
||||
if (bw, bh) != (w, h):
|
||||
raise BenchError(f"размеры кадров разные: база {bw}x{bh}, прогон {w}x{h}")
|
||||
brng = motion_range(brgbs)
|
||||
diff_name, motion_name = f"{s.name}_diff.png", f"{s.name}_motion.png"
|
||||
entry["vs_baseline"] = compare_runs(rgbs, rng, brgbs, brng, w, h, args.motion_level,
|
||||
out / diff_name, out / motion_name)
|
||||
entry["vs_baseline"]["base_frames"] = len(brgbs)
|
||||
entry["diff_image"] = diff_name
|
||||
entry["motion_image"] = motion_name
|
||||
except BenchError as e:
|
||||
entry["error"] = str(e)
|
||||
if baseline and not entry.get("error"):
|
||||
base_png = baseline / f"{s.name}.png"
|
||||
if base_png.exists():
|
||||
try:
|
||||
diff_name = f"{s.name}_diff.png"
|
||||
m = compare_frames(base_png, out / f"{s.name}.png", out / diff_name)
|
||||
entry["vs_baseline"] = m
|
||||
entry["diff_image"] = diff_name
|
||||
except BenchError as e:
|
||||
entry["vs_baseline"] = {"error": str(e)}
|
||||
else:
|
||||
entry["vs_baseline"] = {"error": f"в базе нет {base_png.name}"}
|
||||
entry["fail"] = judge(entry, args)
|
||||
entries.append(entry)
|
||||
stop_game()
|
||||
@@ -567,15 +688,16 @@ def main(argv=None):
|
||||
break
|
||||
if size is None and entries and entries[0].get("flicker"):
|
||||
size = entries[0]["flicker"]["size"]
|
||||
results = {"out": str(out), "env": env_extra, "budget_ms": args.budget_ms,
|
||||
results = {"out": str(out), "env": env_extra, "budget_ms": args.budget_ms, "meta": meta,
|
||||
"baseline": str(baseline) if baseline else None, "shots": entries}
|
||||
(out / "results.json").write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n")
|
||||
write_report(out, entries, args, env_extra, size, str(baseline) if baseline else None)
|
||||
write_report(out, entries, args, env_extra, size, str(baseline) if baseline else None, meta)
|
||||
|
||||
failed = [e for e in entries if e["fail"]]
|
||||
for e in entries:
|
||||
print(f"{e['name']:22} GPU {e.get('gpu_avg_ms') if e.get('gpu_avg_ms') is None else round(e['gpu_avg_ms'], 2)} мс "
|
||||
f"мерцание {e['flicker']['pct']:.2f}%" if e.get("flicker") else f"{e['name']:22} {e.get('error', '')}")
|
||||
f"мерцание {e['flicker']['pct']:.2f}% движение {e['flicker']['animated_pct']:.2f}%" if e.get("flicker")
|
||||
else f"{e['name']:22} {e.get('error', '')}")
|
||||
for r in e["fail"]:
|
||||
print(f" FAIL: {r}")
|
||||
print(f"отчёт: {out / 'report.md'}")
|
||||
|
||||
Reference in New Issue
Block a user