Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ced60e84f | |||
| 6f3fc644bc | |||
| 9075717872 | |||
| e3148bf458 | |||
| d0b9498aaf | |||
| 8065f2225c | |||
| a8551b5c28 | |||
| f443f0a94a | |||
| e33daac636 | |||
| 661c852bbf | |||
| 428713d4bc | |||
| 5839da129a |
@@ -22,7 +22,39 @@ jobs:
|
||||
if: inputs.os == 'macos' || inputs.os == 'both'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Проверка релиза
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -e
|
||||
if ! [[ "$TAG" =~ ^build-[0-9]+$ ]]; then echo "::error::тег '$TAG' не похож на build-N"; exit 1; fi
|
||||
REL_JSON=$(mktemp)
|
||||
CODE=$(curl -s -o "$REL_JSON" -w '%{http_code}' -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG")
|
||||
if [ "$CODE" != "200" ]; then echo "::error::релиза с тегом $TAG нет (HTTP $CODE): сначала дождись сборки build.yml на main"; exit 1; fi
|
||||
python3 - "$TAG" "$REL_JSON" <<'PY'
|
||||
import json, os, sys
|
||||
tag, path = sys.argv[1], sys.argv[2]
|
||||
r = json.load(open(path))
|
||||
assets = [a.get('name') for a in (r.get('assets') or [])]
|
||||
if r.get('tag_name') != tag or r.get('name') != tag or 'airdef.hmod' not in assets:
|
||||
print('::error::релиз %s не создан build.yml (нужны имя = тег и вложение airdef.hmod): чужой релиз не трогаем' % tag)
|
||||
sys.exit(1)
|
||||
with open(os.environ['GITHUB_ENV'], 'a') as f:
|
||||
f.write('RELEASE_ID=%s\nRELEASE_SHA=%s\n' % (r['id'], r.get('target_commitish', '')))
|
||||
PY
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
- name: Проверка коммита
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -e
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
if [[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]] && [ "$HEAD_SHA" != "$RELEASE_SHA" ]; then echo "::error::тег $TAG указывает на $HEAD_SHA, а релиз создан из $RELEASE_SHA"; exit 1; fi
|
||||
echo "собираю $TAG = $HEAD_SHA"
|
||||
- name: Rust
|
||||
run: |
|
||||
command -v cargo || curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
@@ -47,11 +79,10 @@ jobs:
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -e
|
||||
ID=$(curl -sf -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
|
||||
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/airdef-macos-arm64.tar.gz" "$API/releases/$ID/assets?name=airdef-macos-arm64.tar.gz" > /dev/null
|
||||
# RELEASE_ID взят в шаге «Проверка релиза» (релиз build-N, созданный build.yml)
|
||||
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/airdef-macos-arm64.tar.gz" "$API/releases/$RELEASE_ID/assets?name=airdef-macos-arm64.tar.gz" > /dev/null
|
||||
|
||||
game-windows:
|
||||
if: inputs.os == 'windows' || inputs.os == 'both'
|
||||
@@ -60,7 +91,34 @@ jobs:
|
||||
run:
|
||||
shell: pwsh
|
||||
steps:
|
||||
- name: Проверка релиза
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($env:TAG -notmatch '^build-\d+$') { Write-Output "::error::тег '$env:TAG' не похож на build-N"; exit 1 }
|
||||
$h = @{ Authorization = "token $env:TOKEN" }
|
||||
try { $r = Invoke-RestMethod -Headers $h "$env:API/releases/tags/$env:TAG" }
|
||||
catch { Write-Output "::error::релиза с тегом $env:TAG нет: сначала дождись сборки build.yml на main"; exit 1 }
|
||||
$assets = @($r.assets | ForEach-Object { $_.name })
|
||||
if ($r.tag_name -ne $env:TAG -or $r.name -ne $env:TAG -or ($assets -notcontains 'airdef.hmod')) {
|
||||
Write-Output "::error::релиз $env:TAG не создан build.yml (нужны имя = тег и вложение airdef.hmod): чужой релиз не трогаем"; exit 1
|
||||
}
|
||||
"RELEASE_ID=$($r.id)" | Out-File -Append -Encoding utf8 $env:GITHUB_ENV
|
||||
"RELEASE_SHA=$($r.target_commitish)" | Out-File -Append -Encoding utf8 $env:GITHUB_ENV
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
- name: Проверка коммита
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$head = git rev-parse HEAD
|
||||
if ($env:RELEASE_SHA -match '^[0-9a-f]{40}$' -and $head -ne $env:RELEASE_SHA) { Write-Output "::error::тег $env:TAG указывает на $head, а релиз создан из $env:RELEASE_SHA"; exit 1 }
|
||||
Write-Output "собираю $env:TAG = $head"
|
||||
- name: Rust
|
||||
run: |
|
||||
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
|
||||
@@ -88,9 +146,7 @@ jobs:
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
$h = @{ Authorization = "token $env:TOKEN" }
|
||||
$id = (Invoke-RestMethod -Headers $h "$env:API/releases/tags/$env:TAG").id
|
||||
curl.exe -sf -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@out\airdef-windows-x86_64.zip" "$env:API/releases/$id/assets?name=airdef-windows-x86_64.zip"
|
||||
# RELEASE_ID взят в шаге «Проверка релиза» (релиз build-N, созданный build.yml)
|
||||
curl.exe -sf -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@out\airdef-windows-x86_64.zip" "$env:API/releases/$env:RELEASE_ID/assets?name=airdef-windows-x86_64.zip"
|
||||
|
||||
|
||||
@@ -25,7 +25,39 @@ jobs:
|
||||
FREE=$(df --output=avail -BG / | tail -1 | tr -dc 0-9)
|
||||
echo "свободно: ${FREE} ГБ"
|
||||
if [ "$FREE" -lt 15 ]; then echo "::error::на сервере свободно ${FREE} ГБ, нужно хотя бы 15: сборка игры забьёт диск и уронит Gitea"; exit 1; fi
|
||||
- name: Проверка релиза
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -e
|
||||
if ! [[ "$TAG" =~ ^build-[0-9]+$ ]]; then echo "::error::тег '$TAG' не похож на build-N"; exit 1; fi
|
||||
REL_JSON=$(mktemp)
|
||||
CODE=$(curl -s -o "$REL_JSON" -w '%{http_code}' -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG")
|
||||
if [ "$CODE" != "200" ]; then echo "::error::релиза с тегом $TAG нет (HTTP $CODE): сначала дождись сборки build.yml на main"; exit 1; fi
|
||||
python3 - "$TAG" "$REL_JSON" <<'PY'
|
||||
import json, os, sys
|
||||
tag, path = sys.argv[1], sys.argv[2]
|
||||
r = json.load(open(path))
|
||||
assets = [a.get('name') for a in (r.get('assets') or [])]
|
||||
if r.get('tag_name') != tag or r.get('name') != tag or 'airdef.hmod' not in assets:
|
||||
print('::error::релиз %s не создан build.yml (нужны имя = тег и вложение airdef.hmod): чужой релиз не трогаем' % tag)
|
||||
sys.exit(1)
|
||||
with open(os.environ['GITHUB_ENV'], 'a') as f:
|
||||
f.write('RELEASE_ID=%s\nRELEASE_SHA=%s\n' % (r['id'], r.get('target_commitish', '')))
|
||||
PY
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
- name: Проверка коммита
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -e
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
if [[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]] && [ "$HEAD_SHA" != "$RELEASE_SHA" ]; then echo "::error::тег $TAG указывает на $HEAD_SHA, а релиз создан из $RELEASE_SHA"; exit 1; fi
|
||||
echo "собираю $TAG = $HEAD_SHA"
|
||||
- name: Системные библиотеки
|
||||
run: |
|
||||
SUDO=$(command -v sudo || true)
|
||||
@@ -45,12 +77,11 @@ jobs:
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -e
|
||||
D=airdef-linux-x86_64
|
||||
mkdir -p "$D" out
|
||||
cp horizon/target/fast/horizon-client horizon/target/fast/horizon-ctl airdef-mod/target/horizon-mod/airdef.hmod dist/play.sh docs/INSTALL.md "$D/"
|
||||
tar czf "out/$D.tar.gz" "$D"
|
||||
ID=$(curl -sf -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
|
||||
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/$D.tar.gz" "$API/releases/$ID/assets?name=$D.tar.gz" > /dev/null
|
||||
# RELEASE_ID взят в шаге «Проверка релиза» (релиз build-N, созданный build.yml)
|
||||
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/$D.tar.gz" "$API/releases/$RELEASE_ID/assets?name=$D.tar.gz" > /dev/null
|
||||
|
||||
@@ -5,3 +5,4 @@ airdef-mod/target/
|
||||
__pycache__/
|
||||
/out/
|
||||
.DS_Store
|
||||
run-home-bench/
|
||||
|
||||
@@ -27,7 +27,9 @@ python3 tools/airdef.py play # запустить (аргументы игр
|
||||
CI: `.gitea/workflows/build.yml` на каждый пуш собирает только мод (`airdef.hmod`), а пуш в `main` публикует его
|
||||
релизом `build-N`. Игру-форк собирают вручную: `.gitea/workflows/game.yml` (Linux) и `.gitea/workflows/desktop.yml`
|
||||
(macOS и Windows, на раннерах с метками `macos-latest` / `windows-latest`); оба запускаются из Actions с тегом
|
||||
готового релиза и дописывают свои архивы в него.
|
||||
готового релиза `build-N`. Перед сборкой workflow проверяет, что такой релиз создан `build.yml` (имя релиза = тег,
|
||||
есть `airdef.hmod`), иначе падает с ошибкой; затем выкачивает ровно коммит этого тега (а не ветку, с которой запущен),
|
||||
сверяет его с коммитом релиза и дописывает архивы только в этот релиз.
|
||||
|
||||
## Как играть
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
Status of terrain-aware flight (not committed):
|
||||
- Done: sim/terrain.rs (look-ahead terrain following, dive only down a clear line, interceptor steer/loft/self-destruct, launch predicate), planner relief pricing + spawn above land (raid.rs), SAM launch check + why line (mod.rs, why.rs), Tally.scrubbed, world.probe, harness terrain fixes, 6 new tests in regress.rs. Build has no warnings; cargo test passes (43 + 4).
|
||||
- Numbers (6 seeds, sim_run big-smart): hills:70:100:140 crashes 13 -> 3, hills:70:150:120 29 -> 2, default hills 2 -> 2 (all terminal-dive near-target by Kh101/Kalibr).
|
||||
- Half-done / known: terminal dive of slow-turning cruise missiles still sometimes hits ground 7-20 blocks short of target; hills:70:150:120 tick max 38 ms / p95 2 ms (planner terrain phase + ground queries) not profiled; not tested in game.
|
||||
- Next: tune dive geometry for Kh101/Kalibr, profile the planner's 1024-columns-per-tick height requests, optional in-game check.
|
||||
@@ -18,6 +18,13 @@ pub enum Terrain {
|
||||
Hills { base: i32, amp: f64, period: f64 },
|
||||
}
|
||||
|
||||
/// The land as blocks: solid at and below the column's height (what the game would answer `render.sections` with).
|
||||
impl airdef::sim::world::WorldSource for Terrain {
|
||||
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
|
||||
Some(y < self.height(x, z))
|
||||
}
|
||||
}
|
||||
|
||||
impl Terrain {
|
||||
pub fn height(self, x: i32, z: i32) -> i32 {
|
||||
match self {
|
||||
@@ -98,6 +105,11 @@ pub struct Report {
|
||||
/// Missiles fired by air defence (sum of the systems' `fired`).
|
||||
pub fired: u32,
|
||||
pub ground_queries: u64,
|
||||
/// The block cache's counters (issue #43) and the drones that struck the land (issue #42).
|
||||
pub world: String,
|
||||
pub crashes: u32,
|
||||
/// What went wrong for the air defence, as the raid report words it.
|
||||
pub failures: String,
|
||||
pub ground_batches: u64,
|
||||
pub tick_p50: Duration,
|
||||
pub tick_p95: Duration,
|
||||
@@ -125,14 +137,16 @@ pub fn run(sc: &Scenario) -> Report {
|
||||
sim.damage = false; // explosions would change the land the height function describes
|
||||
for u in &sc.units {
|
||||
let id = sim.id();
|
||||
let mut s = Sam::new(id, u.kind, u.pos, u.heading);
|
||||
// Systems stand on the land, whatever the layout says.
|
||||
let pos = DVec3::new(u.pos.x, f64::from(sc.terrain.height(u.pos.x.floor() as i32, u.pos.z.floor() as i32)), u.pos.z);
|
||||
let mut s = Sam::new(id, u.kind, pos, u.heading);
|
||||
if u.empty {
|
||||
s.empty = true;
|
||||
s.missiles = 0;
|
||||
}
|
||||
sim.sams.push(s);
|
||||
}
|
||||
sim.targets = sc.objects.clone();
|
||||
sim.targets = sc.objects.iter().map(|o| DVec3::new(o.x, f64::from(sc.terrain.height(o.x.floor() as i32, o.z.floor() as i32)), o.z)).collect();
|
||||
sim.target_health = vec![100.0; sc.objects.len()];
|
||||
if sc.ew {
|
||||
sim.set_ew(true);
|
||||
@@ -153,7 +167,8 @@ pub fn run(sc: &Scenario) -> Report {
|
||||
if !ordered[i] && now >= spec.at && !sim.planning() && (i == 0 || ended[i - 1]) {
|
||||
let order = RaidOrder { kinds: spec.kinds.clone(), approach: spec.approach, dist: spec.dist, aim: spec.aim };
|
||||
launched_before[i] = sim.launched;
|
||||
sim.order_raid(&order, &sc.objects, &mut out);
|
||||
let objects = sim.targets.clone();
|
||||
sim.order_raid(&order, &objects, &mut out);
|
||||
ordered[i] = true;
|
||||
}
|
||||
}
|
||||
@@ -163,6 +178,8 @@ pub fn run(sc: &Scenario) -> Report {
|
||||
let t = Instant::now();
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
let wanted = ground.take_wanted();
|
||||
let sections = ground.world.take_wanted(now);
|
||||
ground.world.serve(&sc.terrain, sections, now);
|
||||
let dt = t.elapsed();
|
||||
times.push(dt);
|
||||
if planning {
|
||||
@@ -212,6 +229,9 @@ pub fn run(sc: &Scenario) -> Report {
|
||||
r.hits = sim.hits;
|
||||
r.fired = sim.sams.iter().map(|s| s.fired).sum();
|
||||
r.raids_ordered = ordered.iter().filter(|o| **o).count();
|
||||
r.world = ground.world.text();
|
||||
r.crashes = sim.crashes;
|
||||
r.failures = sim.tally.text();
|
||||
r.raids_ended = ended.iter().filter(|o| **o).count();
|
||||
r.all_ended = r.raids_ended == sc.raids.len();
|
||||
r.tick_total = times.iter().sum();
|
||||
@@ -253,7 +273,9 @@ impl Report {
|
||||
planning: {pl:.1} ms over {pt} ticks\n\
|
||||
ground queries: {gq} in {gb} batches\n\
|
||||
non-finite positions: {nan}\n\
|
||||
stuck trucks: {stuck:?} (busy truck-ticks {busy})\n",
|
||||
stuck trucks: {stuck:?} (busy truck-ticks {busy})\n\
|
||||
crashes into the land: {crashes}; {world}\n\
|
||||
air-defence failures: {failures}\n",
|
||||
name = self.name,
|
||||
seed = self.seed,
|
||||
units = self.units,
|
||||
@@ -276,6 +298,9 @@ impl Report {
|
||||
nan = self.nan_positions,
|
||||
stuck = self.stuck_trucks,
|
||||
busy = self.truck_busy_ticks,
|
||||
crashes = self.crashes,
|
||||
world = self.world,
|
||||
failures = if self.failures.is_empty() { "none" } else { &self.failures },
|
||||
)
|
||||
}
|
||||
pub fn json(&self) -> String {
|
||||
|
||||
+60
-6
@@ -330,6 +330,16 @@ impl AirDef {
|
||||
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);
|
||||
}
|
||||
// Fires burn on for minutes: a flickering, wide glow that dies down with the flames.
|
||||
for f in &self.sim.fires.list {
|
||||
let (fl, sm) = f.level(tick);
|
||||
if f.quiet {
|
||||
continue;
|
||||
}
|
||||
let glow = fl.max(0.12 * sm * (1.0 - (tick.saturating_sub(f.start)) as f32 / f.life as f32));
|
||||
let id = 0x6200_0000 | (f.id & 0x00ff_ffff);
|
||||
add(id, f.at + DVec3::Y * 1.2, (1.0, 0.5, 0.17), 70.0 * f.size * glow * flicker(f.id), 20.0 + 10.0 * f.size);
|
||||
}
|
||||
for s in &self.sim.sams {
|
||||
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);
|
||||
@@ -541,8 +551,11 @@ impl AirDef {
|
||||
} else {
|
||||
(s.health, s.cargo, s.missiles, s.bursts, s.empty, s.reloading)
|
||||
};
|
||||
// The parts' states (sensor, mount, launcher, gun, tracks, comms) close the line; older saves lack them.
|
||||
let comps = if fresh { new.comps } else { s.comps };
|
||||
let parts = comps.0.iter().map(|v| format!("{v:.3}")).collect::<Vec<_>>().join(" ");
|
||||
text += &format!(
|
||||
"sam {} {} {} {} {} {} {} {} {} {} {} {} {dest}\n",
|
||||
"sam {} {} {} {} {} {} {} {} {} {} {} {} {dest} {parts}\n",
|
||||
s.kind.key(),
|
||||
s.pos.x,
|
||||
s.pos.y,
|
||||
@@ -629,6 +642,13 @@ impl AirDef {
|
||||
(Some(x), Some(y), Some(z)) => Some(DVec3::new(x, y, z)),
|
||||
_ => None,
|
||||
};
|
||||
// The parts: absent in older saves, which load with every part whole.
|
||||
for (k, v) in sam.comps.0.iter_mut().enumerate() {
|
||||
if let Some(c) = num(16 + k) {
|
||||
*v = c.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
sam.fit_cells();
|
||||
self.sim.sams.push(sam);
|
||||
if let Some(to) = dest {
|
||||
let i = self.sim.sams.len() - 1;
|
||||
@@ -765,6 +785,7 @@ impl AirDef {
|
||||
|
||||
fn stop_replay(&mut self) {
|
||||
self.replay = None;
|
||||
render::set_time_override(None);
|
||||
input::claim_wheel(false);
|
||||
render::set_hud(&[]);
|
||||
render::set_views(&[]);
|
||||
@@ -959,7 +980,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 | shader on|off|hd | hold on|off | roles [комплекс классы] | stats";
|
||||
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";
|
||||
|
||||
impl Mod for AirDef {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
@@ -1061,14 +1082,38 @@ impl Mod for AirDef {
|
||||
}
|
||||
let now = self.sim.tick;
|
||||
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);
|
||||
let sections = render::sections()
|
||||
.into_iter()
|
||||
.map(|s| sim::world::SectionAnswer {
|
||||
sec: (s.x, s.y, s.z),
|
||||
fill: match s.fill {
|
||||
render::SectionFill::Unknown => sim::world::Fill::Unknown,
|
||||
render::SectionFill::Empty => sim::world::Fill::Empty,
|
||||
render::SectionFill::Full => sim::world::Fill::Full,
|
||||
render::SectionFill::Mixed => match <[u8; 512]>::try_from(s.bits) {
|
||||
Ok(bits) => sim::world::Fill::Mixed(Box::new(bits)),
|
||||
Err(_) => sim::world::Fill::Unknown,
|
||||
},
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
self.ground.world.answer(sections, now);
|
||||
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);
|
||||
let wanted = self.ground.take_wanted();
|
||||
if !wanted.is_empty() {
|
||||
render::query_ground(&wanted);
|
||||
}
|
||||
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);
|
||||
if r.done {
|
||||
@@ -1085,6 +1130,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 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;
|
||||
} else if self.grid {
|
||||
let stale = self.grid_ids.iter().any(|id| !self.sim.sams.iter().any(|s| s.id == *id));
|
||||
@@ -1123,7 +1170,8 @@ impl Mod for AirDef {
|
||||
let at = s.dest.unwrap_or(s.pos);
|
||||
let place = (at.x * 10.0) as i64 as u64 ^ ((at.z * 10.0) as i64 as u64).rotate_left(21) ^ (if s.dest.is_some() { 0 } else { (s.heading * 100.0) as i64 as u64 }).rotate_left(42);
|
||||
let ammo = u64::from(s.missiles) | u64::from(s.bursts) << 16 | u64::from(s.cargo) << 32 | u64::from(s.empty) << 48 | u64::from(s.reloading > 0) << 49;
|
||||
((s.health * 1000.0) as u64).wrapping_mul(1_000_003) ^ place ^ ammo.rotate_left(7)
|
||||
let parts = s.comps.0.iter().fold(0u64, |a, v| a.wrapping_mul(131).wrapping_add((v * 100.0) as u64));
|
||||
((s.health * 1000.0) as u64).wrapping_mul(1_000_003) ^ place ^ ammo.rotate_left(7) ^ parts.rotate_left(13)
|
||||
})
|
||||
.chain(self.sim.target_health.iter().map(|h| (h * 10.0) as u64))
|
||||
.fold(self.sim.sams.len() as u64, |a, h| a.wrapping_mul(31).wrapping_add(h));
|
||||
@@ -1386,7 +1434,7 @@ impl Mod for AirDef {
|
||||
["repair"] => {
|
||||
let n = self.sim.repair();
|
||||
self.dirty = true;
|
||||
Some(format!("Отремонтировано комплексов: {n}; объекты восстановлены"))
|
||||
Some(format!("Отремонтировано комплексов: {n} (корпус и все узлы: РЛС, привод, пусковая, орудие, ходовая, связь); объекты восстановлены"))
|
||||
}
|
||||
["clear"] => {
|
||||
self.sim.clear();
|
||||
@@ -1405,6 +1453,10 @@ impl Mod for AirDef {
|
||||
self.dirty = true;
|
||||
Some("Цели убраны".to_string())
|
||||
}
|
||||
["truth", v @ ("on" | "off")] => {
|
||||
self.sim.truth = *v == "on";
|
||||
Some(format!("Истинные данные на радаре: {} (боевую логику не меняют)", if self.sim.truth { "вкл" } else { "выкл" }))
|
||||
}
|
||||
["damage", v @ ("on" | "off")] => {
|
||||
self.sim.damage = *v == "on";
|
||||
self.dirty = true;
|
||||
@@ -1451,12 +1503,14 @@ impl Mod for AirDef {
|
||||
Some(format!("Эффекты: {}", match *v { "off" => "выкл", "hd" => "HD", _ => "обычные" }))
|
||||
}
|
||||
["stats"] => Some(format!(
|
||||
"В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}",
|
||||
"В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}, разбились о препятствия: {}; {}",
|
||||
self.sim.drones.iter().filter(|d| d.alive()).count(),
|
||||
self.sim.missiles.len(),
|
||||
self.sim.sams.len(),
|
||||
self.sim.kills,
|
||||
self.sim.hits
|
||||
self.sim.hits,
|
||||
self.sim.crashes,
|
||||
self.ground.world.text()
|
||||
)),
|
||||
_ => return Err(HELP.to_string()),
|
||||
};
|
||||
|
||||
@@ -119,8 +119,10 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
|
||||
_ => spec.sensor,
|
||||
};
|
||||
let scale = if reach > 0.0 { rr as f64 / (reach * 1.25) } else { 0.0 };
|
||||
let sweep = (tick as f32 * 0.12 + index as f32) % std::f32::consts::TAU;
|
||||
for k in 0..6 {
|
||||
// The beam is the radar's real one: targets are measured as it passes them.
|
||||
let sweep = sam.beam() as f32;
|
||||
let radar_up = sam.radar_up();
|
||||
for k in 0..if radar_up { 6 } else { 0 } {
|
||||
let a1 = sweep - k as f32 * 0.09;
|
||||
let a0 = a1 - 0.09;
|
||||
let p = |a: f32| (rx + a.sin() * rr, ry - a.cos() * rr);
|
||||
@@ -129,8 +131,14 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
|
||||
let target = sim.watched(sam);
|
||||
let mut seen = 0;
|
||||
// Only what its own radar holds.
|
||||
for d in sim.drones.iter().filter(|d| d.alive() && sam.seen.contains(&d.id)) {
|
||||
let rel = d.pos - sam.pos;
|
||||
for (d, tr) in sim.drones.iter().filter(|d| d.alive()).filter_map(|d| sam.track(d.id).map(|t| (d, t))) {
|
||||
// Where the system believes it is: the last measurement carried on; a forecast is drawn hollow.
|
||||
let est = tr.est(tick);
|
||||
if sim.truth {
|
||||
let r = d.pos - sam.pos;
|
||||
s.push(fill(rx + r.x as f32 * scale as f32 - 0.5, ry + r.z as f32 * scale as f32 - 0.5, 1.0, 1.0, 0xFFFFFFFF));
|
||||
}
|
||||
let rel = est - sam.pos;
|
||||
let flat = (rel.x * rel.x + rel.z * rel.z).sqrt();
|
||||
if reach <= 0.0 || flat > reach * 1.25 {
|
||||
continue;
|
||||
@@ -146,10 +154,24 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
|
||||
} else {
|
||||
0xFF808080
|
||||
};
|
||||
s.push(fill(bx - 1.5, by - 1.5, 3.0, 3.0, color));
|
||||
if tr.forecast(tick) {
|
||||
s.push(fill(bx - 1.5, by - 1.5, 3.0, 1.0, color));
|
||||
s.push(fill(bx - 1.5, by + 0.5, 3.0, 1.0, color));
|
||||
s.push(fill(bx - 1.5, by - 0.5, 1.0, 1.0, color));
|
||||
s.push(fill(bx + 0.5, by - 0.5, 1.0, 1.0, color));
|
||||
} else {
|
||||
s.push(fill(bx - 1.5, by - 1.5, 3.0, 3.0, color));
|
||||
}
|
||||
}
|
||||
s.push(fill(rx - 1.5, ry - 1.5, 3.0, 3.0, 0xFF40A0FF));
|
||||
s.push(text(fit(format!("РЛС {} · {}", distance(reach), seen), rr * 2.0 + 4.0), x + 4.0, y + h - 10.0, 0xFF80FF90));
|
||||
let radar_text = if spec.sensor <= 0.0 {
|
||||
format!("сеть · {seen}")
|
||||
} else if radar_up {
|
||||
format!("РЛС {} · {}", distance(reach), seen)
|
||||
} else {
|
||||
"РЛС ОТКАЗ".to_string()
|
||||
};
|
||||
s.push(text(fit(radar_text, rr * 2.0 + 4.0), x + 4.0, y + h - 10.0, if spec.sensor > 0.0 && !radar_up { 0xFFFF5040 } else { 0xFF80FF90 }));
|
||||
|
||||
// Its health under the radar: a bar, red when it is low.
|
||||
if sam.alive() && sam.health < 1.0 {
|
||||
@@ -184,6 +206,11 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
|
||||
(false, false) => 0,
|
||||
};
|
||||
s.push(Shape::View(ViewShape { view: sam.id, rect: Rect { x: cx, y: cy, w: cw, h: ch }, mode }));
|
||||
// Worn or failed parts, over the picture: orange, red when one has failed.
|
||||
if !sam.comps.whole() {
|
||||
let failed = crate::sim::Part::ALL.iter().any(|p| sam.part_state(*p) == crate::sim::PartState::Failed);
|
||||
s.push(text(fit(sam.parts_text(), cw * 0.55), cx + 3.0, cy + 3.0, if failed { 0xFFFF4040 } else { 0xFFFFB030 }));
|
||||
}
|
||||
// Crosshair.
|
||||
s.push(fill(cx + cw / 2.0 - 0.5, cy + 3.0, 1.0, ch * 0.18, 0x90FFFFFF));
|
||||
s.push(fill(cx + cw / 2.0 - 0.5, cy + ch * 0.82 - 3.0, 1.0, ch * 0.18, 0x90FFFFFF));
|
||||
@@ -200,12 +227,22 @@ fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Sha
|
||||
if !t.alive() || sim.showing_kill(sam) {
|
||||
s.push(text("ПОРАЖЕНА", cx + cw / 2.0 - 24.0, cy + ch / 2.0 + bh / 2.0 + 2.0, 0xFF60FF60));
|
||||
}
|
||||
let rel = t.pos - sam.pos;
|
||||
// The system knows the target by its track: the forecast of the last measurement.
|
||||
let track = sam.track(t.id);
|
||||
let at = track.map_or(t.pos, |tr| tr.est(tick));
|
||||
let rel = at - sam.pos;
|
||||
let bearing = (heading_of(rel).to_degrees() + 180.0).rem_euclid(360.0);
|
||||
if ch > 50.0 {
|
||||
s.push(text(fit(t.kind.spec().name.into(), cw - 6.0), cx + 3.0, cy + ch - 20.0, 0xFFFFFFFF));
|
||||
}
|
||||
let mut info = format!("{} в{:.0} {:.0}км/ч", distance(rel.length()), t.pos.y - sam.pos.y, t.vel.length() * 72.0);
|
||||
let mut info = format!("{} в{:.0} {:.0}км/ч", distance(rel.length()), at.y - sam.pos.y, t.vel.length() * 72.0);
|
||||
if let Some(tr) = track {
|
||||
if tr.forecast(tick) {
|
||||
info += &format!(" прогноз {:.1}с", tr.age(tick) as f64 / 20.0);
|
||||
} else if cw > 200.0 {
|
||||
info += &format!(" {}", tr.ident().name());
|
||||
}
|
||||
}
|
||||
if cw > 220.0 {
|
||||
info += &format!(" П{bearing:03.0}");
|
||||
}
|
||||
|
||||
+24
-8
@@ -108,24 +108,40 @@ pub fn draw(sim: &Sim, player: DVec3, yaw: f32) -> Vec<Shape> {
|
||||
}
|
||||
}
|
||||
let mut far = 0;
|
||||
// The air picture is what the systems' radars hold; with none deployed, everything.
|
||||
// The air picture is what the systems' radars hold, as they last measured it (a track older than a second
|
||||
// is a forecast, dimmer); with no system deployed, everything as it is.
|
||||
let any = !sim.sams.is_empty();
|
||||
for d in sim.drones.iter().filter(|d| !any || !d.alive() || sim.tracked(d.id)) {
|
||||
match scope(d.pos) {
|
||||
let picture = sim.picture();
|
||||
let now = sim.tick;
|
||||
for d in sim.drones.iter().filter(|d| !any || !d.alive() || picture.contains_key(&d.id)) {
|
||||
let track = picture.get(&d.id).filter(|_| any && d.alive());
|
||||
let (pos, vel) = track.map_or((d.pos, d.vel), |t| (t.est(now), t.vel));
|
||||
match scope(pos) {
|
||||
Some((x, y, bearing)) => {
|
||||
let a = fade(bearing);
|
||||
if d.alive() {
|
||||
s.push(square(x, y, 1.8, alpha(0xFF4040, a)));
|
||||
// Fresh: bright and red; a forecast: orange, fading as it ages towards the track's drop.
|
||||
let (a, color) = match track {
|
||||
Some(t) if t.forecast(now) => ((1.0 - t.age(now) as f64 / t.ttl.max(1) as f64 * 0.7).max(0.3), 0xFFB040),
|
||||
Some(_) => (1.0, 0xFF4040),
|
||||
None => (fade(bearing), 0xFF4040),
|
||||
};
|
||||
s.push(square(x, y, 1.8, alpha(color, a)));
|
||||
// Its heading, a short tail.
|
||||
let v = d.vel.normalize_or_zero();
|
||||
s.push(quad(pt(x, y), pt(x + 0.6, y), pt(x - v.x as f32 * 6.0 + 0.6, y - v.z as f32 * 6.0), pt(x - v.x as f32 * 6.0, y - v.z as f32 * 6.0), alpha(0xFF4040, a * 0.6)));
|
||||
let v = vel.normalize_or_zero();
|
||||
s.push(quad(pt(x, y), pt(x + 0.6, y), pt(x - v.x as f32 * 6.0 + 0.6, y - v.z as f32 * 6.0), pt(x - v.x as f32 * 6.0, y - v.z as f32 * 6.0), alpha(color, a * 0.6)));
|
||||
} else {
|
||||
s.push(square(x, y, 1.2, alpha(0x909090, a)));
|
||||
s.push(square(x, y, 1.2, alpha(0x909090, fade(bearing))));
|
||||
}
|
||||
}
|
||||
None if d.alive() => far += 1,
|
||||
None => {}
|
||||
}
|
||||
// The true place, when asked for: a white dot beside the track, to see how far the picture is off.
|
||||
if sim.truth && d.alive() && any {
|
||||
if let Some((x, y, _)) = scope(d.pos) {
|
||||
s.push(square(x, y, 0.8, 0xFFFFFFFF));
|
||||
}
|
||||
}
|
||||
}
|
||||
// The player: a white arrow the way they look.
|
||||
// Minecraft's yaw 0 looks south (+z, down the scope).
|
||||
|
||||
@@ -107,6 +107,7 @@ pub fn title(rec: &Recording, i: usize) -> String {
|
||||
EventKind::Hit { kind, .. } => format!("Прилёт: «{}»", kind.spec().name),
|
||||
EventKind::SamLost { kind, index, .. } => format!("{} №{} уничтожен", kind.name(), index + 1),
|
||||
EventKind::Split { .. } => "«Орешник»: разделение боевых блоков".into(),
|
||||
EventKind::Crash { kind, .. } => format!("«{}» разбивается о препятствие", kind.spec().name),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +123,7 @@ pub fn row(rec: &Recording, i: usize) -> String {
|
||||
EventKind::Hit { kind, .. } => format!("{}: прилёт", kind.spec().name),
|
||||
EventKind::SamLost { kind, index, .. } => format!("{}-{} уничтожен", kind.name(), index + 1),
|
||||
EventKind::Split { .. } => "Орешник: разделение".into(),
|
||||
EventKind::Crash { kind, .. } => format!("{}: о рельеф", kind.spec().name),
|
||||
};
|
||||
format!("{} {what}", clock(e.at as f64))
|
||||
}
|
||||
@@ -152,6 +154,7 @@ fn interest(rec: &Recording, i: usize) -> f64 {
|
||||
power as f64 * 1.5 + if object.is_some() { 6.0 } else if sam.is_some() { 5.0 } else { 0.0 }
|
||||
}
|
||||
EventKind::SamLost { .. } => 12.0,
|
||||
EventKind::Crash { power, .. } => power as f64,
|
||||
EventKind::Split { drone } => {
|
||||
// The bus's split, not each block's.
|
||||
let first = rec.events.iter().position(|o| matches!(o.kind, EventKind::Split { .. }));
|
||||
@@ -168,6 +171,7 @@ fn clip(rec: &Recording, i: usize) -> Clip {
|
||||
EventKind::Hit { .. } => (160.0, 60.0),
|
||||
EventKind::SamLost { .. } => (80.0, 90.0),
|
||||
EventKind::Split { .. } => (40.0, 170.0),
|
||||
EventKind::Crash { .. } => (120.0, 60.0),
|
||||
};
|
||||
Clip { event: i, from: (at - before).max(0.0), to: (at + after).min(rec.len as f64) }
|
||||
}
|
||||
@@ -293,6 +297,11 @@ impl Player {
|
||||
self.fade = 6;
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let Some(rec) = sim.replay(self.rec) else {
|
||||
@@ -352,7 +361,7 @@ impl Player {
|
||||
for e in rec.events.iter().filter(|e| (self.heard..self.t).contains(&(e.at as f64))) {
|
||||
let event = match e.kind {
|
||||
EventKind::Kill { .. } => "airdef:blast.air",
|
||||
EventKind::Hit { power, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][(power as usize).min(4)],
|
||||
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][(power as usize).min(4)],
|
||||
EventKind::SamLost { .. } => "airdef:blast.big",
|
||||
EventKind::Split { .. } => "airdef:boom.sonic",
|
||||
};
|
||||
@@ -412,7 +421,7 @@ impl Player {
|
||||
}
|
||||
}
|
||||
}
|
||||
EventKind::Hit { drone, .. } => {
|
||||
EventKind::Hit { drone, .. } | EventKind::Crash { drone, .. } => {
|
||||
if t < te - 50.0 {
|
||||
if let Some((eye, look)) = chase(drone, 12.0, 4.0) {
|
||||
return (eye, look, 55.0, 0);
|
||||
@@ -672,7 +681,7 @@ impl Player {
|
||||
s.push(line((x - 2.5, y - 2.5), (x + 2.5, y + 2.5), 1.2, 0xFFFFE040));
|
||||
s.push(line((x + 2.5, y - 2.5), (x - 2.5, y + 2.5), 1.2, 0xFFFFE040));
|
||||
}
|
||||
EventKind::Hit { power, .. } => {
|
||||
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => {
|
||||
let r = 2.0 + power as f32;
|
||||
s.push(fill(x - r, y - r, r * 2.0, r * 2.0, 0xB0FF3020));
|
||||
}
|
||||
@@ -705,6 +714,7 @@ impl Player {
|
||||
let c = match e.kind {
|
||||
EventKind::Kill { .. } => 0xFFFFE040,
|
||||
EventKind::Hit { .. } => 0xFFFF3020,
|
||||
EventKind::Crash { .. } => 0xFF903020,
|
||||
EventKind::SamLost { .. } => 0xFFFF00FF,
|
||||
EventKind::Split { .. } => 0xFFFF9020,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,124 @@ use super::*;
|
||||
/// By blast power (as `explode` grades it): how far it reaches a vehicle, and how hard it hits one at the centre.
|
||||
const REACH: [f64; 5] = [4.0, 7.0, 12.0, 18.0, 26.0];
|
||||
const HIT: [f64; 5] = [0.2, 0.45, 0.85, 1.25, 1.7];
|
||||
/// A unit's working parts, each 1 (whole) to 0 (failed); the hull is `Sam::health` (0 destroys it).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Part {
|
||||
/// The radar (or optics): its reach and its scan.
|
||||
Sensor,
|
||||
/// The aiming drive of the launcher or turret.
|
||||
Mount,
|
||||
/// The launcher: cells, reload.
|
||||
Launcher,
|
||||
/// The gun: it jams.
|
||||
Gun,
|
||||
/// The running gear: it can't march when it fails.
|
||||
Tracks,
|
||||
/// The data link: no network when it fails.
|
||||
Comms,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum PartState {
|
||||
Ok,
|
||||
Damaged,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl Part {
|
||||
pub const ALL: [Part; 6] = [Part::Sensor, Part::Mount, Part::Launcher, Part::Gun, Part::Tracks, Part::Comms];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Part::Sensor => "РЛС",
|
||||
Part::Mount => "привод наведения",
|
||||
Part::Launcher => "пусковая",
|
||||
Part::Gun => "орудие",
|
||||
Part::Tracks => "ходовая",
|
||||
Part::Comms => "связь",
|
||||
}
|
||||
}
|
||||
pub fn short(self) -> &'static str {
|
||||
match self {
|
||||
Part::Sensor => "РЛС",
|
||||
Part::Mount => "Прив",
|
||||
Part::Launcher => "ПУ",
|
||||
Part::Gun => "Ор",
|
||||
Part::Tracks => "Ход",
|
||||
Part::Comms => "Св",
|
||||
}
|
||||
}
|
||||
fn index(self) -> usize {
|
||||
self as usize
|
||||
}
|
||||
/// Whether this kind of unit has the part at all.
|
||||
pub fn on(self, kind: SamKind) -> bool {
|
||||
let spec = kind.spec();
|
||||
match self {
|
||||
Part::Sensor => spec.sensor > 0.0,
|
||||
Part::Mount => kind.armed() && kind != SamKind::Light && (spec.missile.is_some() || spec.gun.is_some()),
|
||||
Part::Launcher => spec.missile.is_some(),
|
||||
Part::Gun => spec.gun.is_some(),
|
||||
Part::Tracks => kind.mobile(),
|
||||
Part::Comms => true,
|
||||
}
|
||||
}
|
||||
/// Where the part is on the vehicle: right, up, back, blocks at scale 1 (the nose points to -back).
|
||||
fn place(self, spec: &super::units::SamSpec) -> (f64, f64, f64) {
|
||||
match self {
|
||||
Part::Sensor => (0.0, spec.mast / spec.scale, 1.0),
|
||||
Part::Mount => (0.0, spec.mount.0, -spec.mount.1),
|
||||
Part::Launcher => (0.0, spec.mount.0, -spec.mount.1 + 0.4),
|
||||
Part::Gun => (0.0, spec.mount.0, -spec.mount.1 - 1.0),
|
||||
Part::Tracks => (0.0, 0.3, 0.0),
|
||||
Part::Comms => (0.0, 1.6, 1.8),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Below this a part is damaged (works worse), at 0 it has failed.
|
||||
pub const WORN: f64 = 0.6;
|
||||
|
||||
/// The state of every part: `Part::ALL`'s order.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub struct Comps(pub [f64; 6]);
|
||||
|
||||
impl Default for Comps {
|
||||
fn default() -> Self {
|
||||
Self([1.0; 6])
|
||||
}
|
||||
}
|
||||
|
||||
impl Comps {
|
||||
pub fn get(&self, p: Part) -> f64 {
|
||||
self.0[p.index()]
|
||||
}
|
||||
pub fn state(&self, p: Part) -> PartState {
|
||||
let v = self.get(p);
|
||||
if v <= 0.0 {
|
||||
PartState::Failed
|
||||
} else if v < WORN {
|
||||
PartState::Damaged
|
||||
} else {
|
||||
PartState::Ok
|
||||
}
|
||||
}
|
||||
pub fn whole(&self) -> bool {
|
||||
self.0.iter().all(|&v| v >= 1.0)
|
||||
}
|
||||
/// Parts of this kind that are not whole, as "РЛС 40%, ходовая отказ".
|
||||
pub fn describe(&self, kind: SamKind) -> String {
|
||||
Part::ALL
|
||||
.iter()
|
||||
.filter(|p| p.on(kind) && self.get(**p) < 1.0)
|
||||
.map(|p| match self.state(*p) {
|
||||
PartState::Failed => format!("{} отказ", p.short()),
|
||||
_ => format!("{} {:.0}%", p.short(), self.get(*p) * 100.0),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Health points an object loses at the centre of a blast.
|
||||
const OBJECT_HIT: [f64; 5] = [2.0, 6.0, 14.0, 26.0, 40.0];
|
||||
|
||||
@@ -28,7 +146,72 @@ impl MissileKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// A strike that ends farther than this from its aim point misses (the lethal radius of a point target).
|
||||
pub(super) const MISS_RADIUS: f64 = 3.0;
|
||||
/// A system's hull, as a cylinder: its radius (× scale) and how far above the ground it reaches (× scale).
|
||||
const HULL_RADIUS: f64 = 2.2;
|
||||
const HULL_TOP: f64 = 3.0;
|
||||
|
||||
impl DroneKind {
|
||||
/// Strike accuracy: the CEP (blocks; half the strikes land within it, the scatter is Rayleigh)
|
||||
/// and the share of strikes with a rare gross navigation fault, which lands beyond `MISS_RADIUS`.
|
||||
pub(super) fn accuracy(self) -> (f64, f64) {
|
||||
match self {
|
||||
DroneKind::Shahed => (1.2, 0.03),
|
||||
DroneKind::Geran3 => (1.0, 0.03),
|
||||
DroneKind::Lancet => (0.8, 0.02),
|
||||
DroneKind::Gerbera => (1.5, 0.04),
|
||||
DroneKind::Kh101 => (1.3, 0.05),
|
||||
DroneKind::Kalibr => (1.3, 0.05),
|
||||
DroneKind::Kh22 => (1.6, 0.06),
|
||||
DroneKind::Zircon => (1.5, 0.06),
|
||||
DroneKind::Iskander => (1.4, 0.06),
|
||||
DroneKind::Kinzhal => (1.5, 0.06),
|
||||
// The bus and its blocks: the blocks are aimed by the split, so only the bus's own error applies.
|
||||
DroneKind::Oreshnik | DroneKind::OreshnikBlock | DroneKind::OreshnikSub => (1.6, 0.08),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
/// The aim error of a new strike: drawn once from the seed, the drone keeps it for its whole flight.
|
||||
pub(super) fn aim_error(&mut self, kind: DroneKind) -> DVec3 {
|
||||
let (cep, gross) = kind.accuracy();
|
||||
let angle = self.rng.f() * std::f64::consts::TAU;
|
||||
let r = if self.rng.chance(gross) {
|
||||
MISS_RADIUS + cep * (0.5 + 2.5 * self.rng.f())
|
||||
} else {
|
||||
// Rayleigh with its median at the CEP.
|
||||
let u = 1.0 - self.rng.f();
|
||||
cep * (-2.0 * u.ln() / (2.0 * std::f64::consts::LN_2)).sqrt()
|
||||
};
|
||||
DVec3::new(angle.cos() * r, 0.0, angle.sin() * r)
|
||||
}
|
||||
|
||||
/// An armed warhead reached `pos` (its last step was `vel`): a system whose hull it swept through is
|
||||
/// destroyed at once, whatever the blast would have done to it. A near miss is only splash.
|
||||
pub(super) fn direct_hit(&mut self, pos: DVec3, vel: DVec3, tnt: u32, out: &mut Out) {
|
||||
if !self.damage || tnt == 0 {
|
||||
return;
|
||||
}
|
||||
let from = pos - vel;
|
||||
for i in 0..self.sams.len() {
|
||||
let sam = &self.sams[i];
|
||||
if !sam.alive() {
|
||||
continue;
|
||||
}
|
||||
let s = sam.kind.spec().scale;
|
||||
let hit = (0..=8).any(|k| {
|
||||
let p = from.lerp(pos, k as f64 / 8.0);
|
||||
let across = DVec3::new(p.x - sam.pos.x, 0.0, p.z - sam.pos.z).length();
|
||||
across <= HULL_RADIUS * s && p.y >= sam.pos.y - 1.0 && p.y <= sam.pos.y + HULL_TOP * s
|
||||
});
|
||||
if hit {
|
||||
self.destroy(i, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A blast of `power` (0 to 4) at `pos`: the systems and objects round it take damage.
|
||||
pub(super) fn blast_damage(&mut self, pos: DVec3, power: usize, out: &mut Out) {
|
||||
let (reach, hit) = (REACH[power], HIT[power]);
|
||||
@@ -47,9 +230,39 @@ impl Sim {
|
||||
sam.health -= hit * k * k * if d < 1.0 { 1.5 } else { 1.0 };
|
||||
if sam.health <= 0.0 {
|
||||
destroyed.push(i);
|
||||
} else if before >= 0.5 && sam.health < 0.5 {
|
||||
continue;
|
||||
}
|
||||
if before >= 0.5 && sam.health < 0.5 {
|
||||
out.messages.push(format!("{} №{} повреждён", sam.kind.name(), i + 1));
|
||||
}
|
||||
// The parts take the blast by their own distance from it: the side it came from decides which.
|
||||
let spec = sam.kind.spec();
|
||||
let rot = DQuat::from_rotation_y(sam.heading);
|
||||
for part in Part::ALL {
|
||||
if !part.on(sam.kind) {
|
||||
continue;
|
||||
}
|
||||
let (x, y, back) = part.place(spec);
|
||||
let at = sam.pos + rot * DVec3::new(x, y, back) * spec.scale;
|
||||
let size = if part == Part::Tracks { 2.5 } else { 1.0 };
|
||||
let dp = (at.distance(pos) - size).max(0.0);
|
||||
if dp >= reach {
|
||||
continue;
|
||||
}
|
||||
let kp = 1.0 - dp / reach;
|
||||
let roll = dice(self.seed, self.tick, sam.id as u64, part as u64);
|
||||
// The parts are softer than the hull: a blast the hull survives can still put one out.
|
||||
let dmg = hit * kp * kp * (0.7 + roll) * 1.5 * if dp < 1.0 { 1.4 } else { 1.0 };
|
||||
let was = sam.comps.state(part);
|
||||
let v = &mut sam.comps.0[part as usize];
|
||||
*v = (*v - dmg).max(0.0);
|
||||
let now = sam.comps.state(part);
|
||||
if now != was && now != PartState::Ok {
|
||||
let word = if now == PartState::Failed { "отказал(а)" } else { "повреждён(а)" };
|
||||
out.messages.push(format!("{} №{}: {} {word}", sam.kind.name(), i + 1, part.name()));
|
||||
}
|
||||
}
|
||||
sam.fit_cells();
|
||||
}
|
||||
for i in destroyed {
|
||||
self.destroy(i, out);
|
||||
@@ -79,6 +292,9 @@ impl Sim {
|
||||
sam.target = None;
|
||||
sam.gun_target = None;
|
||||
sam.reloading = 0;
|
||||
sam.tracks.clear();
|
||||
sam.seen.clear();
|
||||
sam.own.clear();
|
||||
let spec = sam.kind.spec();
|
||||
let missiles = sam.missiles;
|
||||
let bursts = sam.bursts;
|
||||
@@ -159,11 +375,76 @@ impl Sim {
|
||||
|
||||
}
|
||||
|
||||
/// A roll from 0 to 1 fixed by the seed and three numbers: the same events roll the same in every run,
|
||||
/// and the simulation's own random sequence is left alone.
|
||||
pub(super) fn dice(seed: u64, a: u64, b: u64, c: u64) -> f64 {
|
||||
let mut x = seed ^ a.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ b.wrapping_mul(0xBF58_476D_1CE4_E5B9) ^ c.wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
x ^= x >> 31;
|
||||
(x >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
impl Sam {
|
||||
/// Its wreck still burns.
|
||||
pub fn burning(&self) -> bool {
|
||||
self.burn > 0
|
||||
}
|
||||
/// A part's state, `Ok` for one this unit does not have.
|
||||
pub fn part(&self, p: Part) -> f64 {
|
||||
if p.on(self.kind) { self.comps.get(p) } else { 1.0 }
|
||||
}
|
||||
pub fn part_state(&self, p: Part) -> PartState {
|
||||
if p.on(self.kind) { self.comps.state(p) } else { PartState::Ok }
|
||||
}
|
||||
pub fn parts_text(&self) -> String {
|
||||
self.comps.describe(self.kind)
|
||||
}
|
||||
/// The aiming drive's share of its speed: a failed one doesn't turn at all.
|
||||
pub(super) fn drive_fit(&self) -> f64 {
|
||||
let h = self.part(Part::Mount);
|
||||
if h <= 0.0 { 0.0 } else { 0.3 + 0.7 * h }
|
||||
}
|
||||
/// The radar's share of its reach; the scan is slower by the same.
|
||||
pub(super) fn radar_fit(&self) -> f64 {
|
||||
let h = self.part(Part::Sensor);
|
||||
if h <= 0.0 { 0.0 } else { 0.4 + 0.6 * h }
|
||||
}
|
||||
/// The gun's share of its hit chance.
|
||||
pub(super) fn gun_fit(&self) -> f64 {
|
||||
0.5 + 0.5 * self.part(Part::Gun)
|
||||
}
|
||||
/// Its launcher's cells still good: a damaged one holds fewer missiles.
|
||||
pub(super) fn cells(&self) -> u32 {
|
||||
let Some(load) = &self.kind.spec().missile else { return 0 };
|
||||
let h = self.part(Part::Launcher);
|
||||
if h <= 0.0 { 0 } else { ((load.magazine as f64 * (0.4 + 0.6 * h)).ceil() as u32).clamp(1, load.magazine) }
|
||||
}
|
||||
/// Missiles above what the cells hold are lost with them.
|
||||
pub fn fit_cells(&mut self) {
|
||||
if self.kind.spec().missile.is_some() {
|
||||
self.missiles = self.missiles.min(self.cells());
|
||||
}
|
||||
}
|
||||
/// Ticks a reload takes: longer with a damaged launcher.
|
||||
pub(super) fn reload_time(&self) -> u32 {
|
||||
let r = self.kind.spec().reload.max(1);
|
||||
(r as f64 * (1.0 + 1.5 * (1.0 - self.part(Part::Launcher)))).round() as u32
|
||||
}
|
||||
/// The gun fires (not jammed for good, not jammed now).
|
||||
pub(super) fn gun_up(&self) -> bool {
|
||||
self.part(Part::Gun) > 0.0 && self.jam == 0
|
||||
}
|
||||
/// How far it can march: the running gear's share of the speed (0 can't).
|
||||
pub(super) fn drive_speed(&self) -> f64 {
|
||||
let h = self.part(Part::Tracks);
|
||||
if h <= 0.0 { 0.0 } else { 0.35 + 0.65 * h }
|
||||
}
|
||||
/// The data link's reach, 0 when the link has failed.
|
||||
pub(super) fn link_range(&self) -> f64 {
|
||||
let h = self.part(Part::Comms);
|
||||
if h <= 0.0 { 0.0 } else { super::sight::LINK * (0.3 + 0.7 * h) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
@@ -171,9 +452,11 @@ impl Sim {
|
||||
pub fn repair(&mut self) -> u32 {
|
||||
let mut n = 0;
|
||||
for sam in &mut self.sams {
|
||||
if sam.health < 1.0 {
|
||||
if sam.health < 1.0 || !sam.comps.whole() {
|
||||
n += 1;
|
||||
}
|
||||
sam.comps = Comps::default();
|
||||
sam.jam = 0;
|
||||
let spec = sam.kind.spec();
|
||||
sam.health = 1.0;
|
||||
sam.burn = 0;
|
||||
@@ -191,6 +474,7 @@ impl Sim {
|
||||
}
|
||||
// The wrecks' ammunition still to cook off goes with them.
|
||||
self.later.clear();
|
||||
self.fires.extinguish();
|
||||
n
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Long fires (#52): where a drone or a missile came down, where a warhead struck and on a
|
||||
//! destroyed system a fire burns for minutes: it flares up, burns steadily, smokes thick and
|
||||
//! heavy, dies down and smoulders. Visual first (flames, smoke columns, a flickering light, a
|
||||
//! crackle); with damage on, a few real fire blocks on solid ground (never on water or in the
|
||||
//! air), put back while it burns and taken away when it is out. At most `MAX` fires burn, the
|
||||
//! oldest dying down first. A replay draws the same fires from its events (`replay_fire`).
|
||||
use super::*;
|
||||
|
||||
/// Fires burning at once.
|
||||
pub const MAX: usize = 12;
|
||||
/// 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.
|
||||
const FADE: u32 = 160;
|
||||
/// Ticks between the fire blocks being set again.
|
||||
const REFRESH: u64 = 80;
|
||||
|
||||
pub struct Fire {
|
||||
pub id: u32,
|
||||
pub at: DVec3,
|
||||
pub start: u64,
|
||||
pub life: u32,
|
||||
/// 0.6 (a crashed drone) to 3 (a cruise or ballistic warhead, a depot).
|
||||
pub size: f32,
|
||||
/// The system it burns on, if any: its own wreck fire does the flames for as long as it lasts.
|
||||
host: Option<u32>,
|
||||
/// The land under it found: `Some(true)` land, `Some(false)` water (steam only).
|
||||
land: Option<bool>,
|
||||
/// Asked about the ground since this tick.
|
||||
asked: u64,
|
||||
/// Fire blocks set (whole coordinates).
|
||||
blocks: Vec<(i32, i32, i32)>,
|
||||
/// The host's wreck is flaming: this fire leaves the flames to it.
|
||||
pub quiet: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Fires {
|
||||
pub list: Vec<Fire>,
|
||||
/// Commands to take fire blocks away, sent next tick.
|
||||
cleanup: Vec<String>,
|
||||
}
|
||||
|
||||
/// How long a fire of this power burns, in ticks: 2 to 5 minutes.
|
||||
pub fn life_for(power: u32) -> u32 {
|
||||
2400 + 900 * power.min(4)
|
||||
}
|
||||
|
||||
/// The size of a fire of this power.
|
||||
pub fn size_for(power: u32) -> f32 {
|
||||
[0.6, 1.0, 1.6, 2.4, 3.0][power.min(4) as usize]
|
||||
}
|
||||
|
||||
/// How strong the flames (`.0`) and the smoke (`.1`) are, 0 to about 1, `age` ticks into a fire
|
||||
/// that burns `life`: flaring up, steady flame, thick smoke while it dies down, then embers.
|
||||
pub fn level(age: f64, life: f64) -> (f32, f32) {
|
||||
let f = (age / life.max(1.0)).clamp(0.0, 1.0);
|
||||
let flare = if age < 40.0 { 0.35 + 0.65 * age / 40.0 } else { 1.0 };
|
||||
let flame = if f < 0.45 { flare } else { flare * (1.0 - (f - 0.45) / 0.4).clamp(0.0, 1.0) };
|
||||
let smoke = if age < 100.0 { age / 100.0 } else if f < 0.8 { 1.0 } else { 1.0 - 0.85 * (f - 0.8) / 0.2 };
|
||||
(flame as f32, smoke.clamp(0.0, 1.0) as f32)
|
||||
}
|
||||
|
||||
impl Fires {
|
||||
/// Fires burning now.
|
||||
pub fn len(&self) -> usize {
|
||||
self.list.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.list.is_empty()
|
||||
}
|
||||
/// Puts every fire out; the fire blocks are taken away on the next step.
|
||||
pub fn extinguish(&mut self) {
|
||||
let list = std::mem::take(&mut self.list);
|
||||
for f in list {
|
||||
self.cleanup.extend(f.take_blocks());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Fire {
|
||||
fn take_blocks(self) -> Vec<String> {
|
||||
self.blocks
|
||||
.into_iter()
|
||||
.map(|(x, y, z)| format!("fill {x} {y} {z} {x} {y} {z} minecraft:air replace minecraft:fire"))
|
||||
.collect()
|
||||
}
|
||||
/// How strong it burns at tick `now`: (flames, smoke); water leaves steam only.
|
||||
pub fn level(&self, now: u64) -> (f32, f32) {
|
||||
let (fl, sm) = level(now.saturating_sub(self.start) as f64, self.life as f64);
|
||||
if self.land == Some(false) { (0.0, sm * 0.5) } else { (fl, sm) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
/// Something came down or struck at `pos` (power 0 to 4): a fire starts there, or the fire
|
||||
/// already burning close by grows. `host` is the system it burns on.
|
||||
pub(super) fn ignite(&mut self, pos: DVec3, power: u32, host: Option<u32>) {
|
||||
let now = self.tick;
|
||||
let (size, life) = (size_for(power), life_for(power));
|
||||
if let Some(f) = self.fires.list.iter_mut().find(|f| f.at.distance(pos) < 6.0) {
|
||||
// The same spot (a warhead and the wreck it made): one fire, the greater of the two.
|
||||
f.size = f.size.max(size);
|
||||
f.life = f.life.max((now - f.start) as u32 + life);
|
||||
f.host = f.host.or(host);
|
||||
return;
|
||||
}
|
||||
let id = self.id();
|
||||
self.fires.list.push(Fire {
|
||||
id,
|
||||
at: pos,
|
||||
start: now,
|
||||
life,
|
||||
size,
|
||||
host,
|
||||
land: None,
|
||||
asked: 0,
|
||||
blocks: Vec::new(),
|
||||
quiet: false,
|
||||
});
|
||||
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);
|
||||
if let Some(f) = old {
|
||||
f.life = (now - f.start) as u32 + FADE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a tick: finds the ground under each new fire, burns the old ones, ends the burnt out.
|
||||
pub(super) fn step_fires(&mut self, ground: &mut Ground, out: &mut Out) {
|
||||
let now = self.tick;
|
||||
out.commands.append(&mut self.fires.cleanup);
|
||||
let damage = self.damage;
|
||||
let mut fires = std::mem::take(&mut self.fires.list);
|
||||
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.
|
||||
if f.land.is_none() && age >= f.asked {
|
||||
f.asked = age + 10;
|
||||
if let Some(h) = ground.at(f.at, now) {
|
||||
let (x, z) = (f.at.x.floor() as i32, f.at.z.floor() as i32);
|
||||
let h = h as i32;
|
||||
if ground.world.is_known_solid(x, h - 1, z, now) {
|
||||
f.at.y = h as f64;
|
||||
f.land = Some(true);
|
||||
} else if ground.world.is_known_open(x, h - 1, z, now) {
|
||||
// Water (or another fluid): the fire goes out in a cloud of steam.
|
||||
f.at.y = h as f64;
|
||||
f.land = Some(false);
|
||||
f.life = f.life.min(age as u32 + STEAM);
|
||||
}
|
||||
}
|
||||
// Not known for a while: it burns where it is, on the land.
|
||||
if f.land.is_none() && age > 100 {
|
||||
f.land = Some(true);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
let top = f.at + DVec3::Y * 0.4;
|
||||
let spread = 0.4 + 0.5 * s;
|
||||
if fl > 0.04 && now % 2 == 0 {
|
||||
out.particles("minecraft:flame", top, spread, DVec3::new(0.0, 0.05 + 0.05 * fl as f64, 0.0), 0.02, (3.0 * s * fl).ceil() as u32);
|
||||
}
|
||||
if sm > 0.04 && now % 3 == 0 {
|
||||
out.particles("minecraft:large_smoke", top + DVec3::Y * 0.6, spread, DVec3::new(0.0, 0.1, 0.0), 0.03, (2.0 * s * sm).ceil() as u32);
|
||||
}
|
||||
// The tall column: a plume that rises far.
|
||||
if sm > 0.3 && now % 6 == (f.id % 6) as u64 {
|
||||
out.particles("minecraft:campfire_signal_smoke", top, 0.5 * s, DVec3::new(0.0, 0.09, 0.0), 0.01, 1 + (s * sm) as u32);
|
||||
}
|
||||
if fl > 0.5 && now % 20 == (f.id % 20) as u64 {
|
||||
out.particles("minecraft:lava", top + DVec3::Y * 0.5, 0.4 * s, DVec3::new(0.0, 0.2, 0.0), 0.1, 2);
|
||||
}
|
||||
if now % 50 == (f.id % 50) as u64 && fl + sm * 0.3 > 0.1 {
|
||||
out.sound("minecraft:block.fire.ambient", f.at, 0.5 + 1.2 * fl, 0.8 + 0.2 * s.min(1.0));
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
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 {
|
||||
let (dx, dz) = if k == 0 { (0, 0) } else { [(1, 0), (-1, 0), (0, 1), (0, -1)][k - 1] };
|
||||
let (bx, bz) = (x + dx, z + dz);
|
||||
// Only where the land is as high as at the centre.
|
||||
let level = ground.at(DVec3::new(bx as f64 + 0.5, 0.0, bz as f64 + 0.5), now).is_some_and(|h| h as i32 == y);
|
||||
if level && ground.world.is_known_solid(bx, y - 1, bz, now) && ground.world.is_known_open(bx, y, bz, now) {
|
||||
out.commands.push(format!("setblock {bx} {y} {bz} minecraft:fire keep"));
|
||||
if !f.blocks.contains(&(bx, y, bz)) {
|
||||
f.blocks.push((bx, y, bz));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The burnt out: their fire blocks go too.
|
||||
let mut keep = Vec::with_capacity(fires.len());
|
||||
for f in fires {
|
||||
if now.saturating_sub(f.start) < f.life as u64 {
|
||||
keep.push(f);
|
||||
} else {
|
||||
out.commands.extend(f.take_blocks());
|
||||
}
|
||||
}
|
||||
// Fires lit this very tick were pushed to the list meanwhile (none are: ignite runs inside the step).
|
||||
keep.append(&mut self.fires.list);
|
||||
self.fires.list = keep;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let (fl, sm) = level(age, life_for(power) as f64);
|
||||
(fl * size_for(power), sm)
|
||||
}
|
||||
@@ -50,7 +50,7 @@ impl Sim {
|
||||
// The trucks: a job each, when idle.
|
||||
for t in 0..self.sams.len() {
|
||||
let truck = &self.sams[t];
|
||||
if truck.kind != SamKind::Tzm || !truck.alive() || truck.moving() || now % 10 != t as u64 % 10 {
|
||||
if truck.kind != SamKind::Tzm || !truck.alive() || truck.moving() || truck.drive_speed() <= 0.0 || now % 10 != t as u64 % 10 {
|
||||
continue;
|
||||
}
|
||||
let at = truck.pos;
|
||||
@@ -116,7 +116,7 @@ impl Sim {
|
||||
fn reload_now(&mut self, i: usize, out: &mut Out) {
|
||||
let sam = &mut self.sams[i];
|
||||
sam.empty = false;
|
||||
sam.reloading = sam.kind.spec().reload.max(1);
|
||||
sam.reloading = sam.reload_time();
|
||||
out.sound("minecraft:block.piston.extend", sam.pos, 2.0, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
+351
-69
@@ -10,18 +10,26 @@ use glam::{DQuat, DVec3};
|
||||
use std::collections::HashMap;
|
||||
|
||||
mod damage;
|
||||
pub mod fires;
|
||||
mod raid;
|
||||
pub mod record;
|
||||
mod sight;
|
||||
mod terrain;
|
||||
mod ew;
|
||||
mod lights;
|
||||
mod logistics;
|
||||
mod orders;
|
||||
mod units;
|
||||
mod why;
|
||||
pub mod world;
|
||||
#[cfg(test)]
|
||||
mod regress;
|
||||
#[cfg(test)]
|
||||
mod sensor_tests;
|
||||
pub use ew::EW_RADIUS;
|
||||
pub use damage::{Comps, Part, PartState};
|
||||
pub use sight::{Ident, Track};
|
||||
use damage::dice;
|
||||
pub use units::{Family, SamKind};
|
||||
use units::Muzzle;
|
||||
pub use raid::{Approach, Aim, RaidOrder};
|
||||
@@ -211,6 +219,8 @@ 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 blocks themselves (issues #42, #43): what stops a flight.
|
||||
pub world: world::WorldCache,
|
||||
}
|
||||
|
||||
/// Ticks between full clean-ups of a big ground cache.
|
||||
@@ -251,10 +261,32 @@ impl Ground {
|
||||
self.asked.clear();
|
||||
std::mem::take(&mut self.wanted)
|
||||
}
|
||||
/// The first solid block on the way from `a` to `b` (the real blocks, not the height of the land).
|
||||
pub fn sweep(&mut self, a: DVec3, b: DVec3, skip_start: bool, now: u64) -> world::Sweep {
|
||||
self.world.sweep(a, b, skip_start, now)
|
||||
}
|
||||
/// 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);
|
||||
}
|
||||
/// Whether `pos` is in the land by the column's height (`below`, as `at` gave it), given what the sweep
|
||||
/// of this step saw: over blocks known and open, the height only counts where its top block is known
|
||||
/// to be open, a fluid's surface (not a roof or a bridge deck above the flight, nor a block unread).
|
||||
pub fn ground_hit(&mut self, below: Option<f64>, pos: DVec3, offset: f64, sweep: &world::Sweep, now: u64) -> bool {
|
||||
let Some(g) = below else { return false };
|
||||
if pos.y > g + offset {
|
||||
return false;
|
||||
}
|
||||
match sweep {
|
||||
world::Sweep::Clear => self.world.is_known_open(pos.x.floor() as i32, g.floor() as i32 - 1, pos.z.floor() as i32, now),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
/// Explosions change the land: forget what was known round them.
|
||||
fn forget(&mut self, at: DVec3, radius: i32) {
|
||||
let (cx, cz) = (at.x.floor() as i32, at.z.floor() as i32);
|
||||
self.known.retain(|&(x, z), _| (x - cx).abs() > radius || (z - cz).abs() > radius);
|
||||
self.world.touched(at, f64::from(radius) + 2.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,6 +746,23 @@ pub struct Drone {
|
||||
sector: u8,
|
||||
/// Sent by the smart enemy: it may be turned on systems that give themselves away.
|
||||
smart: bool,
|
||||
/// What the land ahead asks of its flight (see `terrain`).
|
||||
nav: Nav,
|
||||
}
|
||||
|
||||
/// A drone's terrain following: refreshed every few ticks, used every one.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Nav {
|
||||
/// The height it must be at now to clear the land ahead (unknown: -inf).
|
||||
need: f64,
|
||||
/// The height from which a straight dive on its target clears the land (unknown: -inf).
|
||||
dive: f64,
|
||||
}
|
||||
|
||||
impl Default for Nav {
|
||||
fn default() -> Self {
|
||||
Nav { need: f64::NEG_INFINITY, dive: f64::NEG_INFINITY }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drone {
|
||||
@@ -722,9 +771,13 @@ impl Drone {
|
||||
}
|
||||
/// How much a system wants to shoot it: fast and heavy first, decoys look real.
|
||||
fn threat(&self, from: DVec3) -> f64 {
|
||||
self.threat_at(from, self.pos)
|
||||
}
|
||||
/// The same for a target believed to be at `at` (a track's forecast).
|
||||
fn threat_at(&self, from: DVec3, at: DVec3) -> f64 {
|
||||
let s = self.kind.spec();
|
||||
let warhead = if s.decoy { 3.0 } else { s.warhead as f64 };
|
||||
(1.0 + warhead) * (1.0 + s.speed) / (1.0 + self.pos.distance(from) * 0.02)
|
||||
(1.0 + warhead) * (1.0 + s.speed) / (1.0 + at.distance(from) * 0.02)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1009,7 +1062,14 @@ pub struct Sam {
|
||||
pub health: f64,
|
||||
/// Ticks its wreck still burns.
|
||||
burn: u32,
|
||||
/// The drones it has tracks of this tick: its own radar's and the network's.
|
||||
/// Its working parts (the hull is `health`).
|
||||
pub comps: Comps,
|
||||
/// Ticks its gun stays jammed.
|
||||
jam: u32,
|
||||
/// Where its radar beam points (radians), and the tracks it holds: its own radar's and the network's.
|
||||
pub(super) beam: f64,
|
||||
pub tracks: Vec<Track>,
|
||||
/// The ids of the tracks it holds this tick: its own radar's and the network's.
|
||||
pub seen: Vec<u32>,
|
||||
/// The drones its own radar (or optics) holds: guns need these.
|
||||
pub own: Vec<u32>,
|
||||
@@ -1031,6 +1091,10 @@ pub struct Sam {
|
||||
pub ew_lock: bool,
|
||||
/// Ticks till a gun fires its next illumination flare.
|
||||
flare_cd: u32,
|
||||
/// 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.
|
||||
waits: HashMap<u32, u64>,
|
||||
}
|
||||
|
||||
impl Sam {
|
||||
@@ -1062,6 +1126,10 @@ impl Sam {
|
||||
launched: 0,
|
||||
health: 1.0,
|
||||
burn: 0,
|
||||
comps: Comps::default(),
|
||||
jam: 0,
|
||||
beam: id as f64 * 2.4,
|
||||
tracks: Vec::new(),
|
||||
seen: Vec::new(),
|
||||
own: Vec::new(),
|
||||
kill_view: None,
|
||||
@@ -1078,17 +1146,23 @@ impl Sam {
|
||||
heat: 0.0,
|
||||
ew_lock: false,
|
||||
flare_cd: 0,
|
||||
terrain: HashMap::new(),
|
||||
waits: HashMap::new(),
|
||||
}
|
||||
}
|
||||
pub fn alive(&self) -> bool {
|
||||
self.health > 0.0
|
||||
}
|
||||
/// Where its radar beam points now (radians clockwise from north).
|
||||
pub fn beam(&self) -> f64 {
|
||||
self.beam
|
||||
}
|
||||
/// How well it still works: turning, aiming, its radar's reach.
|
||||
/// Turns the mount towards `yaw` (and `pitch`, if it elevates) under its drive's limits; a damaged
|
||||
/// system has the less of both speed and acceleration.
|
||||
fn aim_towards(&mut self, yaw: f64, pitch: Option<f64>) {
|
||||
let d = self.kind.spec().drive();
|
||||
let fit = self.fitness();
|
||||
let fit = self.drive_fit();
|
||||
self.yaw += drive(&mut self.yaw_vel, angle_diff(self.yaw, yaw), d.yaw.0 * fit, d.yaw.1 * fit);
|
||||
if let Some(pitch) = pitch {
|
||||
self.pitch += drive(&mut self.pitch_vel, pitch - self.pitch, d.pitch.0 * fit, d.pitch.1 * fit);
|
||||
@@ -1099,9 +1173,6 @@ impl Sam {
|
||||
self.yaw_vel = 0.0;
|
||||
self.pitch_vel = 0.0;
|
||||
}
|
||||
fn fitness(&self) -> f64 {
|
||||
0.45 + 0.55 * self.health.clamp(0.0, 1.0)
|
||||
}
|
||||
pub fn status(&self) -> String {
|
||||
let spec = self.kind.spec();
|
||||
let mut text = spec.name.to_string();
|
||||
@@ -1118,7 +1189,10 @@ impl Sam {
|
||||
let state = if self.ew_on { "ГЛУШИТ" } else if self.ew_lock { "остывает" } else { "готова" };
|
||||
return format!("{text}: {state}, нагрев {:.0}%", self.heat * 100.0);
|
||||
}
|
||||
SamKind::Radar => return format!("{text}: целей {}", self.own.len()),
|
||||
SamKind::Radar => {
|
||||
let broken = if self.comps.whole() { String::new() } else { format!(" [{}]", self.parts_text()) };
|
||||
return format!("{text}: целей {}{broken}", self.own.len());
|
||||
}
|
||||
SamKind::Light => return format!("{text}{}", if self.target.is_some() { ": в луче цель" } else { "" }),
|
||||
_ => {}
|
||||
}
|
||||
@@ -1128,6 +1202,9 @@ impl Sam {
|
||||
if self.health < 1.0 {
|
||||
text += &format!(" ({:.0}%)", self.health * 100.0);
|
||||
}
|
||||
if !self.comps.whole() {
|
||||
text += &format!(" [{}]", self.parts_text());
|
||||
}
|
||||
if self.reloading > 0 {
|
||||
text += &format!(" перезарядка {}с", self.reloading.div_ceil(20));
|
||||
} else {
|
||||
@@ -1172,9 +1249,15 @@ pub struct Sim {
|
||||
pub tick: u64,
|
||||
pub kills: u32,
|
||||
pub hits: u32,
|
||||
/// Drones that struck the land or a building short of their aim.
|
||||
pub crashes: u32,
|
||||
pub launched: u32,
|
||||
/// Explosions damage the land (primed TNT through commands).
|
||||
pub damage: bool,
|
||||
/// The seed: what falls to chance without the sequence (scan hits, damage to parts) is rolled from it.
|
||||
seed: u64,
|
||||
/// The air picture shows the true positions beside the tracks (a sandbox and analysis aid; changes no behaviour).
|
||||
pub truth: bool,
|
||||
/// The shader draws fireballs: no glowing cubes.
|
||||
pub shader: bool,
|
||||
/// Air defence holds fire.
|
||||
@@ -1201,6 +1284,10 @@ pub struct Sim {
|
||||
intel: ew::Intel,
|
||||
/// Night: guns need light (the mod sets it from the game's clock).
|
||||
pub night: bool,
|
||||
/// The long fires at crash and impact points (#52).
|
||||
pub fires: fires::Fires,
|
||||
/// The world clock's time of day in ticks (the mod sets it; recordings keep it).
|
||||
pub day_time: Option<u64>,
|
||||
/// Illumination flares in the sky, and the drones lit this tick.
|
||||
pub flares: Vec<lights::Flare>,
|
||||
pub lit: Vec<u32>,
|
||||
@@ -1225,8 +1312,11 @@ impl Sim {
|
||||
tick: 0,
|
||||
kills: 0,
|
||||
hits: 0,
|
||||
crashes: 0,
|
||||
launched: 0,
|
||||
damage: true,
|
||||
seed: seed.wrapping_mul(0x2545_F491_4F6C_DD1D) | 1,
|
||||
truth: false,
|
||||
shader: false,
|
||||
hold: false,
|
||||
horizons: HashMap::new(),
|
||||
@@ -1241,6 +1331,8 @@ impl Sim {
|
||||
tally: record::Tally::default(),
|
||||
intel: ew::Intel::default(),
|
||||
night: false,
|
||||
fires: fires::Fires::default(),
|
||||
day_time: None,
|
||||
flares: Vec::new(),
|
||||
lit: Vec::new(),
|
||||
}
|
||||
@@ -1289,6 +1381,8 @@ 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);
|
||||
@@ -1314,6 +1408,7 @@ impl Sim {
|
||||
jammed: false,
|
||||
sector: 0,
|
||||
smart: false,
|
||||
nav: Nav::default(),
|
||||
});
|
||||
self.launched += 1;
|
||||
}
|
||||
@@ -1357,12 +1452,13 @@ impl Sim {
|
||||
self.later.clear();
|
||||
self.flares.clear();
|
||||
self.lit.clear();
|
||||
self.fires.extinguish();
|
||||
}
|
||||
|
||||
/// One game tick.
|
||||
pub fn step(&mut self, ground: &mut Ground, player: Option<DVec3>, out: &mut Out) {
|
||||
self.tick += 1;
|
||||
self.plan_step(out);
|
||||
self.plan_step(Some(&mut *ground), out);
|
||||
self.adapt();
|
||||
self.release_queue();
|
||||
self.step_drones(ground, player, out);
|
||||
@@ -1372,11 +1468,12 @@ impl Sim {
|
||||
self.step_ew(out);
|
||||
self.update_sight(ground);
|
||||
self.step_lights(out);
|
||||
self.step_sams(out);
|
||||
self.step_missiles(out);
|
||||
self.step_sams(ground, out);
|
||||
self.step_missiles(ground, out);
|
||||
self.step_fx(ground, out);
|
||||
self.draw_targets(out);
|
||||
self.step_wrecks(out);
|
||||
self.step_fires(ground, out);
|
||||
self.report_wave(out);
|
||||
self.record();
|
||||
let now = self.tick;
|
||||
@@ -1394,10 +1491,14 @@ impl Sim {
|
||||
// Warheads that reached their aim (for the replays): drone, kind, where, TNT; and splits.
|
||||
let mut impacts: Vec<(u32, DroneKind, DVec3, u32)> = Vec::new();
|
||||
let mut splits: Vec<(u32, DVec3)> = Vec::new();
|
||||
// Drones that struck the land or a building short of their aim: drone, kind, where, TNT.
|
||||
let mut crashes: Vec<(u32, DroneKind, DVec3, u32)> = Vec::new();
|
||||
for d in &mut self.drones {
|
||||
d.age += 1;
|
||||
let spec = d.kind.spec();
|
||||
let below = ground.at(d.pos, now);
|
||||
let from = d.pos;
|
||||
ground.ahead(d.pos, d.vel, (120.0 / d.vel.length().max(0.05)).max(20.0), now);
|
||||
// Near its aim it looks at the land there again: a platform or hilltop blown away
|
||||
// meanwhile, it goes for what is left below, not for the air where it stood.
|
||||
if d.flight != Flight::Falling
|
||||
@@ -1412,11 +1513,15 @@ 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);
|
||||
if let Some((_, p)) = sweep.hit() {
|
||||
d.pos = p;
|
||||
}
|
||||
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);
|
||||
}
|
||||
out.particles("minecraft:flame", d.pos, 0.25, DVec3::ZERO, 0.02, 2);
|
||||
if below.is_some_and(|g| d.pos.y <= g + 0.3) || d.age > 600 {
|
||||
if sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.3, &sweep, now) || d.age > 600 {
|
||||
// The wreck's warhead may still go off where it lands.
|
||||
let warhead = if spec.decoy || !self.rng.chance(0.35) { 0 } else { spec.warhead.min(2) };
|
||||
boom.push((d.pos, warhead, true));
|
||||
@@ -1439,13 +1544,24 @@ 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);
|
||||
if let Some((_, p)) = sweep.hit() {
|
||||
d.pos = p;
|
||||
}
|
||||
let speed = d.vel.length();
|
||||
let landed = sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.2, &sweep, now);
|
||||
let hit = d.pos.distance(d.target) < speed.max(2.0)
|
||||
|| below.is_some_and(|g| d.pos.y <= g + 0.2)
|
||||
|| landed
|
||||
|| (d.vel.y < 0.0 && d.pos.y < d.target.y - 1.0 && horizontal(d.target - d.pos).length() < speed * 2.0)
|
||||
|| d.age > 4000;
|
||||
if hit {
|
||||
boom.push((d.pos, spec.warhead, false));
|
||||
// Into the land or a building short of its aim: a crash, not a hit.
|
||||
if landed && d.pos.distance(d.target) >= speed.max(2.0) + 6.0 {
|
||||
crashes.push((d.id, d.kind, d.pos, spec.warhead));
|
||||
removed.push(d.id);
|
||||
continue;
|
||||
}
|
||||
impacts.push((d.id, d.kind, d.pos, spec.warhead));
|
||||
removed.push(d.id);
|
||||
self.hits += 1;
|
||||
@@ -1505,9 +1621,6 @@ impl Sim {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if d.flight == Flight::Cruise && d.route.len() <= 1 && horizontal(d.target - d.pos).length() < spec.dive {
|
||||
d.flight = Flight::Dive;
|
||||
}
|
||||
// An interceptor coming: drones drop to the treetops (radars lose them sooner).
|
||||
if d.engaged > 0 && matches!(spec.class, Class::Drone | Class::Jet) {
|
||||
d.low = true;
|
||||
@@ -1523,33 +1636,107 @@ impl Sim {
|
||||
{
|
||||
d.route.remove(0);
|
||||
}
|
||||
let goal = if d.flight == Flight::Dive {
|
||||
d.target
|
||||
} else {
|
||||
// Cruise height over the target, and `clearance` over the land ahead.
|
||||
let ahead = ground.at(d.pos + horizontal(d.vel).normalize_or_zero() * 20.0, now);
|
||||
let floor = match (below, ahead) {
|
||||
(Some(a), Some(b)) => a.max(b) + spec.clearance,
|
||||
(Some(g), None) | (None, Some(g)) => g + spec.clearance,
|
||||
(None, None) => d.target.y + spec.clearance,
|
||||
};
|
||||
let to = d.route.first().copied().unwrap_or(d.target);
|
||||
// Under the radars: as low as the land lets it, all the way.
|
||||
let height = if d.low { floor.max(d.target.y + 4.0) } else { (d.target.y + spec.cruise).max(floor) };
|
||||
DVec3::new(to.x, height, to.z)
|
||||
let climb = d.kind.climb();
|
||||
// A dive begins far enough out to bring it down onto the target: a missile that turns slowly needs a long
|
||||
// way to lose height it has over the aim.
|
||||
let dive_at = {
|
||||
let r = spec.speed * 1.35 / (spec.turn * 3.5).max(0.01);
|
||||
let drop = (d.pos.y - d.target.y).clamp(0.0, r);
|
||||
spec.dive.max(((2.0 * r * drop - drop * drop).sqrt() * 1.1).min(spec.dive * 2.5))
|
||||
};
|
||||
let way = d.route.first().copied().unwrap_or(d.target);
|
||||
let to_target = horizontal(d.target - d.pos).length();
|
||||
// What the land ahead asks: along its heading and towards its next point, several seconds out.
|
||||
if d.age <= 1 || (now + u64::from(d.id)) % 3 == 0 {
|
||||
let heading = horizontal(d.vel).normalize_or_zero();
|
||||
let towards = horizontal(way - d.pos).normalize_or_zero();
|
||||
let look = d.kind.look_len();
|
||||
let mut lines = vec![(heading, look)];
|
||||
let angle = heading.angle_between(towards);
|
||||
if towards != DVec3::ZERO && angle > 0.1 {
|
||||
let reach = look.min(horizontal(way - d.pos).length() + 30.0);
|
||||
lines.push((towards, reach));
|
||||
if angle > 0.4 {
|
||||
lines.push(((heading + towards).normalize_or_zero(), reach));
|
||||
}
|
||||
}
|
||||
d.nav.need = terrain::required_height(ground, d.pos, &lines, spec.clearance, &climb).unwrap_or(f64::NEG_INFINITY);
|
||||
d.nav.dive = if to_target < dive_at * 3.0 {
|
||||
terrain::dive_height(ground, d.pos, d.target, terrain::DIVE_MARGIN).unwrap_or(f64::NEG_INFINITY)
|
||||
} else {
|
||||
f64::NEG_INFINITY
|
||||
};
|
||||
}
|
||||
// The dive into the target starts when the line down to it is clear of the land, else it comes in higher;
|
||||
// a dive that finds the line blocked pulls out and climbs.
|
||||
if d.flight == Flight::Cruise && d.route.len() <= 1 && to_target < dive_at && d.pos.y >= d.nav.dive - 1.0 && d.vel.y < 0.3 * spec.speed {
|
||||
d.flight = Flight::Dive;
|
||||
} else if d.flight == Flight::Dive && to_target > 40.0 && d.pos.y < d.nav.dive - 4.0 {
|
||||
d.flight = Flight::Cruise;
|
||||
}
|
||||
let rate = if d.flight == Flight::Dive { spec.turn * 3.5 } else { spec.turn };
|
||||
let old = d.vel.normalize_or(DVec3::NEG_Z);
|
||||
let new = turn(old, goal - d.pos, rate);
|
||||
let probe_len = (spec.speed * 10.0).clamp(50.0, 150.0);
|
||||
let new = if d.flight == Flight::Dive {
|
||||
let mut new = turn(old, d.target - d.pos, rate);
|
||||
// Something solid across the dive, short of the target (a building the heights don't show): up and round.
|
||||
if to_target > 40.0
|
||||
&& d.age > 8
|
||||
&& let world::Sweep::Hit { point, .. } = ground.world.probe(d.pos, d.pos + new * probe_len.min(to_target - 20.0), now)
|
||||
&& point.distance(d.target) > 16.0
|
||||
{
|
||||
d.flight = Flight::Cruise;
|
||||
d.nav.need = d.nav.need.max(point.y + spec.clearance);
|
||||
new = turn(old, horizontal(way - d.pos).normalize_or(horizontal(old)) + DVec3::Y * climb.pitch.tan(), spec.turn);
|
||||
}
|
||||
new
|
||||
} else {
|
||||
let heading = horizontal(way - d.pos).normalize_or(horizontal(old).normalize_or(DVec3::NEG_Z));
|
||||
// Cruise height over the target; as low as the land lets it when under the radars.
|
||||
let base = if d.low { d.target.y + 4.0 } else { d.target.y + spec.cruise };
|
||||
let own = below.map_or(f64::NEG_INFINITY, |g| g + spec.clearance);
|
||||
let need = d.nav.need.max(own);
|
||||
let safe = if need.is_finite() { need + 2.0 } else { d.target.y + spec.clearance };
|
||||
let height = base.max(safe).max(d.nav.dive + 1.0);
|
||||
let full = climb.pitch.tan();
|
||||
let urgent = need.is_finite() && d.pos.y < need;
|
||||
let slope = if urgent {
|
||||
full
|
||||
} else {
|
||||
((height - d.pos.y) / (climb.radius * 1.5).min((to_target * 0.5).max(30.0)).max(30.0)).clamp(-0.35, full)
|
||||
};
|
||||
let mut new = terrain::fly_towards(old, heading, slope, rate, urgent);
|
||||
// Something solid ahead that the heights don't show (trees, a building, an overhang): climb.
|
||||
if d.age > 8
|
||||
&& let world::Sweep::Hit { point, .. } =
|
||||
ground.world.probe(d.pos - DVec3::Y * 4.0, d.pos - DVec3::Y * 4.0 + new * probe_len, now)
|
||||
{
|
||||
d.nav.need = d.nav.need.max(point.y + spec.clearance);
|
||||
new = terrain::fly_towards(old, heading, full, rate, true);
|
||||
}
|
||||
terrain::clamp_pitch(new, -0.6, climb.pitch, heading)
|
||||
};
|
||||
let side = old.cross(new).y;
|
||||
d.roll += ((side * 40.0).clamp(-0.8, 0.8) - d.roll) * 0.15;
|
||||
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);
|
||||
if let Some((_, p)) = sweep.hit() {
|
||||
d.pos = p;
|
||||
}
|
||||
let at_target = d.pos.distance(d.target) < speed.max(1.5);
|
||||
let in_ground = below.is_some_and(|g| d.pos.y <= g + 0.2);
|
||||
let in_ground = sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.2, &sweep, now);
|
||||
if at_target || in_ground || d.age > 8000 {
|
||||
boom.push((d.pos, spec.warhead, spec.decoy));
|
||||
// Into the land or a building short of its aim: a crash, not a hit.
|
||||
if in_ground && d.pos.distance(d.target) >= speed.max(1.5) + 3.0 {
|
||||
if !spec.decoy {
|
||||
crashes.push((d.id, d.kind, d.pos, spec.warhead));
|
||||
}
|
||||
removed.push(d.id);
|
||||
continue;
|
||||
}
|
||||
if !spec.decoy {
|
||||
impacts.push((d.id, d.kind, d.pos, spec.warhead));
|
||||
}
|
||||
@@ -1580,6 +1767,17 @@ impl Sim {
|
||||
for (drone, pos) in splits {
|
||||
self.log(pos, record::EventKind::Split { drone });
|
||||
}
|
||||
for (drone, kind, pos, tnt) in crashes {
|
||||
self.crashes += 1;
|
||||
let power = match tnt {
|
||||
0 => 0,
|
||||
1 | 2 => 1,
|
||||
3..=5 => 2,
|
||||
6 | 7 => 3,
|
||||
_ => 4,
|
||||
};
|
||||
self.log(pos, record::EventKind::Crash { drone, kind, power });
|
||||
}
|
||||
for (drone, kind, pos, tnt) in impacts {
|
||||
if let Some(sector) = self.drones.iter().find(|x| x.id == drone).map(|x| x.sector as usize) {
|
||||
self.learn(sector, false);
|
||||
@@ -1594,6 +1792,8 @@ impl Sim {
|
||||
let object = self.targets.iter().position(|t| t.distance(pos) < 16.0);
|
||||
let sam = self.sams.iter().enumerate().find(|(_, s)| s.alive() && s.pos.distance(pos) < 12.0).map(|(i, s)| (s.kind, i));
|
||||
self.log(pos, record::EventKind::Hit { drone, kind, power, object, sam });
|
||||
let vel = self.drones.iter().find(|x| x.id == drone).map_or(DVec3::ZERO, |x| x.vel);
|
||||
self.direct_hit(pos, vel, tnt, out);
|
||||
}
|
||||
for (pos, warhead, small) in boom {
|
||||
self.explode(pos, warhead, small, ground, player, out);
|
||||
@@ -1617,6 +1817,7 @@ impl Sim {
|
||||
jammed: false,
|
||||
sector,
|
||||
smart: false,
|
||||
nav: Nav::default(),
|
||||
});
|
||||
}
|
||||
for d in &self.drones {
|
||||
@@ -1768,7 +1969,7 @@ impl Sim {
|
||||
|
||||
// ------------------------------------------------ air defence
|
||||
|
||||
fn step_sams(&mut self, out: &mut Out) {
|
||||
fn step_sams(&mut self, ground: &mut Ground, out: &mut Out) {
|
||||
if self.hold {
|
||||
for i in 0..self.sams.len() {
|
||||
if self.sams[i].alive() && !self.sams[i].moving() {
|
||||
@@ -1797,18 +1998,25 @@ impl Sim {
|
||||
retarget(&mut guns_on, sam.gun_target.take(), None);
|
||||
continue;
|
||||
}
|
||||
let fit = self.sams[i].fitness();
|
||||
{
|
||||
let sam = &mut self.sams[i];
|
||||
sam.cooldown = sam.cooldown.saturating_sub(1);
|
||||
sam.gun_cooldown = sam.gun_cooldown.saturating_sub(1);
|
||||
sam.flash = sam.flash.saturating_sub(1);
|
||||
sam.recoil *= 0.6;
|
||||
sam.dish = (sam.dish + 0.15) % TAU;
|
||||
if sam.radar_up() {
|
||||
sam.dish = sam.beam;
|
||||
}
|
||||
sam.jam = sam.jam.saturating_sub(1);
|
||||
if self.tick % 64 == 0 {
|
||||
let now = self.tick;
|
||||
sam.terrain.retain(|_, (when, _)| now.saturating_sub(*when) < 4 * terrain::VERDICT_AGE);
|
||||
sam.waits.retain(|_, since| now.saturating_sub(*since) < 4 * terrain::WAIT_TICKS);
|
||||
}
|
||||
if sam.reloading > 0 {
|
||||
sam.reloading -= 1;
|
||||
if sam.reloading == 0 {
|
||||
sam.missiles = spec.missile.as_ref().map_or(0, |m| m.magazine);
|
||||
sam.missiles = sam.cells();
|
||||
sam.bursts = spec.gun.as_ref().map_or(0, |g| g.bursts);
|
||||
out.sound("minecraft:block.piston.extend", sam.pos, 2.0, 0.6);
|
||||
}
|
||||
@@ -1830,23 +2038,30 @@ impl Sim {
|
||||
let pos = self.sams[i].pos;
|
||||
let roles = self.sams[i].roles;
|
||||
let (had_target, had_gun) = (self.sams[i].target, self.sams[i].gun_target);
|
||||
let now = self.tick;
|
||||
let (target, gun_target) = {
|
||||
let (seen, own) = (&self.sams[i].seen, &self.sams[i].own);
|
||||
// The conditions are `why`'s, shared with the diagnostic.
|
||||
let allowed = |d: &Drone| why::allowed(roles, seen, d);
|
||||
let me = &self.sams[i];
|
||||
let tracks = &me.tracks;
|
||||
// A shot the land ruled out a moment ago is not tried again at once.
|
||||
let open = |id: u32| me.terrain_verdict(id, now).is_none_or(|b| b == 0);
|
||||
// The conditions are `why`'s, shared with the diagnostic. A system knows its targets by their tracks:
|
||||
// the forecast of the last measurement, never the true place.
|
||||
let drone = |tr: &Track| drone_at.get(&tr.id).map(|&k| &self.drones[k]);
|
||||
let claimed = |d: &Drone| why::claimed(d, &missile_on, had_target);
|
||||
let needed = why::needed;
|
||||
let target = spec.missile.as_ref().map(|load| {
|
||||
let in_reach = |d: &Drone, k: f64| why::in_reach(load.min, load.max, k, pos, d);
|
||||
let keep = had_target.filter(|t| {
|
||||
drone_at.get(t).is_some_and(|&k| allowed(&self.drones[k]) && in_reach(&self.drones[k], 1.1))
|
||||
});
|
||||
let ok = |tr: &Track, k: f64| {
|
||||
drone(tr).is_some_and(|d| why::allowed(roles, tr, now, d) && open(tr.id) && why::in_reach(load.min, load.max, k, pos, tr.est(now)))
|
||||
};
|
||||
let keep = had_target.filter(|t| tracks.iter().any(|tr| tr.id == *t && ok(tr, 1.1)));
|
||||
keep.or_else(|| {
|
||||
self.drones
|
||||
tracks
|
||||
.iter()
|
||||
.filter(|d| allowed(d) && in_reach(d, 1.0) && claimed(d) < needed(d))
|
||||
.max_by(|a, b| a.threat(pos).total_cmp(&b.threat(pos)))
|
||||
.map(|d| d.id)
|
||||
.filter(|tr| ok(tr, 1.0))
|
||||
.filter_map(|tr| drone(tr).map(|d| (tr, d)))
|
||||
.filter(|(_, d)| claimed(d) < needed(d))
|
||||
.max_by(|a, b| a.1.threat_at(pos, a.0.est(now)).total_cmp(&b.1.threat_at(pos, b.0.est(now))))
|
||||
.map(|(_, d)| d.id)
|
||||
})
|
||||
});
|
||||
// Guns: the nearest, spread over the targets the other guns and missiles aren't on.
|
||||
@@ -1854,12 +2069,13 @@ impl Sim {
|
||||
let on_it = |d: &Drone| {
|
||||
(guns_on.get(&d.id).copied().unwrap_or(0) - u32::from(had_gun == Some(d.id))) as f64
|
||||
};
|
||||
self.drones
|
||||
tracks
|
||||
.iter()
|
||||
.filter(|d| why::gun_candidate(roles, seen, own, gun.track, pos, d))
|
||||
.map(|d| {
|
||||
.filter_map(|tr| drone(tr).map(|d| (tr, d)))
|
||||
.filter(|(tr, d)| why::gun_candidate(roles, tr, now, gun.track, pos, d))
|
||||
.map(|(tr, d)| {
|
||||
let busy = 1.0 + 2.0 * on_it(d) + if d.engaged > 0 { 1.5 } else { 0.0 };
|
||||
(d.pos.distance(pos) * busy, d.id)
|
||||
(tr.est(now).distance(pos) * busy, d.id)
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, id)| id)
|
||||
@@ -1883,18 +2099,23 @@ impl Sim {
|
||||
}
|
||||
let sam = &mut self.sams[i];
|
||||
// What the turret or launcher points at: guns first when a target is close.
|
||||
let aim = sam.gun_target.or(sam.target).and_then(|t| drone_at.get(&t).map(|&k| &self.drones[k]));
|
||||
let aim = sam
|
||||
.gun_target
|
||||
.or(sam.target)
|
||||
.filter(|t| drone_at.contains_key(t))
|
||||
.and_then(|t| sam.track(t))
|
||||
.map(|tr| (tr.id, tr.est(now), tr.vel));
|
||||
sam.launched = sam.launched.saturating_sub(1);
|
||||
let Some(aim) = aim else {
|
||||
// Out of missiles or of shells, and nothing to shoot: reload now (both, as a
|
||||
// combined system's crew would), not only once both are spent.
|
||||
let spent = (spec.missile.is_some() && sam.missiles == 0) || (spec.gun.is_some() && sam.bursts == 0);
|
||||
let spent = (spec.missile.is_some() && sam.missiles == 0 && sam.part(Part::Launcher) > 0.0) || (spec.gun.is_some() && sam.bursts == 0);
|
||||
if spent && sam.reloading == 0 && !sam.empty {
|
||||
if logistics {
|
||||
sam.empty = true;
|
||||
ammo_waits += 1;
|
||||
} else {
|
||||
sam.reloading = spec.reload
|
||||
sam.reloading = sam.reload_time()
|
||||
}
|
||||
}
|
||||
sam.state = if sam.reloading > 0 {
|
||||
@@ -1910,7 +2131,8 @@ impl Sim {
|
||||
};
|
||||
let (mount, _) = sam.mount();
|
||||
let lead_speed = spec.gun.as_ref().map_or(5.0, |g| g.shell);
|
||||
let (lead, want_yaw, want_pitch) = why::aim_wants(mount, aim, lead_speed);
|
||||
let (aim_id, aim_pos, aim_vel) = aim;
|
||||
let (lead, want_yaw, want_pitch) = why::aim_wants(mount, aim_pos, aim_vel, lead_speed);
|
||||
let cells = matches!(spec.muzzle, Muzzle::Cells);
|
||||
match spec.family {
|
||||
// Vertical launch: no aiming (the Tor's turret turns its radar to the target).
|
||||
@@ -1933,7 +2155,7 @@ impl Sim {
|
||||
SamState::Fire
|
||||
} else if !vertical && aim_error > 0.15 {
|
||||
SamState::Slew
|
||||
} else if aim.pos.distance(mount) < self::SamKind::range(sam.kind) {
|
||||
} else if aim_pos.distance(mount) < self::SamKind::range(sam.kind) {
|
||||
SamState::Lock
|
||||
} else {
|
||||
SamState::Track
|
||||
@@ -1941,8 +2163,16 @@ impl Sim {
|
||||
// Guns.
|
||||
if let (Some(gun), Some(gt)) = (&spec.gun, sam.gun_target) {
|
||||
if let Some(t) = drone_at.get(>).map(|&k| &self.drones[k]) {
|
||||
let dist = t.pos.distance(mount);
|
||||
if why::gun_blocks(sam, aim.id, gt, aim_error, dist, gun.range) == 0 {
|
||||
let est = sam.track(gt).map_or(t.pos, |tr| tr.est(now));
|
||||
let dist = est.distance(mount);
|
||||
let mut blocks = why::gun_blocks(sam, aim_id, gt, aim_error, dist, gun.range, now);
|
||||
// A damaged gun jams now and then, for a while: the burst it was about to fire is lost.
|
||||
let wear = 1.0 - sam.part(Part::Gun);
|
||||
if blocks == 0 && wear > 0.0 && dice(self.seed, now, sam.id as u64, 77) < wear * 0.1 {
|
||||
sam.jam = 30 + (dice(self.seed, now, sam.id as u64, 78) * 90.0) as u32;
|
||||
blocks = why::JAMMED;
|
||||
}
|
||||
if blocks == 0 {
|
||||
sam.gun_cooldown = 2;
|
||||
sam.bursts -= 1;
|
||||
sam.flash = 2;
|
||||
@@ -1952,7 +2182,7 @@ impl Sim {
|
||||
sam.empty = true;
|
||||
ammo_waits += 1;
|
||||
} else {
|
||||
sam.reloading = spec.reload
|
||||
sam.reloading = sam.reload_time()
|
||||
}
|
||||
}
|
||||
let (_, rot) = sam.mount();
|
||||
@@ -1979,7 +2209,9 @@ impl Sim {
|
||||
}
|
||||
// At night a gun sees what is lit (a searchlight, a flare) and little else.
|
||||
let dark = if self.night && !self.lit.contains(&t.id) { lights::DARK_HITS } else { 1.0 };
|
||||
let p = gun.p * fit * dark / t.kind.spec().stealth * (1.0 - dist / (gun.range * 1.3)).max(0.35);
|
||||
// The aim point is the track's forecast: the farther it is from the truth, the more shells miss.
|
||||
let off = est.distance(t.pos);
|
||||
let p = gun.p * sam.gun_fit() * dark / (t.kind.spec().stealth * (1.0 + (off / 8.0).powi(2))) * (1.0 - dist / (gun.range * 1.3)).max(0.35);
|
||||
if self.rng.chance(p) {
|
||||
gun_hits.push((gt, sam.id));
|
||||
} else {
|
||||
@@ -1992,11 +2224,40 @@ impl Sim {
|
||||
if let (Some(load), Some(mt)) = (&spec.missile, sam.target) {
|
||||
let ready = why::missile_blocks(sam) == 0;
|
||||
// The missile turns after its boost, but the launcher must point near the target.
|
||||
let t = drone_at.get(&mt).map(|&k| &self.drones[k]);
|
||||
let aligned = why::missile_aligned(spec.family, sam.yaw, mount, t, aim.id, mt, aim_error);
|
||||
if ready && aligned {
|
||||
let n = load.magazine - sam.missiles;
|
||||
let (muzzle, dir) = sam.muzzle(n);
|
||||
let t = sam.track(mt).filter(|_| drone_at.contains_key(&mt)).map(|tr| tr.est(now));
|
||||
let aligned = why::missile_aligned(spec.family, sam.yaw, mount, t, aim_id, mt, aim_error);
|
||||
let n = load.magazine - sam.missiles;
|
||||
let (muzzle, dir) = sam.muzzle(n);
|
||||
// The land: a target behind a hill, or a path the hill closes, is not fired at; the system looks for another.
|
||||
let land = if ready && aligned {
|
||||
match (sam.terrain_verdict(mt, now), sam.track(mt)) {
|
||||
(Some(bits), _) => bits,
|
||||
(None, Some(tr)) => {
|
||||
let mut bits = terrain::launch_blocks(ground, load.kind, muzzle, sight::antenna(sam), tr.est(now), tr.vel);
|
||||
if bits == terrain::WAIT {
|
||||
// The land is being asked for: a moment's wait, not a long one (the game may not answer).
|
||||
if now.saturating_sub(*sam.waits.entry(mt).or_insert(now)) > terrain::WAIT_TICKS {
|
||||
bits = 0;
|
||||
}
|
||||
} else {
|
||||
sam.waits.remove(&mt);
|
||||
if bits != 0 {
|
||||
sam.terrain.insert(mt, (now, bits));
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
(None, None) => 0,
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if land == terrain::WAIT {
|
||||
// The land under the shot is not known yet: it waits.
|
||||
} else if land != 0 {
|
||||
retarget(&mut missile_on, Some(mt), None);
|
||||
sam.target = None;
|
||||
} else if ready && aligned {
|
||||
launches.push((load.kind, muzzle, dir, mt, sam.id));
|
||||
// Its missile is on it from now, so the systems after this one this tick see
|
||||
// the target taken; this one's next launch looks for another.
|
||||
@@ -2014,7 +2275,7 @@ impl Sim {
|
||||
sam.empty = true;
|
||||
ammo_waits += 1;
|
||||
} else {
|
||||
sam.reloading = spec.reload
|
||||
sam.reloading = sam.reload_time()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2061,10 +2322,12 @@ impl Sim {
|
||||
|
||||
// ------------------------------------------------ interceptors
|
||||
|
||||
fn step_missiles(&mut self, out: &mut Out) {
|
||||
fn step_missiles(&mut self, ground: &mut Ground, out: &mut Out) {
|
||||
let now = self.tick;
|
||||
let mut done: Vec<(u32, Option<u32>, bool, u32)> = Vec::new(); // (missile, target, kill, sam)
|
||||
let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32);
|
||||
let sams_fit: HashMap<u32, f64> = self.sams.iter().map(|s| (s.id, s.fitness())).collect();
|
||||
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();
|
||||
for m in &mut self.missiles {
|
||||
m.age += 1;
|
||||
let spec = m.kind.spec();
|
||||
@@ -2082,7 +2345,14 @@ impl Sim {
|
||||
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;
|
||||
turn(dir, t.pos + t.vel * tgo - m.pos, spec.turn)
|
||||
// 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);
|
||||
if fail {
|
||||
scrubbed += 1;
|
||||
done.push((m.id, Some(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),
|
||||
@@ -2091,13 +2361,16 @@ impl Sim {
|
||||
let before = m.pos;
|
||||
m.vel = new * speed;
|
||||
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();
|
||||
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;
|
||||
let rel1 = t.pos - m.pos;
|
||||
let delta = rel1 - rel0;
|
||||
let k = (-rel0.dot(delta) / delta.length_squared().max(1e-9)).clamp(0.0, 1.0);
|
||||
if (rel0 + delta * k).length() < spec.fuse {
|
||||
if (rel0 + delta * k).length() < spec.fuse && world.is_none_or(|(w, _)| k <= w) {
|
||||
// A target faster than the interceptor is hard to hit.
|
||||
let ratio = (spec.speed / t.vel.length().max(0.1)).min(1.0).powf(1.5);
|
||||
let fit = sams_fit.get(&m.sam).copied().unwrap_or(1.0);
|
||||
@@ -2107,6 +2380,13 @@ impl Sim {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some((_, p)) = world {
|
||||
// The land or a building is in the way: the interceptor is lost there.
|
||||
m.pos = p;
|
||||
blocked += 1;
|
||||
done.push((m.id, None, false, m.sam));
|
||||
continue;
|
||||
}
|
||||
if m.age > spec.life || (target.is_none() && m.age > 30) {
|
||||
// Its target gone (shot down by another, or lost), or its life spent chasing one.
|
||||
if target.is_none() {
|
||||
@@ -2151,6 +2431,8 @@ impl Sim {
|
||||
t.missed += missed;
|
||||
t.expired += expired;
|
||||
t.orphaned += orphaned;
|
||||
t.blocked += blocked;
|
||||
t.scrubbed += scrubbed;
|
||||
});
|
||||
for (mid, target, kill, sam) in done {
|
||||
if let Some(i) = self.missiles.iter().position(|m| m.id == mid) {
|
||||
|
||||
@@ -36,10 +36,10 @@ impl Sam {
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
/// Orders unit `i` to drive to `to`. False for one that can't (a depot, a wreck).
|
||||
/// Orders unit `i` to drive to `to`. False for one that can't (a depot, a wreck, failed running gear).
|
||||
pub fn order_move(&mut self, i: usize, to: DVec3) -> bool {
|
||||
let Some(sam) = self.sams.get_mut(i) else { return false };
|
||||
if !sam.alive() || !sam.kind.mobile() {
|
||||
if !sam.alive() || !sam.kind.mobile() || sam.drive_speed() <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
sam.dest = Some(to);
|
||||
@@ -75,6 +75,10 @@ impl Sim {
|
||||
}
|
||||
}
|
||||
SamState::Moving => {
|
||||
// The running gear failed on the way: it stops where it is and deploys there.
|
||||
if sam.drive_speed() <= 0.0 {
|
||||
sam.dest = None;
|
||||
}
|
||||
let Some(to) = sam.dest else {
|
||||
sam.state = SamState::Deploying;
|
||||
sam.march = sam.kind.march_ticks();
|
||||
@@ -93,7 +97,7 @@ impl Sim {
|
||||
sam.heading = slew(sam.heading, heading_of(flat), 0.06);
|
||||
let ahead = DQuat::from_rotation_y(sam.heading) * DVec3::NEG_Z;
|
||||
let turning = angle_diff(sam.heading, heading_of(flat)).abs();
|
||||
let step = SPEED * (1.0 - turning / std::f64::consts::PI).max(0.2);
|
||||
let step = SPEED * sam.drive_speed() * (1.0 - turning / std::f64::consts::PI).max(0.2);
|
||||
let next = sam.pos + ahead * step;
|
||||
let y = ground.at(next, now).unwrap_or(sam.pos.y);
|
||||
sam.pos = DVec3::new(next.x, sam.pos.y + (y - sam.pos.y).clamp(-0.5, 0.5), next.z);
|
||||
|
||||
+108
-6
@@ -84,6 +84,12 @@ pub(super) struct Plan {
|
||||
approach: Approach,
|
||||
total: u32,
|
||||
centre: DVec3,
|
||||
/// The land under the grid's cells (NaN where the game has none to tell of), the cells still unanswered, and
|
||||
/// the ticks the planner has waited for the answers. A smart raid is routed over the land, not round it.
|
||||
heights: Vec<f32>,
|
||||
unanswered: Vec<u32>,
|
||||
waited: u32,
|
||||
mapped: bool,
|
||||
}
|
||||
|
||||
/// The raid in progress: the counts at its start, for the report.
|
||||
@@ -123,6 +129,20 @@ impl Grid {
|
||||
|
||||
/// The cheapest way over `grid` from `from` to `to` by its prices (A*): points along it, `to` last.
|
||||
pub(super) fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> (Vec<DVec3>, usize) {
|
||||
plan_route_on(grid, prices, None, from, to)
|
||||
}
|
||||
|
||||
/// The land a route crosses: its height under each cell (NaN unknown), the climb a kind holds for long
|
||||
/// (rise a block), and the height above which the land forces a drone up from its low altitude.
|
||||
pub(super) struct Relief<'a> {
|
||||
pub heights: &'a [f32],
|
||||
pub slope: f32,
|
||||
pub floor: f32,
|
||||
}
|
||||
|
||||
/// [`plan_route`] over relief: a climb steeper than the kind holds is dear (a cliff or a ridge it would have to
|
||||
/// pull up over), and high land costs more than a valley, where it can fly low and stay under the radars.
|
||||
pub(super) fn plan_route_on(grid: &Grid, prices: &[f32], relief: Option<&Relief>, from: DVec3, to: DVec3) -> (Vec<DVec3>, usize) {
|
||||
let mut opened = 0;
|
||||
let n = grid.size;
|
||||
let at = |(i, j): (i32, i32)| (i * n + j) as usize;
|
||||
@@ -151,7 +171,21 @@ pub(super) fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) ->
|
||||
}
|
||||
let len = if di != 0 && dj != 0 { std::f32::consts::SQRT_2 } else { 1.0 };
|
||||
// The target's own cell is where it must go, whatever covers it.
|
||||
let price = if nb == goal { len } else { len * prices[at(nb)] };
|
||||
let mut price = if nb == goal { len } else { len * prices[at(nb)] };
|
||||
if let Some(r) = relief
|
||||
&& nb != goal
|
||||
{
|
||||
let (h0, h1) = (r.heights[at(c)], r.heights[at(nb)]);
|
||||
if h1.is_finite() {
|
||||
price += len * ((h1 - r.floor) / 30.0).clamp(0.0, 4.0);
|
||||
if h0.is_finite() {
|
||||
let slope = (h1 - h0) / (len * grid.cell as f32);
|
||||
if slope > r.slope {
|
||||
price += (slope - r.slope) * 20.0 * len;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let ng = g + price;
|
||||
if ng < best[at(nb)] {
|
||||
best[at(nb)] = ng;
|
||||
@@ -250,15 +284,53 @@ impl Sim {
|
||||
approach: order.approach,
|
||||
total,
|
||||
centre,
|
||||
heights: Vec::new(),
|
||||
unanswered: Vec::new(),
|
||||
waited: 0,
|
||||
mapped: !smart,
|
||||
});
|
||||
// A raid without routes is ready at once.
|
||||
self.plan_step(out);
|
||||
self.plan_step(None, out);
|
||||
total
|
||||
}
|
||||
|
||||
/// Works on the raid's plan a while (a tick's share), and launches it when it is ready.
|
||||
pub(super) fn plan_step(&mut self, out: &mut Out) {
|
||||
pub(super) fn plan_step(&mut self, ground: Option<&mut Ground>, out: &mut Out) {
|
||||
let Some(mut plan) = self.plan.take() else { return };
|
||||
// A smart raid first learns the land under its grid: asked for in bulk, a thousand columns a tick, for
|
||||
// up to two seconds (what the game has no land for stays unknown and costs nothing).
|
||||
if !plan.mapped {
|
||||
let Some(ground) = ground else {
|
||||
self.plan = Some(plan);
|
||||
return;
|
||||
};
|
||||
let n = plan.grid.size;
|
||||
if plan.heights.is_empty() {
|
||||
plan.heights = vec![f32::NAN; (n * n) as usize];
|
||||
plan.unanswered = (0..(n * n) as u32).collect();
|
||||
}
|
||||
let Plan { grid, heights, unanswered, .. } = &mut plan;
|
||||
unanswered.retain(|&k| {
|
||||
let (i, j) = (k as i32 / n, k as i32 % n);
|
||||
let c = grid.centre((i, j), 0.0);
|
||||
match ground.column(c.x.floor() as i32, c.z.floor() as i32, 1024) {
|
||||
None => true,
|
||||
Some(h) => {
|
||||
if let Some(h) = h {
|
||||
heights[k as usize] = h as f32;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
});
|
||||
plan.waited += 1;
|
||||
if plan.unanswered.is_empty() || plan.waited >= 40 {
|
||||
plan.mapped = true;
|
||||
} else {
|
||||
self.plan = Some(plan);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut work = 0usize;
|
||||
while plan.next < plan.items.len() && work < WORK_PER_TICK {
|
||||
let i = plan.next;
|
||||
@@ -268,17 +340,39 @@ impl Sim {
|
||||
};
|
||||
let spec = kind.spec();
|
||||
let mut d = plan.items[i].d.clone();
|
||||
// It starts over the land, not in it: a hill under the starting point lifts it.
|
||||
let mut from = from;
|
||||
if !plan.heights.is_empty() {
|
||||
let (gi, gj) = plan.grid.index(from);
|
||||
let g = plan.heights[(gi * plan.grid.size + gj) as usize];
|
||||
let floor = f64::from(g) + if spec.ballistic.is_some() { 10.0 } else { spec.clearance + 10.0 };
|
||||
if g.is_finite() && from.y < floor {
|
||||
from.y = floor;
|
||||
d.pos.y = floor;
|
||||
if spec.ballistic.is_some() {
|
||||
let flat = horizontal(aim - 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut path = from.distance(aim);
|
||||
if let Some(key) = key {
|
||||
let route = match plan.routes.get(&key) {
|
||||
Some(r) => r.clone(),
|
||||
None => {
|
||||
if !plan.prices.contains_key(&kind) {
|
||||
let (p, cost) = self.price_grid(&plan.grid, kind);
|
||||
let heights = (!plan.heights.is_empty()).then_some(plan.heights.as_slice());
|
||||
let (p, cost) = self.price_grid_on(&plan.grid, kind, heights);
|
||||
work += cost;
|
||||
plan.prices.insert(kind, p);
|
||||
}
|
||||
let (r, cost) = plan_route(&plan.grid, &plan.prices[&kind], from, aim);
|
||||
let relief = (!plan.heights.is_empty()).then(|| Relief {
|
||||
heights: &plan.heights,
|
||||
slope: (kind.climb_pitch().tan() * 0.5) as f32,
|
||||
floor: (aim.y + spec.clearance) as f32,
|
||||
});
|
||||
let (r, cost) = plan_route_on(&plan.grid, &plan.prices[&kind], relief.as_ref(), from, aim);
|
||||
work += cost;
|
||||
plan.routes.insert(key, r.clone());
|
||||
r
|
||||
@@ -357,6 +451,7 @@ impl Sim {
|
||||
jammed: false,
|
||||
sector: 0,
|
||||
smart: false,
|
||||
nav: Nav::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,6 +499,12 @@ impl Sim {
|
||||
/// The price of each cell of `grid` for this kind flying low: 1, and much more where a
|
||||
/// system sees it there (its radar's reach for it, the land) and can shoot it.
|
||||
pub(super) fn price_grid(&self, grid: &Grid, kind: DroneKind) -> (Vec<f32>, usize) {
|
||||
self.price_grid_on(grid, kind, None)
|
||||
}
|
||||
|
||||
/// The same over known land: a cell's drone flies `height` over the land there, not over the system's feet,
|
||||
/// so what a radar sees and what a hill hides follow the relief.
|
||||
pub(super) fn price_grid_on(&self, grid: &Grid, kind: DroneKind, land: Option<&[f32]>) -> (Vec<f32>, usize) {
|
||||
let mut cost = 0;
|
||||
let spec = kind.spec();
|
||||
let height = spec.clearance.max(6.0);
|
||||
@@ -446,7 +547,8 @@ impl Sim {
|
||||
for i in (ci - r).max(0)..=(ci + r).min(n - 1) {
|
||||
for j in (cj - r).max(0)..=(cj + r).min(n - 1) {
|
||||
cost += 1;
|
||||
let p = grid.centre((i, j), s.pos.y + height);
|
||||
let ground = land.map(|l| l[(i * n + j) as usize]).filter(|g| g.is_finite());
|
||||
let p = grid.centre((i, j), ground.map_or(s.pos.y + height, |g| f64::from(g) + height));
|
||||
let d = horizontal(p - s.pos).length();
|
||||
if d > far {
|
||||
continue;
|
||||
|
||||
@@ -39,6 +39,8 @@ pub enum EventKind {
|
||||
SamLost { sam: u32, kind: SamKind, index: usize },
|
||||
/// The Oreshnik's bus or a block splitting.
|
||||
Split { drone: u32 },
|
||||
/// A drone's warhead went off against the land or a building short of its aim.
|
||||
Crash { drone: u32, kind: DroneKind, power: u32 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -61,6 +63,10 @@ pub struct Tally {
|
||||
pub tracks_lost: u32,
|
||||
/// Times a system ran dry and began waiting for a reload truck.
|
||||
pub ammo_waits: u32,
|
||||
/// Interceptors lost against the land or a building on their way.
|
||||
pub blocked: u32,
|
||||
/// Interceptors that blew up in the air short of land they could not climb over.
|
||||
pub scrubbed: u32,
|
||||
}
|
||||
|
||||
impl Tally {
|
||||
@@ -71,6 +77,8 @@ impl Tally {
|
||||
orphaned: self.orphaned.saturating_sub(start.orphaned),
|
||||
tracks_lost: self.tracks_lost.saturating_sub(start.tracks_lost),
|
||||
ammo_waits: self.ammo_waits.saturating_sub(start.ammo_waits),
|
||||
blocked: self.blocked.saturating_sub(start.blocked),
|
||||
scrubbed: self.scrubbed.saturating_sub(start.scrubbed),
|
||||
}
|
||||
}
|
||||
/// The non-zero counts as a line, or nothing.
|
||||
@@ -81,6 +89,8 @@ impl Tally {
|
||||
(self.orphaned, "ракет остались без цели"),
|
||||
(self.tracks_lost, "потерь трека"),
|
||||
(self.ammo_waits, "ожиданий БК"),
|
||||
(self.blocked, "ракет разбились о препятствие"),
|
||||
(self.scrubbed, "ракет самоуничтожились у рельефа"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|p| p.0 > 0)
|
||||
@@ -104,10 +114,16 @@ 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>,
|
||||
/// Ticks the sky has been clear.
|
||||
quiet: u32,
|
||||
}
|
||||
|
||||
/// Ticks between the recording's clock samples.
|
||||
const TIME_STEP: u32 = 20;
|
||||
|
||||
impl Track {
|
||||
/// Where it was at `t` (the recording's ticks, fractional), if it was.
|
||||
pub fn at(&self, origin: DVec3, t: f64) -> Option<DVec3> {
|
||||
@@ -163,6 +179,7 @@ impl Recording {
|
||||
tally: Tally::default(),
|
||||
sams: sim.sams.iter().map(|s| (s.id, s.kind, s.pos)).collect(),
|
||||
objects: sim.targets.clone(),
|
||||
times: Vec::new(),
|
||||
quiet: 0,
|
||||
}
|
||||
}
|
||||
@@ -178,6 +195,19 @@ impl Recording {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The time of day (ticks, 24000 a day) the world showed at the recording's tick `t`: the
|
||||
/// 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 };
|
||||
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),
|
||||
_ => Some(a),
|
||||
}
|
||||
}
|
||||
pub fn track(&self, id: u32) -> Option<&Track> {
|
||||
self.index.get(&id).map(|&i| &self.tracks[i])
|
||||
}
|
||||
@@ -205,6 +235,15 @@ impl Sim {
|
||||
|
||||
/// Something worth a replay happened.
|
||||
pub(super) fn log(&mut self, pos: DVec3, kind: EventKind) {
|
||||
match kind {
|
||||
EventKind::Crash { power, .. } => self.ignite(pos, power, None),
|
||||
EventKind::Hit { power, sam, .. } => {
|
||||
let host = sam.and_then(|(_, i)| self.sams.get(i)).map(|s| s.id);
|
||||
self.ignite(pos, power.max(1), host)
|
||||
}
|
||||
EventKind::SamLost { sam, .. } => self.ignite(pos, 2, Some(sam)),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(r) = &mut self.rec {
|
||||
let at = r.len;
|
||||
if let EventKind::Kill { drone, .. } = kind
|
||||
@@ -230,6 +269,12 @@ impl Sim {
|
||||
self.rec = Some(Recording::new(self.recordings, self));
|
||||
}
|
||||
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);
|
||||
}
|
||||
if r.len % STEP == 0 {
|
||||
for d in &self.drones {
|
||||
r.point(d.id, What::Drone(d.kind), d.pos);
|
||||
@@ -304,6 +349,31 @@ 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;
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Blasts: a fireball growing and fading for 1.5 s after each.
|
||||
for (k, e) in rec.events.iter().enumerate() {
|
||||
let age = t - e.at as f64;
|
||||
@@ -312,7 +382,7 @@ impl Sim {
|
||||
}
|
||||
let size = match e.kind {
|
||||
EventKind::Kill { .. } => 3.5,
|
||||
EventKind::Hit { power, .. } => [2.5, 4.5, 7.0, 10.0, 13.0][(power as usize).min(4)],
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Regression tests for the issues filed on Gitea (each names its issue).
|
||||
use super::*;
|
||||
use super::raid::Grid;
|
||||
use super::damage::MISS_RADIUS;
|
||||
|
||||
// #1: [bug] Запросы рельефа голодают: часть направлений РЛС никогда не получает высоты
|
||||
#[test]
|
||||
@@ -186,7 +187,8 @@ fn turret_waits_for_its_aim() {
|
||||
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
// Straight behind the turret's heading, a slow target well inside the gun's reach.
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, 60.0), DVec3::ZERO, DVec3::new(0.0, 70.0, 900.0));
|
||||
sim.drones[0].vel = DVec3::ZERO;
|
||||
// (Pointing away from the gun, so that it doesn't start by turning back over it.)
|
||||
sim.drones[0].vel = DVec3::Z * 0.01;
|
||||
let (mut ground, mut out) = (Ground::default(), Out::default());
|
||||
let mut turned = 0;
|
||||
for _ in 0..200 {
|
||||
@@ -222,3 +224,577 @@ fn drive_arrives_without_overshoot() {
|
||||
assert!(left.abs() < 1e-9 && vel == 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
// #49: a direct hit by an armed drone destroys the system at once, not with a dent of its health
|
||||
#[test]
|
||||
fn armed_strike_on_a_system_destroys_it_at_once() {
|
||||
let centre = DVec3::new(0.0, 70.0, 0.0);
|
||||
let hull = 2.2 * SamKind::Patriot.spec().scale;
|
||||
for kind in [DroneKind::Lancet, DroneKind::Shahed] {
|
||||
let mut destroyed = 0;
|
||||
for seed in 1..=40 {
|
||||
let mut sim = Sim::new(seed);
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Patriot, centre, 0.0));
|
||||
let mut ground = Ground::default();
|
||||
sim.add_drone(kind, DVec3::new(0.0, 120.0, 0.0), DVec3::NEG_Y, centre);
|
||||
let mut out = Out::default();
|
||||
let mut impact = None;
|
||||
for _ in 0..400 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
if let Some(b) = sim.blasts.first() {
|
||||
impact = Some(b.at);
|
||||
break;
|
||||
}
|
||||
if sim.drones.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let at = impact.expect("the strike must go off");
|
||||
let inside = DVec3::new(at.x, 0.0, at.z).distance(DVec3::new(centre.x, 0.0, centre.z)) <= hull && at.y < centre.y + 3.0;
|
||||
let dead = !sim.sams[0].alive();
|
||||
if inside {
|
||||
assert!(dead, "{kind:?} seed {seed} struck the hull at {at:?} but the system lives");
|
||||
}
|
||||
destroyed += dead as u32;
|
||||
}
|
||||
assert!(destroyed >= 30, "{kind:?}: only {destroyed}/40 direct strikes destroyed the system");
|
||||
}
|
||||
}
|
||||
|
||||
// #49: a near miss is only splash, an unarmed body is no direct hit, the top of the hull counts
|
||||
#[test]
|
||||
fn near_miss_and_unarmed_body_are_not_direct_hits() {
|
||||
let mut sim = Sim::new(1);
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
let down = DVec3::new(0.0, -2.0, 0.0);
|
||||
sim.direct_hit(DVec3::new(6.0, 71.0, 0.0), down, 3, &mut Out::default());
|
||||
assert!(sim.sams[0].alive(), "a near miss six blocks off destroyed the system");
|
||||
sim.direct_hit(DVec3::new(0.0, 71.0, 0.0), down, 0, &mut Out::default());
|
||||
assert!(sim.sams[0].alive(), "an unarmed body destroyed the system");
|
||||
sim.direct_hit(DVec3::new(0.0, 73.0, 0.0), down, 1, &mut Out::default());
|
||||
assert!(!sim.sams[0].alive(), "an armed warhead into the top of the hull did not destroy the system");
|
||||
}
|
||||
|
||||
// #59: a strike misses at a small, kind-specific rate; its scatter is the kind's CEP (half land within it)
|
||||
#[test]
|
||||
fn strike_scatter_matches_each_kinds_cep_and_miss_rate() {
|
||||
const N: u64 = 5000;
|
||||
for kind in DroneKind::ALL {
|
||||
let mut sim = Sim::new(7);
|
||||
let (cep, gross) = kind.accuracy();
|
||||
let sigma = cep / (2.0 * std::f64::consts::LN_2).sqrt();
|
||||
let expected = (1.0 - gross) * (-MISS_RADIUS * MISS_RADIUS / (2.0 * sigma * sigma)).exp() + gross;
|
||||
let (mut misses, mut within, mut farthest) = (0u64, 0u64, 0.0f64);
|
||||
for _ in 0..N {
|
||||
let e = sim.aim_error(kind);
|
||||
assert_eq!(e.y, 0.0);
|
||||
let r = e.length();
|
||||
within += (r <= cep) as u64;
|
||||
if r > MISS_RADIUS {
|
||||
misses += 1;
|
||||
farthest = farthest.max(r);
|
||||
}
|
||||
}
|
||||
let rate = misses as f64 / N as f64;
|
||||
let half = within as f64 / N as f64;
|
||||
assert!((rate - expected).abs() < 0.015, "{kind:?}: miss rate {rate:.4}, expected {expected:.4}");
|
||||
assert!((0.005..=0.20).contains(&rate), "{kind:?}: miss rate {rate:.4} is not a small share (the IRBM bus is the loosest)");
|
||||
assert!((half - 0.5 * (1.0 - gross)).abs() < 0.03, "{kind:?}: {half:.3} of strikes within the CEP {cep}");
|
||||
assert!(farthest < MISS_RADIUS + 4.0 * cep, "{kind:?}: a miss {farthest:.2} blocks out, far from the aim");
|
||||
}
|
||||
}
|
||||
|
||||
// #59: the drone flies to its own error point and bursts there: the miss lands near the aim, by the CEP
|
||||
#[test]
|
||||
fn strikes_burst_near_their_aim_within_the_kinds_cep() {
|
||||
let aim = DVec3::new(0.0, 70.0, 0.0);
|
||||
for kind in [DroneKind::Shahed, DroneKind::Lancet] {
|
||||
let cep = kind.accuracy().0;
|
||||
let mut misses = 0;
|
||||
for seed in 1..=60 {
|
||||
let mut sim = Sim::new(seed);
|
||||
let mut ground = Ground::default();
|
||||
sim.add_drone(kind, DVec3::new(0.0, 120.0, 0.0), DVec3::NEG_Y, aim);
|
||||
let mut out = Out::default();
|
||||
let mut impact = None;
|
||||
for _ in 0..400 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
if let Some(b) = sim.blasts.first() {
|
||||
impact = Some(b.at);
|
||||
break;
|
||||
}
|
||||
if sim.drones.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let at = impact.expect("the strike must go off");
|
||||
let off = DVec3::new(at.x - aim.x, 0.0, at.z - aim.z).length();
|
||||
assert!(off < MISS_RADIUS + 4.0 * cep + 2.0, "{kind:?} seed {seed} burst {off:.2} blocks from its aim");
|
||||
misses += (off > MISS_RADIUS) as u32;
|
||||
}
|
||||
assert!(misses <= 6, "{kind:?}: {misses}/60 strikes missed");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- #42, #43: flights are swept against the blocks of the world (a synthetic world stands in for the game)
|
||||
|
||||
/// Blocks from a function: `Some(solid)`, or `None` where there is no chunk.
|
||||
struct Blocks<F: Fn(i32, i32, i32) -> Option<bool>>(F);
|
||||
impl<F: Fn(i32, i32, i32) -> Option<bool>> world::WorldSource for Blocks<F> {
|
||||
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
|
||||
(self.0)(x, y, z)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the sections a flight along `a` to `b` needs, as the game would answer them over a few ticks.
|
||||
fn warm(ground: &mut Ground, src: &dyn world::WorldSource, a: DVec3, b: DVec3) {
|
||||
for _ in 0..4 {
|
||||
ground.world.sweep(a, b, false, 0);
|
||||
let wanted = ground.world.take_wanted(0);
|
||||
ground.world.serve(src, wanted, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// One tick of the drones with the game answering what was asked.
|
||||
fn fly(sim: &mut Sim, ground: &mut Ground, src: &dyn world::WorldSource) {
|
||||
sim.tick += 1;
|
||||
sim.step_drones(ground, None, &mut Out::default());
|
||||
let wanted = ground.world.take_wanted(sim.tick);
|
||||
ground.world.serve(src, wanted, sim.tick);
|
||||
}
|
||||
|
||||
// #42: a drone that crosses a one-block wall in a tick explodes at the wall, and that is no hit on its aim
|
||||
#[test]
|
||||
fn fast_drone_must_not_cross_a_one_block_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);
|
||||
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;
|
||||
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");
|
||||
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");
|
||||
assert!(sim.replays.iter().chain(sim.rec.iter()).all(|r| r.events.iter().all(|e| !matches!(e.kind, record::EventKind::Hit { .. }))));
|
||||
}
|
||||
|
||||
// #42: a drone flying into a hill side explodes at the face of the hill
|
||||
#[test]
|
||||
fn drone_flying_into_a_hill_explodes_at_its_face() {
|
||||
// Flat at 64 to x=50, then a slope of 1:1.
|
||||
let height = |x: i32| 64 + (x - 50).max(0);
|
||||
let src = Blocks(move |x, y, _| Some(y < height(x)));
|
||||
for kind in [DroneKind::Shahed, DroneKind::Zircon] {
|
||||
let mut sim = Sim::new(3);
|
||||
sim.damage = false;
|
||||
let mut ground = Ground::default();
|
||||
// The aim is on the far side of the face, close enough that the dive is not called off for the hill (it is
|
||||
// below, at the end of the dive, where flight planning leaves it alone).
|
||||
sim.add_drone(kind, DVec3::new(0.0, 80.0, 0.0), DVec3::X, DVec3::new(80.0, 80.0, 0.0));
|
||||
sim.drones[0].flight = Flight::Dive;
|
||||
for _ in 0..300 {
|
||||
if sim.drones.is_empty() {
|
||||
break;
|
||||
}
|
||||
fly(&mut sim, &mut ground, &src);
|
||||
}
|
||||
assert!(sim.drones.is_empty(), "{kind:?} flew on");
|
||||
let at = sim.blasts.first().expect("no blast").at;
|
||||
// The slope meets y=80 at x=66.
|
||||
assert!(at.x > 62.0 && at.x < 67.5, "{kind:?} burst at {at:?}, not at the hill face (x~66)");
|
||||
assert_eq!(sim.crashes, 1, "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// #42: an interceptor cannot pass through a wall to its target, and does not kill it from behind the wall
|
||||
#[test]
|
||||
fn missile_cannot_pass_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();
|
||||
// The target stands still just behind the wall, inside the fuse's reach.
|
||||
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());
|
||||
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 });
|
||||
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());
|
||||
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");
|
||||
assert_eq!(sim.drones[0].engaged, 0, "the target stays claimed by a missile that is gone");
|
||||
assert_eq!(sim.tally.blocked, 1);
|
||||
assert!(sim.blasts.iter().all(|b| b.at.x < 3.5), "burst behind the wall");
|
||||
}
|
||||
|
||||
// #42, #43: a bridge deck above the flight is no ground under it
|
||||
#[test]
|
||||
fn flight_under_a_bridge_is_not_a_ground_hit() {
|
||||
let src = Blocks(|_, y, _| Some(y < 64 || y == 140));
|
||||
let mut sim = Sim::new(1);
|
||||
sim.damage = false;
|
||||
let mut ground = Ground::default();
|
||||
// The height map knows only the deck: 141 everywhere.
|
||||
ground.answer((-5..400).map(|x| (x, 0, 141)).collect(), 0);
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 100.0, 0.0), DVec3::X, DVec3::new(2000.0, 100.0, 0.0));
|
||||
warm(&mut ground, &src, DVec3::new(0.0, 100.0, 0.0), DVec3::new(300.0, 100.0, 0.0));
|
||||
for _ in 0..40 {
|
||||
fly(&mut sim, &mut ground, &src);
|
||||
}
|
||||
assert_eq!(sim.drones.len(), 1, "the deck above was taken for the ground");
|
||||
assert_eq!(sim.crashes, 0);
|
||||
}
|
||||
|
||||
// #43: unknown chunks and spent budgets are never air; changes and another world void the cache
|
||||
#[test]
|
||||
fn unknown_is_not_clear_and_changes_invalidate() {
|
||||
let mut ground = Ground::default();
|
||||
let (a, b) = (DVec3::new(0.5, 70.5, 0.5), DVec3::new(30.5, 70.5, 0.5));
|
||||
// Nothing read yet: the segment is unknown and the sections are asked for.
|
||||
assert!(matches!(ground.world.sweep(a, b, false, 1), world::Sweep::Unknown { .. }));
|
||||
let asked = ground.world.take_wanted(1);
|
||||
assert!(asked.contains(&(0, 4, 0)), "{asked:?}");
|
||||
// The game has no chunk there: still unknown, not air.
|
||||
let nothing = Blocks(|_, _, _| None);
|
||||
ground.world.serve(¬hing, asked, 2);
|
||||
assert!(matches!(ground.world.sweep(a, b, false, 3), world::Sweep::Unknown { .. }));
|
||||
// A wall appears in a chunk that now is loaded: found after it is read again.
|
||||
let wall = Blocks(|x, _, _| Some(x == 20));
|
||||
for now in [70, 71, 72] {
|
||||
ground.world.sweep(a, b, false, now);
|
||||
let w = ground.world.take_wanted(now);
|
||||
ground.world.serve(&wall, w, now);
|
||||
}
|
||||
let hit = ground.world.sweep(a, b, false, 73);
|
||||
assert!(matches!(hit, world::Sweep::Hit { block: [20, 70, 0], normal: [-1, 0, 0], .. }), "{hit:?}");
|
||||
// The wall is broken: the game says which section changed, and the next sweep asks again.
|
||||
let open = Blocks(|_, _, _| Some(false));
|
||||
ground.world.changed(7, false, &[(1, 4, 0)]);
|
||||
assert!(matches!(ground.world.sweep(a, b, false, 74), world::Sweep::Hit { .. }), "stale data is used until the answer");
|
||||
let w = ground.world.take_wanted(74);
|
||||
assert!(w.contains(&(1, 4, 0)), "{w:?}");
|
||||
ground.world.serve(&open, w, 75);
|
||||
assert_eq!(ground.world.sweep(a, b, false, 76), world::Sweep::Clear);
|
||||
// Another world: everything goes.
|
||||
ground.world.changed(8, false, &[]);
|
||||
assert!(ground.world.is_empty());
|
||||
}
|
||||
|
||||
// #43: the same wall and tunnel fixtures answer alike by sweep and by sections, and the cache is bounded
|
||||
#[test]
|
||||
fn wall_and_tunnel_fixtures_agree() {
|
||||
let tunnel = Blocks(|x, y, z| Some(y < 60 || (x == 10 && !(y == 70 && z == 0))));
|
||||
let mut ground = Ground::default();
|
||||
for z in [0.5, 1.5] {
|
||||
let (a, b) = (DVec3::new(0.5, 70.5, z), DVec3::new(30.5, 70.5, z));
|
||||
warm(&mut ground, &tunnel, a, b);
|
||||
let r = ground.world.sweep(a, b, false, 1);
|
||||
if z == 0.5 {
|
||||
assert_eq!(r, world::Sweep::Clear, "the tunnel is open");
|
||||
} else {
|
||||
assert!(matches!(r, world::Sweep::Hit { block: [10, 70, 1], .. }), "{r:?}");
|
||||
}
|
||||
}
|
||||
assert!(ground.world.bytes() < 64 * 1024);
|
||||
}
|
||||
|
||||
// #57: a recording keeps the world clock of the raid, with jumps as steps.
|
||||
#[test]
|
||||
fn recordings_keep_the_time_of_day() {
|
||||
let mut sim = Sim::new(1);
|
||||
sim.day_time = Some(18000);
|
||||
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 {
|
||||
sim.day_time = Some(if i < 50 { 18000 + i } else { 6000 + i });
|
||||
sim.step(&mut g, None, &mut out);
|
||||
}
|
||||
let r = sim.rec.as_ref().unwrap();
|
||||
assert_eq!(r.day_time(0.0), Some(18000 + sim_first(r)));
|
||||
// The jump is a step, not a glide through the day.
|
||||
for t in 0..r.len.min(100) {
|
||||
let v = r.day_time(t as f64).unwrap();
|
||||
assert!((18000..18060).contains(&v) || (6000..6110).contains(&v), "{t}: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
fn sim_first(r: &record::Recording) -> u64 {
|
||||
r.tracks.first().map_or(0, |t| t.first as u64)
|
||||
}
|
||||
|
||||
// #52: a crash lights a fire; the oldest of too many dies down; water leaves steam only.
|
||||
#[test]
|
||||
fn fires_start_merge_and_are_limited() {
|
||||
let mut sim = Sim::new(1);
|
||||
sim.ignite(DVec3::new(0.0, 70.0, 0.0), 1, None);
|
||||
sim.ignite(DVec3::new(2.0, 70.0, 0.0), 2, None);
|
||||
assert_eq!(sim.fires.len(), 1, "one fire where two things fell");
|
||||
assert!(sim.fires.list[0].size >= fires::size_for(2));
|
||||
for i in 0..30 {
|
||||
sim.tick += 10;
|
||||
sim.ignite(DVec3::new(100.0 * (i + 1) as f64, 70.0, 0.0), 1, None);
|
||||
}
|
||||
let now = sim.tick;
|
||||
// Past the limit the oldest are already dying down.
|
||||
let healthy = sim.fires.list.iter().filter(|f| (now - f.start) + 160 < f.life as u64).count();
|
||||
assert!(healthy <= fires::MAX, "{healthy}");
|
||||
let (fl, sm) = fires::level(10.0, 3000.0);
|
||||
assert!(fl > 0.0 && sm > 0.0);
|
||||
assert_eq!(fires::level(3000.0, 3000.0).0, 0.0);
|
||||
}
|
||||
|
||||
// ---- flight over the land: strike drones follow it, launchers keep their missiles for shots the land allows,
|
||||
// interceptors keep clear of it. A synthetic land (a height function) stands in for the game.
|
||||
|
||||
/// Land from a height function: blocks below it are solid, and the game's height map says the same.
|
||||
struct Land<F: Fn(i32, i32) -> i32>(F);
|
||||
impl<F: Fn(i32, i32) -> i32> world::WorldSource for Land<F> {
|
||||
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
|
||||
Some(y < (self.0)(x, z))
|
||||
}
|
||||
}
|
||||
|
||||
/// What the game answers a tick after the sim asked: land heights, and the sections it needs.
|
||||
fn answer_land<F: Fn(i32, i32) -> i32>(ground: &mut Ground, land: &Land<F>, now: u64) {
|
||||
let columns = ground.take_wanted().into_iter().map(|(x, z)| (x, z, (land.0)(x, z))).collect();
|
||||
ground.answer(columns, now);
|
||||
let sections = ground.world.take_wanted(now);
|
||||
ground.world.serve(land, sections, now);
|
||||
}
|
||||
|
||||
/// A ridge across the way, `rise` blocks high over a flat at 64, `half` blocks of slope to each side, crest at x=300.
|
||||
fn ridge(rise: i32, half: i32) -> Land<impl Fn(i32, i32) -> i32> {
|
||||
ridge_at(300, rise, half)
|
||||
}
|
||||
|
||||
fn ridge_at(crest: i32, rise: i32, half: i32) -> Land<impl Fn(i32, i32) -> i32> {
|
||||
Land(move |x, _| 64 + (rise - (x - crest).abs() * rise / half).max(0))
|
||||
}
|
||||
|
||||
/// Flies one drone of `kind` along +x over `land` from x=0 to its aim at x=750, to the end. Returns the sim.
|
||||
fn fly_over<F: Fn(i32, i32) -> i32>(kind: DroneKind, land: &Land<F>, low: bool) -> Sim {
|
||||
let mut sim = Sim::new(5);
|
||||
sim.damage = false;
|
||||
let mut ground = Ground::default();
|
||||
let spec = kind.spec();
|
||||
let height = if spec.cruise > 0.0 { spec.cruise } else { 25.0 };
|
||||
sim.add_drone(kind, DVec3::new(0.0, 64.0 + height, 0.0), DVec3::X, DVec3::new(750.0, 64.0, 0.0));
|
||||
sim.drones[0].low = low;
|
||||
for _ in 0..4 {
|
||||
sim.tick += 1;
|
||||
answer_land(&mut ground, land, sim.tick);
|
||||
}
|
||||
let mut out = Out::default();
|
||||
for _ in 0..4000 {
|
||||
if sim.drones.is_empty() {
|
||||
break;
|
||||
}
|
||||
sim.tick += 1;
|
||||
sim.step_drones(&mut ground, None, &mut out);
|
||||
answer_land(&mut ground, land, sim.tick);
|
||||
}
|
||||
sim
|
||||
}
|
||||
|
||||
// Strike drones climb over a hill they could not have seen 20 blocks ahead.
|
||||
#[test]
|
||||
fn a_raid_crossing_a_hill_flies_over_it() {
|
||||
for (rise, half) in [(60, 100), (60, 60), (90, 120)] {
|
||||
let land = ridge(rise, half);
|
||||
for kind in [DroneKind::Shahed, DroneKind::Geran3, DroneKind::Lancet, DroneKind::Gerbera, DroneKind::Kh101, DroneKind::Kalibr, DroneKind::Kh22, DroneKind::Zircon] {
|
||||
for low in [false, true] {
|
||||
let sim = fly_over(kind, &land, low);
|
||||
assert!(sim.drones.is_empty(), "{kind:?}: still flying");
|
||||
// It bursts at its aim, not on the hill 450 blocks short of it (where a crash or a decoy's end shows too).
|
||||
let at = sim.blasts.first().expect("no blast").at;
|
||||
assert!(at.x > 700.0, "{kind:?} low={low} burst at x={:.0} over a {rise}-block hill ({half} to the crest)", at.x);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A low drone hugs the land: it does not fly at cruise height over the flat behind the ridge.
|
||||
#[test]
|
||||
fn a_low_drone_hugs_the_land_after_the_ridge() {
|
||||
let land = ridge(60, 100);
|
||||
let mut sim = Sim::new(5);
|
||||
sim.damage = false;
|
||||
let mut ground = Ground::default();
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, 0.0), DVec3::X, DVec3::new(900.0, 64.0, 0.0));
|
||||
sim.drones[0].low = true;
|
||||
let mut out = Out::default();
|
||||
let mut highest_after = 0.0f64;
|
||||
for _ in 0..4000 {
|
||||
if sim.drones.is_empty() {
|
||||
break;
|
||||
}
|
||||
sim.tick += 1;
|
||||
sim.step_drones(&mut ground, None, &mut out);
|
||||
answer_land(&mut ground, &land, sim.tick);
|
||||
if let Some(d) = sim.drones.first()
|
||||
&& d.pos.x > 560.0
|
||||
&& d.pos.x < 800.0
|
||||
{
|
||||
highest_after = highest_after.max(d.pos.y);
|
||||
}
|
||||
}
|
||||
assert_eq!(sim.crashes, 0);
|
||||
assert!(highest_after < 64.0 + 40.0, "still {highest_after:.0} high far behind the ridge");
|
||||
}
|
||||
|
||||
/// A target flying slowly along the ridge's far side at `x`, with a radar on the crest to see it.
|
||||
fn behind_ridge(land: &Land<impl Fn(i32, i32) -> i32>, crest: i32, kind: SamKind, x: f64, ticks: u32) -> Sim {
|
||||
let mut sim = Sim::new(9);
|
||||
sim.damage = false;
|
||||
let ground_y = (land.0)(0, 0) as f64;
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, kind, DVec3::new(0.0, ground_y, 0.0), 0.0));
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(f64::from(crest), (land.0)(crest, 0) as f64, -60.0), 0.0));
|
||||
let mut ground = Ground::default();
|
||||
let mut out = Out::default();
|
||||
for _ in 0..40 {
|
||||
sim.tick += 1;
|
||||
answer_land(&mut ground, land, sim.tick);
|
||||
}
|
||||
// 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));
|
||||
for _ in 0..ticks {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
answer_land(&mut ground, land, sim.tick);
|
||||
}
|
||||
sim
|
||||
}
|
||||
|
||||
// A short-range system behind a hill does not fire at a target the hill masks from it, whatever the network says.
|
||||
#[test]
|
||||
fn a_system_does_not_fire_at_a_target_masked_by_a_hill() {
|
||||
// A short-range system 250 blocks short of the crest, the target 80 blocks beyond it.
|
||||
type Boxed = Land<Box<dyn Fn(i32, i32) -> i32>>;
|
||||
let hill: Boxed = Land(Box::new(|x, _| 64 + (60 - (x - 150).abs()).max(0)));
|
||||
let flat: Boxed = Land(Box::new(|_, _| 64));
|
||||
let shot = |land: &Boxed| {
|
||||
let mut sim = Sim::new(9);
|
||||
sim.damage = false;
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Tor, DVec3::new(100.0, 64.0, 0.0), 0.0));
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(150.0, (land.0)(150, 0) as f64, -60.0), 0.0));
|
||||
let mut ground = Ground::default();
|
||||
let mut out = Out::default();
|
||||
for _ in 0..40 {
|
||||
sim.tick += 1;
|
||||
answer_land(&mut ground, land, sim.tick);
|
||||
}
|
||||
sim.add_drone(DroneKind::Gerbera, DVec3::new(200.0, 84.0, -60.0), DVec3::Z, DVec3::new(200.0, 64.0, 3000.0));
|
||||
let mut masked = 0u32;
|
||||
let mut seen = false;
|
||||
let mut told = false;
|
||||
for _ in 0..300 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
answer_land(&mut ground, land, sim.tick);
|
||||
seen |= sim.sams[0].seen.len() == 1;
|
||||
masked += sim.sams[0].terrain.values().filter(|(_, b)| *b == terrain::MASKED).count() as u32;
|
||||
// `why` names the reason while the verdict stands.
|
||||
told |= sim.why(0).iter().any(|c| !c.ok && c.text.contains("за рельефом"));
|
||||
}
|
||||
(sim, seen, masked, told)
|
||||
};
|
||||
let (open, seen, _, _) = shot(&flat);
|
||||
assert!(seen && open.sams[0].fired > 0, "control: with a clear line the Tor must fire (seen {seen}, fired {})", open.sams[0].fired);
|
||||
let (shut, seen, masked, told) = shot(&hill);
|
||||
assert!(seen, "the network must have shown the target to the Tor");
|
||||
assert_eq!(shut.sams[0].fired, 0, "the Tor fired at a target behind the hill");
|
||||
assert!(masked > 0, "no launch was judged and refused for the land");
|
||||
assert_eq!(shut.tally.blocked + shut.tally.scrubbed, 0);
|
||||
assert!(told, "`why` never named the land as the reason");
|
||||
}
|
||||
|
||||
// A long-range system lofts over a low ridge (its interceptor does not hit it) but not over a mountain.
|
||||
#[test]
|
||||
fn a_long_range_system_lofts_over_a_ridge_or_waits() {
|
||||
let low = ridge_at(150, 25, 60);
|
||||
let sim = behind_ridge(&low, 150, SamKind::IrisT, 230.0, 300);
|
||||
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<_>>());
|
||||
}
|
||||
|
||||
// Interceptors fly over rolling land at a target low over it, and none ends in the ground.
|
||||
#[test]
|
||||
fn an_interceptor_over_rolling_land_does_not_hit_the_ground() {
|
||||
let rolling = Land(|x: i32, z: i32| 70 + (28.0 * ((f64::from(x) / 21.0).sin() + (f64::from(z) / 33.0).cos()) / 2.0).round() as i32);
|
||||
for kind in [MissileKind::Pac3, MissileKind::M48n6, MissileKind::IrisT, MissileKind::M57e6, MissileKind::M9m338, MissileKind::P1sun] {
|
||||
for dx in [160.0, 260.0, 380.0] {
|
||||
let mut sim = Sim::new(4);
|
||||
sim.damage = false;
|
||||
let mut ground = Ground::default();
|
||||
let mut out = Out::default();
|
||||
for _ in 0..4 {
|
||||
sim.tick += 1;
|
||||
answer_land(&mut ground, &rolling, sim.tick);
|
||||
}
|
||||
// A Gerbera 20 blocks over the land at the far end, flying away from the launcher.
|
||||
let at = DVec3::new(dx, (rolling.0)(dx as i32, 0) as f64 + 20.0, 0.0);
|
||||
sim.add_drone(DroneKind::Gerbera, at, DVec3::X, DVec3::new(5000.0, 64.0, 0.0));
|
||||
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 });
|
||||
for _ in 0..500 {
|
||||
if sim.missiles.is_empty() {
|
||||
break;
|
||||
}
|
||||
sim.tick += 1;
|
||||
sim.step_missiles(&mut ground, &mut out);
|
||||
answer_land(&mut ground, &rolling, sim.tick);
|
||||
}
|
||||
assert!(sim.missiles.is_empty(), "{kind:?} at {dx}: still flying");
|
||||
assert_eq!(sim.tally.blocked, 0, "{kind:?} at {dx}: the interceptor hit the ground");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
let wall = ridge(300, 40);
|
||||
let mut sim = Sim::new(4);
|
||||
sim.damage = false;
|
||||
let mut ground = Ground::default();
|
||||
let mut out = Out::default();
|
||||
for _ in 0..4 {
|
||||
sim.tick += 1;
|
||||
answer_land(&mut ground, &wall, sim.tick);
|
||||
}
|
||||
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 });
|
||||
for _ in 0..200 {
|
||||
if sim.missiles.is_empty() {
|
||||
break;
|
||||
}
|
||||
sim.tick += 1;
|
||||
sim.step_missiles(&mut ground, &mut out);
|
||||
answer_land(&mut ground, &wall, sim.tick);
|
||||
}
|
||||
assert!(sim.missiles.is_empty());
|
||||
assert_eq!((sim.tally.scrubbed, sim.tally.blocked), (1, 0), "{:?}", sim.tally);
|
||||
assert_eq!(sim.drones[0].engaged, 0);
|
||||
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:?}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Radar scan cycles with ageing tracks (#46) and damage to a system's parts (#50).
|
||||
use super::sight::{swept, FORECAST_AGE};
|
||||
use super::*;
|
||||
|
||||
fn flat() -> (Ground, Out) {
|
||||
(Ground::default(), Out::default())
|
||||
}
|
||||
|
||||
fn radar_at(sim: &mut Sim, kind: SamKind, x: f64) -> usize {
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, kind, DVec3::new(x, 70.0, 0.0), 0.0));
|
||||
sim.sams.len() - 1
|
||||
}
|
||||
|
||||
/// A Shahed flying east along z = 40, from x = `from`, high enough to be above any horizon of the systems.
|
||||
fn drone_from(sim: &mut Sim, from: f64) -> u32 {
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(from, 95.0, 40.0), DVec3::X, DVec3::new(5000.0, 95.0, 40.0));
|
||||
sim.drones.last().unwrap().id
|
||||
}
|
||||
|
||||
// #46: the radar measures a target when the beam passes it; between passes its track stays where it was measured.
|
||||
#[test]
|
||||
fn a_radar_measures_once_a_turn_and_the_track_stays_put_between() {
|
||||
let mut sim = Sim::new(5);
|
||||
let (mut ground, mut out) = flat();
|
||||
let r = radar_at(&mut sim, SamKind::Radar, 0.0);
|
||||
let id = drone_from(&mut sim, -150.0);
|
||||
let (mut seen_at, mut last) = (Vec::new(), None::<(u64, DVec3)>);
|
||||
let mut stale_gap = 0.0f64;
|
||||
for _ in 0..700 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
let Some(t) = sim.sams[r].track(id).copied() else { continue };
|
||||
match last {
|
||||
Some((tick, pos)) if tick == t.tick => {
|
||||
assert_eq!(pos, t.pos, "the track moved without a new measurement");
|
||||
let d = sim.drones.iter().find(|d| d.id == id).unwrap();
|
||||
stale_gap = stale_gap.max(d.pos.distance(t.pos));
|
||||
if t.age(sim.tick) > FORECAST_AGE {
|
||||
assert!(t.forecast(sim.tick));
|
||||
}
|
||||
}
|
||||
_ => seen_at.push(t.tick),
|
||||
}
|
||||
last = Some((t.tick, t.pos));
|
||||
}
|
||||
assert!(seen_at.len() >= 2, "measured {} times", seen_at.len());
|
||||
// A turn of the early-warning radar is 140 ticks (less relative to a target that moves round it): never every tick.
|
||||
assert!(seen_at.windows(2).all(|w| w[1] - w[0] >= 60), "{seen_at:?}");
|
||||
assert!(stale_gap > 20.0, "the track never fell behind the target ({stale_gap})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_beam_crossing_north_between_ticks_still_passes_a_target_there() {
|
||||
let tau = std::f64::consts::TAU;
|
||||
assert!(swept(0.01, tau - 0.05, 0.1));
|
||||
assert!(swept(tau - 0.02, tau - 0.05, 0.1));
|
||||
assert!(!swept(0.2, tau - 0.05, 0.1));
|
||||
assert!(!swept(tau - 0.1, tau - 0.05, 0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_radars_have_their_own_phase_and_the_network_keeps_the_age() {
|
||||
let mut sim = Sim::new(7);
|
||||
let (mut ground, mut out) = flat();
|
||||
let a = radar_at(&mut sim, SamKind::Radar, 0.0);
|
||||
let b = radar_at(&mut sim, SamKind::Radar, 500.0);
|
||||
let n = radar_at(&mut sim, SamKind::P1sun, 250.0);
|
||||
assert_ne!(sim.sams[a].beam, sim.sams[b].beam, "radars start at different phases");
|
||||
let id = drone_from(&mut sim, -50.0);
|
||||
let mut relayed = 0;
|
||||
for _ in 0..800 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
let own = |s: usize| sim.sams[s].track(id).map_or(0, |t| t.own_tick);
|
||||
let Some(t) = sim.sams[n].track(id) else { continue };
|
||||
relayed += 1;
|
||||
assert_eq!(t.own_tick, 0, "a relayed measurement is not the receiver's own");
|
||||
assert!(t.tick <= own(a).max(own(b)), "the network made a track fresher than any measurement");
|
||||
if own(a).max(own(b)) > 0 {
|
||||
assert_eq!(t.tick, own(a).max(own(b)), "the network holds the freshest measurement");
|
||||
}
|
||||
}
|
||||
assert!(relayed > 100, "the network never passed a track ({relayed})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lock_measures_every_tick_and_a_dead_radar_stops_the_feed() {
|
||||
let mut sim = Sim::new(3);
|
||||
let (mut ground, mut out) = flat();
|
||||
let p = radar_at(&mut sim, SamKind::Pantsir, 0.0);
|
||||
let net = radar_at(&mut sim, SamKind::P1sun, 100.0);
|
||||
let id = drone_from(&mut sim, -190.0);
|
||||
let mut locked = 0;
|
||||
for _ in 0..600 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
if sim.sams[p].target == Some(id)
|
||||
&& let Some(t) = sim.sams[p].track(id)
|
||||
&& t.age(sim.tick) <= 1
|
||||
&& t.locked
|
||||
{
|
||||
locked += 1;
|
||||
}
|
||||
}
|
||||
assert!(locked > 5, "no fire-control lock ({locked})");
|
||||
// The radar is destroyed: the network's track is no longer renewed.
|
||||
let newest = sim.sams[net].tracks.iter().map(|t| t.tick).max();
|
||||
sim.sams[p].health = 0.0;
|
||||
let drones = sim.drones.iter().filter(|d| d.alive()).count();
|
||||
for _ in 0..40 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
assert!(sim.sams[p].tracks.is_empty() && sim.sams[p].seen.is_empty());
|
||||
if drones > 0 {
|
||||
assert!(sim.sams[net].tracks.iter().map(|t| t.tick).max() <= newest, "a dead radar still renewed a track");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_radar_gives_no_own_tracks_but_the_network_still_feeds_it() {
|
||||
let mut sim = Sim::new(9);
|
||||
let (mut ground, mut out) = flat();
|
||||
let p = radar_at(&mut sim, SamKind::Patriot, 0.0);
|
||||
let early = radar_at(&mut sim, SamKind::Radar, 120.0);
|
||||
let id = drone_from(&mut sim, -150.0);
|
||||
sim.sams[p].comps.0[Part::Sensor as usize] = 0.0;
|
||||
for _ in 0..500 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
assert!(!sim.sams[p].radar_up());
|
||||
assert!(sim.sams[p].own.is_empty(), "a failed radar measured something");
|
||||
assert!(sim.sams[p].track(id).is_some(), "the network should still feed the system");
|
||||
// Without its link it is blind.
|
||||
sim.sams[p].comps.0[Part::Comms as usize] = 0.0;
|
||||
sim.sams[p].tracks.clear();
|
||||
for _ in 0..200 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
assert!(sim.sams[p].tracks.is_empty(), "no link, no network tracks");
|
||||
assert!(sim.sams[early].own.len() >= sim.sams[p].own.len());
|
||||
}
|
||||
|
||||
// #50: a blast hurts the parts nearest to it.
|
||||
#[test]
|
||||
fn a_blast_from_behind_hurts_the_link_a_blast_from_the_front_the_drive() {
|
||||
let at = DVec3::new(0.0, 70.0, 0.0);
|
||||
let hurt = |dz: f64| {
|
||||
let mut sim = Sim::new(4);
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Patriot, at, 0.0));
|
||||
let mut out = Out::default();
|
||||
sim.blast_damage(at + DVec3::new(0.0, 3.0, dz), 2, &mut out);
|
||||
assert!(sim.sams[0].alive());
|
||||
let c = sim.sams[0].comps;
|
||||
(1.0 - c.get(Part::Comms), 1.0 - c.get(Part::Mount), 1.0 - c.get(Part::Sensor))
|
||||
};
|
||||
let (back_comms, back_mount, _) = hurt(6.0);
|
||||
let (front_comms, front_mount, _) = hurt(-6.0);
|
||||
assert!(back_comms > back_mount, "behind: link {back_comms} drive {back_mount}");
|
||||
assert!(front_mount > front_comms, "front: link {front_comms} drive {front_mount}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_blast_can_leave_a_firing_but_immobile_system() {
|
||||
// Over many blasts at moderate range some leave the system able to fire and unable to drive.
|
||||
let mut found = false;
|
||||
for seed in 0..60 {
|
||||
let mut sim = Sim::new(seed);
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Pantsir, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
let mut out = Out::default();
|
||||
sim.tick = seed;
|
||||
sim.blast_damage(DVec3::new(0.0, 69.0, 6.0 + seed as f64 * 0.06), 3, &mut out);
|
||||
let s = &sim.sams[0];
|
||||
if s.alive() && s.part(Part::Tracks) <= 0.0 && s.part(Part::Launcher) > 0.0 && s.part(Part::Gun) > 0.0 {
|
||||
assert!(!sim.order_move(0, DVec3::new(300.0, 70.0, 0.0)), "failed running gear must refuse a march");
|
||||
assert!(why::missile_blocks(&sim.sams[0]) & why::LAUNCHER_DOWN == 0);
|
||||
assert!(sim.why(0).iter().any(|c| c.text.contains("ходовая отказала")), "{:?}", sim.why(0).iter().map(|c| &c.text).collect::<Vec<_>>());
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(found, "no seed left it firing but immobile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_parts_block_what_they_serve_and_repair_restores_them() {
|
||||
let mut sim = Sim::new(2);
|
||||
let g = radar_at(&mut sim, SamKind::Gepard, 0.0);
|
||||
let p = radar_at(&mut sim, SamKind::Patriot, 400.0);
|
||||
sim.sams[g].comps.0[Part::Gun as usize] = 0.0;
|
||||
assert!(!sim.sams[g].gun_up());
|
||||
assert!(why::gun_blocks(&sim.sams[g], 1, 1, 0.0, 10.0, 90.0, 0) & why::JAMMED != 0);
|
||||
sim.sams[p].comps.0[Part::Launcher as usize] = 0.0;
|
||||
assert!(why::missile_blocks(&sim.sams[p]) & why::LAUNCHER_DOWN != 0);
|
||||
// A worn launcher: fewer cells, a longer reload.
|
||||
sim.sams[p].comps.0[Part::Launcher as usize] = 0.3;
|
||||
sim.sams[p].fit_cells();
|
||||
let full = Sam::new(0, SamKind::Patriot, DVec3::ZERO, 0.0);
|
||||
assert!(sim.sams[p].cells() < full.cells());
|
||||
assert!(sim.sams[p].missiles <= sim.sams[p].cells());
|
||||
assert!(sim.sams[p].reload_time() > full.reload_time());
|
||||
// A worn mount turns slower, a failed one not at all.
|
||||
sim.sams[p].comps.0[Part::Mount as usize] = 0.0;
|
||||
assert_eq!(sim.sams[p].drive_fit(), 0.0);
|
||||
sim.sams[p].comps.0[Part::Tracks as usize] = 0.0;
|
||||
assert_eq!(sim.repair(), 2, "both damaged systems are counted");
|
||||
assert!(sim.sams.iter().all(|s| s.comps.whole() && s.cells() == s.kind.spec().missile.as_ref().map_or(0, |m| m.magazine)));
|
||||
assert!(sim.later.is_empty(), "repair must not leave cook-offs behind");
|
||||
assert!(sim.order_move(p, DVec3::new(500.0, 70.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_seed_rolls_the_same_scan_and_damage() {
|
||||
let run = || {
|
||||
let mut sim = Sim::new(11);
|
||||
let (mut ground, mut out) = flat();
|
||||
let r = radar_at(&mut sim, SamKind::Radar, 0.0);
|
||||
let id = drone_from(&mut sim, -150.0);
|
||||
let mut ticks = Vec::new();
|
||||
for _ in 0..500 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
if let Some(t) = sim.sams[r].track(id) {
|
||||
ticks.push(t.tick);
|
||||
}
|
||||
}
|
||||
ticks
|
||||
};
|
||||
assert_eq!(run(), run());
|
||||
}
|
||||
+246
-22
@@ -3,6 +3,17 @@
|
||||
//! (over the curve of the earth, scaled to the game's distances), and the land
|
||||
//! hides what flies behind hills and woods: each system keeps its horizon, the
|
||||
//! highest the land rises seen from its antenna, ray by ray round it.
|
||||
//!
|
||||
//! A radar does not know where everything is all the time. Its beam sweeps round
|
||||
//! (a period by the kind of radar; an electronic one also revisits what it holds
|
||||
//! more often), a target is measured when the beam passes it, with a chance that
|
||||
//! falls with range and size, and the measurement is kept as a *track*: where it
|
||||
//! was and how fast it went then, and when. Between measurements the track is
|
||||
//! carried on by that velocity (a forecast, shown as one), and after three missed
|
||||
//! scans it is dropped. A fire-control lock on a target the system is engaging is
|
||||
//! a dedicated channel: it measures every tick while the target is in reach and in
|
||||
//! sight. The network passes tracks with the age of their measurement; relaying
|
||||
//! makes none of them fresher.
|
||||
use super::*;
|
||||
|
||||
/// Rays round a radar, and blocks between the land's samples along them.
|
||||
@@ -10,6 +21,13 @@ const RAYS: usize = 48;
|
||||
const STEP: f64 = 16.0;
|
||||
/// How far units share their tracks (the network's data links).
|
||||
pub(super) const LINK: f64 = 1500.0;
|
||||
/// A launcher fires on a track no older than this (ticks); a gun needs its own radar's, newer than `GUN_AGE`.
|
||||
pub(super) const MISSILE_AGE: u64 = 120;
|
||||
pub(super) const GUN_AGE: u64 = 3;
|
||||
/// A gun takes a target its radar measured this recently (then the lock refreshes it every tick).
|
||||
pub(super) const GUN_PICK_AGE: u64 = 40;
|
||||
/// A track older than this is shown as a forecast.
|
||||
pub const FORECAST_AGE: u64 = 20;
|
||||
/// Radio horizon: `HORIZON * (√mast + √height)` blocks.
|
||||
const HORIZON: f64 = 35.0;
|
||||
/// Anything this near is seen (and heard), whatever hides it.
|
||||
@@ -70,13 +88,144 @@ pub(super) fn reach(sam: &Sam, kind: DroneKind, height: f64) -> f64 {
|
||||
if spec.sensor <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let radar = spec.sensor * sam.fitness() * kind.rcs().powf(0.25);
|
||||
let radar = spec.sensor * sam.radar_fit() * kind.rcs().powf(0.25);
|
||||
let horizon = HORIZON * (spec.mast.sqrt() + height.max(0.0).sqrt());
|
||||
radar.min(horizon).max(NEAR)
|
||||
}
|
||||
|
||||
/// How a kind's radar looks round.
|
||||
pub(super) struct Scan {
|
||||
/// Ticks for the beam to go round.
|
||||
pub period: u32,
|
||||
/// An electronic radar: what it holds it revisits at once a third of the period, between the beam's passes.
|
||||
pub agile: bool,
|
||||
}
|
||||
|
||||
impl SamKind {
|
||||
pub(super) fn scan(self) -> Scan {
|
||||
let (period, agile) = match self {
|
||||
SamKind::Patriot => (30, true),
|
||||
SamKind::S400 => (36, true),
|
||||
SamKind::S500 => (24, true),
|
||||
SamKind::IrisT | SamKind::Nasams => (40, true),
|
||||
SamKind::Buk => (50, false),
|
||||
SamKind::Tor | SamKind::Pantsir => (40, false),
|
||||
SamKind::Gepard => (50, false),
|
||||
// The early-warning radar: a big antenna turning slowly.
|
||||
SamKind::Radar => (140, false),
|
||||
_ => (40, false),
|
||||
};
|
||||
Scan { period, agile }
|
||||
}
|
||||
}
|
||||
|
||||
/// How well a track says what it is.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Ident {
|
||||
/// One measurement: something is there.
|
||||
Unknown,
|
||||
/// Measured again: a drone or missile by how it flies.
|
||||
Probable,
|
||||
/// Held by a lock or by several measurements.
|
||||
Confirmed,
|
||||
}
|
||||
|
||||
impl Ident {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Ident::Unknown => "неопознана",
|
||||
Ident::Probable => "предположительно воздушная цель",
|
||||
Ident::Confirmed => "подтверждена",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a system knows of one target: the last measurement, not the target itself.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Track {
|
||||
pub id: u32,
|
||||
/// Where it was and how it went when last measured (blocks, blocks a tick).
|
||||
pub pos: DVec3,
|
||||
pub vel: DVec3,
|
||||
/// When that was, and when this system's own radar last measured it (0: never, the network's only).
|
||||
pub tick: u64,
|
||||
pub own_tick: u64,
|
||||
/// Ticks the track lives without a new measurement.
|
||||
pub ttl: u32,
|
||||
pub hits: u8,
|
||||
/// The last own measurement came from a fire-control lock.
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
pub fn age(&self, now: u64) -> u64 {
|
||||
now.saturating_sub(self.tick)
|
||||
}
|
||||
/// Where the target should be by now, carrying the last measurement on: the forecast, not a measurement.
|
||||
pub fn est(&self, now: u64) -> DVec3 {
|
||||
self.pos + self.vel * self.age(now) as f64
|
||||
}
|
||||
pub fn forecast(&self, now: u64) -> bool {
|
||||
self.age(now) > FORECAST_AGE
|
||||
}
|
||||
/// Own radar measured it within `age` ticks.
|
||||
pub fn own_within(&self, now: u64, age: u64) -> bool {
|
||||
self.own_tick > 0 && now.saturating_sub(self.own_tick) <= age
|
||||
}
|
||||
pub fn ident(&self) -> Ident {
|
||||
match self.hits {
|
||||
0 | 1 => Ident::Unknown,
|
||||
2 => Ident::Probable,
|
||||
_ => Ident::Confirmed,
|
||||
}
|
||||
}
|
||||
/// How far the forecast may be off, blocks: grows with the age.
|
||||
pub fn uncertainty(&self, now: u64) -> f64 {
|
||||
1.0 + self.age(now) as f64 * (0.05 + self.vel.length() * 0.1)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sam {
|
||||
/// Ticks for its beam to go round (longer when the radar is damaged).
|
||||
pub(super) fn scan_ticks(&self) -> f64 {
|
||||
self.kind.scan().period as f64 / self.radar_fit().max(0.2)
|
||||
}
|
||||
/// Its own radar works: it has one, it is not failed and the unit is deployed.
|
||||
pub fn radar_up(&self) -> bool {
|
||||
self.alive() && !self.moving() && self.kind.spec().sensor > 0.0 && self.part(Part::Sensor) > 0.0
|
||||
}
|
||||
pub fn track(&self, id: u32) -> Option<&Track> {
|
||||
self.tracks.iter().find(|t| t.id == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a beam that moved from `from` by `w` radians this tick passed `bearing` (all angles taken round 2π,
|
||||
/// so a beam crossing north between two ticks still passes a target there).
|
||||
pub(super) fn swept(bearing: f64, from: f64, w: f64) -> bool {
|
||||
(bearing - from).rem_euclid(TAU) < w
|
||||
}
|
||||
|
||||
/// A new measurement into a list of tracks (the index maps ids to places): a fresher one replaces an older.
|
||||
fn merge(tracks: &mut Vec<Track>, index: &mut HashMap<u32, usize>, t: Track) {
|
||||
match index.get(&t.id) {
|
||||
Some(&k) => {
|
||||
let mine = &mut tracks[k];
|
||||
if t.tick > mine.tick {
|
||||
let (own_tick, locked) = (mine.own_tick, if t.own_tick > 0 { t.locked } else { mine.locked });
|
||||
let hits = mine.hits.max(t.hits);
|
||||
*mine = Track { own_tick: if t.own_tick > 0 { t.own_tick } else { own_tick }, hits, locked, ..t };
|
||||
}
|
||||
}
|
||||
None => {
|
||||
index.insert(t.id, tracks.len());
|
||||
tracks.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
/// Rebuilds the systems' horizons in turn, and what each radar holds now.
|
||||
/// Rebuilds the systems' horizons in turn, then lets each radar's beam pass the sky: new measurements
|
||||
/// into the tracks, old tracks out, and the network's tracks shared.
|
||||
pub(super) fn update_sight(&mut self, ground: &mut Ground) {
|
||||
let now = self.tick;
|
||||
// One horizon every few ticks: the land answers come in over time.
|
||||
@@ -95,44 +244,108 @@ impl Sim {
|
||||
// Each drone's height over the land under it (or over the systems' ground, unknown).
|
||||
let heights: Vec<Option<f64>> =
|
||||
self.drones.iter().map(|d| ground.at(d.pos, now).map(|g| d.pos.y - g)).collect();
|
||||
let drone_at: HashMap<u32, usize> = self.drones.iter().enumerate().map(|(k, d)| (d.id, k)).collect();
|
||||
let seed = self.seed;
|
||||
let mut index: HashMap<u32, usize> = HashMap::new();
|
||||
for sam in &mut self.sams {
|
||||
sam.seen.clear();
|
||||
sam.own.clear();
|
||||
if !sam.alive() || sam.kind.spec().sensor <= 0.0 || sam.moving() {
|
||||
// A track of a drone that is gone is dropped (the kill is seen), so is one not renewed for its time.
|
||||
sam.tracks.retain(|t| now - t.tick <= t.ttl as u64 && drone_at.get(&t.id).is_some_and(|&k| self.drones[k].alive()));
|
||||
if !sam.alive() || sam.moving() {
|
||||
sam.tracks.clear();
|
||||
continue;
|
||||
}
|
||||
if !sam.radar_up() {
|
||||
continue;
|
||||
}
|
||||
index.clear();
|
||||
index.extend(sam.tracks.iter().enumerate().map(|(k, t)| (t.id, k)));
|
||||
let spec = sam.kind.spec();
|
||||
let scan = sam.kind.scan();
|
||||
let period = sam.scan_ticks();
|
||||
let w = TAU / period;
|
||||
let (b0, ttl) = (sam.beam, ((period * 3.0) as u32).max(60));
|
||||
sam.beam = (b0 + w) % TAU;
|
||||
let at = antenna(sam);
|
||||
let horizon = self.horizons.get(&sam.id);
|
||||
let mut fresh: Vec<Track> = Vec::new();
|
||||
for (d, h) in self.drones.iter().zip(&heights) {
|
||||
if !d.alive() {
|
||||
continue;
|
||||
}
|
||||
let dist = d.pos.distance(at);
|
||||
let rel = d.pos - at;
|
||||
let dist = rel.length();
|
||||
if dist > spec.sensor * 1.2 {
|
||||
continue;
|
||||
}
|
||||
let locked = sam.target == Some(d.id) || sam.gun_target == Some(d.id);
|
||||
let held = index.get(&d.id).map(|&k| sam.tracks[k]);
|
||||
// The beam passes its bearing this tick (the angle wraps at 2π: the difference is taken round).
|
||||
let in_beam = swept(rel.x.atan2(-rel.z), b0, w);
|
||||
let revisit = scan.agile && held.is_some_and(|t| t.own_tick > 0 && now - t.own_tick >= (period / 3.0) as u64);
|
||||
if !(in_beam || locked || revisit) {
|
||||
continue;
|
||||
}
|
||||
let height = h.unwrap_or(d.pos.y - sam.pos.y);
|
||||
if dist > reach(sam, d.kind, height) {
|
||||
let reach = reach(sam, d.kind, height);
|
||||
if dist > reach || (dist > NEAR && horizon.is_some_and(|hz| hz.hides(at, d.pos))) {
|
||||
continue;
|
||||
}
|
||||
if dist > NEAR && horizon.is_some_and(|hz| hz.hides(at, d.pos)) {
|
||||
let key = (sam.id as u64) << 32 | d.id as u64;
|
||||
// A target in the beam is measured with a chance that falls towards the edge of reach and
|
||||
// with the target's stealth; one the radar already follows is lost only now and then.
|
||||
let p = if locked || revisit {
|
||||
0.985
|
||||
} else {
|
||||
((0.97 - 0.5 * (dist / reach).powi(3)) / d.kind.spec().stealth.sqrt()).clamp(0.05, 0.97)
|
||||
};
|
||||
if dist > NEAR && dice(seed, now, key, 0) >= p {
|
||||
continue;
|
||||
}
|
||||
sam.own.push(d.id);
|
||||
let sigma = if locked { 0.3 } else { 0.8 + dist * 0.004 };
|
||||
let noise = DVec3::new(dice(seed, now, key, 1) - 0.5, (dice(seed, now, key, 2) - 0.5) * 0.5, dice(seed, now, key, 3) - 0.5) * 2.0 * sigma;
|
||||
fresh.push(Track {
|
||||
id: d.id,
|
||||
pos: d.pos + noise,
|
||||
vel: d.vel,
|
||||
tick: now,
|
||||
own_tick: now,
|
||||
ttl,
|
||||
hits: held.map_or(1, |t| t.hits.saturating_add(1)),
|
||||
locked,
|
||||
});
|
||||
}
|
||||
for t in fresh {
|
||||
merge(&mut sam.tracks, &mut index, t);
|
||||
}
|
||||
}
|
||||
// The network: every unit gets the tracks of the radars within data-link range.
|
||||
let tracks: Vec<(DVec3, Vec<u32>)> =
|
||||
self.sams.iter().filter(|s| !s.own.is_empty()).map(|s| (s.pos, s.own.clone())).collect();
|
||||
// Merged in the radars' order, each track once: a set marks what is in already.
|
||||
let mut have: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
// The network: every unit gets the tracks the radars within data-link range measured themselves,
|
||||
// with the time they were measured: a relayed track is never fresher than its measurement.
|
||||
let shared: Vec<(u32, DVec3, f64, Vec<Track>)> = self
|
||||
.sams
|
||||
.iter()
|
||||
.filter(|s| s.alive() && !s.moving() && s.link_range() > 0.0)
|
||||
.map(|s| (s.id, s.pos, s.link_range(), s.tracks.iter().filter(|t| t.own_tick == t.tick).copied().collect::<Vec<_>>()))
|
||||
.filter(|s| !s.3.is_empty())
|
||||
.collect();
|
||||
for sam in &mut self.sams {
|
||||
sam.seen.clone_from(&sam.own);
|
||||
if !sam.alive() || sam.moving() || !sam.kind.armed() {
|
||||
continue;
|
||||
if sam.alive() && !sam.moving() && sam.kind.armed() && sam.link_range() > 0.0 && !shared.is_empty() {
|
||||
index.clear();
|
||||
index.extend(sam.tracks.iter().enumerate().map(|(k, t)| (t.id, k)));
|
||||
let mine = sam.link_range();
|
||||
for (id, at, range, tracks) in &shared {
|
||||
if *id != sam.id && at.distance(sam.pos) <= range.min(mine) {
|
||||
for t in tracks {
|
||||
merge(&mut sam.tracks, &mut index, Track { own_tick: 0, ..*t });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
have.clear();
|
||||
have.extend(sam.own.iter().copied());
|
||||
for (at, ids) in &tracks {
|
||||
if at.distance(sam.pos) <= LINK {
|
||||
sam.seen.extend(ids.iter().copied().filter(|id| have.insert(*id)));
|
||||
sam.seen.clear();
|
||||
sam.own.clear();
|
||||
for t in &sam.tracks {
|
||||
sam.seen.push(t.id);
|
||||
if t.own_within(now, t.ttl as u64) {
|
||||
sam.own.push(t.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,4 +355,15 @@ impl Sim {
|
||||
pub fn tracked(&self, id: u32) -> bool {
|
||||
self.sams.iter().any(|s| s.seen.contains(&id))
|
||||
}
|
||||
|
||||
/// The freshest track of each drone any system holds: the air picture.
|
||||
pub fn picture(&self) -> HashMap<u32, Track> {
|
||||
let mut best: HashMap<u32, Track> = HashMap::new();
|
||||
for t in self.sams.iter().flat_map(|s| s.tracks.iter()) {
|
||||
if best.get(&t.id).is_none_or(|b| t.tick > b.tick) {
|
||||
best.insert(t.id, *t);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Flights and the land. Strike drones follow the terrain with a look-ahead worth several seconds of flight
|
||||
//! (what they can still climb is a question of their speed and turn rate), dive only down a clear line, and
|
||||
//! interceptors keep a safety height over the land ahead, loft over a ridge when the line is blocked, and
|
||||
//! blow up high when there is no way over. The launch predicates (`why`) ask the same questions of a
|
||||
//! shot before it is fired, so a system doesn't spend missiles on targets behind a hill.
|
||||
//!
|
||||
//! The land comes from `Ground::land`: heights sampled on a coarse lattice, so many flights share columns and
|
||||
//! the questions to the game stay few; what the heights miss (trees, buildings, overhangs) is caught by a
|
||||
//! probe through the block cache.
|
||||
use super::*;
|
||||
|
||||
/// Land heights are sampled at the centres of cells this wide.
|
||||
const LATTICE: i32 = 8;
|
||||
/// Land questions waiting at once: flights ask for more than a tick can send, and wait.
|
||||
const ASK_MAX: usize = 192;
|
||||
|
||||
/// What the game has said of the land at a place.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub(super) enum Rise {
|
||||
/// Not yet: asked.
|
||||
Pending,
|
||||
/// There is no land to tell of (no chunk).
|
||||
Void,
|
||||
/// One above the highest solid block.
|
||||
At(f64),
|
||||
}
|
||||
|
||||
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.
|
||||
pub fn land(&mut self, p: DVec3) -> Option<f64> {
|
||||
match self.rise(p) {
|
||||
Rise::At(g) => Some(g),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = (
|
||||
(p.x.floor() as i32).div_euclid(LATTICE) * LATTICE + LATTICE / 2,
|
||||
(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,
|
||||
None => {
|
||||
if self.wanted.len() < ASK_MAX && self.asked.insert(key) {
|
||||
self.wanted.push(key);
|
||||
}
|
||||
Rise::Pending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The land's height at a column for the raid planner: `None` while the game hasn't answered, `Some(None)`
|
||||
/// 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))),
|
||||
None => {
|
||||
if self.wanted.len() < cap && self.asked.insert((x, z)) {
|
||||
self.wanted.push((x, z));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a flight can do about a rise ahead.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Climb {
|
||||
/// Radius of its tightest pull-up (blocks).
|
||||
pub radius: f64,
|
||||
/// The steepest climb it holds (radians).
|
||||
pub pitch: f64,
|
||||
}
|
||||
|
||||
impl Climb {
|
||||
/// How much height it can have gained by the time it has gone `s` blocks on, starting level.
|
||||
pub fn gain(&self, s: f64) -> f64 {
|
||||
let arc = self.radius * self.pitch.sin();
|
||||
if s <= arc {
|
||||
self.radius - (self.radius * self.radius - s * s).max(0.0).sqrt()
|
||||
} else {
|
||||
self.radius * (1.0 - self.pitch.cos()) + (s - arc) * self.pitch.tan()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DroneKind {
|
||||
/// The steepest climb it holds in cruise (radians): cruise missiles are the shallowest.
|
||||
pub(super) fn climb_pitch(self) -> f64 {
|
||||
match self {
|
||||
DroneKind::Kh101 | DroneKind::Kalibr => 0.45,
|
||||
DroneKind::Lancet => 0.7,
|
||||
DroneKind::Geran3 | DroneKind::Kh22 => 0.6,
|
||||
_ => 0.5,
|
||||
}
|
||||
}
|
||||
pub(super) fn climb(self) -> Climb {
|
||||
let s = self.spec();
|
||||
Climb { radius: (s.speed / s.turn.max(0.01) * 1.15).max(12.0), pitch: self.climb_pitch() }
|
||||
}
|
||||
/// Blocks it looks ahead: eight seconds of flight, at least a pull-up and a climb over a hill.
|
||||
pub(super) fn look_len(self) -> f64 {
|
||||
let s = self.spec();
|
||||
(s.speed * 160.0).clamp(160.0, 900.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A flight's new heading: it turns in yaw towards `heading` and in pitch towards a climb of `slope`, together
|
||||
/// at most `rate` radians a tick, so a turn on to a new course doesn't dip or climb by itself (a great-circle
|
||||
/// turn between nearly opposite headings can go either way). With `climb` set the pitch comes first and the
|
||||
/// yaw gets what is left of the rate.
|
||||
pub(super) fn fly_towards(old: DVec3, heading: DVec3, slope: f64, rate: f64, climb: bool) -> DVec3 {
|
||||
let (old_yaw, old_pitch) = (old.x.atan2(old.z), old.y.clamp(-1.0, 1.0).asin());
|
||||
let want_yaw = heading.x.atan2(heading.z);
|
||||
let mut yaw = angle_diff(old_yaw, want_yaw);
|
||||
let mut pitch = slope.atan() - old_pitch;
|
||||
if climb {
|
||||
pitch = pitch.clamp(-rate, rate);
|
||||
yaw = yaw.clamp(-(rate * rate - pitch * pitch).max(0.0).sqrt(), (rate * rate - pitch * pitch).max(0.0).sqrt());
|
||||
} else {
|
||||
let total = yaw.hypot(pitch);
|
||||
if total > rate {
|
||||
yaw *= rate / total;
|
||||
pitch *= rate / total;
|
||||
}
|
||||
}
|
||||
let (yaw, pitch) = (old_yaw + yaw, old_pitch + pitch);
|
||||
DVec3::new(yaw.sin() * pitch.cos(), pitch.sin(), yaw.cos() * pitch.cos())
|
||||
}
|
||||
|
||||
/// How far over the land a dive's line passes.
|
||||
pub(super) const DIVE_MARGIN: f64 = 8.0;
|
||||
|
||||
/// `v` with its pitch held between `lo` and `hi` (radians); `level` is the way it faces if it points straight up or down.
|
||||
pub(super) fn clamp_pitch(v: DVec3, lo: f64, hi: f64, level: DVec3) -> DVec3 {
|
||||
let pitch = v.y.clamp(-1.0, 1.0).asin();
|
||||
if (lo..=hi).contains(&pitch) {
|
||||
return v;
|
||||
}
|
||||
let pitch = pitch.clamp(lo, hi);
|
||||
horizontal(v).normalize_or(level) * pitch.cos() + DVec3::Y * pitch.sin()
|
||||
}
|
||||
|
||||
/// Distances ahead where the land is sampled.
|
||||
const REACH: [f64; 21] = [
|
||||
0.0, 8.0, 16.0, 24.0, 32.0, 44.0, 56.0, 72.0, 90.0, 112.0, 136.0, 164.0, 196.0, 232.0, 272.0, 316.0, 366.0, 424.0, 500.0, 600.0,
|
||||
720.0,
|
||||
];
|
||||
|
||||
/// The height a flight must be at *now* to clear the land along each of `lines` (a unit horizontal direction and
|
||||
/// how far out to look) by `clear`, given what it can climb: the greatest over the samples of the land's height plus clearance
|
||||
/// less the height it can gain on the way. `None` when nothing is known.
|
||||
pub(super) fn required_height(ground: &mut Ground, pos: DVec3, lines: &[(DVec3, f64)], clear: f64, climb: &Climb) -> Option<f64> {
|
||||
let mut need: Option<f64> = None;
|
||||
for &(dir, len) in lines {
|
||||
for &s in REACH.iter().chain(&[900.0]) {
|
||||
if s > len {
|
||||
break;
|
||||
}
|
||||
if let Some(g) = ground.land(pos + dir * s) {
|
||||
let h = g + clear - climb.gain(s);
|
||||
need = Some(need.map_or(h, |n: f64| n.max(h)));
|
||||
}
|
||||
}
|
||||
}
|
||||
need
|
||||
}
|
||||
|
||||
/// The least height to start from, over here, so that the straight line down to `target` stays `margin` over the
|
||||
/// land (the last stretch before the aim is the target's own business). `None` when the land isn't known.
|
||||
pub(super) fn dive_height(ground: &mut Ground, pos: DVec3, target: DVec3, margin: f64) -> Option<f64> {
|
||||
let flat = horizontal(target - pos);
|
||||
let total = flat.length();
|
||||
if total < 20.0 {
|
||||
return None;
|
||||
}
|
||||
let dir = flat / total;
|
||||
let mut need: Option<f64> = None;
|
||||
let n = ((total - 14.0) / 12.0).clamp(1.0, 40.0) as i32;
|
||||
for k in 0..=n {
|
||||
let s = (total - 14.0) * f64::from(k) / f64::from(n);
|
||||
if let Some(g) = ground.land(pos + dir * s) {
|
||||
let f = s / total;
|
||||
let y = (g + margin - target.y * f) / (1.0 - f);
|
||||
need = Some(need.map_or(y, |m: f64| m.max(y)));
|
||||
}
|
||||
}
|
||||
need
|
||||
}
|
||||
|
||||
/// The slope (rise a block) a flight at `from` must hold to clear the land by `margin` on its way towards
|
||||
/// `to`, with a pull-up of `radius` for the climb to begin early, ignoring the last `skip` blocks before `to`.
|
||||
/// Returns the slope and how far away the land that asks for it is; `None` when nothing is in the way or known.
|
||||
pub(super) fn rope(ground: &mut Ground, from: DVec3, to: DVec3, margin: f64, radius: f64, (near, reach, skip): (f64, f64, f64), pending: &mut bool) -> Option<(f64, f64)> {
|
||||
let flat = horizontal(to - from);
|
||||
let total = flat.length();
|
||||
if total < 1.0 {
|
||||
return None;
|
||||
}
|
||||
let dir = flat / total;
|
||||
let end = (total - skip).min(reach);
|
||||
let mut best: Option<(f64, f64)> = None;
|
||||
let mut s = near;
|
||||
while s <= end {
|
||||
match ground.rise(from + dir * s) {
|
||||
Rise::At(g) => {
|
||||
let slope = (g + margin - from.y) / (s - radius * 0.6).max(6.0);
|
||||
if best.is_none_or(|b| slope > b.0) {
|
||||
best = Some((slope, s));
|
||||
}
|
||||
}
|
||||
Rise::Pending => *pending = true,
|
||||
Rise::Void => {}
|
||||
}
|
||||
s += if s < 64.0 { 8.0 } else { 16.0 };
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- interceptors
|
||||
|
||||
impl MissileKind {
|
||||
/// The steepest climb it flies to get over land (radians): long-range missiles loft, short-range ones don't.
|
||||
pub(super) fn loft(self) -> f64 {
|
||||
match self {
|
||||
MissileKind::M77n6 => 1.25,
|
||||
MissileKind::M48n6 => 1.1,
|
||||
MissileKind::Pac3 | MissileKind::IrisT | MissileKind::Amraam => 0.9,
|
||||
MissileKind::M9m317 => 0.8,
|
||||
MissileKind::P1sun => 0.5,
|
||||
MissileKind::M57e6 | MissileKind::M9m338 => 0.3,
|
||||
}
|
||||
}
|
||||
/// Whether it lofts over a ridge to a target behind it.
|
||||
pub(super) fn lofts(self) -> bool {
|
||||
self.loft() >= 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// How high above the land an interceptor keeps.
|
||||
pub(super) const MISSILE_MARGIN: f64 = 8.0;
|
||||
|
||||
/// What keeps an interceptor from being launched at a target for the land in the way.
|
||||
pub(super) const MASKED: u8 = 1;
|
||||
pub(super) const PATH_BLOCKED: u8 = 2;
|
||||
/// The land under the line of fire has been asked for and not answered: the shot waits a tick or two.
|
||||
pub(super) const WAIT: u8 = 4;
|
||||
/// Ticks a shot waits for the land to be answered before it is judged on what is known.
|
||||
pub(super) const WAIT_TICKS: u64 = 30;
|
||||
|
||||
/// Whether a shot from `muzzle` (launcher at `antenna`) at a target forecast at `est` moving at `vel` may be
|
||||
/// fired for the land: the intercept point must not be behind a hill from the launcher, unless the missile
|
||||
/// lofts, and the path the missile would fly must be clear.
|
||||
pub(super) fn launch_blocks(ground: &mut Ground, kind: MissileKind, muzzle: DVec3, antenna: DVec3, est: DVec3, vel: DVec3) -> u8 {
|
||||
let spec = kind.spec();
|
||||
// Where it meets the target: the forecast carried on for the flight time.
|
||||
let mut at = est;
|
||||
for _ in 0..2 {
|
||||
let tgo = muzzle.distance(at) / (spec.speed * 0.8);
|
||||
at = est + vel * tgo;
|
||||
}
|
||||
let flat = horizontal(at - antenna);
|
||||
let total = flat.length();
|
||||
if total < 40.0 {
|
||||
return 0;
|
||||
}
|
||||
let dir = flat / total;
|
||||
// The line of sight from the launcher: any rise of the land over the line to the intercept point masks it.
|
||||
let mut masked = false;
|
||||
let mut waiting = false;
|
||||
let mut s = 12.0;
|
||||
while s < total - 12.0 {
|
||||
match ground.rise(antenna + dir * s) {
|
||||
Rise::At(g) if g + 1.0 > antenna.y + (at.y - antenna.y) * s / total => {
|
||||
masked = true;
|
||||
break;
|
||||
}
|
||||
Rise::Pending => waiting = true,
|
||||
_ => {}
|
||||
}
|
||||
s += 16.0;
|
||||
}
|
||||
if !masked && waiting {
|
||||
return WAIT;
|
||||
}
|
||||
if !masked {
|
||||
return 0;
|
||||
}
|
||||
if !kind.lofts() {
|
||||
return MASKED;
|
||||
}
|
||||
// A lofted path: climbs at the slope the highest land in the way asks for, and comes down on the target.
|
||||
let radius = spec.speed / spec.turn.max(0.05);
|
||||
let mut pending = false;
|
||||
let path = rope(ground, muzzle, at, MISSILE_MARGIN, radius, (32.0, total, 24.0), &mut pending);
|
||||
match path {
|
||||
Some((slope, _)) if slope > kind.loft().tan() => PATH_BLOCKED,
|
||||
_ if pending => WAIT,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
impl Sam {
|
||||
/// What the land said of a shot at `target` when it was last judged, if lately.
|
||||
pub(super) fn terrain_verdict(&self, target: u32, now: u64) -> Option<u8> {
|
||||
self.terrain.get(&target).filter(|(when, _)| now.saturating_sub(*when) <= VERDICT_AGE).map(|(_, bits)| *bits)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticks a launch verdict (the land in the way) is kept before the shot is judged again.
|
||||
pub(super) const VERDICT_AGE: u64 = 20;
|
||||
|
||||
/// Which way an interceptor steers to keep clear of the land: `aim` is where it would go; the result is a direction
|
||||
/// (unit) raised as far as its loft allows, and whether the land is in the way beyond that.
|
||||
pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f64, aim: DVec3) -> (DVec3, bool) {
|
||||
let spec = kind.spec();
|
||||
let want = (aim - pos).normalize_or(DVec3::Y);
|
||||
let flat = horizontal(want);
|
||||
let flat_len = flat.length();
|
||||
let reach = (speed * 18.0).clamp(60.0, 320.0);
|
||||
let radius = speed / spec.turn.max(0.05);
|
||||
let Some((slope, at)) = rope(ground, pos, aim, MISSILE_MARGIN, radius, (8.0, reach, 20.0), &mut false) else { return (want, false) };
|
||||
let wanted = if flat_len > 1e-6 { want.y / flat_len } else { f64::INFINITY };
|
||||
if slope <= wanted {
|
||||
return (want, false);
|
||||
}
|
||||
let cap = kind.loft().tan();
|
||||
let dir = flat.normalize_or(DVec3::X);
|
||||
let raised = (dir + DVec3::Y * slope.min(cap)).normalize();
|
||||
// No way over, and the land close enough ahead that the next ticks will end in it: blow up before.
|
||||
let mut fail = false;
|
||||
if slope > cap + 0.02 && at < (speed * 6.0).max(40.0) {
|
||||
fail = (1..=4).any(|k| {
|
||||
let p = pos + raised * speed * f64::from(k);
|
||||
ground.land(p).is_some_and(|g| g + 2.0 > p.y)
|
||||
});
|
||||
}
|
||||
(raised, fail)
|
||||
}
|
||||
+110
-33
@@ -11,29 +11,32 @@ use super::*;
|
||||
pub(super) const DEAD: u8 = 1;
|
||||
pub(super) const ROLE: u8 = 2;
|
||||
pub(super) const UNSEEN: u8 = 4;
|
||||
/// Its track is older than a launcher fires on.
|
||||
pub(super) const STALE: u8 = 8;
|
||||
|
||||
pub(super) fn target_blocks(roles: u8, seen: &[u32], d: &Drone) -> u8 {
|
||||
pub(super) fn target_blocks(roles: u8, track: Option<&Track>, now: u64, d: &Drone) -> u8 {
|
||||
(if d.alive() { 0 } else { DEAD })
|
||||
| (if roles & d.kind.spec().class.bit() != 0 { 0 } else { ROLE })
|
||||
| (if seen.contains(&d.id) { 0 } else { UNSEEN })
|
||||
| (if track.is_some() { 0 } else { UNSEEN })
|
||||
| (if track.is_some_and(|t| t.age(now) > sight::MISSILE_AGE) { STALE } else { 0 })
|
||||
}
|
||||
|
||||
/// Alive, of a class the system engages, and on its picture (own radar or the network's).
|
||||
pub(super) fn allowed(roles: u8, seen: &[u32], d: &Drone) -> bool {
|
||||
target_blocks(roles, seen, d) == 0
|
||||
/// Alive, of a class the system engages, and on its picture (own radar or the network's), fresh enough.
|
||||
pub(super) fn allowed(roles: u8, track: &Track, now: u64, d: &Drone) -> bool {
|
||||
target_blocks(roles, Some(track), now, d) == 0
|
||||
}
|
||||
|
||||
pub(super) const TOO_CLOSE: u8 = 1;
|
||||
pub(super) const TOO_FAR: u8 = 2;
|
||||
|
||||
/// Missile range: `min` to `max * k` (a target already held may stray to 1.1 × max).
|
||||
pub(super) fn reach_blocks(min: f64, max: f64, k: f64, pos: DVec3, d: &Drone) -> u8 {
|
||||
let dist = d.pos.distance(pos);
|
||||
pub(super) fn reach_blocks(min: f64, max: f64, k: f64, pos: DVec3, at: DVec3) -> u8 {
|
||||
let dist = at.distance(pos);
|
||||
(if dist >= min { 0 } else { TOO_CLOSE }) | (if dist < max * k { 0 } else { TOO_FAR })
|
||||
}
|
||||
|
||||
pub(super) fn in_reach(min: f64, max: f64, k: f64, pos: DVec3, d: &Drone) -> bool {
|
||||
reach_blocks(min, max, k, pos, d) == 0
|
||||
pub(super) fn in_reach(min: f64, max: f64, k: f64, pos: DVec3, at: DVec3) -> bool {
|
||||
reach_blocks(min, max, k, pos, at) == 0
|
||||
}
|
||||
|
||||
/// Interceptors a target has or will have: in flight, and other systems' launchers on it.
|
||||
@@ -47,9 +50,10 @@ pub(super) fn needed(d: &Drone) -> u32 {
|
||||
if d.kind.spec().class == Class::Ballistic { 2 } else { 1 }
|
||||
}
|
||||
|
||||
/// A gun's candidates: held by the system's own radar and within the gun's tracking range.
|
||||
pub(super) fn gun_candidate(roles: u8, seen: &[u32], own: &[u32], track: f64, pos: DVec3, d: &Drone) -> bool {
|
||||
allowed(roles, seen, d) && own.contains(&d.id) && d.pos.distance(pos) < track
|
||||
/// A gun's candidates: measured by the system's own radar a moment ago and within the gun's tracking range
|
||||
/// (by the track's forecast, not the target's true place).
|
||||
pub(super) fn gun_candidate(roles: u8, track: &Track, now: u64, gun_track: f64, pos: DVec3, d: &Drone) -> bool {
|
||||
allowed(roles, track, now, d) && track.own_within(now, sight::GUN_PICK_AGE) && track.est(now).distance(pos) < gun_track
|
||||
}
|
||||
|
||||
/// How many systems have each drone as their missile target and as their gun target.
|
||||
@@ -66,8 +70,8 @@ pub(super) fn engagement_counts(sams: &[Sam]) -> (HashMap<u32, u32>, HashMap<u32
|
||||
// ---------------------------------------------------------------- aiming
|
||||
|
||||
/// Where the lead point is and the yaw and pitch that point the mount at it.
|
||||
pub(super) fn aim_wants(mount: DVec3, aim: &Drone, lead_speed: f64) -> (DVec3, f64, f64) {
|
||||
let lead = aim.pos + aim.vel * (aim.pos.distance(mount) / lead_speed);
|
||||
pub(super) fn aim_wants(mount: DVec3, at: DVec3, vel: DVec3, lead_speed: f64) -> (DVec3, f64, f64) {
|
||||
let lead = at + vel * (at.distance(mount) / lead_speed);
|
||||
let to = lead - mount;
|
||||
let want_yaw = heading_of(to);
|
||||
let want_pitch = (to.y / to.length().max(0.01)).asin().clamp(-0.1, 1.45);
|
||||
@@ -88,19 +92,22 @@ pub(super) fn aim_error(family: Family, yaw: f64, pitch: f64, want_yaw: f64, wan
|
||||
pub(super) const COOLDOWN: u8 = 1;
|
||||
pub(super) const RELOADING: u8 = 2;
|
||||
pub(super) const NO_ROUNDS: u8 = 4;
|
||||
/// The launcher has failed.
|
||||
pub(super) const LAUNCHER_DOWN: u8 = 8;
|
||||
|
||||
/// What keeps a loaded launcher from firing now (aim aside).
|
||||
pub(super) fn missile_blocks(s: &Sam) -> u8 {
|
||||
(if s.cooldown == 0 { 0 } else { COOLDOWN })
|
||||
(if s.part(Part::Launcher) > 0.0 { 0 } else { LAUNCHER_DOWN })
|
||||
| (if s.cooldown == 0 { 0 } else { COOLDOWN })
|
||||
| (if s.reloading == 0 { 0 } else { RELOADING })
|
||||
| (if s.missiles > 0 { 0 } else { NO_ROUNDS })
|
||||
}
|
||||
|
||||
/// The launcher points near enough: a trainable one only by its training, a turret by its
|
||||
/// whole aim at this target, the vertical ones need nothing.
|
||||
pub(super) fn missile_aligned(family: Family, yaw: f64, mount: DVec3, t: Option<&Drone>, aim_id: u32, mt: u32, aim_error: f64) -> bool {
|
||||
pub(super) fn missile_aligned(family: Family, yaw: f64, mount: DVec3, t: Option<DVec3>, aim_id: u32, mt: u32, aim_error: f64) -> bool {
|
||||
match family {
|
||||
Family::Trainable => t.is_some_and(|t| angle_diff(yaw, heading_of(t.pos - mount)).abs() < 0.15),
|
||||
Family::Trainable => t.is_some_and(|t| angle_diff(yaw, heading_of(t - mount)).abs() < 0.15),
|
||||
Family::Turret => aim_id == mt && aim_error < 0.15,
|
||||
_ => true,
|
||||
}
|
||||
@@ -109,11 +116,17 @@ pub(super) fn missile_aligned(family: Family, yaw: f64, mount: DVec3, t: Option<
|
||||
pub(super) const NOT_AIMED: u8 = 8;
|
||||
pub(super) const AIM_ERROR: u8 = 16;
|
||||
pub(super) const OUT_OF_RANGE: u8 = 32;
|
||||
/// The gun is jammed (for a while, or failed).
|
||||
pub(super) const JAMMED: u8 = 64;
|
||||
/// Its radar has no fresh measurement of the target.
|
||||
pub(super) const NO_LOCK: u8 = 128;
|
||||
|
||||
/// What keeps a gun from firing now: the turret on another target, not yet laid, out of range,
|
||||
/// and the gun's own cooldown, reload and shells (`COOLDOWN`, `RELOADING`, `NO_ROUNDS` bits).
|
||||
pub(super) fn gun_blocks(s: &Sam, aim_id: u32, gt: u32, aim_error: f64, dist: f64, range: f64) -> u8 {
|
||||
pub(super) fn gun_blocks(s: &Sam, aim_id: u32, gt: u32, aim_error: f64, dist: f64, range: f64, now: u64) -> u8 {
|
||||
(if aim_id == gt { 0 } else { NOT_AIMED })
|
||||
| (if s.gun_up() { 0 } else { JAMMED })
|
||||
| (if s.track(gt).is_some_and(|t| t.own_within(now, sight::GUN_AGE)) { 0 } else { NO_LOCK })
|
||||
| (if aim_error < 0.1 { 0 } else { AIM_ERROR })
|
||||
| (if dist < range { 0 } else { OUT_OF_RANGE })
|
||||
| (if s.gun_cooldown == 0 { 0 } else { COOLDOWN })
|
||||
@@ -160,9 +173,38 @@ impl Sim {
|
||||
let logistics = self.logistics();
|
||||
let (missile_on, guns_on) = engagement_counts(&self.sams);
|
||||
let alive = self.drones.iter().filter(|d| d.alive()).count();
|
||||
let now = self.tick;
|
||||
let seen = self.drones.iter().filter(|d| d.alive() && sam.seen.contains(&d.id)).count();
|
||||
let own = self.drones.iter().filter(|d| d.alive() && sam.own.contains(&d.id)).count();
|
||||
v.push(check(alive > 0, format!("в небе {alive}, на картине {seen} (своя РЛС {own}, остальные по сети)")));
|
||||
// The parts: what is worn or failed and what it costs.
|
||||
if !sam.comps.whole() {
|
||||
v.push(check(false, format!("узлы: {}", sam.parts_text())));
|
||||
}
|
||||
for p in Part::ALL {
|
||||
if !p.on(sam.kind) || sam.part_state(p) == PartState::Ok {
|
||||
continue;
|
||||
}
|
||||
let failed = sam.part_state(p) == PartState::Failed;
|
||||
let effect = match (p, failed) {
|
||||
(Part::Sensor, true) => "РЛС отказала: своих измерений нет, стволы не стреляют, ракеты — по данным сети".to_string(),
|
||||
(Part::Sensor, false) => format!("РЛС повреждена: дальность {:.0}%, обзор вдвое реже при худшем", sam.radar_fit() * 100.0),
|
||||
(Part::Mount, true) => format!("привод наведения заклинил: стоит на {:.0}°, не доворачивается", sam.yaw.to_degrees().rem_euclid(360.0)),
|
||||
(Part::Mount, false) => format!("привод наведения повреждён: поворот {:.0}% от нормы", sam.drive_fit() * 100.0),
|
||||
(Part::Launcher, true) => "пусковая отказала: пуски невозможны".to_string(),
|
||||
(Part::Launcher, false) => format!("пусковая повреждена: {} ячеек из {}, перезарядка {:.0} с", sam.cells(), spec.missile.as_ref().map_or(0, |m| m.magazine), sam.reload_time() as f64 / 20.0),
|
||||
(Part::Gun, true) => "орудие заклинило: не стреляет".to_string(),
|
||||
(Part::Gun, false) => format!("орудие повреждено: меткость {:.0}%, заклинивает", sam.gun_fit() * 100.0),
|
||||
(Part::Tracks, true) => "ходовая отказала: на марш не выйдет".to_string(),
|
||||
(Part::Tracks, false) => format!("ходовая повреждена: скорость марша {:.0}%", sam.drive_speed() * 100.0),
|
||||
(Part::Comms, true) => "связь отказала: сети нет, цели только свои".to_string(),
|
||||
(Part::Comms, false) => format!("связь повреждена: линия {:.0} м", sam.link_range()),
|
||||
};
|
||||
v.push(check(false, effect));
|
||||
}
|
||||
if sam.jam > 0 {
|
||||
v.push(check(false, format!("орудие заклинило, ещё {:.1} с", sam.jam as f64 / 20.0)));
|
||||
}
|
||||
let drone_at = |id: u32| self.drones.iter().find(|d| d.id == id);
|
||||
let subject = sam.target.or(sam.gun_target).and_then(drone_at).or_else(|| {
|
||||
self.drones
|
||||
@@ -175,24 +217,42 @@ impl Sim {
|
||||
return v;
|
||||
};
|
||||
let held = sam.target == Some(t.id) || sam.gun_target == Some(t.id);
|
||||
let dist = t.pos.distance(sam.pos);
|
||||
let dist = sam.track(t.id).map_or(t.pos, |tr| tr.est(self.tick)).distance(sam.pos);
|
||||
v.push(check(
|
||||
true,
|
||||
format!("цель: {} {}, {}", t.kind.spec().name, if held { "(сопровождается)" } else { "(самая опасная)" }, crate::panel::distance(dist)),
|
||||
));
|
||||
let blocks = target_blocks(sam.roles, &sam.seen, t);
|
||||
let track = sam.track(t.id);
|
||||
let blocks = target_blocks(sam.roles, track, now, t);
|
||||
// Where the system believes the target is: its track's forecast (the true place only when it has no track).
|
||||
let est = track.map_or(t.pos, |tr| tr.est(now));
|
||||
let evel = track.map_or(t.vel, |tr| tr.vel);
|
||||
v.push(check(
|
||||
blocks & ROLE == 0,
|
||||
format!("роль: {} {} [{}]", t.kind.spec().class.name(), if blocks & ROLE == 0 { "в ролях" } else { "не в ролях" }, roles_text(sam.roles)),
|
||||
));
|
||||
let detect = match (sam.own.contains(&t.id), sam.seen.contains(&t.id)) {
|
||||
(true, _) => "обнаружена своей РЛС",
|
||||
(false, true) => "видна только по сети (стволы не стреляют)",
|
||||
_ => "не обнаружена ни своей РЛС, ни сетью",
|
||||
let detect = match (track, sam.own.contains(&t.id)) {
|
||||
(Some(tr), own) => {
|
||||
let kind = if own { "своей РЛС" } else { "по сети (стволы не стреляют)" };
|
||||
let age = tr.age(now) as f64 / 20.0;
|
||||
let lock = if tr.own_within(now, sight::GUN_AGE) && tr.locked { ", захват" } else { "" };
|
||||
let how = if tr.forecast(now) { format!("прогноз ±{:.0} м", tr.uncertainty(now)) } else { "измерение".to_string() };
|
||||
format!("трек {kind}: {}, возраст {age:.1} с, {how}{lock}, {}", tr.ident().name(), if blocks & STALE != 0 { "устарел для пуска" } else { "годен для пуска" })
|
||||
}
|
||||
_ => "не обнаружена ни своей РЛС, ни сетью".to_string(),
|
||||
};
|
||||
v.push(check(blocks & UNSEEN == 0, detect));
|
||||
let fit = sam.fitness();
|
||||
v.push(check(fit >= 0.9, format!("состояние {:.0}%: поворот и точность {:.0}%", sam.health * 100.0, fit * 100.0)));
|
||||
v.push(check(blocks & (UNSEEN | STALE) == 0, detect));
|
||||
if sam.kind.spec().sensor > 0.0 {
|
||||
let period = sam.scan_ticks();
|
||||
v.push(check(
|
||||
sam.radar_up(),
|
||||
if sam.radar_up() {
|
||||
format!("обзор: оборот {:.1} с{}, луч на {:.0}°", period / 20.0, if sam.kind.scan().agile { ", электронный, цели в треке чаще" } else { "" }, sam.beam.to_degrees())
|
||||
} else {
|
||||
"обзор: РЛС не работает".to_string()
|
||||
},
|
||||
));
|
||||
}
|
||||
// Ammunition and its supply.
|
||||
let mut rounds = String::new();
|
||||
if let Some(load) = &spec.missile {
|
||||
@@ -211,12 +271,12 @@ impl Sim {
|
||||
v.push(check(sam.reloading == 0 && !sam.empty && (sam.missiles > 0 || sam.bursts > 0), format!("{rounds}— {supply}")));
|
||||
let mount = sam.mount().0;
|
||||
let lead_speed = spec.gun.as_ref().map_or(5.0, |g| g.shell);
|
||||
let (_, want_yaw, want_pitch) = aim_wants(mount, t, lead_speed);
|
||||
let (_, want_yaw, want_pitch) = aim_wants(mount, est, evel, lead_speed);
|
||||
let err = aim_error(spec.family, sam.yaw, sam.pitch, want_yaw, want_pitch);
|
||||
let vertical = spec.family == Family::Vertical;
|
||||
let mut fires = false;
|
||||
if let Some(load) = &spec.missile {
|
||||
let reach = reach_blocks(load.min, load.max, 1.0, sam.pos, t);
|
||||
let reach = reach_blocks(load.min, load.max, 1.0, sam.pos, est);
|
||||
v.push(check(
|
||||
reach == 0,
|
||||
format!(
|
||||
@@ -241,15 +301,30 @@ impl Sim {
|
||||
if mb & NO_ROUNDS != 0 {
|
||||
why.push("нет ракет".to_string());
|
||||
}
|
||||
if mb & LAUNCHER_DOWN != 0 {
|
||||
why.push("пусковая отказала".to_string());
|
||||
}
|
||||
v.push(check(mb == 0, if mb == 0 { "пусковая готова".to_string() } else { format!("пусковая не готова: {}", why.join(", ")) }));
|
||||
let aligned = missile_aligned(spec.family, sam.yaw, mount, Some(t), t.id, t.id, err);
|
||||
let aligned = missile_aligned(spec.family, sam.yaw, mount, Some(est), t.id, t.id, err);
|
||||
let turn = if vertical { "вертикальный старт" } else { "доворот" };
|
||||
v.push(check(aligned, format!("{turn}: ошибка наведения {:.0}°{}", err.to_degrees(), if aligned { "" } else { " — ещё доворачивается" })));
|
||||
fires |= reach == 0 && free < need && mb == 0 && aligned && blocks == 0;
|
||||
// The land: the verdict the launch decision gave this target (`terrain::launch_blocks`) a moment ago.
|
||||
let land = sam.terrain_verdict(t.id, now).unwrap_or(0);
|
||||
if land != 0 {
|
||||
v.push(check(
|
||||
false,
|
||||
if land & terrain::MASKED != 0 {
|
||||
"рельеф: цель за рельефом — ракета не стартует, ждём, пока цель выйдет из-за него"
|
||||
} else {
|
||||
"рельеф: траектория перекрыта рельефом — подняться над ним ракета не может"
|
||||
},
|
||||
));
|
||||
}
|
||||
fires |= reach == 0 && free < need && mb == 0 && aligned && blocks == 0 && land == 0;
|
||||
}
|
||||
if let Some(gun) = &spec.gun {
|
||||
let on_it = guns_on.get(&t.id).copied().unwrap_or(0) - u32::from(sam.gun_target == Some(t.id));
|
||||
let cand = gun_candidate(sam.roles, &sam.seen, &sam.own, gun.track, sam.pos, t);
|
||||
let cand = track.is_some_and(|tr| gun_candidate(sam.roles, tr, now, gun.track, sam.pos, t));
|
||||
v.push(check(
|
||||
cand,
|
||||
format!(
|
||||
@@ -259,7 +334,7 @@ impl Sim {
|
||||
if cand { "" } else { " — вне сопровождения (нужна своя РЛС)" }
|
||||
),
|
||||
));
|
||||
let gb = gun_blocks(sam, t.id, t.id, err, mount.distance(t.pos), gun.range);
|
||||
let gb = gun_blocks(sam, t.id, t.id, err, mount.distance(est), gun.range, now);
|
||||
let mut why = Vec::new();
|
||||
for (bit, name) in [
|
||||
(AIM_ERROR, format!("орудие ещё наводится ({:.0}°)", err.to_degrees())),
|
||||
@@ -267,6 +342,8 @@ impl Sim {
|
||||
(COOLDOWN, "пауза между очередями".to_string()),
|
||||
(RELOADING, "перезарядка".to_string()),
|
||||
(NO_ROUNDS, "нет снарядов".to_string()),
|
||||
(JAMMED, "орудие заклинило".to_string()),
|
||||
(NO_LOCK, "нет свежего захвата своей РЛС".to_string()),
|
||||
] {
|
||||
if gb & bit != 0 {
|
||||
why.push(name);
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
//! What the game's blocks are to the simulation (issues #42, #43): a cache of
|
||||
//! 16-block sections answered by `render.sections` (a bit per block that
|
||||
//! stops a projectile), a swept segment through it, and the bookkeeping that
|
||||
//! keeps the questions few: the sections along a flight asked for ahead of
|
||||
//! time, at most 64 a tick, nearest need first; changes of the world marking
|
||||
//! sections stale; a bounded cache. A chunk the game doesn't have answers
|
||||
//! `Unknown`, which is never the same as air: the callers fall back to the
|
||||
//! height of the land there and the counters say how often.
|
||||
use glam::DVec3;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// A section: block coordinates divided by 16.
|
||||
pub type Sec = (i32, i32, i32);
|
||||
|
||||
/// Sections asked in one tick (the game's budget).
|
||||
pub const PER_TICK: usize = 64;
|
||||
/// Sections kept; the least recently used go first beyond it.
|
||||
const MAX_SECTIONS: usize = 12_000;
|
||||
/// Ticks before an unanswered or unknown section is asked again.
|
||||
const RETRY: u64 = 60;
|
||||
/// 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).
|
||||
const QUEUE_MAX: usize = 512;
|
||||
|
||||
/// What a section holds, as the game answered.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Fill {
|
||||
/// The game has no such chunk.
|
||||
Unknown,
|
||||
Empty,
|
||||
Full,
|
||||
/// 512 bytes, one bit per block, index `(y & 15) * 256 + (z & 15) * 16 + (x & 15)`.
|
||||
Mixed(Box<[u8; 512]>),
|
||||
}
|
||||
|
||||
/// An answer for a section, as the game gives it.
|
||||
pub struct SectionAnswer {
|
||||
pub sec: Sec,
|
||||
pub fill: Fill,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
fill: Fill,
|
||||
/// A block changed (or may have) since it was read: asked again when next used.
|
||||
stale: bool,
|
||||
/// The tick it was last used in a sweep.
|
||||
used: u64,
|
||||
/// The tick it was answered.
|
||||
since: u64,
|
||||
}
|
||||
|
||||
/// How a swept segment ended.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Sweep {
|
||||
/// Every block along it is known and none stops it.
|
||||
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 },
|
||||
}
|
||||
|
||||
impl Sweep {
|
||||
pub fn hit(&self) -> Option<(f64, DVec3)> {
|
||||
match *self {
|
||||
Sweep::Hit { t, point, .. } => Some((t, point)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Counters for the report: questions, unknowns, memory, cost.
|
||||
#[derive(Clone, Copy, Default, Debug)]
|
||||
pub struct WorldStats {
|
||||
pub sweeps: u64,
|
||||
pub cells: u64,
|
||||
pub hits: u64,
|
||||
pub unknown_sweeps: u64,
|
||||
pub sections_asked: u64,
|
||||
pub sections_answered: u64,
|
||||
pub sections_unknown: u64,
|
||||
pub invalidated: u64,
|
||||
pub evicted: u64,
|
||||
pub world_changes: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WorldCache {
|
||||
secs: HashMap<Sec, Entry>,
|
||||
urgent: Vec<Sec>,
|
||||
ahead: Vec<Sec>,
|
||||
queued: HashSet<Sec>,
|
||||
/// Sections asked, and when.
|
||||
asked: HashMap<Sec, u64>,
|
||||
epoch: u32,
|
||||
/// Sweeps made as a look ahead (flight planning) ask for sections after the urgent ones.
|
||||
quiet: bool,
|
||||
pub stats: WorldStats,
|
||||
}
|
||||
|
||||
fn sec_of(x: i32, y: i32, z: i32) -> Sec {
|
||||
(x >> 4, y >> 4, z >> 4)
|
||||
}
|
||||
|
||||
impl WorldCache {
|
||||
/// Memory the cached sections hold, about.
|
||||
pub fn bytes(&self) -> usize {
|
||||
self.secs
|
||||
.values()
|
||||
.map(|e| 48 + if matches!(e.fill, Fill::Mixed(_)) { 512 } else { 0 })
|
||||
.sum()
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.secs.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.secs.is_empty()
|
||||
}
|
||||
/// One line for the report.
|
||||
pub fn text(&self) -> String {
|
||||
let s = &self.stats;
|
||||
format!(
|
||||
"мир: {} секций ({} КБ), запросов {}, неизвестно {}, лучей {} (ударов {}, неизв. {}), клеток {}",
|
||||
self.len(),
|
||||
self.bytes() / 1024,
|
||||
s.sections_asked,
|
||||
s.sections_unknown,
|
||||
s.sweeps,
|
||||
s.hits,
|
||||
s.unknown_sweeps,
|
||||
s.cells
|
||||
)
|
||||
}
|
||||
|
||||
/// 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]) {
|
||||
if epoch != self.epoch {
|
||||
if self.epoch != 0 {
|
||||
self.secs.clear();
|
||||
self.urgent.clear();
|
||||
self.ahead.clear();
|
||||
self.queued.clear();
|
||||
self.asked.clear();
|
||||
}
|
||||
self.epoch = epoch;
|
||||
}
|
||||
if overflow {
|
||||
for e in self.secs.values_mut() {
|
||||
e.stale = true;
|
||||
}
|
||||
self.stats.invalidated += self.secs.len() as u64;
|
||||
}
|
||||
for s in sections {
|
||||
if let Some(e) = self.secs.get_mut(s) {
|
||||
e.stale = true;
|
||||
self.stats.invalidated += 1;
|
||||
}
|
||||
}
|
||||
self.stats.world_changes += sections.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);
|
||||
let hi = sec_of((at.x + radius).floor() as i32, (at.y + radius).floor() as i32, (at.z + radius).floor() as i32);
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
if let Some(e) = self.secs.get_mut(&(x, y, z)) {
|
||||
e.stale = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn answer(&mut self, answers: Vec<SectionAnswer>, now: u64) {
|
||||
for a in answers {
|
||||
self.asked.remove(&a.sec);
|
||||
self.stats.sections_answered += 1;
|
||||
if a.fill == Fill::Unknown {
|
||||
self.stats.sections_unknown += 1;
|
||||
}
|
||||
self.secs.insert(a.sec, Entry { fill: a.fill, stale: false, used: now, since: now });
|
||||
}
|
||||
if self.secs.len() > MAX_SECTIONS {
|
||||
// Whatever wasn't used for a minute goes; if that's not enough, the older half.
|
||||
self.secs.retain(|_, e| now.saturating_sub(e.used) < 1200);
|
||||
if self.secs.len() > MAX_SECTIONS {
|
||||
let mut used: Vec<u64> = self.secs.values().map(|e| e.used).collect();
|
||||
used.sort_unstable();
|
||||
let cut = used[used.len() / 2];
|
||||
self.secs.retain(|_, e| e.used >= cut);
|
||||
}
|
||||
self.stats.evicted += 1;
|
||||
}
|
||||
// Forget questions the game never answered, so they can be asked again.
|
||||
if now % 64 == 0 {
|
||||
self.asked.retain(|_, t| now.saturating_sub(*t) < RETRY);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sections to ask this tick (at most [`PER_TICK`]): the ones a sweep
|
||||
/// needed first, then those along flights.
|
||||
pub fn take_wanted(&mut self, now: u64) -> Vec<Sec> {
|
||||
let mut out = Vec::new();
|
||||
for list in [&mut self.urgent, &mut self.ahead] {
|
||||
let room = PER_TICK - out.len();
|
||||
let n = room.min(list.len());
|
||||
out.extend(list.drain(..n));
|
||||
}
|
||||
for s in &out {
|
||||
self.queued.remove(s);
|
||||
self.asked.insert(*s, now);
|
||||
}
|
||||
self.stats.sections_asked += out.len() as u64;
|
||||
out
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
if self.urgent.len() + self.ahead.len() >= QUEUE_MAX {
|
||||
return;
|
||||
}
|
||||
if urgent {
|
||||
self.urgent.push(s);
|
||||
} else {
|
||||
self.ahead.push(s);
|
||||
}
|
||||
self.queued.insert(s);
|
||||
}
|
||||
|
||||
/// Asks for the sections along a flight from `pos` at `vel` a tick, `ticks` ahead.
|
||||
pub fn look_ahead(&mut self, pos: DVec3, vel: DVec3, ticks: f64, now: u64) {
|
||||
let mut d = vel * ticks;
|
||||
let len = d.length();
|
||||
if len < 1e-6 {
|
||||
return;
|
||||
}
|
||||
if len > AHEAD_MAX {
|
||||
d *= AHEAD_MAX / len;
|
||||
}
|
||||
// A section at a time along the segment (a section is 16 wide: half-section steps meet each).
|
||||
let steps = (d.length() / 8.0).ceil().max(1.0) as i32;
|
||||
let mut last = None;
|
||||
for i in 0..=steps {
|
||||
let p = pos + d * (i as f64 / steps as f64);
|
||||
let s = sec_of(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
if last == Some(s) {
|
||||
continue;
|
||||
}
|
||||
last = Some(s);
|
||||
self.touch(s, false, now);
|
||||
}
|
||||
}
|
||||
|
||||
/// Touches a section the way a sweep or a look needs it: used now, and
|
||||
/// asked again when stale or (after a while) unknown.
|
||||
fn touch(&mut self, s: Sec, urgent: bool, now: u64) -> bool {
|
||||
let Some(e) = self.secs.get_mut(&s) else {
|
||||
self.want(s, urgent, now);
|
||||
return false;
|
||||
};
|
||||
e.used = now;
|
||||
let refresh = e.stale || (e.fill == Fill::Unknown && now.saturating_sub(e.since) >= RETRY);
|
||||
if refresh {
|
||||
self.want(s, urgent, now);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether the block at `(x, y, z)` stops a projectile: `None` where the
|
||||
/// game hasn't said. `last` is the section just touched.
|
||||
fn solid(&mut self, last: &mut Option<Sec>, x: i32, y: i32, z: i32, now: u64) -> Option<bool> {
|
||||
let s = sec_of(x, y, z);
|
||||
if *last != Some(s) {
|
||||
*last = Some(s);
|
||||
self.touch(s, !self.quiet, now);
|
||||
}
|
||||
match &self.secs.get(&s)?.fill {
|
||||
Fill::Unknown => None,
|
||||
Fill::Empty => Some(false),
|
||||
Fill::Full => Some(true),
|
||||
Fill::Mixed(bits) => {
|
||||
let i = ((y & 15) << 8 | (z & 15) << 4 | (x & 15)) as usize;
|
||||
Some(bits[i >> 3] >> (i & 7) & 1 != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the block is known to hold no solid block, with the sky above it
|
||||
/// in view: used to tell a fluid surface from a roof.
|
||||
pub fn is_known_open(&mut self, x: i32, y: i32, z: i32, now: u64) -> bool {
|
||||
self.solid(&mut None, x, y, z, now) == Some(false)
|
||||
}
|
||||
|
||||
/// Whether the block is known to be solid.
|
||||
pub fn is_known_solid(&mut self, x: i32, y: i32, z: i32, now: u64) -> bool {
|
||||
self.solid(&mut None, x, y, z, now) == Some(true)
|
||||
}
|
||||
|
||||
/// A sweep made to plan a flight, not to move one: the sections it needs are asked after the urgent ones,
|
||||
/// and a start inside a block is not a hit.
|
||||
pub fn probe(&mut self, a: DVec3, b: DVec3, now: u64) -> Sweep {
|
||||
self.quiet = true;
|
||||
let r = self.sweep(a, b, true, now);
|
||||
self.quiet = false;
|
||||
r
|
||||
}
|
||||
|
||||
/// 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).
|
||||
pub fn sweep(&mut self, a: DVec3, b: DVec3, skip_start: bool, now: u64) -> Sweep {
|
||||
self.stats.sweeps += 1;
|
||||
let d = b - a;
|
||||
let mut pos = [a.x.floor() as i32, a.y.floor() as i32, a.z.floor() as i32];
|
||||
let dv = [d.x, d.y, d.z];
|
||||
let av = [a.x, a.y, a.z];
|
||||
let step: [i32; 3] = dv.map(|v| if v > 0.0 { 1 } else { -1 });
|
||||
let mut t_max = [f64::INFINITY; 3];
|
||||
let mut t_delta = [f64::INFINITY; 3];
|
||||
for i in 0..3 {
|
||||
if dv[i] != 0.0 {
|
||||
let edge = f64::from(pos[i]) + if dv[i] > 0.0 { 1.0 } else { 0.0 };
|
||||
t_max[i] = (edge - av[i]) / dv[i];
|
||||
t_delta[i] = (1.0 / dv[i]).abs();
|
||||
}
|
||||
}
|
||||
let mut last: Option<Sec> = None;
|
||||
let mut face = [0i32; 3];
|
||||
let mut t = 0.0f64;
|
||||
let mut first = true;
|
||||
for _ in 0..4096 {
|
||||
self.stats.cells += 1;
|
||||
match self.solid(&mut last, pos[0], pos[1], pos[2], now) {
|
||||
None => {
|
||||
self.stats.unknown_sweeps += 1;
|
||||
return Sweep::Unknown { t };
|
||||
}
|
||||
Some(true) if !(first && skip_start) => {
|
||||
self.stats.hits += 1;
|
||||
return Sweep::Hit { t, point: a + d * t, normal: face, block: pos };
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
first = false;
|
||||
let axis = if t_max[0] < t_max[1] {
|
||||
if t_max[0] < t_max[2] { 0 } else { 2 }
|
||||
} else if t_max[1] < t_max[2] {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
t = t_max[axis];
|
||||
if t > 1.0 {
|
||||
return Sweep::Clear;
|
||||
}
|
||||
t_max[axis] += t_delta[axis];
|
||||
pos[axis] += step[axis];
|
||||
face = [0; 3];
|
||||
face[axis] = -step[axis];
|
||||
}
|
||||
self.stats.unknown_sweeps += 1;
|
||||
Sweep::Unknown { t }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the game's blocks come from in tests and the native runner: any
|
||||
/// world that can say what a block is.
|
||||
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>;
|
||||
}
|
||||
|
||||
/// A [`WorldSource`] as the game would answer `render.query-sections` with it.
|
||||
pub fn section_of(source: &dyn WorldSource, s: Sec) -> Fill {
|
||||
let mut bits = Box::new([0u8; 512]);
|
||||
let (mut set, mut known) = (0u32, 0u32);
|
||||
for i in 0..4096usize {
|
||||
let (x, y, z) = (s.0 * 16 + (i & 15) as i32, s.1 * 16 + (i >> 8) as i32, s.2 * 16 + ((i >> 4) & 15) as i32);
|
||||
match source.solid(x, y, z) {
|
||||
Some(true) => {
|
||||
bits[i >> 3] |= 1 << (i & 7);
|
||||
set += 1;
|
||||
known += 1;
|
||||
}
|
||||
Some(false) => known += 1,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
match (known, set) {
|
||||
(0, _) => Fill::Unknown,
|
||||
(_, 0) if known == 4096 => Fill::Empty,
|
||||
(_, 4096) => Fill::Full,
|
||||
_ => Fill::Mixed(bits),
|
||||
}
|
||||
}
|
||||
|
||||
impl WorldCache {
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
+151
-15
@@ -8,8 +8,9 @@
|
||||
|
||||
HD — третий пресет графики мода (`/airdef shader off|on|hd`): отложенное освещение в стиле шейдерпаков
|
||||
(BSL/Complementary-класс), но написанное с нуля внутри нашего форка Horizon (Minecraft 26.3 на Rust/wgpu).
|
||||
Код шейдерпаков (BSL, Complementary, Photon, Iris/OptiFine) **не копируем**: их лицензии запрещают перенос, а в
|
||||
Horizon действует правило clean-room. Пишем по знанию техник (статьи, общеизвестные алгоритмы).
|
||||
Код шейдерпаков (BSL, Complementary, Iris/OptiFine и прочих) **не копируем**: их лицензии запрещают перенос, а в
|
||||
Horizon действует правило clean-room. Исключение — Photon (sixthsurge), коммит 26c2906ee02cc484359b835fba6993d443d45966: он под MIT, из него можно
|
||||
переносить идеи и код **с указанием авторства** (комментарий у кода + `gfx/THIRD_PARTY_PHOTON.md`). Остальное пишем по знанию техник (статьи, общеизвестные алгоритмы).
|
||||
|
||||
Пресеты должны оставаться: `off` (ваниль), `on` (пост-эффекты мода), `hd` (этот рендер), позже `rt` (аппаратная
|
||||
трассировка, отдельно).
|
||||
@@ -18,7 +19,13 @@ Horizon действует правило clean-room. Пишем по знани
|
||||
|
||||
| Что | Где |
|
||||
|---|---|
|
||||
| Весь HD-конвейер: цели, проходы, WGSL-шейдеры (строками), тайлы света, AO, тени, bloom, tonemap, TAA | `horizon/crates/horizon-client/src/gfx/hd.rs` |
|
||||
| Оркестровка HD: `Hd`, общие цели, буфер кадра, `resize`/`write`, сборка шейдеров (`assemble`: прелюдия + тело), контракты цвета/глубины/альф/времени (комментарий в начале файла) | `horizon/crates/horizon-client/src/gfx/hd/mod.rs` |
|
||||
| Униформ кадра `HdFrame` (Rust) и сцена `HdScene`/`HdLight`/`HdSettings`, свет солнца/луны/облаков (`light_of`, `cloud_light`); общая WGSL-прелюдия (`HdFrame`, `FullOut`, `vs_full`) | `gfx/hd/frame.rs`, `gfx/hd/prelude.wgsl` |
|
||||
| Тени (каскады, `Shadows`, `cascades()`); G-буфер (`GBuffer`, форматы) | `gfx/hd/shadows.rs`; `gfx/hd/gbuffer.rs` |
|
||||
| AO (GTAO + блюр); освещение `fs_light` (+ тайлы источников: `tiles.rs`); вода (проход геометрии и композит) | `gfx/hd/ao.rs` + `ao.wgsl`, `ao_blur.wgsl`; `gfx/hd/lighting.rs` + `light.wgsl`, `gfx/hd/tiles.rs`; `gfx/hd/water.rs` + `water.wgsl` |
|
||||
| TAA/TAAU и джиттер; bloom; tonemap (+ `Hd::finish`) | `gfx/hd/taa.rs` + `taa.wgsl`; `gfx/hd/bloom.rs` + `bloom.wgsl`; `gfx/hd/tonemap.rs` + `tonemap.wgsl` |
|
||||
| Объёмные облака (рэймарч слоя, тени облаков на землю) и объёмный туман, их цели и проходы; CPU-шум (2D, 3D Worley) | `gfx/hd/volumes.rs` + `cloud_common.wgsl`, `cloud.wgsl`, `cloud_shadow.wgsl`, `fog.wgsl`; `gfx/hd/noise.rs` |
|
||||
| Атмосфера: сборка текстуры неба (WGSL), чтение неба/дымки/тона света в шейдерах, цвет солнца (`sun_transmittance`) | `gfx/hd/sky.rs` + `sky.wgsl`, `sky_funcs.wgsl` (+ `THIRD_PARTY_PHOTON.md`) |
|
||||
| Встраивание в кадр: 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` |
|
||||
@@ -39,10 +46,11 @@ cd .. && rm -f horizon-patches/*.patch && git -C horizon format-patch -q $(cat H
|
||||
|
||||
## Конвейер кадра (HD включён)
|
||||
|
||||
Все цели в «масштабированном» разрешении мира (`world_post`), HDR — `Rgba16Float`. Глубина камеры — reversed-Z
|
||||
Цели мира (G-буфер, AO, освещение, пост-проход мода) — в «масштабированном» разрешении мира (`world_post`; при `HORIZON_HD_SCALE` < 1 оно меньше окна), цели после TAA (история, bloom, tonemap) — в разрешении окна. HDR — `Rgba16Float`. Глубина камеры — reversed-Z
|
||||
(`Depth32Float`, 1 = близко, 0 = далеко). Позиции везде относительно камеры (camera-relative), мир — в `f64`
|
||||
на CPU.
|
||||
|
||||
0. **Небо** (`hd.sky`, проход `hd sky`, перед освещением): текстура 128×72 (`Rgba16Float`): 128 азимутов (от солнца, 0..π) × 64 высот (v = √(h/(π/2))), плюс строка 64 с «окружающим светом» (текселы 0 — зенит, 1 — среднее у горизонта). Каждый тексел — марш по лучу (32 шага) однократного рассеяния Рэлея/Ми + озон, плоский член многократного рассеяния (`MULTI_SCATTER`), солнце и луна (луна — напротив) как источники; пропускание до источника — аналитическое (Chapman, как у Photon). Зависит только от высоты солнца → перерисовывается, лишь когда она сдвинулась больше чем на 0.001. Читают её `fs_light` и композит воды (привязки 15 и 10).
|
||||
1. **Каскады теней** (2 шт., `CASCADES = [40, 176]` блоков полуразмера, `SHADOW_SIZE = 2048`, массив глубины
|
||||
из 2 слоёв). Источник — солнце днём, луна ночью (`light_of`). Матрицы `cascades()` привязаны к текселям
|
||||
(тени не «ползут»). Во время прохода буфер глобалов мира подменяется на глобалы каскада (copy-трюк) и потом
|
||||
@@ -50,18 +58,42 @@ cd .. && rm -f horizon-patches/*.patch && git -C horizon format-patch -q $(cat H
|
||||
(`shadow_casters`: без оверлеев 4/9/10/40 — 40 это светящиеся модели мода).
|
||||
2. **G-буфер** (свой depth): albedo `Rgba8Unorm` (a = эмиссия), нормаль `Rgba16Float` (мировая, из `dpdx/dpdy`),
|
||||
уровни света `Rgba8Unorm` (x = небо, y = блоки — сырые координаты лайтмапа /240). Террейн + дальние LOD-тайлы +
|
||||
сущности. Вода/полупрозрачное в G-буфер **не** попадает (идёт ванильным путём) — см. план.
|
||||
3. **GTAO** (полразрешения, `Rg16Float`: окклюзия, дистанция) + depth-aware блюр 5×5. 3 среза × 5 шагов, радиус
|
||||
сущности. Полупрозрачное в G-буфер **не** попадает (идёт ванильным путём); вода — отдельный путь, см. п. 3 и «Вода».
|
||||
3. **Вода** (`water_pass`, только если на экране есть полупрозрачные секции, нет `frame.underwater` и не `HORIZON_HD_WATER=0`):
|
||||
те же секции ещё раз с `fs_water` (terrain.wgsl), который отбрасывает всё, кроме слоёв спрайтов `water_still`/`water_flow`
|
||||
(номера слоёв передаются в `Renderer::new` из `sprite_layers` и ставятся override-константами `WATER_STILL/WATER_FLOW`; стекло,
|
||||
лёд и прочее остаются ванильными). Цели: цвет (спрайт × биомный тинт, a = свет блоков, `Rgba16Float`), нормаль грани (a = свет неба,
|
||||
`Rgba8Unorm`) и собственная глубина воды (`Depth32Float`, 0 = воды нет; тест глубины только среди воды, перекрытие
|
||||
землёй решают потребители). Пайплайны `gbuffer_water` (секции) и `flat.water` (flat-путь).
|
||||
3a. **GTAO** (полразрешения, `Rg16Float`: окклюзия, дистанция) + depth-aware блюр 5×5. 3 среза × 5 шагов, радиус
|
||||
1.8 блока, шум IGN по кадру. Сила — `HdFrame.ambient.w` (`HORIZON_HD_AO=<число>`, 0 = выкл).
|
||||
4. **Освещение** `fs_light` → `lit` (HDR): `max(небо·ambient.x, блоки·ambient.y)·AO + солнце·N·L·тень + точечные
|
||||
3b. **Облака и туман** (`hd.volumes`, до освещения, после прохода неба): `hd cloud shadows` (карта 256² оптической толщины облаков по ходу солнца, ±1200 блоков вокруг камеры), `hd clouds` (половина разрешения мира, `Rgba16Float`: свет + пропускание; рэймарч слоя 192.33…264 блока, 8–26 шагов с джиттером IGN, свет — 3 шага с удвоением, небо — 1, 4 октавы многократного рассеяния Photon; история ping-pong с репроекцией по направлению и сдвигу камеры, вес до 0.9, отбрасывается при расхождении) и `hd fog` (половина разрешения: 18 шагов от камеры до глубины мира, не дальше 192 блоков, плотность — высотная дымка + утренний туман у земли + дождь + шум, свет — солнце × тень каскадов × фаза Photon + свет неба; вторая цель — длина луча для апсэмплинга). Ванильные облака при этом не рисуются (`hd_volumetric_clouds` в `gfx.rs`). `fs_light` накладывает облака на небо (`c·T + S`), туман на всё (`fog_at`: 4 текселя с весом по близости длины луча), а тень облаков (`cloud_shadow`) умножает прямой солнечный свет. `HORIZON_HD_CLOUDS=0` / `HORIZON_HD_VOLFOG=0` выключают (облака тогда ванильные, лучи — старые 14 шагов).
|
||||
4. **Освещение** `fs_light` → `lit` (HDR): пиксель неба (глубина мира 0, Оверворлд, без env-тумана) — это небо атмосферы + то, что мир нарисовал поверх ванильного неба (`max(scene − vanilla_sky(view), 0)`: спрайты луны, звёзды; квадрат ванильного солнца вырезается вокруг направления солнца и заменяется круглым диском с гало); облака (глубина > 0, нет в G-буфере, y > 8, дальше 48 блоков) на закате/рассвете окрашиваются цветом солнца. Свет неба: ванильный `lightmap` × подкраска цветом неба (зенит сверху, горизонт сбоку; яркость 1) × форма по нормали (вверх 1.1, вниз 0.66), только там, где есть небесный свет. Тени: ближний каскад — PCSS-lite (поиск блокировщиков 8 выборок, ширина полутени от расстояния до блокировщика, 12 выборок спирали Фогеля, повёрнутых IGN; сглаживает TAA), дальний — 3×3. Туман: цвет — небо над горизонтом в направлении пикселя (`haze_colour`) + воздушная перспектива `1 − exp(−d/700)` × 0.5. Далее прежнее: пиксель — вода, если `water_depth >= g` и перед водой в мировой глубине ничего нет
|
||||
(`d - wd <= 0.2%`); тогда освещается **дно** по данным G-буфера (одна выборка тени, без AO), а в альфу `lit` пишется 0.5 (признак воды). `max(небо·ambient.x, блоки·ambient.y)·AO + солнце·N·L·тень + точечные
|
||||
источники` × albedo + эмиссия; затем ванильный туман заново, объёмные лучи (14 шагов, Henyey-Greenstein g=0.6),
|
||||
ореолы вокруг источников. Точечные источники — по тайлам 16×16 (`tile_lists`, буфер `tiles`: на тайл 4 слова —
|
||||
начало/длина списка для поверхностей и для ореолов, потом индексы; переполнение → «все источники»).
|
||||
4a. **Композит воды** `fs_water` (`hd.water_composite`, `lit` → `lit_water`, дальше везде `hd.lit_view()`; на пикселях без воды копия).
|
||||
Волны — сумма 10 синусоид в мировых координатах (векторы — целые кратные 2π/256, скорости — целые циклы за 600 с: период 256 блоков
|
||||
и 10 минут, не «плывут» с камерой; мелкие волны гасятся по размеру пикселя против мерцания); нормаль = нормаль грани + градиент
|
||||
(только на горизонтальных гранях). Преломление: смещение луча относительно плоской воды, выборка `lit` по uv со сдвигом (отбраковка
|
||||
по глубине: не берутся банк/сущности перед водой); толщина = расстояние от поверхности до дна; поглощение `exp(-(1-цвет)·0.8·(t+0.3))`
|
||||
+ рассеяние цветом воды при свете «объёма» (лайтмап + солнце с тенью). Отражение: SSR по глубине G-буфера (24 шага, растущие,
|
||||
джиттер IGN по кадру, 5 шагов бинарного уточнения, толщина `0.4+0.08·s`), при промахе — небо/туман (`sky_color`, `fog_color`, тусклее
|
||||
под навесом). Волны: к 10 крупным добавлены 16 мелких (длины 0.35–3 блока, уклон до 0.05, гасятся по размеру пикселя) — густая мелкая рябь для искорок блика; блик GGX с a=0.07. Цвет воды светлее: биом × 0.85, поглощение 0.8, рассеяние 0.85 (ночью до 0.4). Небо отражения и туман — из атмосферы. Френель Шлика F0=0.02 (SSR пропускается при F<0.04: взгляд вниз), блик солнца GGX (a=0.05) × тень, туман поверх.
|
||||
Подводный вид и `HORIZON_HD_WATER=0` — композит и проход воды не работают, `lit` как раньше.
|
||||
5. **Пост-проход мода** `lit` → `fx` (`effects.wgsl`: взрывы, дым, вспышки, свечение; при HD его собственная
|
||||
«подсветка огнём» выключена — свет даёт HD).
|
||||
6. **TAA** — после пост-прохода мода (`fx`, либо `lit` без него), до bloom: история пинг-понгом, в альфе истории —
|
||||
дистанция для отбраковки дизокклюзии; телепорт > 64 блоков сбрасывает историю. Джиттер добавляется к
|
||||
`view_projection` только при HD (публичная матрица без джиттера); рука и HUD рисуются после tonemap и не дрожат.
|
||||
**TAAU**: если мир меньше окна (`HORIZON_HD_SCALE=0.67`), тот же проход (`fs_taau`, в таймингах `hd taau`) восстанавливает
|
||||
полное разрешение. История, bloom и tonemap — в разрешении окна. Для каждого пикселя выхода берётся 3×3 выборки мира
|
||||
вокруг (с учётом джиттера в `taa_params.zw`), веса — полином, аппроксимирующий Lanczos-2, по расстоянию от центра
|
||||
пикселя выхода до выборки; variance clip в YCoCg по тем же 3×3; глубина — ближайшая из 2×2; история (Catmull-Rom,
|
||||
полное разрешение) вес 0.92, но ближе к ней там, где ближайшая выборка далеко от центра пикселя. Джиттер —
|
||||
Halton(2,3) на `max(16, ceil(8·ratio²))` кадров (0.67 → 18, 0.5 → 32). При масштабе 1 работает прежний `fs_taa`
|
||||
без изменений. С `HORIZON_HD_TAA=0` и масштабом < 1 остаётся простой билинейный апскейл в tonemap.
|
||||
7. **Bloom** (prefilter + 6 уровней: 13-tap вниз, tent вверх) и **tonemap**: до 0.8 как есть (ванильный вид),
|
||||
выше — мягкий roll-off по ярчайшему каналу; лёгкий грейд (+12% насыщенности, мягкая S-кривая).
|
||||
|
||||
@@ -81,10 +113,38 @@ Bind groups кэшируются и пересоздаются только пр
|
||||
| 10, 11 | лайтмап, линейный сэмплер |
|
||||
| 12 | тайлы источников (storage) |
|
||||
| 13 | AO |
|
||||
| 14 | глубина воды (`water_depth`) |
|
||||
| 15 | текстура неба (`sky_lut`) |
|
||||
| 16, 17 | объёмный туман (свет+пропускание) и длина его луча |
|
||||
| 18, 19 | объёмные облака, карта теней облаков |
|
||||
|
||||
`HdFrame` (Rust и WGSL держать в синхроне!): `inverse_view_proj`, `resolution` (w, h, 1/w, 1/h), `params` (число
|
||||
источников, экспозиция, bloom, счётчик кадров), `cascades[2]`, `sun` (направление, сила), `sun_color` (rgb, ночь),
|
||||
`fog_color`, `fog`, `ambient` (тень неба, блоки, плотность воздуха, AO), `shadow` (вкл, R0, R1, тайлов в строке).
|
||||
`fog_color`, `fog`, `ambient` (тень неба, блоки, плотность воздуха, AO), `shadow` (вкл, R0, R1, тайлов в строке), `prev_view_proj`, `taa`, `taa_params`, `view_proj` (с джиттером, для лучей воды), `water` (x: эффект идёт, y: секунды волн mod 600, zw: камера x, z mod 256), `sky_color` (w: есть небо), `sky_sun` (xyz: на само солнце, w: 1 — небо рисует атмосфера), `sky_extra` (x: конец ванильного тумана неба, z: пасмурность от дождя 0..0.85), `sunrise` (ванильная полоса восхода/заката — вычитается из мира), `clouds` (низ слоя относительно камеры, толщина, вкл, секунды ветра), `cloud_cam` (x, z камеры по модулю 65536, добавка покрытия от дождя, история облаков годна), `cloud_dir` / `cloud_sun` (откуда и каким цветом светят облаку: солнце до чуть ниже горизонта, ночью луна), `vfog` (туман вкл, высота камеры, утренний туман).
|
||||
|
||||
Композит воды (`WATER`, group 0): 0 `HdFrame`, 1 `lit`, 2 глубина G-буфера, 3 цвет воды, 4 нормаль воды, 5 глубина воды, 6–7 тени, 8 лайтмап, 9 сэмплер, 10 текстура неба. Проход неба (`SKY_PASS`, group 0): только `HdFrame`.
|
||||
|
||||
## Контракты (цвет, глубина, альфа, время)
|
||||
|
||||
Полная таблица «проход: что читает → что пишет» — в начале `gfx/hd/mod.rs`, подробности — в начале файла каждого прохода.
|
||||
|
||||
- **Цвет.** От освещения до tonemap всё в «показанном» пространстве ванили (гамма, основные цвета Rec. 709), значения могут быть > 1
|
||||
(`Rgba16Float`); tonemap не трогает ≤ 0.8. Исключения: текстура неба — линейная яркость Rec. 709 (марш в Rec. 2020; показывают
|
||||
читатели через `sky_show`); albedo G-буфера — `Rgba8Unorm` в том же показанном пространстве.
|
||||
- **Глубина.** Камера: reversed-Z, `Depth32Float`, 1 — близко, 0 — далеко/ничего (очистка в 0; глубина воды 0 — воды нет). Тени:
|
||||
обычная Z ортопроекции света (0 — у света, 1 — далеко), сравнение `<=`.
|
||||
- **Альфа.** albedo.a — эмиссия (свет добавляет `rgb·a·1.5`); `lit.a`: 1 — обычный пиксель, 0.5 — вода (в rgb дно без воздуха),
|
||||
0.6 — прозрачное перед водой; `air.a` — пропускание воздуха; облака и туман: `a` — пропускание (`c·a + rgb`); вода: цвет.a —
|
||||
свет блоков, нормаль.a — свет неба; история TAA: a — дистанция; AO: x — окклюзия, y — дистанция.
|
||||
- **Координаты.** Позиции относительно камеры; для узоров (волны, шум облаков, ветер) мировая координата передаётся по модулю периода
|
||||
(256, 65536). Мировая проекция с джиттером; `prev_view_proj` — с джиттером для `fs_taa` и без для `fs_taau` (выбирает `Hd::write`).
|
||||
- **Время.** Три разных часа: счётчик кадров (`params.w`, mod 64, вращение шума), время воды (`water.y`, секунды стенных часов mod 600),
|
||||
игровое время (`clouds.w`, секунды `HdScene.seconds` mod 300000, ветер облаков). Реплей, замораживающий игровое время, не
|
||||
останавливает воду.
|
||||
- **Истории.** Две цели TAA и две цели облаков принадлежат своим проходам, создаются в `resize`, сбрасываются при resize, телепорте
|
||||
(> 64 блоков), выключенном TAA; один `Hd` обслуживает одну камеру, истории разных камер не смешиваются.
|
||||
- **Размеры.** world — масштабированная цель мира (G-буфер, AO ½, свет, вода, облака/туман ½), out — окно (TAA, bloom, tonemap).
|
||||
- **Bind groups** кэшируются по видам, которые читают; пересоздаются при resize и смене входного вида; нет роста в установившемся кадре.
|
||||
|
||||
## Как проверять
|
||||
|
||||
@@ -95,8 +155,10 @@ cargo test -p horizon-client --lib hd # naga-разбор всех HD-ш
|
||||
cd ../airdef-mod && cargo horizon-mod build && cargo test # мод и его шейдер
|
||||
```
|
||||
|
||||
WGSL-ошибки в `hd.rs` всплывают только в рантайме — поэтому каждый новый шейдер добавляй в naga-тест
|
||||
`lighting_shader_parses`.
|
||||
WGSL-ошибки всплывают только в рантайме — поэтому каждый новый шейдер добавляй в список `shaders()` в тестах
|
||||
`gfx/hd/mod.rs`: `lighting_shader_parses` (naga-разбор каждого итогового шейдера), `every_wgsl_file_is_tested` (каждый `.wgsl`
|
||||
входит в проверенный шейдер), `bindings_match_the_layouts` (привязки WGSL = `*_BINDINGS` в Rust: номера, виды, размерности),
|
||||
`frame_layout_matches_the_prelude` (поля и размер `HdFrame` в Rust и в прелюдии).
|
||||
|
||||
Игра офскрин (только одна копия за раз; данные в `run-home/`, мир `Polygon`):
|
||||
|
||||
@@ -113,8 +175,19 @@ $H key f1 tap; $H screenshot out.png; $H bench 4; $H quit
|
||||
Тайминги проходов на GPU: `HORIZON_SHADER_PACK_TIMING=1` (лог в `run-home/ctl.log`) или список F3.
|
||||
Ориентиры (M5, 2560×1440): тени 0.6 + 2.85 мс (дальний каскад через кадр, ≈1.4 в среднем), G-буфер 0.8,
|
||||
AO 2.9, освещение 5.2, TAA 2.7, bloom 1.1, tonemap 0.2 — кадр ≈ 15 мс GPU (замеры шумные).
|
||||
TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс; 0.67 — 13.4 мс; 0.5 — 10.4 мс (освещение 6.1 → 2.7 → 1.6, AO 3.5 → 1.4 → 0.8, `hd taau` ≈ 3.5 мс на любом масштабе, дальний каскад теней 3.4 мс не зависит от масштаба).
|
||||
Следующие цели по скорости: освещение (лучи 14 шагов на полном разрешении → полразрешения + апскейл) и AO.
|
||||
|
||||
Проверка мерцания: два скриншота с неподвижной камеры подряд и разница по пикселям (Pillow `ImageChops.difference`)
|
||||
— на мире `Hills` с травой и деревьями; после исправлений ≈2% пикселей с разницей > 20/255 (дрожание TAA на пиксельных
|
||||
текстурах и анимация воды), а при ошибке теней было 8.5%. Сравни с `HORIZON_HD_TAA=0` / `HORIZON_HD_AO=0` /
|
||||
`HORIZON_HD_SHADOW_FAST=0`, чтобы найти виновника.
|
||||
|
||||
Графический полигон: `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 и кадрами.
|
||||
|
||||
Отладка: временно выводи промежуточные величины цветом в `fs_light` (так нашли пустую карту теней и неверное
|
||||
направление солнца). Помни: солнце в мире идёт по оси X (`(-sin a, cos a, 0)`), а `Globals.sun` неба — в своей
|
||||
плоскости YZ.
|
||||
@@ -131,25 +204,40 @@ AO 2.9, освещение 5.2, TAA 2.7, bloom 1.1, tonemap 0.2 — кадр ≈
|
||||
| Отсечение взрывов в `effects.wgsl` (#11), порядок дыма (#16), метрика перекрытия свечения (#14) | готово | 67a2a52, ed2db85 |
|
||||
| Тени: только грани к солнцу, без «закопанных» секций, дальний каскад через кадр (`HORIZON_HD_SHADOW_FAST=0` — по-старому) | готово | патч 16 |
|
||||
| TAA: джиттер Halton(2,3) ×16, перепроекция по глубине и сдвигу камеры, variance clip в YCoCg, история Catmull-Rom 0.9, лёгкий шарп в tonemap (`HORIZON_HD_TAA=0` — выкл) | готово | патч 16 |
|
||||
| TAAU: мир в `HORIZON_HD_SCALE` (0.25–1, по умолчанию 1 = как раньше), восстановление в TAA-проходе `fs_taau`; без LOD-bias текстур (не доступен) | готово | патч 18 |
|
||||
| Исправлено мерцание теней у игрока: ближний каскад терял секции в кадры, когда дальний держится; держимая матрица дальнего каскада сдвигается на шаг камеры; у растений (наклонные квады) нормаль «вверх», а не к камере; TAA держит 94% истории в покое | готово | патч 18 |
|
||||
| Вода: проход `fs_water`, преломление, SSR, Френель, блик (`HORIZON_HD_WATER=0` — выкл) | готово | патч 19 (+20: без ванильной текстуры на поверхности, цвет темнее и глубже) |
|
||||
| Вода: светлее, густая мелкая рябь (26 волн), блик a=0.07 (мерцание на реке ≈1.4% пикселей >20/255) | готово | патч 21 |
|
||||
| Небо и атмосфера (Рэлей/Ми/озон, текстура неба, воздушная перспектива, цвет солнца из пропускания, голубоватый ambient по нормали, PCSS-lite ближнего каскада, диск солнца, цвет облаков на закате; порт констант и Chapman из Photon, MIT) | готово | патч 22 |
|
||||
| Закат как у Photon: атмосфера в Rec. 2020 (коэффициенты и матрицы Photon), фаза Клейна-Нишины, тинт и усиление солнца на закате/рассвете/синем часе (`get_sun_tint`), насыщенность неба у низкого солнца | готово | патч 23 |
|
||||
| Объёмные облака (рэймарч, мультирассеяние Photon, тени на землю, TAA-история, ванильные скрыты) | готово | патч 24 |
|
||||
| Объёмный туман (лучи через тени каскадов, высотная дымка, утренний туман, полразрешения) | готово | патч 25 |
|
||||
| API моделей по хэндлам (#30, уровень API 11: `model-id`, `model-generation`, `set-models-by-id`) | готово | патч 17 |
|
||||
| #41: пасмурность неба и сила солнца берутся из мирового уровня дождя (`Sky.rain`, ставит `apply_weather`), а не из наличия геометрии осадков | готово | в рабочем дереве форка |
|
||||
| #38: TAA без апскейла ищет историю по матрице прошлого кадра **с джиттером** (`HdScene.prev_view_proj_jittered`); `fs_taau` по-прежнему берёт матрицу без джиттера | готово | в рабочем дереве форка |
|
||||
| #35: ванильные облака (HD-облака выключены) красятся закатным цветом в собственном проходе (`hd::vanilla_cloud_tint` → цвет в `clouds::Input`), эвристика по глубине G-буфера убрана из `fs_light` | готово | в рабочем дереве форка |
|
||||
| #37: воздух (ванильный туман, aerial, объёмный туман, лучи, ореолы) на воде считается до **поверхности** и кладётся один раз поверх готовой воды: `fs_light` пишет во вторую цель `air` (рассеяние + пропускание), `fs_water` применяет её после поглощения | готово | в рабочем дереве форка |
|
||||
| #36: ванильная вода не рисует цвет в HD (`fs_translucent` при `Globals.hd_flags.x`, глубину оставляет); прозрачное перед водой (`d` ближе `wd`) не отключает HD-воду: вода считается на освещённом дне и добавляется к сцене с весом 0.7; преломление не берёт тексели с таким прозрачным (`textureGather` по альфе) | готово (проверены стекло; частицы и scale 0.67 не проверялись) | в рабочем дереве форка |
|
||||
| #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) | готово | в рабочем дереве форка |
|
||||
|
||||
## План (по порядку) и ТЗ
|
||||
|
||||
Каждый пункт — самостоятельная задача. Перед началом прочитай `hd.rs` целиком.
|
||||
Каждый пункт — самостоятельная задача. Перед началом прочитай `gfx/hd/mod.rs` (контракты) и файл нужного прохода.
|
||||
|
||||
1. **TAAU — временной апскейлер** (поверх TAA). Мир и G-буфер рендерятся в `render_scale` (например 0.67),
|
||||
1. **TAAU — временной апскейлер** (поверх TAA) — СДЕЛАНО (патч 18, см. статус; масштаб — `HORIZON_HD_SCALE`, переопределяет «масштаб разрешения» настроек Horizon только для HD; поля в `HdSettings` нет). Осталось: LOD-bias текстур террейна, MetalFX. Исходное ТЗ: Мир и G-буфер рендерятся в `render_scale` (например 0.67),
|
||||
TAA-проход восстанавливает полное разрешение: история в полном разрешении, текущий кадр семплируется с учётом
|
||||
джиттера (Catmull-Rom/Lanczos-веса по субпиксельной позиции), клиппинг окрестности как в TAA. Настройка
|
||||
масштаба (`HORIZON_HD_SCALE` или поле в `HdSettings`). Цель: при 0.67 картинка близка к нативной, FPS заметно
|
||||
выше. Альтернатива на Mac — MetalFX через wgpu-hal (нативный код, только Metal) — отдельной опцией потом. DLSS
|
||||
нереален для нашей цели (только NVIDIA через Streamline, нет в wgpu).
|
||||
2. **Вода**: отдельный G-буфер-путь для воды (маска + нормаль с анимированными волнами), SSR (трассировка по
|
||||
2. **Вода** — СДЕЛАНО (патч 19, см. «Вода» ниже). Исходное ТЗ: отдельный G-буфер-путь для воды (маска + нормаль с анимированными волнами), SSR (трассировка по
|
||||
глубине, откат на небо/туман), преломление (смещение по нормали), затухание по глубине воды (цвет биома),
|
||||
блики солнца (GGX-подобный), френель. Подводный вид не ломать (`frame.underwater`).
|
||||
3. **Небо и атмосфера**: рассеяние Рэлея/Ми (предвычисленная LUT пропускания и небесного цвета в маленькой
|
||||
3. **Небо и атмосфера** — СДЕЛАНО (патч 22, см. «Небо и свет» ниже). Исходное ТЗ: рассеяние Рэлея/Ми (предвычисленная LUT пропускания и небесного цвета в маленькой
|
||||
текстуре), солнце/луна дисками, звёзды из ванили; туман и лучи берут цвет из той же модели. Учесть дождь
|
||||
(`weather`), закат, Незер/Энд (там — ванильное небо).
|
||||
4. **Объёмные облака** (рэймарч слоя с шумом) + их тени на земле (в карту/маску солнца).
|
||||
4. **Объёмные облака** — СДЕЛАНО (патч 24, см. «Закат, облака и туман»). Осталось: перистые/высококучевые слои Photon, облака в отражении воды.
|
||||
5. **Блики и материалы**: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из
|
||||
мода), мокрые поверхности и лужи в дождь.
|
||||
6. **PCSS** (мягкость тени по расстоянию до тенеотбрасывателя) и цветные тени через стекло/листву.
|
||||
@@ -159,8 +247,32 @@ AO 2.9, освещение 5.2, TAA 2.7, bloom 1.1, tonemap 0.2 — кадр ≈
|
||||
10. **RT-пресет**: `wgpu` `EXPERIMENTAL_RAY_QUERY` (Vulkan/DX12/Metal M3+), BLAS по секциям, ray-query тени и
|
||||
отражения, затем GI. Отдельный пресет, HD остаётся.
|
||||
|
||||
## Вода (патч 19): что знать преемнику
|
||||
|
||||
- Тайминги проходов: `hd water` (геометрия воды; на Metal его метка часто 0 — работа учитывается в соседних проходах) и `hd water composite`.
|
||||
Замеры 2560×1440, Hills, река на ~40% экрана, M5 (шумно: у меня GPU делят браузеры): композит 0.8–2 мс, `hd lighting` на 2–3 мс
|
||||
выше, чем без воды (теперь освещается дно под водой — раньше эти пиксели были ванильными); всего вода ≈ +3 мс при большой воде
|
||||
на экране, около 0.5–1 мс при малой. Цель «≤ 2 мс» при полной реке не достигнута. Идеи: отражения в полразрешения (с учётом маски воды
|
||||
на границе), SSR только при F>0.04 уже есть, ужать освещение дна (без точечных источников на глубине > N блоков).
|
||||
- Вода находится по слоям спрайтов. LOD-тайлы воды (`lod/mesh.rs`) идут тем же путём, если их слой — water_still; вода из resource pack с
|
||||
другим именем спрайта не распознается (останется ванильной). `water_overlay` (боковые грани у стекла) не трогаем.
|
||||
- Стекло/частицы/лёд перед водой: проверка `d - wd` возвращает ванильный вид пикселя (вода под стеклом без эффекта).
|
||||
- Известные ограничения: отражается только то, что есть на экране (и только непрозрачное: листва — да, вода/стекло — нет); дождь на воде,
|
||||
пена у берега, подводная каустика не сделаны; ночью вода слегка ярче неба (нужно привязать к модели неба — п. 4 плана «Небо»); отражение
|
||||
солнечного диска — только блик GGX (без диска в SSR). Проверка мерцания на реке: разница двух скриншотов ≈1.6% пикселей >20/255 (норма ~2%).
|
||||
|
||||
## Небо и свет (патч 22): что знать преемнику
|
||||
|
||||
- Тайминги (M5, 2560×1440, Hills, река в кадре): `hd sky` ≈ 2.4–4.7 мс по таймстемпам, но кадр не вырос (GPU avg 18.4 мс против 18.1) — на Metal метка крошечного прохода ловит ожидание соседних; с кэшем по высоте солнца в покое проход не выполняется. Освещение 4.0–5.1 мс (PCSS-lite и небо почти не добавили), композит воды 0.6–2.4, TAA 2.6–2.9.
|
||||
- Яркость: небо — физическая радиация → сжатие по максимальному каналу (`SKY_EXPOSURE` 0.85) → гамма 1/1.9 → насыщенность 1.2 (+0.45 у низкого солнца). Весь конвейер в гамма-пространстве, поэтому цвета неба и подкраски света — «показанные» значения; подкраска света неба имеет яркость 1 и не меняет экспозицию (яркость задаёт ванильный лайтмап × `ambient.x` = 0.62 днём, 0.95 ночью).
|
||||
- Цвет солнца (`light_of`): пропускание атмосферы вдоль солнца (`hd_sky::sun_transmittance`), нормировано на 1, степень 0.6, × (1, 0.97, 0.88). Луна — холодная, как раньше.
|
||||
- Дождь: `sky_extra.z` смешивает небо/дымку с ванильным градиентом (серым от ванили); Нижний мир/Энд/подводное — `sky_sun.w = 0`, всё как раньше (ванильное небо и туман).
|
||||
- Облака — по-прежнему ванильные блоки; объёмные облака — п. 4 плана. Окраска облаков цветом закатного солнца — эвристика по маске (глубина есть, G-буфера нет, высота и дальность), может задеть далёкие частицы.
|
||||
- Идеи дальше: многократное рассеяние по Hillaire вместо плоского члена, тени облаков, диск луны с фазой, звёзды/галактика по Photon (`sky.glsl`), розовый оттенок заката (`get_sun_tint` у Photon).
|
||||
|
||||
## Правила
|
||||
|
||||
- Код Photon (MIT) переносить можно только с атрибуцией (см. «Что это»); код других шейдерпаков — нельзя.
|
||||
- Ничего не ломать в `off`/`on`; HD выключен — код HD не работает вовсе (`hd = None`).
|
||||
- Без потери качества ради скорости без договорённости (шаги, разрешение, число эффектов).
|
||||
- Каждый новый WGSL — в naga-тест. Каждый новый проход — в тайминги (`PassTiming`).
|
||||
@@ -168,3 +280,27 @@ AO 2.9, освещение 5.2, TAA 2.7, bloom 1.1, tonemap 0.2 — кадр ≈
|
||||
- Коммиты: в форке, затем патчи; подпись `Co-Authored-By: Claude <модель> <noreply@anthropic.com>`.
|
||||
- Пуш в Gitea с машины без DNS: `ssh://git@100.92.6.40:2222/chexit/airdef.git` (ключ `~/Documents/claude-keys/deploy`).
|
||||
- Обнови этот документ (статус + что изменилось в конвейере) в том же коммите, что и код.
|
||||
|
||||
## Тюнинг после проверки на полигоне (патч 22)
|
||||
|
||||
Голубая заливка неба по умолчанию делала белый бетон «снегом»: оттенок неба в заливке ослаблен до 0.4
|
||||
(`sky_light_tint`, в `fs_light`), заливка днём `ambient.x` = 0.5 (+0.45 ночью), солнце 0.7 и теплее
|
||||
(`light_of`: множитель (1, 0.93, 0.78)). Проверять баланс на `Polygon` в полдень (`time set 6000`) — белое должно
|
||||
оставаться белым с тёплым оттенком.
|
||||
|
||||
|
||||
## Закат, облака и туман (патчи 23–25): что знать преемнику
|
||||
|
||||
- **Закат (23).** Небо марширует в Rec. 2020 (коэффициенты Photon сдвинуты матрицей `rec709_to_rec2020`, результат возвращается в 709): это убрало жёлто-зелёный горизонт. Добавлены фаза Клейна-Нишины для Ми и деполяризованная Рэлея, `sun_light_tint` (розоватый тинт и подъём яркости на закате, фиолетовый на синем часе, из `get_sun_tint`), сильнее насыщенность у низкого солнца (`sky_show`). Цвет прямого света (`light_of`) умножен на тот же тинт. Сверка: 12000/12500/13000 (запад) и 23000/23500 (восток) — оранжевый/розовый/пурпурный; полдень Polygon (6000) прежний.
|
||||
- **Облака (24).** Слой 192.33…264.33 блока (`CLOUD_BASE/CLOUD_THICKNESS`), ветер 3 блока/с (`CLOUD_WIND`), шум — текстуры, сгенерированные на CPU при создании HD (`hd_noise.rs`: 2D 256² покрытие/форма, 3D 64³ Worley). Плотность — по `clouds_cumulus_density` Photon (порог покрытия × форма, «яйцо» по высоте, эрозия Worley, резкий верх), рассеяние — `clouds_cumulus_scattering` (октавы, пудра, фазы). Цвет света — `cloud_light` (Rust): пропускание атмосферы на высоте солнца; солнце светит облакам до −0.2 по высоте, дальше луна. Воздушная перспектива `exp(−d/3600)` к цвету дымки. История сбрасывается при resize, телепорте и без TAA. Опция ванильных облаков «выкл» → `frame.clouds` пуст → нет ни облаков, ни теней. Тень: `cloud_shadow` в `fs_light` (карта оптической толщины; гаснет при солнце ниже ~0.1–0.25 по высоте).
|
||||
- **Туман (25).** `fs_fog`: 18 шагов, плотность `fog_sigma` (высотная дымка от y=62, масштаб 42; утренний туман — пока солнце на «утренней» стороне и низко; пятна из 3D-шума; дождь; затухание ниже y≈28–58, чтобы не светить в пещерах), свет — Photon-смесь двух HG, «вечернее свечение» ×1…3. Старые 14 шагов лучей остаются при `HORIZON_HD_VOLFOG=0`; ореолы вокруг источников мода — всегда.
|
||||
- **Тайминги.** Метки Metal (`HORIZON_SHADER_PACK_TIMING=1`, Hills, 2560×1440): `hd clouds` ≈ 6 мс (завышено: метка ловит ожидание соседних проходов), `hd cloud shadows` 0.2–3 (шум), `hd fog` ≈ 1.3. По среднему кадру GPU «оба выкл → оба вкл»: +0.2…3.7 мс (замеры шумные: рядом шла чужая игра на том же GPU). Как удешевить: у облаков — длина шага (14) и потолок шагов (26) в `fs_cloud`, 3 шага света, 4 октавы, облака в четверть разрешения; у тумана — `FOG_STEPS`.
|
||||
- **Разработка шейдеров.** `HORIZON_HD_SHADER_DIR=<папка>`: каждый HD-шейдер читается из `<папка>/<метка>.wgsl` (записывается из встроенного, если файла нет; это тело без прелюдии, она добавляется всегда) — правки видны после перезапуска игры без пересборки; готовое переносится в `gfx/hd/*.wgsl` вручную.
|
||||
- **Слабые места.** Облака не отражаются в воде (SSR берёт небо без облаков); не закрывают далёкую землю выше слоя (только небо); карта теней облаков ±1200 блоков и почти не работает при солнце ниже ~10°; туман не знает рельефа (только высоту над y=62); нет перистых облаков; на быстром повороте камеры история облаков отбрасывается (кратковременный шум).
|
||||
|
||||
## Заметки по исправлениям #35–#41 (преемнику)
|
||||
|
||||
- **Цели света.** `fs_light` пишет две цели: `lit` и `air` (`Rgba16Float`). Альфа `lit`: 1 — обычный пиксель, 0.5 — вода (в `lit` дно без воздуха, в `air` рассеяние воздуха до поверхности и пропускание в альфе), 0.6 — прозрачное перед водой (в `lit` сцена как нарисована, в `air.rgb` освещённое дно). Привязка 11 композита воды — `air`.
|
||||
- **Ванильная вода в 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 мс).
|
||||
|
||||
@@ -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/17] Mod API (AirDef fork): world models, client particles,
|
||||
Subject: [PATCH 01/27] 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/17] cargo-horizon-mod: report particles and sounds
|
||||
Subject: [PATCH 02/27] 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/17] Mod API (AirDef fork): full-screen post pass for mods
|
||||
Subject: [PATCH 03/27] 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/17] Mod models: draw far models as scaled impostors past
|
||||
Subject: [PATCH 04/27] 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/17] Mod cameras: render.set-views draws the world from
|
||||
Subject: [PATCH 05/27] 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/17] Mods: more fuel per tick and per event (AirDef plans
|
||||
Subject: [PATCH 06/27] 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/17] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
Subject: [PATCH 07/27] 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/17] Mod cameras: distant terrain tiles, fog measured from
|
||||
Subject: [PATCH 08/27] 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/17] Mods: game.world-id, so mods keep state per world (API
|
||||
Subject: [PATCH 09/27] 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/17] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
Subject: [PATCH 10/27] 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/17] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
Subject: [PATCH 11/27] 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/17] HD lighting stage 2: sun and moon shadow cascades, the
|
||||
Subject: [PATCH 12/27] 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/17] HD: bind groups cached until a resize or a new source,
|
||||
Subject: [PATCH 13/27] 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/17] HD: lights culled per 16x16 tile (surface and halo
|
||||
Subject: [PATCH 14/27] 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/17] HD: GPU time per pass in the F3 timing list,
|
||||
Subject: [PATCH 15/27] 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/17] HD: shadows draw only sun-facing quads and skip buried
|
||||
Subject: [PATCH 16/27] 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/17] Mods: model handles (render.model-id, model-generation,
|
||||
Subject: [PATCH 17/27] 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
|
||||
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
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,
|
||||
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
|
||||
camera's step, plants' normals turned with the camera); TAA keeps more
|
||||
history when still
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx.rs | 33 ++-
|
||||
crates/horizon-client/src/gfx/hd.rs | 211 ++++++++++++++++++--
|
||||
crates/horizon-client/src/gfx/world_post.rs | 8 +-
|
||||
crates/horizon-client/src/terrain.wgsl | 4 +
|
||||
4 files changed, 223 insertions(+), 33 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||||
index eb152667c..b85c6e269 100644
|
||||
--- a/crates/horizon-client/src/gfx.rs
|
||||
+++ b/crates/horizon-client/src/gfx.rs
|
||||
@@ -3970,13 +3970,15 @@ impl Renderer {
|
||||
// offset each frame; `unjittered` is what the next frame reprojects with.
|
||||
let unjittered = self.view_projection(frame);
|
||||
let taa = hd_wanted && hd::taa_enabled();
|
||||
+ let mut jitter_px = [0.0; 2];
|
||||
let view_proj = if taa {
|
||||
- let size = self
|
||||
- .world_post
|
||||
- .size()
|
||||
- .unwrap_or([self.config.width, self.config.height]);
|
||||
- let j = hd::jitter(self.taa_frame, size);
|
||||
+ let window = [self.config.width, self.config.height];
|
||||
+ let size = self.world_post.size().unwrap_or(window);
|
||||
+ // Upscaling (the world smaller than the window) cycles through more positions.
|
||||
+ let ratio = window[0] as f32 / size[0].max(1) as f32;
|
||||
+ let j = hd::jitter(self.taa_frame, size, hd::jitter_count(ratio));
|
||||
self.taa_frame = self.taa_frame.wrapping_add(1);
|
||||
+ jitter_px = [j[0] * size[0] as f32 * 0.5, -j[1] * size[1] as f32 * 0.5];
|
||||
Mat4::from_translation(Vec3::new(j[0], j[1], 0.0)) * unjittered
|
||||
} else {
|
||||
unjittered
|
||||
@@ -4192,6 +4194,7 @@ impl Renderer {
|
||||
cascades: [Mat4::IDENTITY; 2],
|
||||
view_proj,
|
||||
taa: taa && taa_prev.is_some(),
|
||||
+ jitter_px,
|
||||
prev_view_proj: taa_prev.map_or(unjittered, |p| p.0),
|
||||
camera_delta: taa_prev.map_or(Vec3::ZERO, |p| (frame.camera - p.1).as_vec3()),
|
||||
// The world's sun: vanilla turns the sky -90° about Y, so it crosses from east to west.
|
||||
@@ -4218,8 +4221,11 @@ impl Renderer {
|
||||
};
|
||||
if fresh {
|
||||
self.hd_far = Some((hd_cascades[1], camera, shadow_dir, 0));
|
||||
- } else if let Some((held, _, _, age)) = &mut self.hd_far {
|
||||
- hd_cascades[1] = *held;
|
||||
+ } else if let Some((held, at, _, age)) = &mut self.hd_far {
|
||||
+ // The held matrix is relative to the camera it was drawn from: shifted by the
|
||||
+ // camera's step since, so the map stays put in the world as the player walks.
|
||||
+ let step = (camera - *at).as_vec3();
|
||||
+ hd_cascades[1] = *held * Mat4::from_translation(step);
|
||||
*age += 1;
|
||||
}
|
||||
fresh
|
||||
@@ -4230,11 +4236,14 @@ impl Renderer {
|
||||
hd_scene.cascades = hd_cascades;
|
||||
let mut hd_shadow_visible = std::mem::take(&mut self.hd_shadow_visible);
|
||||
hd_shadow_visible.clear();
|
||||
- if hd_wanted && shadow_strength > 0.0 && hd_far_fresh {
|
||||
+ if hd_wanted && shadow_strength > 0.0 {
|
||||
+ // The near cascade is drawn every frame: between the far one's redraws the list holds
|
||||
+ // only the sections in the near box.
|
||||
+ let c = if hd_far_fresh { 1 } else { 0 };
|
||||
let radius =
|
||||
- ((frame.detail_distance / 16.0) as i32).min((hd::CASCADES[1] / 16.0) as i32 + 2);
|
||||
+ ((frame.detail_distance / 16.0) as i32).min((hd::CASCADES[c] / 16.0) as i32 + 2);
|
||||
let light = Frustum {
|
||||
- planes: frustum(hd_cascades[1]),
|
||||
+ planes: frustum(hd_cascades[c]),
|
||||
};
|
||||
self.sections.visible(
|
||||
camera,
|
||||
@@ -5028,6 +5037,8 @@ impl Renderer {
|
||||
.as_ref()
|
||||
.and_then(|p| p.1.as_ref())
|
||||
.is_some_and(|p| p.has_weather());
|
||||
+ // AirDef fork: HD renders the world at its own scale and upscales it in the TAA pass.
|
||||
+ self.world_post.hd_scale = hd_on.then(hd::scale_override).flatten();
|
||||
let scaled = self.world_post.prepare(
|
||||
&self.device,
|
||||
[self.config.width, self.config.height],
|
||||
@@ -5046,7 +5057,7 @@ impl Renderer {
|
||||
let hd = self
|
||||
.hd
|
||||
.get_or_insert_with(|| hd::Hd::new(device, size, format, globals_size));
|
||||
- hd.resize(device, size);
|
||||
+ hd.resize(device, size, [self.config.width, self.config.height]);
|
||||
if let Some(settings) = frame.hd {
|
||||
hd.write(&self.queue, &hd_scene, frame.hd_lights, settings);
|
||||
}
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index 16610cb02..c54b7b961 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -89,7 +89,7 @@ struct HdFrame {
|
||||
prev_view_proj: [[f32; 4]; 4],
|
||||
/// xyz: how far the camera moved since that frame; w: 1 when the history is usable.
|
||||
taa: [f32; 4],
|
||||
- /// x: the history's weight; y: the sharpening (0 without TAA).
|
||||
+ /// x: the history's weight; y: the sharpening (0 without TAA); zw: the jitter in world pixels.
|
||||
taa_params: [f32; 4],
|
||||
}
|
||||
|
||||
@@ -105,6 +105,9 @@ pub struct HdScene {
|
||||
pub camera_delta: glam::Vec3,
|
||||
/// This frame is anti-aliased over time (the projection is jittered).
|
||||
pub taa: bool,
|
||||
+ /// The jitter in world-target pixels, screen orientation (x right, y down): the world's sample
|
||||
+ /// of a pixel lies this far from its centre (the upscaler weighs samples by it).
|
||||
+ pub jitter_px: [f32; 2],
|
||||
/// Towards the sun, and the sky's darkening (0 day, 1 night).
|
||||
pub sun: glam::Vec3,
|
||||
pub dark: f32,
|
||||
@@ -662,8 +665,8 @@ fn fs_taa(in: FullOut) -> TaaOut {
|
||||
if abs(seen - expected) <= expected * 0.03 + 0.25 {
|
||||
let mean = sum / 9.0;
|
||||
let sigma = sqrt(max(sum2 / 9.0 - mean * mean, vec3<f32>(0.0)));
|
||||
- let box_lo = min(max(mean - sigma * 1.25, lo), centre);
|
||||
- let box_hi = max(min(mean + sigma * 1.25, hi), centre);
|
||||
+ let box_lo = min(max(mean - sigma * 1.5, lo), centre);
|
||||
+ let box_hi = max(min(mean + sigma * 1.5, hi), centre);
|
||||
let past = clip_to_box(box_lo, box_hi, to_ycocg(squash(history_color(uv))));
|
||||
// Fast motion trusts the history less.
|
||||
let moved = length((uv - in.uv) * vec2<f32>(size));
|
||||
@@ -679,6 +682,99 @@ fn fs_taa(in: FullOut) -> TaaOut {
|
||||
out.history = vec4<f32>(color, dist);
|
||||
return out;
|
||||
}
|
||||
+
|
||||
+// The jittered samples' reconstruction filter (a polynomial fit of Lanczos-2, radius 2 in
|
||||
+// world-target pixels): 1 at 0, 0 at 1 and 2, a little negative between.
|
||||
+fn lanczos2(x: f32) -> f32 {
|
||||
+ let x2 = min(x * x, 4.0);
|
||||
+ let a = 0.4 * x2 - 1.0;
|
||||
+ let b = 0.25 * x2 - 1.0;
|
||||
+ return (1.5625 * a * a - 0.5625) * b * b;
|
||||
+}
|
||||
+
|
||||
+// Temporal upscaling: the world (and its depth) is smaller than the output. Each output pixel
|
||||
+// gathers the 3x3 world samples round it, each weighed by its distance from the pixel's centre
|
||||
+// (the samples lie off the pixel centres by the frame's jitter), clamps the reprojected full-size
|
||||
+// history to their colour box and blends: more history where the nearest sample is far away.
|
||||
+@fragment
|
||||
+fn fs_taau(in: FullOut) -> TaaOut {
|
||||
+ let low = vec2<f32>(textureDimensions(source));
|
||||
+ let lowi = vec2<i32>(low);
|
||||
+ let high = vec2<f32>(textureDimensions(history));
|
||||
+ let jit = frame.taa_params.zw;
|
||||
+ // The output pixel's place among the world's samples: sample i lies at index i - jit, and
|
||||
+ // the nearest to this pixel is `base`.
|
||||
+ let t = in.uv * low - 0.5 + jit;
|
||||
+ let base = vec2<i32>(floor(t + 0.5));
|
||||
+ var sum = vec3<f32>(0.0);
|
||||
+ var sum2 = vec3<f32>(0.0);
|
||||
+ var lo = vec3<f32>(1.0e9);
|
||||
+ var hi = vec3<f32>(-1.0e9);
|
||||
+ var weighted = vec3<f32>(0.0);
|
||||
+ var total = 0.0;
|
||||
+ for (var y = -1; y <= 1; y++) {
|
||||
+ for (var x = -1; x <= 1; x++) {
|
||||
+ let q = clamp(base + vec2<i32>(x, y), vec2<i32>(0), lowi - 1);
|
||||
+ let s = to_ycocg(squash(textureLoad(source, q, 0).rgb));
|
||||
+ sum += s;
|
||||
+ sum2 += s * s;
|
||||
+ lo = min(lo, s);
|
||||
+ hi = max(hi, s);
|
||||
+ let off = vec2<f32>(base + vec2<i32>(x, y)) - t;
|
||||
+ let w = lanczos2(off.x) * lanczos2(off.y);
|
||||
+ weighted += s * w;
|
||||
+ total += w;
|
||||
+ }
|
||||
+ }
|
||||
+ let current = weighted / max(total, 0.001);
|
||||
+ // Depth: the nearest of the 2x2 samples round the pixel (steady between frames at an edge).
|
||||
+ let corner = vec2<i32>(floor(in.uv * low - 0.5 + jit));
|
||||
+ var d = 0.0;
|
||||
+ for (var y = 0; y <= 1; y++) {
|
||||
+ for (var x = 0; x <= 1; x++) {
|
||||
+ d = max(d, textureLoad(depth_tex, clamp(corner + vec2<i32>(x, y), vec2<i32>(0), lowi - 1), 0));
|
||||
+ }
|
||||
+ }
|
||||
+ let sky = d <= 0.0;
|
||||
+ // The world was drawn shifted by the jitter in clip space; this pixel's unshifted point.
|
||||
+ let jc = vec2<f32>(jit.x * 2.0 / low.x, -jit.y * 2.0 / low.y);
|
||||
+ let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0 + jc.x, 1.0 - in.uv.y * 2.0 + jc.y, max(d, 0.000001), 1.0);
|
||||
+ let h = frame.inverse_view_proj * ndc;
|
||||
+ let p = h.xyz / h.w;
|
||||
+ let dist = select(length(p), 0.0, sky);
|
||||
+ var resolved = current;
|
||||
+ if frame.taa.w > 0.5 {
|
||||
+ let prev = frame.prev_view_proj * vec4<f32>(select(p + frame.taa.xyz, p, sky), 1.0);
|
||||
+ if prev.w > 0.0 {
|
||||
+ let pn = prev.xy / prev.w;
|
||||
+ let uv = vec2<f32>(pn.x * 0.5 + 0.5, 0.5 - pn.y * 0.5);
|
||||
+ if all(uv > vec2<f32>(0.0)) && all(uv < vec2<f32>(1.0)) {
|
||||
+ let hp = clamp(vec2<i32>(uv * high), vec2<i32>(0), vec2<i32>(high) - 1);
|
||||
+ let seen = textureLoad(history, hp, 0).a;
|
||||
+ let expected = select(length(p + frame.taa.xyz), 0.0, sky);
|
||||
+ if abs(seen - expected) <= expected * 0.03 + 0.25 {
|
||||
+ let mean = sum / 9.0;
|
||||
+ let sigma = sqrt(max(sum2 / 9.0 - mean * mean, vec3<f32>(0.0)));
|
||||
+ let box_lo = min(max(mean - sigma * 1.25, lo), current);
|
||||
+ let box_hi = max(min(mean + sigma * 1.25, hi), current);
|
||||
+ let past = clip_to_box(box_lo, box_hi, to_ycocg(squash(history_color(uv))));
|
||||
+ // How far the nearest sample is from the pixel's centre (0 on it, 1 at the
|
||||
+ // corner of the world's pixel): the farther, the less the frame says.
|
||||
+ let near = clamp(length(t - floor(t + 0.5)) / 0.7071, 0.0, 1.0);
|
||||
+ let fresh = (1.0 - frame.taa_params.x) * mix(1.8, 0.5, near);
|
||||
+ let moved = length((uv - in.uv) * high);
|
||||
+ let keep = (1.0 - fresh) * mix(1.0, 0.8, clamp(moved / 4.0, 0.0, 1.0));
|
||||
+ resolved = mix(current, past, clamp(keep, 0.0, 0.98));
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ let color = unsquash(from_ycocg(resolved));
|
||||
+ var out: TaaOut;
|
||||
+ out.color = vec4<f32>(color, 1.0);
|
||||
+ out.history = vec4<f32>(color, dist);
|
||||
+ return out;
|
||||
+}
|
||||
"#;
|
||||
|
||||
struct Target {
|
||||
@@ -744,10 +840,35 @@ fn halton(mut index: u32, base: u32) -> f32 {
|
||||
r
|
||||
}
|
||||
|
||||
+/// The history's weight when upscaling (the frames each pixel gathers are sparse, so more of them
|
||||
+/// are kept than without; `fs_taau` lowers it where the frame's sample is near).
|
||||
+const UPSCALE_HISTORY: f32 = 0.92;
|
||||
+
|
||||
+/// The world's size relative to the output (`HORIZON_HD_SCALE`, per axis, 0.25..=1) while HD is on, if
|
||||
+/// set: it replaces the resolution scale of Horizon's settings for HD. 1 renders at full size.
|
||||
+pub fn scale_override() -> Option<f32> {
|
||||
+ static SCALE: std::sync::OnceLock<Option<f32>> = std::sync::OnceLock::new();
|
||||
+ *SCALE.get_or_init(|| {
|
||||
+ let v: f32 = std::env::var("HORIZON_HD_SCALE").ok()?.parse().ok()?;
|
||||
+ v.is_finite().then(|| v.clamp(0.25, 1.0))
|
||||
+ })
|
||||
+}
|
||||
+
|
||||
+/// How many jitter positions the sequence cycles through for an output `ratio` times the world's
|
||||
+/// size per axis: 16 without upscaling, else 8 per output pixel's share of a world pixel
|
||||
+/// (ratio squared), so every output pixel gets samples all over the world pixel it lies in.
|
||||
+pub fn jitter_count(ratio: f32) -> u32 {
|
||||
+ if ratio <= 1.001 {
|
||||
+ 16
|
||||
+ } else {
|
||||
+ ((8.0 * ratio * ratio).ceil() as u32).clamp(16, 64)
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
/// Frame `frame`'s sub-pixel offset of the projection in clip space (a fraction of the
|
||||
-/// screen's size in x and y), from the Halton(2, 3) sequence over 16 frames.
|
||||
-pub fn jitter(frame: u32, size: [u32; 2]) -> [f32; 2] {
|
||||
- let i = frame % 16 + 1;
|
||||
+/// screen's size in x and y), from the Halton(2, 3) sequence over `count` frames.
|
||||
+pub fn jitter(frame: u32, size: [u32; 2], count: u32) -> [f32; 2] {
|
||||
+ let i = frame % count.max(1) + 1;
|
||||
[
|
||||
(halton(i, 2) - 0.5) * 2.0 / size[0].max(1) as f32,
|
||||
(halton(i, 3) - 0.5) * 2.0 / size[1].max(1) as f32,
|
||||
@@ -755,7 +876,10 @@ pub fn jitter(frame: u32, size: [u32; 2]) -> [f32; 2] {
|
||||
}
|
||||
|
||||
pub struct Hd {
|
||||
+ /// The world's size (the G-buffer, lighting, AO, post pass), and the output's (TAA, bloom,
|
||||
+ /// tone curve): bigger when the world is upscaled.
|
||||
size: [u32; 2],
|
||||
+ out: [u32; 2],
|
||||
pub albedo: wgpu::TextureView,
|
||||
pub normal: wgpu::TextureView,
|
||||
pub levels: wgpu::TextureView,
|
||||
@@ -779,6 +903,7 @@ pub struct Hd {
|
||||
taa_active: bool,
|
||||
taa_layout: wgpu::BindGroupLayout,
|
||||
taa_pipeline: wgpu::RenderPipeline,
|
||||
+ taau_pipeline: wgpu::RenderPipeline,
|
||||
taa_groups: Option<((wgpu::TextureView, wgpu::TextureView), [wgpu::BindGroup; 2])>,
|
||||
bloom: Vec<Target>,
|
||||
/// Bind groups, rebuilt only when their views change: `resize` drops all of them, the
|
||||
@@ -1098,7 +1223,7 @@ impl Hd {
|
||||
entries: &entries(&[UNIFORM, UNFILTERED, DEPTH, FLOAT, SAMPLER]),
|
||||
});
|
||||
let taa_module = module(device, "HD TAA", TAA);
|
||||
- let taa_pipeline = {
|
||||
+ let taa_pipeline = |entry: &str| {
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("HD TAA"),
|
||||
bind_group_layouts: &[Some(&taa_layout)],
|
||||
@@ -1120,7 +1245,7 @@ impl Hd {
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &taa_module,
|
||||
- entry_point: Some("fs_taa"),
|
||||
+ entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[target.clone(), target],
|
||||
}),
|
||||
@@ -1131,6 +1256,7 @@ impl Hd {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
+ let (taa_pipeline, taau_pipeline) = (taa_pipeline("fs_taa"), taa_pipeline("fs_taau"));
|
||||
let ao_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("HD ambient occlusion"),
|
||||
entries: &entries(&[UNIFORM, DEPTH, UNFILTERED]),
|
||||
@@ -1288,6 +1414,7 @@ impl Hd {
|
||||
};
|
||||
let mut hd = Hd {
|
||||
size: [0, 0],
|
||||
+ out: [0, 0],
|
||||
albedo: target(device, "HD albedo", [1, 1], ALBEDO).view,
|
||||
normal: target(device, "HD normal", [1, 1], NORMAL).view,
|
||||
levels: target(device, "HD light levels", [1, 1], LIGHT_LEVELS).view,
|
||||
@@ -1319,6 +1446,7 @@ impl Hd {
|
||||
taa_active: false,
|
||||
taa_layout,
|
||||
taa_pipeline,
|
||||
+ taau_pipeline,
|
||||
taa_groups: None,
|
||||
bloom: Vec::new(),
|
||||
light_group: None,
|
||||
@@ -1355,26 +1483,29 @@ impl Hd {
|
||||
groups_seen: 0,
|
||||
groups_last_frame: 0,
|
||||
};
|
||||
- hd.resize(device, size);
|
||||
+ hd.resize(device, size, size);
|
||||
hd
|
||||
}
|
||||
|
||||
- /// The targets, at the world target's size.
|
||||
- pub fn resize(&mut self, device: &wgpu::Device, size: [u32; 2]) {
|
||||
- if self.size == size {
|
||||
+ /// The targets: the world's at `size` (the world target's), the output's (TAA history, bloom)
|
||||
+ /// at `out`, which is bigger when the world is upscaled.
|
||||
+ pub fn resize(&mut self, device: &wgpu::Device, size: [u32; 2], out: [u32; 2]) {
|
||||
+ let out = [out[0].max(size[0]), out[1].max(size[1])];
|
||||
+ if self.size == size && self.out == out {
|
||||
return;
|
||||
}
|
||||
self.size = size;
|
||||
+ self.out = out;
|
||||
self.albedo = target(device, "HD albedo", size, ALBEDO).view;
|
||||
self.normal = target(device, "HD normal", size, NORMAL).view;
|
||||
self.levels = target(device, "HD light levels", size, LIGHT_LEVELS).view;
|
||||
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
|
||||
self.lit = target(device, "HD lit", size, HDR).view;
|
||||
self.fx = target(device, "HD post", size, HDR).view;
|
||||
- self.taa = target(device, "HD TAA", size, HDR).view;
|
||||
+ self.taa = target(device, "HD TAA", out, HDR).view;
|
||||
self.history = [
|
||||
- target(device, "HD TAA history", size, HDR).view,
|
||||
- target(device, "HD TAA history", size, HDR).view,
|
||||
+ target(device, "HD TAA history", out, HDR).view,
|
||||
+ target(device, "HD TAA history", out, HDR).view,
|
||||
];
|
||||
self.history_ready = false;
|
||||
self.taa_groups = None;
|
||||
@@ -1404,7 +1535,7 @@ impl Hd {
|
||||
self.tiles_x = size[0].div_ceil(TILE).max(1);
|
||||
self.bloom = (0..BLOOM_LEVELS)
|
||||
.map(|k| {
|
||||
- let s = [(size[0] >> (k + 1)).max(1), (size[1] >> (k + 1)).max(1)];
|
||||
+ let s = [(out[0] >> (k + 1)).max(1), (out[1] >> (k + 1)).max(1)];
|
||||
target(device, "HD bloom", s, HDR)
|
||||
})
|
||||
.collect();
|
||||
@@ -1420,6 +1551,11 @@ impl Hd {
|
||||
.collect();
|
||||
}
|
||||
|
||||
+ /// The world is smaller than the output, so TAA upsamples it.
|
||||
+ fn upscaling(&self) -> bool {
|
||||
+ self.out != self.size
|
||||
+ }
|
||||
+
|
||||
/// This frame's camera, sun, fog, lights and settings.
|
||||
pub fn write(
|
||||
&mut self,
|
||||
@@ -1468,7 +1604,18 @@ impl Hd {
|
||||
scene.camera_delta.z,
|
||||
if self.history_ready { 1.0 } else { 0.0 },
|
||||
],
|
||||
- taa_params: [0.9, if self.taa_active { 0.12 } else { 0.0 }, 0.0, 0.0],
|
||||
+ taa_params: [
|
||||
+ if self.upscaling() {
|
||||
+ UPSCALE_HISTORY
|
||||
+ } else {
|
||||
+ // A still picture keeps 94% of the past: the jitter's shimmer on pixel-art
|
||||
+ // textures averages out instead of flickering.
|
||||
+ 0.94
|
||||
+ },
|
||||
+ if self.taa_active { 0.12 } else { 0.0 },
|
||||
+ scene.jitter_px[0],
|
||||
+ scene.jitter_px[1],
|
||||
+ ],
|
||||
inverse_view_proj: scene.view_proj.inverse().to_cols_array_2d(),
|
||||
resolution: [
|
||||
self.size[0] as f32,
|
||||
@@ -1872,9 +2019,13 @@ impl Hd {
|
||||
return source;
|
||||
};
|
||||
let (read, write) = (self.history_flip, 1 - self.history_flip);
|
||||
+ let upscaling = self.upscaling();
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("HD TAA"),
|
||||
- timestamp_writes: stamp(&mut self.timing, "hd taa"),
|
||||
+ timestamp_writes: stamp(
|
||||
+ &mut self.timing,
|
||||
+ if upscaling { "hd taau" } else { "hd taa" },
|
||||
+ ),
|
||||
color_attachments: &[&self.taa, &self.history[write]].map(|view| {
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
@@ -1888,7 +2039,11 @@ impl Hd {
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
- pass.set_pipeline(&self.taa_pipeline);
|
||||
+ pass.set_pipeline(if upscaling {
|
||||
+ &self.taau_pipeline
|
||||
+ } else {
|
||||
+ &self.taa_pipeline
|
||||
+ });
|
||||
pass.set_bind_group(0, &groups[read], &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
drop(pass);
|
||||
@@ -2045,7 +2200,7 @@ mod tests {
|
||||
let size = [1920u32, 1080];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for f in 0..32 {
|
||||
- let j = jitter(f, size);
|
||||
+ let j = jitter(f, size, 16);
|
||||
assert!(j[0].abs() <= 1.0 / size[0] as f32 + 1e-9);
|
||||
assert!(j[1].abs() <= 1.0 / size[1] as f32 + 1e-9);
|
||||
seen.insert((j[0].to_bits(), j[1].to_bits()));
|
||||
@@ -2053,6 +2208,22 @@ mod tests {
|
||||
assert_eq!(seen.len(), 16);
|
||||
}
|
||||
|
||||
+ #[test]
|
||||
+ fn jitter_covers_the_upscale_ratio() {
|
||||
+ assert_eq!(jitter_count(1.0), 16);
|
||||
+ assert_eq!(jitter_count(1.0 / 0.67), 18);
|
||||
+ assert_eq!(jitter_count(2.0), 32);
|
||||
+ let size = [1280u32, 720];
|
||||
+ let count = jitter_count(2.0);
|
||||
+ let seen: std::collections::HashSet<_> = (0..count * 2)
|
||||
+ .map(|f| {
|
||||
+ let j = jitter(f, size, count);
|
||||
+ (j[0].to_bits(), j[1].to_bits())
|
||||
+ })
|
||||
+ .collect();
|
||||
+ assert_eq!(seen.len(), count as usize);
|
||||
+ }
|
||||
+
|
||||
#[test]
|
||||
fn tile_lists_keep_lights_that_reach_a_tile() {
|
||||
let size = [320, 160];
|
||||
diff --git a/crates/horizon-client/src/gfx/world_post.rs b/crates/horizon-client/src/gfx/world_post.rs
|
||||
index 17609d27d..a32bdc964 100644
|
||||
--- a/crates/horizon-client/src/gfx/world_post.rs
|
||||
+++ b/crates/horizon-client/src/gfx/world_post.rs
|
||||
@@ -18,6 +18,8 @@ pub struct Post {
|
||||
format: wgpu::TextureFormat,
|
||||
/// World resolution relative to the window (0.5..=2.0).
|
||||
pub scale: f32,
|
||||
+ /// HD's own scale for this frame (`HORIZON_HD_SCALE`), instead of `scale`.
|
||||
+ pub hd_scale: Option<f32>,
|
||||
pub antialias: bool,
|
||||
target: Option<WorldTarget>,
|
||||
}
|
||||
@@ -94,6 +96,7 @@ impl Post {
|
||||
sampler,
|
||||
format,
|
||||
scale: 1.0,
|
||||
+ hd_scale: None,
|
||||
antialias: false,
|
||||
target: None,
|
||||
}
|
||||
@@ -108,11 +111,12 @@ impl Post {
|
||||
window: [u32; 2],
|
||||
always: bool,
|
||||
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
|
||||
- if self.scale == 1.0 && !self.antialias && !always {
|
||||
+ let scale = self.hd_scale.unwrap_or(self.scale);
|
||||
+ if scale == 1.0 && !self.antialias && !always {
|
||||
self.target = None;
|
||||
return None;
|
||||
}
|
||||
- let size = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
+ let size = window.map(|v| ((v as f32 * scale).round() as u32).clamp(1, 8192));
|
||||
if self.target.as_ref().is_none_or(|t| t.size != size) {
|
||||
let extent = wgpu::Extent3d {
|
||||
width: size[0],
|
||||
diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl
|
||||
index bb5c70f63..c44449cc7 100644
|
||||
--- a/crates/horizon-client/src/terrain.wgsl
|
||||
+++ b/crates/horizon-client/src/terrain.wgsl
|
||||
@@ -166,6 +166,10 @@ struct GBufferOut {
|
||||
}
|
||||
fn face_normal(pos: vec3<f32>) -> vec3<f32> {
|
||||
var n = normalize(cross(dpdx(pos), dpdy(pos)));
|
||||
+ // Block faces are axis-aligned; only plants' crossed quads lean. Those have two
|
||||
+ // sides, so a normal turned to the camera would flip as the player walks by and
|
||||
+ // the sun and the shadow lookup with it: plants take the up normal instead.
|
||||
+ if max(abs(n.x), max(abs(n.y), abs(n.z))) < 0.9 { return vec3(0.0, 1.0, 0.0); }
|
||||
if dot(n, pos) > 0.0 { n = -n; }
|
||||
return n;
|
||||
}
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,982 @@
|
||||
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
|
||||
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)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/game.rs | 9 +
|
||||
crates/horizon-client/src/gfx.rs | 109 +++++-
|
||||
crates/horizon-client/src/gfx/flat.rs | 4 +
|
||||
crates/horizon-client/src/gfx/hd.rs | 497 ++++++++++++++++++++++++-
|
||||
crates/horizon-client/src/terrain.wgsl | 19 +
|
||||
5 files changed, 627 insertions(+), 11 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
|
||||
index 3a11ee283..5a173977f 100644
|
||||
--- a/crates/horizon-client/src/game.rs
|
||||
+++ b/crates/horizon-client/src/game.rs
|
||||
@@ -14658,6 +14658,14 @@ fn build_renderer(
|
||||
prepared,
|
||||
previous,
|
||||
api,
|
||||
+ // AirDef fork, HD water: the layers of the water sprites.
|
||||
+ ["water_still", "water_flow"].map(|name| {
|
||||
+ assets
|
||||
+ .sprite_layers
|
||||
+ .get(&format!("minecraft:block/{name}"))
|
||||
+ .copied()
|
||||
+ .unwrap_or(0xffff)
|
||||
+ }),
|
||||
))?;
|
||||
renderer.hud.sprite_animations = atlas
|
||||
.sprite_animations
|
||||
@@ -14717,6 +14725,7 @@ fn loading_renderer(
|
||||
None,
|
||||
previous,
|
||||
api,
|
||||
+ [0xffff; 2],
|
||||
))?;
|
||||
if let Some(atlas) = atlas {
|
||||
renderer.hud.sprite_animations = atlas
|
||||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||||
index b85c6e269..f9dab8abf 100644
|
||||
--- a/crates/horizon-client/src/gfx.rs
|
||||
+++ b/crates/horizon-client/src/gfx.rs
|
||||
@@ -564,6 +564,8 @@ pub struct Renderer {
|
||||
mod_views: Option<mod_views::Views>,
|
||||
/// AirDef fork: the HD lighting's G-buffer pipelines (terrain, entities) and its targets.
|
||||
gbuffer_terrain: wgpu::RenderPipeline,
|
||||
+ /// The water quads alone into the HD water targets (section draws; `flat.water` for flat ones).
|
||||
+ gbuffer_water: wgpu::RenderPipeline,
|
||||
gbuffer_entity: wgpu::RenderPipeline,
|
||||
/// The HD sun shadows: depth only, from the light, and the sections in its farther cascade.
|
||||
shadow_terrain: wgpu::RenderPipeline,
|
||||
@@ -1083,6 +1085,7 @@ impl Renderer {
|
||||
prepared: Option<PreparedTextures>,
|
||||
previous: Option<&Self>,
|
||||
api: GraphicsApi,
|
||||
+ water_layers: [u32; 2],
|
||||
) -> Result<Self> {
|
||||
let split = sprite_split();
|
||||
if layers > split * SPRITE_ARRAYS {
|
||||
@@ -1205,6 +1208,13 @@ impl Renderer {
|
||||
("SPRITE_ARRAYS", f64::from(sprite_arrays)),
|
||||
("SPRITE_SPLIT", f64::from(split)),
|
||||
];
|
||||
+ // AirDef fork, HD water: the sprite layers terrain.wgsl's fs_water takes for water.
|
||||
+ let water_constants = [
|
||||
+ ("SPRITE_ARRAYS", f64::from(sprite_arrays)),
|
||||
+ ("SPRITE_SPLIT", f64::from(split)),
|
||||
+ ("WATER_STILL", f64::from(water_layers[0])),
|
||||
+ ("WATER_FLOW", f64::from(water_layers[1])),
|
||||
+ ];
|
||||
let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon terrain bindings"),
|
||||
entries: &[
|
||||
@@ -1420,6 +1430,55 @@ impl Renderer {
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
+ // AirDef fork, HD water: only the water quads (`fs_water`), into the colour and normal
|
||||
+ // targets and the water's own depth; `vertex` is the section or the flat vertex stage.
|
||||
+ let water_targets = [
|
||||
+ Some(wgpu::ColorTargetState {
|
||||
+ format: hd::WATER_TINT,
|
||||
+ blend: None,
|
||||
+ write_mask: wgpu::ColorWrites::ALL,
|
||||
+ }),
|
||||
+ Some(wgpu::ColorTargetState {
|
||||
+ format: hd::WATER_NORMAL,
|
||||
+ blend: None,
|
||||
+ write_mask: wgpu::ColorWrites::ALL,
|
||||
+ }),
|
||||
+ ];
|
||||
+ let water_pipeline =
|
||||
+ |layout: &wgpu::PipelineLayout,
|
||||
+ label: &str,
|
||||
+ vertex: &str,
|
||||
+ buffers: &[Option<wgpu::VertexBufferLayout<'_>>]| {
|
||||
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
+ label: Some(label),
|
||||
+ layout: Some(layout),
|
||||
+ vertex: wgpu::VertexState {
|
||||
+ module: &shader,
|
||||
+ entry_point: Some(vertex),
|
||||
+ compilation_options: Default::default(),
|
||||
+ buffers,
|
||||
+ },
|
||||
+ primitive: wgpu::PrimitiveState {
|
||||
+ cull_mode: Some(wgpu::Face::Back),
|
||||
+ ..Default::default()
|
||||
+ },
|
||||
+ depth_stencil: gbuffer_depth.clone(),
|
||||
+ multisample: Default::default(),
|
||||
+ fragment: Some(wgpu::FragmentState {
|
||||
+ module: &shader,
|
||||
+ entry_point: Some("fs_water"),
|
||||
+ compilation_options: wgpu::PipelineCompilationOptions {
|
||||
+ constants: &water_constants,
|
||||
+ ..Default::default()
|
||||
+ },
|
||||
+ targets: &water_targets,
|
||||
+ }),
|
||||
+ multiview_mask: None,
|
||||
+ cache: None,
|
||||
+ })
|
||||
+ };
|
||||
+ let gbuffer_water =
|
||||
+ water_pipeline(&layout, "HD water terrain", "vs_terrain", &terrain_buffers);
|
||||
let shadow_terrain = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("HD shadows terrain"),
|
||||
layout: Some(&layout),
|
||||
@@ -2597,6 +2656,7 @@ impl Renderer {
|
||||
&device,
|
||||
&bindings,
|
||||
arena.page_vertices(),
|
||||
+ |layout, label| water_pipeline(layout, label, "vs_terrain_flat", &buffers),
|
||||
|layout, label, fragment, blend| {
|
||||
pipeline_in(
|
||||
layout,
|
||||
@@ -2816,6 +2876,7 @@ impl Renderer {
|
||||
mod_post_hdr: false,
|
||||
mod_views: None,
|
||||
gbuffer_terrain,
|
||||
+ gbuffer_water,
|
||||
gbuffer_entity,
|
||||
shadow_terrain,
|
||||
shadow_entity,
|
||||
@@ -4204,6 +4265,16 @@ impl Renderer {
|
||||
fog_color,
|
||||
fog,
|
||||
sky: frame.skybox && !frame.end_sky && !frame.nether_lighting && !frame.underwater,
|
||||
+ sky_color: [
|
||||
+ frame.sky.sky_color[0],
|
||||
+ frame.sky.sky_color[1],
|
||||
+ frame.sky.sky_color[2],
|
||||
+ ],
|
||||
+ camera_mod: [
|
||||
+ camera.x.rem_euclid(256.0) as f32,
|
||||
+ camera.z.rem_euclid(256.0) as f32,
|
||||
+ ],
|
||||
+ water: !frame.underwater,
|
||||
};
|
||||
let (shadow_dir, shadow_strength, _) = hd::light_of(&hd_scene);
|
||||
let mut hd_cascades = hd::cascades(camera, shadow_dir);
|
||||
@@ -5058,6 +5129,11 @@ impl Renderer {
|
||||
.hd
|
||||
.get_or_insert_with(|| hd::Hd::new(device, size, format, globals_size));
|
||||
hd.resize(device, size, [self.config.width, self.config.height]);
|
||||
+ // Translucent terrain on screen: the water pass has something to draw.
|
||||
+ hd.water_geometry = match self.flat.as_ref().filter(|_| flat_frame) {
|
||||
+ Some(flat) => !flat.translucent_calls().is_empty(),
|
||||
+ None => translucent_draws > 0,
|
||||
+ };
|
||||
if let Some(settings) = frame.hd {
|
||||
hd.write(&self.queue, &hd_scene, frame.hd_lights, settings);
|
||||
}
|
||||
@@ -5186,6 +5262,32 @@ impl Renderer {
|
||||
drop(pass);
|
||||
// Contact shadows from the G-buffer's depth and normals, for the lighting.
|
||||
hd.ao(&mut encoder);
|
||||
+ // The water quads alone, with their own depth: the lighting finds the water by it and
|
||||
+ // the composite after the lighting lays the surface over the lit floor.
|
||||
+ if hd.water_active() {
|
||||
+ let mut pass = hd.water_pass(&mut encoder);
|
||||
+ pass.set_bind_group(0, &self.group, &[]);
|
||||
+ match self.flat.as_ref().filter(|_| flat_frame) {
|
||||
+ Some(flat) => {
|
||||
+ pass.set_pipeline(&flat.water);
|
||||
+ flat.draw(&mut pass, flat.translucent_calls(), |p| {
|
||||
+ self.arena.buffer(p)
|
||||
+ });
|
||||
+ }
|
||||
+ None => {
|
||||
+ pass.set_pipeline(&self.gbuffer_water);
|
||||
+ pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
+ pass.set_vertex_buffer(1, self.instances.slice(..));
|
||||
+ self.draw_sections(
|
||||
+ &mut pass,
|
||||
+ &self.indirect,
|
||||
+ 0,
|
||||
+ solid_draws,
|
||||
+ translucent_draws,
|
||||
+ );
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
} else if !hd_on {
|
||||
self.hd = None;
|
||||
}
|
||||
@@ -5721,6 +5823,11 @@ impl Renderer {
|
||||
(color, depth),
|
||||
(&self.lightmap_view, &self.lightmap_sampler),
|
||||
);
|
||||
+ hd.water_composite(
|
||||
+ &self.device,
|
||||
+ &mut encoder,
|
||||
+ (&self.lightmap_view, &self.lightmap_sampler),
|
||||
+ );
|
||||
match &mut self.mod_post {
|
||||
Some(post) if mod_post => {
|
||||
let size = self.world_post.size().unwrap_or([1, 1]);
|
||||
@@ -5731,7 +5838,7 @@ impl Renderer {
|
||||
&self.mod_params.0,
|
||||
self.mod_params.1,
|
||||
);
|
||||
- post.run(&self.device, &mut encoder, (&hd.lit, depth), &hd.fx);
|
||||
+ post.run(&self.device, &mut encoder, (hd.lit_view(), depth), &hd.fx);
|
||||
let source = hd.taa(&self.device, &mut encoder, hd::HdSource::Fx, depth);
|
||||
hd.finish(&self.device, &mut encoder, source, &view_target);
|
||||
}
|
||||
diff --git a/crates/horizon-client/src/gfx/flat.rs b/crates/horizon-client/src/gfx/flat.rs
|
||||
index 5d86888da..7e2e20cad 100644
|
||||
--- a/crates/horizon-client/src/gfx/flat.rs
|
||||
+++ b/crates/horizon-client/src/gfx/flat.rs
|
||||
@@ -64,6 +64,8 @@ pub struct Call {
|
||||
pub struct Flat {
|
||||
pub solid: wgpu::RenderPipeline,
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
+ /// The water quads alone, into HD's water targets.
|
||||
+ pub water: wgpu::RenderPipeline,
|
||||
flatten: wgpu::ComputePipeline,
|
||||
compute_layout: wgpu::BindGroupLayout,
|
||||
flatten_seen: wgpu::ComputePipeline,
|
||||
@@ -254,6 +256,7 @@ impl Flat {
|
||||
device: &wgpu::Device,
|
||||
terrain_layout: &wgpu::BindGroupLayout,
|
||||
page_vertices: u64,
|
||||
+ water: impl Fn(&wgpu::PipelineLayout, &str) -> wgpu::RenderPipeline,
|
||||
pipeline: impl Fn(
|
||||
&wgpu::PipelineLayout,
|
||||
&str,
|
||||
@@ -320,6 +323,7 @@ impl Flat {
|
||||
"fs_translucent",
|
||||
Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
),
|
||||
+ water: water(&layout, "HD water terrain (flat)"),
|
||||
flatten,
|
||||
compute_layout,
|
||||
flatten_seen,
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index c54b7b961..f5caa2d0b 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -16,6 +16,9 @@ pub const NORMAL: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
pub const LIGHT_LEVELS: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
/// The lit world and the post pass's output.
|
||||
pub const HDR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
+/// The water pass's targets: the surface colour with the block light, and the normal with the sky light.
|
||||
+pub const WATER_TINT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
+pub const WATER_NORMAL: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
/// The most lights drawn a frame.
|
||||
pub const MAX_LIGHTS: usize = 512;
|
||||
/// The side (pixels) of a tile of the screen with its own short list of lights.
|
||||
@@ -91,6 +94,13 @@ struct HdFrame {
|
||||
taa: [f32; 4],
|
||||
/// x: the history's weight; y: the sharpening (0 without TAA); zw: the jitter in world pixels.
|
||||
taa_params: [f32; 4],
|
||||
+ /// The (jittered) world-to-clip matrix the world was drawn with (rays in the water pass).
|
||||
+ view_proj: [[f32; 4]; 4],
|
||||
+ /// x: the water effect runs this frame; y: seconds for the waves (wraps at 600); zw: the
|
||||
+ /// camera's x and z modulo 256, the waves' period.
|
||||
+ water: [f32; 4],
|
||||
+ /// rgb: the sky's colour; w: 1 when there is a sky.
|
||||
+ sky_color: [f32; 4],
|
||||
}
|
||||
|
||||
/// What the world around looks like this frame (from the renderer's globals).
|
||||
@@ -116,6 +126,11 @@ pub struct HdScene {
|
||||
pub fog: [f32; 4],
|
||||
/// The sky has a sun at all (the Overworld).
|
||||
pub sky: bool,
|
||||
+ pub sky_color: [f32; 3],
|
||||
+ /// The camera's x and z modulo 256 (the waves' period), so the waves hold still in the world.
|
||||
+ pub camera_mod: [f32; 2],
|
||||
+ /// The water effect may run (not underwater, not disabled).
|
||||
+ pub water: bool,
|
||||
}
|
||||
|
||||
/// The cascades' half sizes (blocks) and shadow map size.
|
||||
@@ -137,6 +152,9 @@ struct HdFrame {
|
||||
prev_view_proj: mat4x4<f32>,
|
||||
taa: vec4<f32>,
|
||||
taa_params: vec4<f32>,
|
||||
+ view_proj: mat4x4<f32>,
|
||||
+ water: vec4<f32>,
|
||||
+ sky_color: vec4<f32>,
|
||||
}
|
||||
struct FullOut {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@@ -175,6 +193,8 @@ struct Light {
|
||||
@group(0) @binding(12) var<storage, read> tiles: array<u32>;
|
||||
// Ambient occlusion at half resolution: x the occlusion (1 open), y the distance it was taken at.
|
||||
@group(0) @binding(13) var ao_tex: texture_2d<f32>;
|
||||
+// The water pass's depth: the nearest water surface (0 where there is none).
|
||||
+@group(0) @binding(14) var water_depth: texture_depth_2d;
|
||||
|
||||
// The occlusion at a full-resolution pixel `dist` away: the four nearest half-resolution texels
|
||||
// weighted by closeness and by how near their distance is, so it doesn't bleed across edges.
|
||||
@@ -216,6 +236,12 @@ fn cascade_at(p: vec3<f32>, c: i32) -> vec3<f32> {
|
||||
|
||||
// The sun's (or moon's) light reaching p: 1 lit, 0 in shadow; soft-edged.
|
||||
fn shadow(p: vec3<f32>, n: vec3<f32>) -> f32 {
|
||||
+ return shadow_taps(p, n, 1);
|
||||
+}
|
||||
+
|
||||
+// `shadow` with a (2 * r + 1) square of taps: the floor under water takes one (the water dims and
|
||||
+// blurs it anyway).
|
||||
+fn shadow_taps(p: vec3<f32>, n: vec3<f32>, r: i32) -> f32 {
|
||||
let dist = length(p);
|
||||
let c = select(1, 0, dist < frame.shadow.y * 0.92);
|
||||
let texel = select(frame.shadow.z, frame.shadow.y, c == 0) * 2.0 / 2048.0;
|
||||
@@ -224,13 +250,13 @@ fn shadow(p: vec3<f32>, n: vec3<f32>) -> f32 {
|
||||
if any(s.xy < vec2<f32>(0.0)) || any(s.xy > vec2<f32>(1.0)) || s.z > 1.0 { return 1.0; }
|
||||
var lit = 0.0;
|
||||
let step = 1.0 / 2048.0;
|
||||
- for (var y = -1; y <= 1; y++) {
|
||||
- for (var x = -1; x <= 1; x++) {
|
||||
+ for (var y = -r; y <= r; y++) {
|
||||
+ for (var x = -r; x <= r; x++) {
|
||||
lit += textureSampleCompareLevel(shadow_map, shadow_sampler, s.xy + vec2<f32>(f32(x), f32(y)) * step, c, s.z - 0.0005);
|
||||
}
|
||||
}
|
||||
let fade = clamp((frame.shadow.z - dist) / 16.0, 0.0, 1.0);
|
||||
- return mix(1.0, lit / 9.0, fade);
|
||||
+ return mix(1.0, lit / f32((2 * r + 1) * (2 * r + 1)), fade);
|
||||
}
|
||||
|
||||
// One shadow sample, for the rays through the air.
|
||||
@@ -252,7 +278,15 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let scene = textureLoad(scene_color, px, 0).rgb;
|
||||
let d = textureLoad(scene_depth, px, 0);
|
||||
let g = textureLoad(gbuffer_depth, px, 0);
|
||||
- let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(d, 0.000001), 1.0);
|
||||
+ // Water in front of the opaque world with nothing translucent over it: here the floor is lit
|
||||
+ // and the water composite lays the surface over it (it knows the pixel by the alpha 0.5).
|
||||
+ var water = false;
|
||||
+ if frame.water.x > 0.5 && g > 0.0 {
|
||||
+ let wd = textureLoad(water_depth, px, 0);
|
||||
+ water = wd > 0.0 && wd >= g && d - wd <= d * 0.002;
|
||||
+ }
|
||||
+ let dd = select(d, g, water);
|
||||
+ let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(dd, 0.000001), 1.0);
|
||||
let h = frame.inverse_view_proj * ndc;
|
||||
let p = h.xyz / h.w;
|
||||
let view = normalize(p);
|
||||
@@ -267,7 +301,7 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let halo_all = tiles[tile + 3u] == 0xffffffffu;
|
||||
let halo_count = select(tiles[tile + 3u], count, halo_all);
|
||||
var c = scene;
|
||||
- let surface = d > 0.0 && abs(d - g) <= max(d, g) * 0.002;
|
||||
+ let surface = water || (d > 0.0 && abs(d - g) <= max(d, g) * 0.002);
|
||||
if surface {
|
||||
let n = normalize(textureLoad(normal_tex, px, 0).xyz);
|
||||
let albedo = textureLoad(albedo_tex, px, 0);
|
||||
@@ -278,14 +312,15 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let sky_reach = smoothstep(0.35, 0.95, lm.x);
|
||||
var s = 1.0;
|
||||
let ndl = dot(n, sun);
|
||||
- if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow(p, n); } else { s = 0.0; }
|
||||
+ if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow_taps(p, n, select(1, 0, water)); } else { s = 0.0; }
|
||||
// Shadowed sky light (ambient) keeps vanilla's look, blocks' light stays as is; the sun adds
|
||||
// direct light by the face's angle.
|
||||
let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
|
||||
// Contact shadows from the ambient occlusion: on the ambient and blocks' light fully, on
|
||||
// the mods' lights by half, not on the sun or on emission.
|
||||
var occlusion = 1.0;
|
||||
- if frame.ambient.w > 0.0 { occlusion = ao_at(px, length(p)); }
|
||||
+ // (The water dims and blurs the floor under it: no occlusion there.)
|
||||
+ if frame.ambient.w > 0.0 && !water { occlusion = ao_at(px, length(p)); }
|
||||
let ao_ambient = mix(1.0, occlusion, frame.ambient.w);
|
||||
let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5);
|
||||
var light = max(base * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct;
|
||||
@@ -334,7 +369,253 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let a1 = atan((far - t0) / hh);
|
||||
c += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + density * 300.0);
|
||||
}
|
||||
- return vec4<f32>(c, 1.0);
|
||||
+ return vec4<f32>(c, select(1.0, 0.5, water));
|
||||
+}
|
||||
+"#;
|
||||
+
|
||||
+const WATER: &str = r#"
|
||||
+const PI: f32 = 3.14159265;
|
||||
+const REFLECT_STEPS: i32 = 24;
|
||||
+const REFLECT_RANGE: f32 = 96.0;
|
||||
+@group(0) @binding(0) var<uniform> frame: HdFrame;
|
||||
+@group(0) @binding(1) var lit_tex: texture_2d<f32>;
|
||||
+@group(0) @binding(2) var gbuffer_depth: texture_depth_2d;
|
||||
+@group(0) @binding(3) var water_tint: texture_2d<f32>;
|
||||
+@group(0) @binding(4) var water_normal: texture_2d<f32>;
|
||||
+@group(0) @binding(5) var water_depth: texture_depth_2d;
|
||||
+@group(0) @binding(6) var shadow_map: texture_depth_2d_array;
|
||||
+@group(0) @binding(7) var shadow_sampler: sampler_comparison;
|
||||
+@group(0) @binding(8) var lightmap: texture_2d<f32>;
|
||||
+@group(0) @binding(9) var linear: sampler;
|
||||
+
|
||||
+fn ign(px: vec2<f32>) -> f32 {
|
||||
+ let p = px + vec2<f32>(5.588238 * frame.params.w);
|
||||
+ return fract(52.9829189 * fract(dot(p, vec2<f32>(0.06711056, 0.00583715))));
|
||||
+}
|
||||
+
|
||||
+// The camera-relative position of the screen point `uv` at depth z.
|
||||
+fn position_at(uv: vec2<f32>, z: f32) -> vec3<f32> {
|
||||
+ let h = frame.inverse_view_proj * vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, z, 1.0);
|
||||
+ return h.xyz / h.w;
|
||||
+}
|
||||
+
|
||||
+// The screen point (uv, clip w) of a camera-relative position.
|
||||
+fn project(p: vec3<f32>) -> vec3<f32> {
|
||||
+ let c = frame.view_proj * vec4<f32>(p, 1.0);
|
||||
+ return vec3<f32>(c.x / c.w * 0.5 + 0.5, 0.5 - c.y / c.w * 0.5, c.w);
|
||||
+}
|
||||
+
|
||||
+fn on_screen(uv: vec2<f32>) -> bool {
|
||||
+ return all(uv > vec2<f32>(0.0)) && all(uv < vec2<f32>(1.0));
|
||||
+}
|
||||
+
|
||||
+fn texel(uv: vec2<f32>) -> vec2<i32> {
|
||||
+ return clamp(vec2<i32>(uv * frame.resolution.xy), vec2<i32>(0), vec2<i32>(frame.resolution.xy) - 1);
|
||||
+}
|
||||
+
|
||||
+// Sine waves in world space: wave vectors that are whole multiples of 2 pi / 256 and speeds that
|
||||
+// are whole cycles per 600 s, so the sum repeats every 256 blocks and 10 minutes and holds still
|
||||
+// in the world as the camera moves. x, y: the vector in units of 2 pi / 256; z: the slope the
|
||||
+// wave adds; w: its phase.
|
||||
+const WAVES = array<vec4<f32>, 10>(
|
||||
+ vec4<f32>(3.0, 1.0, 0.040, 0.3),
|
||||
+ vec4<f32>(-2.0, 5.0, 0.045, 2.1),
|
||||
+ vec4<f32>(7.0, 2.0, 0.040, 4.4),
|
||||
+ vec4<f32>(-5.0, -9.0, 0.040, 1.2),
|
||||
+ vec4<f32>(11.0, -4.0, 0.035, 5.6),
|
||||
+ vec4<f32>(13.0, 10.0, 0.035, 3.3),
|
||||
+ vec4<f32>(-17.0, 7.0, 0.030, 0.9),
|
||||
+ vec4<f32>(9.0, 23.0, 0.030, 2.7),
|
||||
+ vec4<f32>(-27.0, 19.0, 0.025, 4.9),
|
||||
+ vec4<f32>(31.0, 30.0, 0.025, 1.8),
|
||||
+);
|
||||
+
|
||||
+// The gradient of the water's height at world x, z: the waves that a pixel `footprint` blocks
|
||||
+// wide can still resolve (finer ones fade out, or they would shimmer).
|
||||
+fn wave_gradient(xz: vec2<f32>, footprint: f32) -> vec2<f32> {
|
||||
+ var grad = vec2<f32>(0.0);
|
||||
+ for (var i = 0; i < 10; i++) {
|
||||
+ let w = WAVES[i];
|
||||
+ let k = w.xy * (2.0 * PI / 256.0);
|
||||
+ let len = length(k);
|
||||
+ let fade = clamp((2.0 * PI / len) / max(footprint, 0.0001) * 0.25 - 0.5, 0.0, 1.0);
|
||||
+ let cycles = round(sqrt(9.8 * len) * 0.5 * 600.0 / (2.0 * PI));
|
||||
+ let omega = cycles * 2.0 * PI / 600.0;
|
||||
+ let phase = dot(k, xz) - omega * frame.water.y + w.w;
|
||||
+ grad += (k / len) * (w.z * fade * cos(phase));
|
||||
+ }
|
||||
+ return grad;
|
||||
+}
|
||||
+
|
||||
+fn cascade_at(p: vec3<f32>, c: i32) -> vec3<f32> {
|
||||
+ let s = frame.cascades[c] * vec4<f32>(p, 1.0);
|
||||
+ return vec3<f32>(s.x * 0.5 + 0.5, 0.5 - s.y * 0.5, s.z);
|
||||
+}
|
||||
+
|
||||
+// The sun's (or moon's) light reaching p: 1 lit, 0 in shadow (as the lighting pass has it).
|
||||
+fn shadow(p: vec3<f32>, n: vec3<f32>) -> f32 {
|
||||
+ let dist = length(p);
|
||||
+ let c = select(1, 0, dist < frame.shadow.y * 0.92);
|
||||
+ let texel_size = select(frame.shadow.z, frame.shadow.y, c == 0) * 2.0 / 2048.0;
|
||||
+ let q = p + n * texel_size * 1.5;
|
||||
+ let s = cascade_at(q, c);
|
||||
+ if any(s.xy < vec2<f32>(0.0)) || any(s.xy > vec2<f32>(1.0)) || s.z > 1.0 { return 1.0; }
|
||||
+ var lit = 0.0;
|
||||
+ let step = 1.0 / 2048.0;
|
||||
+ for (var y = -1; y <= 1; y++) {
|
||||
+ for (var x = -1; x <= 1; x++) {
|
||||
+ lit += textureSampleCompareLevel(shadow_map, shadow_sampler, s.xy + vec2<f32>(f32(x), f32(y)) * step, c, s.z - 0.0005);
|
||||
+ }
|
||||
+ }
|
||||
+ let fade = clamp((frame.shadow.z - dist) / 16.0, 0.0, 1.0);
|
||||
+ return mix(1.0, lit / 9.0, fade);
|
||||
+}
|
||||
+
|
||||
+fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
+ if d <= start { return 0.0; }
|
||||
+ if d >= end { return 1.0; }
|
||||
+ return (d - start) / (end - start);
|
||||
+}
|
||||
+
|
||||
+// What the sky shows along r when the ray leaves the screen: the horizon's colour rising to
|
||||
+// the zenith's, darker where the water lies under cover (`vis` is the sky light there).
|
||||
+fn sky_along(r: vec3<f32>, vis: f32) -> vec3<f32> {
|
||||
+ var c = frame.fog_color.rgb;
|
||||
+ if frame.sky_color.w > 0.5 {
|
||||
+ c = mix(frame.fog_color.rgb, frame.sky_color.rgb, smoothstep(0.0, 0.5, r.y));
|
||||
+ }
|
||||
+ return c * mix(0.12, 1.0, vis);
|
||||
+}
|
||||
+
|
||||
+// Marches the ray from p along r over the depth buffer (28 steps, growing, jittered so TAA
|
||||
+// averages the placement; then a binary search for the crossing). xyz: the colour found,
|
||||
+// w: how much to trust it (0 for a miss, less near the screen's edge).
|
||||
+fn reflection(p: vec3<f32>, r: vec3<f32>, jitter: f32) -> vec4<f32> {
|
||||
+ var near = 0.0;
|
||||
+ for (var i = 0; i < REFLECT_STEPS; i++) {
|
||||
+ let u = (f32(i) + jitter) / f32(REFLECT_STEPS);
|
||||
+ let s = 0.12 + REFLECT_RANGE * u * u;
|
||||
+ let q = p + r * s;
|
||||
+ let sp = project(q);
|
||||
+ if sp.z <= 0.0 || !on_screen(sp.xy) { break; }
|
||||
+ let z = textureLoad(gbuffer_depth, texel(sp.xy), 0);
|
||||
+ if z > 0.0 {
|
||||
+ let c = frame.view_proj * vec4<f32>(q, 1.0);
|
||||
+ // Reversed Z: a larger depth is nearer; the ray is behind the surface when the
|
||||
+ // surface is nearer than it.
|
||||
+ if z > c.z / c.w {
|
||||
+ let behind = length(q) - length(position_at(sp.xy, z));
|
||||
+ if behind < 0.4 + 0.08 * s {
|
||||
+ var lo = near;
|
||||
+ var hi = s;
|
||||
+ for (var k = 0; k < 5; k++) {
|
||||
+ let m = 0.5 * (lo + hi);
|
||||
+ let mq = p + r * m;
|
||||
+ let mp = project(mq);
|
||||
+ let mc = frame.view_proj * vec4<f32>(mq, 1.0);
|
||||
+ let mz = textureLoad(gbuffer_depth, texel(mp.xy), 0);
|
||||
+ if mz > mc.z / mc.w { hi = m; } else { lo = m; }
|
||||
+ }
|
||||
+ let hp = project(p + r * hi);
|
||||
+ let edge = min(min(hp.x, 1.0 - hp.x), min(hp.y, 1.0 - hp.y));
|
||||
+ let colour = textureSampleLevel(lit_tex, linear, hp.xy, 0.0).rgb;
|
||||
+ return vec4<f32>(colour, smoothstep(0.0, 0.08, edge));
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ near = s;
|
||||
+ }
|
||||
+ return vec4<f32>(0.0);
|
||||
+}
|
||||
+
|
||||
+// The sun's glint on the waves (GGX, a rough lobe so it glitters instead of aliasing).
|
||||
+fn glint(n: vec3<f32>, v: vec3<f32>, l: vec3<f32>) -> f32 {
|
||||
+ let h = normalize(l + v);
|
||||
+ let a = 0.05;
|
||||
+ let nh = max(dot(n, h), 0.0);
|
||||
+ let d = a * a / (PI * pow(nh * nh * (a * a - 1.0) + 1.0, 2.0));
|
||||
+ let f = 0.02 + 0.98 * pow(1.0 - max(dot(v, h), 0.0), 5.0);
|
||||
+ return min(d * f * 0.25 * max(dot(n, l), 0.0) / max(dot(n, v), 0.1), 12.0);
|
||||
+}
|
||||
+
|
||||
+// The water over the lit world: the world behind it (refracted by the waves, dimmed and tinted
|
||||
+// by the water's depth), the sky and the world above it (a screen-space reflection) by Fresnel,
|
||||
+// and the sun's glint. Pixels without water pass through.
|
||||
+@fragment
|
||||
+fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
|
||||
+ let px = vec2<i32>(in.position.xy);
|
||||
+ let base = textureLoad(lit_tex, px, 0);
|
||||
+ if base.a > 0.75 { return vec4<f32>(base.rgb, 1.0); }
|
||||
+ let wd = textureLoad(water_depth, px, 0);
|
||||
+ let g = textureLoad(gbuffer_depth, px, 0);
|
||||
+ let tint = textureLoad(water_tint, px, 0);
|
||||
+ let nt = textureLoad(water_normal, px, 0);
|
||||
+ let p = position_at(in.uv, wd);
|
||||
+ let dist = length(p);
|
||||
+ let v = -p / dist;
|
||||
+ let geometric = normalize(nt.xyz * 2.0 - 1.0);
|
||||
+ // The waves tilt the flat parts of the surface only.
|
||||
+ let footprint = dist * frame.resolution.w * 1.4;
|
||||
+ let grad = wave_gradient(p.xz + frame.water.zw, footprint);
|
||||
+ let flat_part = smoothstep(0.6, 0.95, geometric.y);
|
||||
+ var n = normalize(geometric + vec3<f32>(-grad.x, 0.0, -grad.y) * flat_part);
|
||||
+ n = normalize(n + v * max(0.03 - dot(n, v), 0.0));
|
||||
+ let ndv = max(dot(n, v), 0.02);
|
||||
+
|
||||
+ // Light on the water: vanilla's lightmap at the surface, and the sun where it reaches.
|
||||
+ let levels = vec2<f32>(nt.a, tint.a);
|
||||
+ let sky_reach = smoothstep(0.35, 0.95, levels.x);
|
||||
+ let ambient = textureSampleLevel(lightmap, linear, clamp(vec2<f32>(levels.y, levels.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2<f32>(0.5 / 16.0), vec2<f32>(15.5 / 16.0)), 0.0).rgb;
|
||||
+ let block = textureSampleLevel(lightmap, linear, vec2<f32>(levels.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb;
|
||||
+ let sun = frame.sun.xyz;
|
||||
+ let sun_on = frame.sun.w;
|
||||
+ var sunlit = 0.0;
|
||||
+ if sun_on > 0.0 && sun.y > 0.0 && sky_reach > 0.0 { sunlit = shadow(p, vec3<f32>(0.0, 1.0, 0.0)) * sky_reach; }
|
||||
+ let volume_light = max(ambient * frame.ambient.x, block * frame.ambient.y) + frame.sun_color.rgb * sun_on * sunlit * 0.8;
|
||||
+
|
||||
+ // Refraction: where the ray through the waves meets the world, against where the flat surface
|
||||
+ // would send it (so still water looks as vanilla's and only the waves distort).
|
||||
+ let floor0 = position_at(in.uv, g);
|
||||
+ let depth0 = max(length(floor0) - dist, 0.0);
|
||||
+ let bent = refract(-v, n, 0.75) - refract(-v, geometric, 0.75);
|
||||
+ var uv_floor = in.uv;
|
||||
+ var floor_z = g;
|
||||
+ let moved = project(floor0 + bent * min(depth0, 24.0));
|
||||
+ if moved.z > 0.0 && on_screen(moved.xy) {
|
||||
+ let z = textureLoad(gbuffer_depth, texel(moved.xy), 0);
|
||||
+ // Only what lies behind the water: not the bank or an entity in front of it.
|
||||
+ if z > 0.0 && z <= wd {
|
||||
+ uv_floor = moved.xy;
|
||||
+ floor_z = z;
|
||||
+ }
|
||||
+ }
|
||||
+ let thickness = clamp(length(position_at(uv_floor, floor_z)) - dist, 0.0, 48.0);
|
||||
+ let behind = textureSampleLevel(lit_tex, linear, uv_floor, 0.0).rgb;
|
||||
+ let colour = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
|
||||
+ // The colours the water takes up fastest are the ones it has the least of.
|
||||
+ let absorb = exp(-(vec3<f32>(1.0) - colour) * 0.8 * (thickness + 0.3));
|
||||
+ let refracted = behind * absorb + colour * volume_light * 0.9 * (vec3<f32>(1.0) - absorb);
|
||||
+
|
||||
+ // Reflection: the screen where the ray stays on it, the sky where it leaves it or misses.
|
||||
+ var r = reflect(-v, n);
|
||||
+ r.y = abs(r.y);
|
||||
+ // Looking down onto it the reflection is a few percent of the picture: the sky alone does.
|
||||
+ let fresnel = 0.02 + 0.98 * pow(1.0 - ndv, 5.0);
|
||||
+ var hit = vec4<f32>(0.0);
|
||||
+ if fresnel > 0.04 { hit = reflection(p, r, ign(in.position.xy)); }
|
||||
+ let sky_vis = mix(0.15, 1.0, sky_reach);
|
||||
+ let reflected = mix(sky_along(r, sky_vis), hit.rgb, hit.w);
|
||||
+
|
||||
+ var c = refracted * (1.0 - fresnel) + reflected * fresnel;
|
||||
+ if sun_on > 0.0 && sunlit > 0.0 {
|
||||
+ c += frame.sun_color.rgb * sun_on * sunlit * glint(n, v, sun);
|
||||
+ }
|
||||
+ // Vanilla's fog over it, at the surface.
|
||||
+ let sph = dist;
|
||||
+ let cyl = max(length(p.xz), abs(p.y));
|
||||
+ let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w));
|
||||
+ c = mix(c, frame.fog_color.rgb, f * frame.fog_color.a);
|
||||
+ return vec4<f32>(max(c, vec3<f32>(0.0)), 1.0);
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -829,6 +1110,12 @@ pub fn taa_enabled() -> bool {
|
||||
*ON.get_or_init(|| std::env::var("HORIZON_HD_TAA").map_or(true, |v| v != "0"))
|
||||
}
|
||||
|
||||
+/// The water effect is on unless `HORIZON_HD_WATER=0`.
|
||||
+pub fn water_enabled() -> bool {
|
||||
+ static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
+ *ON.get_or_init(|| std::env::var("HORIZON_HD_WATER").map_or(true, |v| v != "0"))
|
||||
+}
|
||||
+
|
||||
/// The Halton sequence's `index`-th number in `base`, in (0, 1).
|
||||
fn halton(mut index: u32, base: u32) -> f32 {
|
||||
let (mut f, mut r) = (1.0, 0.0);
|
||||
@@ -895,6 +1182,22 @@ pub struct Hd {
|
||||
/// The lit world, and the post pass's output.
|
||||
pub lit: wgpu::TextureView,
|
||||
pub fx: wgpu::TextureView,
|
||||
+ /// Water: its surface colour and normal, its depth (the nearest water surface; 0 elsewhere),
|
||||
+ /// the lit world with the water over it, and what draws and composites them.
|
||||
+ water_tint: wgpu::TextureView,
|
||||
+ water_normal: wgpu::TextureView,
|
||||
+ water_depth: wgpu::TextureView,
|
||||
+ lit_water: wgpu::TextureView,
|
||||
+ water_layout: wgpu::BindGroupLayout,
|
||||
+ water_pipeline: wgpu::RenderPipeline,
|
||||
+ water_group: Option<((wgpu::TextureView, wgpu::Sampler), wgpu::BindGroup)>,
|
||||
+ /// The water effect runs this frame (the scene allows it and there is translucent terrain on
|
||||
+ /// screen), and `lit_view` is the composite.
|
||||
+ water_active: bool,
|
||||
+ /// The frame's translucent terrain exists (set by the renderer before `write`).
|
||||
+ pub water_geometry: bool,
|
||||
+ water_composited: bool,
|
||||
+ started: std::time::Instant,
|
||||
/// TAA: the resolved frame, and the two histories (one read, one written, alternating).
|
||||
taa: wgpu::TextureView,
|
||||
history: [wgpu::TextureView; 2],
|
||||
@@ -1216,6 +1519,26 @@ impl Hd {
|
||||
min_binding_size: None,
|
||||
},
|
||||
UNFILTERED,
|
||||
+ DEPTH,
|
||||
+ ]),
|
||||
+ });
|
||||
+ let water_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
+ label: Some("HD water"),
|
||||
+ entries: &entries(&[
|
||||
+ UNIFORM,
|
||||
+ FLOAT,
|
||||
+ DEPTH,
|
||||
+ UNFILTERED,
|
||||
+ UNFILTERED,
|
||||
+ DEPTH,
|
||||
+ wgpu::BindingType::Texture {
|
||||
+ sample_type: wgpu::TextureSampleType::Depth,
|
||||
+ view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||
+ multisampled: false,
|
||||
+ },
|
||||
+ wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
|
||||
+ FLOAT,
|
||||
+ SAMPLER,
|
||||
]),
|
||||
});
|
||||
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -1278,6 +1601,16 @@ impl Hd {
|
||||
let tone = module(device, "HD tone mapping", TONEMAP);
|
||||
let ao = module(device, "HD ambient occlusion", AO);
|
||||
let ao_blur = module(device, "HD ambient occlusion blur", AO_BLUR);
|
||||
+ let water = module(device, "HD water", WATER);
|
||||
+ let water_pipeline = fullscreen(
|
||||
+ device,
|
||||
+ "HD water",
|
||||
+ &water_layout,
|
||||
+ &water,
|
||||
+ "fs_water",
|
||||
+ HDR,
|
||||
+ None,
|
||||
+ );
|
||||
let ao_pipeline = fullscreen(
|
||||
device,
|
||||
"HD ambient occlusion",
|
||||
@@ -1436,6 +1769,23 @@ impl Hd {
|
||||
.view,
|
||||
lit: target(device, "HD lit", [1, 1], HDR).view,
|
||||
fx: target(device, "HD post", [1, 1], HDR).view,
|
||||
+ water_tint: target(device, "HD water colour", [1, 1], WATER_TINT).view,
|
||||
+ water_normal: target(device, "HD water normal", [1, 1], WATER_NORMAL).view,
|
||||
+ water_depth: target(
|
||||
+ device,
|
||||
+ "HD water depth",
|
||||
+ [1, 1],
|
||||
+ wgpu::TextureFormat::Depth32Float,
|
||||
+ )
|
||||
+ .view,
|
||||
+ lit_water: target(device, "HD lit with water", [1, 1], HDR).view,
|
||||
+ water_layout,
|
||||
+ water_pipeline,
|
||||
+ water_group: None,
|
||||
+ water_active: false,
|
||||
+ water_geometry: false,
|
||||
+ water_composited: false,
|
||||
+ started: std::time::Instant::now(),
|
||||
taa: target(device, "HD TAA", [1, 1], HDR).view,
|
||||
history: [
|
||||
target(device, "HD TAA history", [1, 1], HDR).view,
|
||||
@@ -1502,6 +1852,17 @@ impl Hd {
|
||||
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
|
||||
self.lit = target(device, "HD lit", size, HDR).view;
|
||||
self.fx = target(device, "HD post", size, HDR).view;
|
||||
+ self.water_tint = target(device, "HD water colour", size, WATER_TINT).view;
|
||||
+ self.water_normal = target(device, "HD water normal", size, WATER_NORMAL).view;
|
||||
+ self.water_depth = target(
|
||||
+ device,
|
||||
+ "HD water depth",
|
||||
+ size,
|
||||
+ wgpu::TextureFormat::Depth32Float,
|
||||
+ )
|
||||
+ .view;
|
||||
+ self.lit_water = target(device, "HD lit with water", size, HDR).view;
|
||||
+ self.water_group = None;
|
||||
self.taa = target(device, "HD TAA", out, HDR).view;
|
||||
self.history = [
|
||||
target(device, "HD TAA history", out, HDR).view,
|
||||
@@ -1591,6 +1952,7 @@ impl Hd {
|
||||
let (dir, strength, color) = light_of(scene);
|
||||
let cascades = scene.cascades;
|
||||
let night = scene.dark.clamp(0.0, 1.0);
|
||||
+ self.water_active = scene.water && water_enabled() && self.water_geometry;
|
||||
self.taa_active = scene.taa && taa_enabled();
|
||||
// A long jump of the camera (a teleport) starts the history over.
|
||||
if !self.taa_active || scene.camera_delta.length() > 64.0 {
|
||||
@@ -1604,6 +1966,19 @@ impl Hd {
|
||||
scene.camera_delta.z,
|
||||
if self.history_ready { 1.0 } else { 0.0 },
|
||||
],
|
||||
+ view_proj: scene.view_proj.to_cols_array_2d(),
|
||||
+ water: [
|
||||
+ if self.water_active { 1.0 } else { 0.0 },
|
||||
+ (self.started.elapsed().as_secs_f64() % 600.0) as f32,
|
||||
+ scene.camera_mod[0],
|
||||
+ scene.camera_mod[1],
|
||||
+ ],
|
||||
+ sky_color: [
|
||||
+ scene.sky_color[0],
|
||||
+ scene.sky_color[1],
|
||||
+ scene.sky_color[2],
|
||||
+ if scene.sky { 1.0 } else { 0.0 },
|
||||
+ ],
|
||||
taa_params: [
|
||||
if self.upscaling() {
|
||||
UPSCALE_HISTORY
|
||||
@@ -1821,6 +2196,10 @@ impl Hd {
|
||||
binding: 13,
|
||||
resource: wgpu::BindingResource::TextureView(&self.ao_blurred),
|
||||
},
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 14,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.water_depth),
|
||||
+ },
|
||||
],
|
||||
});
|
||||
self.light_group = Some((key, group));
|
||||
@@ -1828,6 +2207,7 @@ impl Hd {
|
||||
let Some((_, group)) = &self.light_group else {
|
||||
return;
|
||||
};
|
||||
+ self.water_composited = false;
|
||||
Self::draw(
|
||||
encoder,
|
||||
"HD lighting",
|
||||
@@ -1894,7 +2274,7 @@ impl Hd {
|
||||
screen: &wgpu::TextureView,
|
||||
) {
|
||||
let source = match source {
|
||||
- HdSource::Lit => self.lit.clone(),
|
||||
+ HdSource::Lit => self.lit_view().clone(),
|
||||
HdSource::Fx => self.fx.clone(),
|
||||
HdSource::Taa => self.taa.clone(),
|
||||
};
|
||||
@@ -1993,7 +2373,7 @@ impl Hd {
|
||||
return source;
|
||||
}
|
||||
let input = match source {
|
||||
- HdSource::Lit => self.lit.clone(),
|
||||
+ HdSource::Lit => self.lit_view().clone(),
|
||||
HdSource::Fx => self.fx.clone(),
|
||||
HdSource::Taa => return source,
|
||||
};
|
||||
@@ -2052,6 +2432,102 @@ impl Hd {
|
||||
HdSource::Taa
|
||||
}
|
||||
|
||||
+ /// The lit world as later passes see it: with the water over it when the effect ran.
|
||||
+ pub fn lit_view(&self) -> &wgpu::TextureView {
|
||||
+ if self.water_composited {
|
||||
+ &self.lit_water
|
||||
+ } else {
|
||||
+ &self.lit
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /// The water effect runs this frame (the renderer draws the water quads with `water_pass`).
|
||||
+ pub fn water_active(&self) -> bool {
|
||||
+ self.water_active
|
||||
+ }
|
||||
+
|
||||
+ /// The water pass's attachments: the surface colour and normal, and the water's own depth,
|
||||
+ /// all cleared. The caller draws the translucent terrain with `fs_water`.
|
||||
+ pub fn water_pass<'e>(
|
||||
+ &mut self,
|
||||
+ encoder: &'e mut wgpu::CommandEncoder,
|
||||
+ ) -> wgpu::RenderPass<'e> {
|
||||
+ let clear = |view| {
|
||||
+ Some(wgpu::RenderPassColorAttachment {
|
||||
+ view,
|
||||
+ resolve_target: None,
|
||||
+ depth_slice: None,
|
||||
+ ops: wgpu::Operations {
|
||||
+ load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
+ store: wgpu::StoreOp::Store,
|
||||
+ },
|
||||
+ })
|
||||
+ };
|
||||
+ encoder
|
||||
+ .begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
+ label: Some("HD water"),
|
||||
+ timestamp_writes: stamp(&mut self.timing, "hd water"),
|
||||
+ color_attachments: &[clear(&self.water_tint), clear(&self.water_normal)],
|
||||
+ depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
+ view: &self.water_depth,
|
||||
+ depth_ops: Some(wgpu::Operations {
|
||||
+ load: wgpu::LoadOp::Clear(0.0),
|
||||
+ store: wgpu::StoreOp::Store,
|
||||
+ }),
|
||||
+ stencil_ops: None,
|
||||
+ }),
|
||||
+ ..Default::default()
|
||||
+ })
|
||||
+ .forget_lifetime()
|
||||
+ }
|
||||
+
|
||||
+ /// Lays the water over the lit world (refraction, reflection, glint) into a target of its
|
||||
+ /// own; `lit_view` is that target after this.
|
||||
+ pub fn water_composite(
|
||||
+ &mut self,
|
||||
+ device: &wgpu::Device,
|
||||
+ encoder: &mut wgpu::CommandEncoder,
|
||||
+ lightmap: (&wgpu::TextureView, &wgpu::Sampler),
|
||||
+ ) {
|
||||
+ if !self.water_active {
|
||||
+ return;
|
||||
+ }
|
||||
+ let key = (lightmap.0.clone(), lightmap.1.clone());
|
||||
+ if self.water_group.as_ref().is_none_or(|(k, _)| *k != key) {
|
||||
+ let group = self.make_group(
|
||||
+ device,
|
||||
+ "HD water",
|
||||
+ &self.water_layout,
|
||||
+ &[
|
||||
+ wgpu::BindingResource::Buffer(self.frame.as_entire_buffer_binding()),
|
||||
+ wgpu::BindingResource::TextureView(&self.lit),
|
||||
+ wgpu::BindingResource::TextureView(&self.depth),
|
||||
+ wgpu::BindingResource::TextureView(&self.water_tint),
|
||||
+ wgpu::BindingResource::TextureView(&self.water_normal),
|
||||
+ wgpu::BindingResource::TextureView(&self.water_depth),
|
||||
+ wgpu::BindingResource::TextureView(&self.shadow_array),
|
||||
+ wgpu::BindingResource::Sampler(&self.shadow_sampler),
|
||||
+ wgpu::BindingResource::TextureView(lightmap.0),
|
||||
+ wgpu::BindingResource::Sampler(&self.sampler),
|
||||
+ ],
|
||||
+ );
|
||||
+ self.water_group = Some((key, group));
|
||||
+ }
|
||||
+ let Some((_, group)) = &self.water_group else {
|
||||
+ return;
|
||||
+ };
|
||||
+ Self::draw(
|
||||
+ encoder,
|
||||
+ "HD water composite",
|
||||
+ &self.lit_water,
|
||||
+ false,
|
||||
+ &self.water_pipeline,
|
||||
+ group,
|
||||
+ stamp(&mut self.timing, "hd water composite"),
|
||||
+ );
|
||||
+ self.water_composited = true;
|
||||
+ }
|
||||
+
|
||||
/// The ambient occlusion from the G-buffer's depth and normals: the half-resolution pass, then
|
||||
/// its blur. Run after the G-buffer pass; nothing when the strength is 0.
|
||||
pub fn ao(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
@@ -2181,6 +2657,7 @@ mod tests {
|
||||
("taa", TAA),
|
||||
("ao", AO),
|
||||
("ao blur", AO_BLUR),
|
||||
+ ("water", WATER),
|
||||
] {
|
||||
let source = format!("{COMMON}{body}");
|
||||
let module = naga::front::wgsl::parse_str(&source).unwrap_or_else(|e| {
|
||||
diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl
|
||||
index c44449cc7..0d5d2fc89 100644
|
||||
--- a/crates/horizon-client/src/terrain.wgsl
|
||||
+++ b/crates/horizon-client/src/terrain.wgsl
|
||||
@@ -182,6 +182,25 @@ fn face_normal(pos: vec3<f32>) -> vec3<f32> {
|
||||
out.light = vec4(clamp(in.lm, vec2(0.0), vec2(1.0)), 0.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
+// AirDef fork, HD water: the water quads once more, after the G-buffer, into small targets of
|
||||
+// their own: the surface colour (the water sprite times the biome tint) with the block light, and
|
||||
+// the face normal with the sky light. The layers are the water sprites' (gfx.rs fills them in;
|
||||
+// nothing else is water: glass, ice and the rest stay as vanilla draws them).
|
||||
+override WATER_STILL: u32 = 0xffffu;
|
||||
+override WATER_FLOW: u32 = 0xffffu;
|
||||
+struct WaterOut {
|
||||
+ @location(0) tint: vec4<f32>,
|
||||
+ @location(1) normal: vec4<f32>,
|
||||
+}
|
||||
+@fragment fn fs_water(in: TerrainOut) -> WaterOut {
|
||||
+ let layer = in.layer & 0x7fffu;
|
||||
+ if layer != WATER_STILL && layer != WATER_FLOW { discard; }
|
||||
+ let color = sprite(in.uv, layer) * in.tint;
|
||||
+ var out: WaterOut;
|
||||
+ out.tint = vec4(color.rgb, clamp(in.lm.y, 0.0, 1.0));
|
||||
+ out.normal = vec4(face_normal(in.pos) * 0.5 + 0.5, clamp(in.lm.x, 0.0, 1.0));
|
||||
+ return out;
|
||||
+}
|
||||
// AirDef fork: the sun's shadow map — depth only, cut-out leaves leave holes.
|
||||
@fragment fn fs_shadow(in: TerrainOut) {
|
||||
let a = sprite(in.uv, in.layer & 0x7fffu).a;
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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
|
||||
colour only), a darker, deeper colour
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx/hd.rs | 9 ++++++---
|
||||
crates/horizon-client/src/terrain.wgsl | 6 ++++--
|
||||
2 files changed, 10 insertions(+), 5 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index f5caa2d0b..8070aab3c 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -591,10 +591,13 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
let thickness = clamp(length(position_at(uv_floor, floor_z)) - dist, 0.0, 48.0);
|
||||
let behind = textureSampleLevel(lit_tex, linear, uv_floor, 0.0).rgb;
|
||||
- let colour = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
|
||||
+ // A deep, dark water: the biome's colour darkened and a little greyed, so it reads as
|
||||
+ // depth rather than as bright paint.
|
||||
+ let biome = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
|
||||
+ let colour = mix(vec3<f32>(dot(biome, vec3<f32>(0.333))), biome, 0.8) * vec3<f32>(0.55, 0.68, 0.8);
|
||||
// The colours the water takes up fastest are the ones it has the least of.
|
||||
- let absorb = exp(-(vec3<f32>(1.0) - colour) * 0.8 * (thickness + 0.3));
|
||||
- let refracted = behind * absorb + colour * volume_light * 0.9 * (vec3<f32>(1.0) - absorb);
|
||||
+ let absorb = exp(-(vec3<f32>(1.0) - colour) * 1.1 * (thickness + 0.3));
|
||||
+ let refracted = behind * absorb + colour * volume_light * 0.6 * (vec3<f32>(1.0) - absorb);
|
||||
|
||||
// Reflection: the screen where the ray stays on it, the sky where it leaves it or misses.
|
||||
var r = reflect(-v, n);
|
||||
diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl
|
||||
index 0d5d2fc89..e892d5a0f 100644
|
||||
--- a/crates/horizon-client/src/terrain.wgsl
|
||||
+++ b/crates/horizon-client/src/terrain.wgsl
|
||||
@@ -195,9 +195,11 @@ struct WaterOut {
|
||||
@fragment fn fs_water(in: TerrainOut) -> WaterOut {
|
||||
let layer = in.layer & 0x7fffu;
|
||||
if layer != WATER_STILL && layer != WATER_FLOW { discard; }
|
||||
- let color = sprite(in.uv, layer) * in.tint;
|
||||
+ // The biome's colour at the texture's mean brightness, not the texture: the HD water draws
|
||||
+ // its own surface (waves, reflections), and vanilla's ripples on top would look painted on.
|
||||
+ let color = vec3(0.62) * in.tint.rgb;
|
||||
var out: WaterOut;
|
||||
- out.tint = vec4(color.rgb, clamp(in.lm.y, 0.0, 1.0));
|
||||
+ out.tint = vec4(color, clamp(in.lm.y, 0.0, 1.0));
|
||||
out.normal = vec4(face_normal(in.pos) * 0.5 + 0.5, clamp(in.lm.x, 0.0, 1.0));
|
||||
return out;
|
||||
}
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,945 @@
|
||||
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,
|
||||
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
|
||||
round the sun; lighter water with fine ripples
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx.rs | 4 +
|
||||
.../src/gfx/THIRD_PARTY_PHOTON.md | 34 ++
|
||||
crates/horizon-client/src/gfx/hd.rs | 259 +++++++++++++-
|
||||
crates/horizon-client/src/gfx/hd_sky.rs | 324 ++++++++++++++++++
|
||||
crates/horizon-client/src/terrain.wgsl | 2 +-
|
||||
5 files changed, 604 insertions(+), 19 deletions(-)
|
||||
create mode 100644 crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md
|
||||
create mode 100644 crates/horizon-client/src/gfx/hd_sky.rs
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||||
index f9dab8abf..d01131433 100644
|
||||
--- a/crates/horizon-client/src/gfx.rs
|
||||
+++ b/crates/horizon-client/src/gfx.rs
|
||||
@@ -72,6 +72,7 @@ mod cull;
|
||||
mod end_sky;
|
||||
mod flat;
|
||||
mod hd;
|
||||
+mod hd_sky;
|
||||
mod hdr;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
@@ -4275,6 +4276,8 @@ impl Renderer {
|
||||
camera.z.rem_euclid(256.0) as f32,
|
||||
],
|
||||
water: !frame.underwater,
|
||||
+ sunrise: frame.sky.sunrise_color,
|
||||
+ sky_fog_end: frame.fog.map_or(frame.distance.min(512.0), |f| f.sky_end),
|
||||
};
|
||||
let (shadow_dir, shadow_strength, _) = hd::light_of(&hd_scene);
|
||||
let mut hd_cascades = hd::cascades(camera, shadow_dir);
|
||||
@@ -5817,6 +5820,7 @@ impl Renderer {
|
||||
{
|
||||
// AirDef fork: lit by the mods' lights into the HD target, the mod's post pass on
|
||||
// that, then bloom and the tone curve onto the screen.
|
||||
+ hd.sky(&mut encoder);
|
||||
hd.light(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
diff --git a/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md b/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md
|
||||
new file mode 100644
|
||||
index 000000000..03d945001
|
||||
--- /dev/null
|
||||
+++ b/crates/horizon-client/src/gfx/THIRD_PARTY_PHOTON.md
|
||||
@@ -0,0 +1,34 @@
|
||||
+# Third-party code: Photon
|
||||
+
|
||||
+Parts of the HD renderer's atmosphere (`hd_sky.rs`) are ported from, or follow closely, the
|
||||
+Photon shader pack by sixthsurge, `shaders/include/sky/atmosphere.glsl` (and the idea of the water
|
||||
+waves in `shaders/include/misc/water_normal.glsl`) at commit
|
||||
+26c2906ee02cc484359b835fba6993d443d45966, https://github.com/sixthsurge/photon
|
||||
+
|
||||
+What is taken: the air's scattering and absorption coefficients (Rayleigh, Mie, ozone), the scale
|
||||
+heights, the ozone density profile, the Mie albedo and Henyey-Greenstein anisotropy, the sunlight
|
||||
+colour outside the atmosphere, and the Chapman-function approximation of the sun's transmittance.
|
||||
+The sky-view texture, its march, the sun's colour and everything else are our own.
|
||||
+
|
||||
+Photon is distributed under the MIT License:
|
||||
+
|
||||
+ The MIT License
|
||||
+
|
||||
+ Copyright © 2025 Benjamin Stott "sixthsurge"
|
||||
+
|
||||
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
+ and associated documentation files (the "Software"), to deal in the Software without
|
||||
+ restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
||||
+ distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
||||
+ Software is furnished to do so, subject to the following conditions:
|
||||
+
|
||||
+ The above copyright notice and this permission notice shall be included in all copies or
|
||||
+ substantial portions of the Software.
|
||||
+
|
||||
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||
+ BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
+
|
||||
+No other shader pack's code (BSL, Complementary and so on) may be used: their licences forbid it.
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index 8070aab3c..031e9f2de 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -5,6 +5,7 @@
|
||||
//! into a high-dynamic-range target; the mod's post pass runs on that; then
|
||||
//! bright parts bloom and a tone curve brings it to the screen — vanilla's
|
||||
//! range (up to about 0.8) left as it is, only what is brighter rolled off.
|
||||
+use super::hd_sky;
|
||||
use super::pack_timing::PassTiming;
|
||||
use glam::Mat4;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -101,6 +102,12 @@ struct HdFrame {
|
||||
water: [f32; 4],
|
||||
/// rgb: the sky's colour; w: 1 when there is a sky.
|
||||
sky_color: [f32; 4],
|
||||
+ /// xyz: towards the sun itself (not the moon); w: 1 when the atmosphere draws the sky.
|
||||
+ sky_sun: [f32; 4],
|
||||
+ /// x: vanilla's sky fog end; z: how overcast the sky is (rain: 0 clear, 1 vanilla's colours).
|
||||
+ sky_extra: [f32; 4],
|
||||
+ /// Vanilla's sunrise colour fan (rgb, alpha), taken out of the sky before the atmosphere is put in.
|
||||
+ sunrise: [f32; 4],
|
||||
}
|
||||
|
||||
/// What the world around looks like this frame (from the renderer's globals).
|
||||
@@ -131,6 +138,9 @@ pub struct HdScene {
|
||||
pub camera_mod: [f32; 2],
|
||||
/// The water effect may run (not underwater, not disabled).
|
||||
pub water: bool,
|
||||
+ /// Vanilla's sunrise fan colour and its sky fog end (to subtract vanilla's sky from the world).
|
||||
+ pub sunrise: [f32; 4],
|
||||
+ pub sky_fog_end: f32,
|
||||
}
|
||||
|
||||
/// The cascades' half sizes (blocks) and shadow map size.
|
||||
@@ -155,6 +165,9 @@ struct HdFrame {
|
||||
view_proj: mat4x4<f32>,
|
||||
water: vec4<f32>,
|
||||
sky_color: vec4<f32>,
|
||||
+ sky_sun: vec4<f32>,
|
||||
+ sky_extra: vec4<f32>,
|
||||
+ sunrise: vec4<f32>,
|
||||
}
|
||||
struct FullOut {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@@ -195,6 +208,9 @@ struct Light {
|
||||
@group(0) @binding(13) var ao_tex: texture_2d<f32>;
|
||||
// The water pass's depth: the nearest water surface (0 where there is none).
|
||||
@group(0) @binding(14) var water_depth: texture_depth_2d;
|
||||
+//SKY15
|
||||
+// The pixel being lit, for the shadow filter's noise.
|
||||
+var<private> frag_px: vec2<f32>;
|
||||
|
||||
// The occlusion at a full-resolution pixel `dist` away: the four nearest half-resolution texels
|
||||
// weighted by closeness and by how near their distance is, so it doesn't bleed across edges.
|
||||
@@ -239,6 +255,42 @@ fn shadow(p: vec3<f32>, n: vec3<f32>) -> f32 {
|
||||
return shadow_taps(p, n, 1);
|
||||
}
|
||||
|
||||
+// A point of a disc of radius 1 (Vogel's spiral), turned by `angle`.
|
||||
+fn vogel(i: i32, count: i32, angle: f32) -> vec2<f32> {
|
||||
+ let r = sqrt((f32(i) + 0.5) / f32(count));
|
||||
+ let a = f32(i) * 2.39996323 + angle;
|
||||
+ return vec2<f32>(cos(a), sin(a)) * r;
|
||||
+}
|
||||
+
|
||||
+// The near cascade's soft shadow: a blocker search sets the penumbra's width (wider the farther
|
||||
+// the shadow's caster is above the receiver), then a ring of taps turned by a per-pixel noise
|
||||
+// filters it; the temporal pass averages the noise. `s`: the point in the cascade (uv, depth),
|
||||
+// `texel`: the cascade's texel in blocks.
|
||||
+fn soft_shadow(s: vec3<f32>, texel: f32) -> f32 {
|
||||
+ let angle = ign(frag_px) * 6.2831853;
|
||||
+ var blocker = 0.0;
|
||||
+ var found = 0.0;
|
||||
+ for (var i = 0; i < 8; i++) {
|
||||
+ let o = vogel(i, 8, angle) * (6.0 / 2048.0);
|
||||
+ let q = clamp(vec2<i32>((s.xy + o) * 2048.0), vec2<i32>(0), vec2<i32>(2047));
|
||||
+ let z = textureLoad(shadow_map, q, 0, 0);
|
||||
+ if z < s.z - 0.0006 {
|
||||
+ blocker += z;
|
||||
+ found += 1.0;
|
||||
+ }
|
||||
+ }
|
||||
+ if found < 0.5 { return 1.0; }
|
||||
+ // The caster's height above the receiver, in blocks (the cascade's depth spans 799 blocks).
|
||||
+ let height = (s.z - blocker / found) * 799.0;
|
||||
+ let radius = clamp(height * 0.18 / texel, 1.6, 8.0);
|
||||
+ var lit = 0.0;
|
||||
+ for (var i = 0; i < 12; i++) {
|
||||
+ let o = vogel(i, 12, angle + 1.7) * (radius / 2048.0);
|
||||
+ lit += textureSampleCompareLevel(shadow_map, shadow_sampler, s.xy + o, 0, s.z - 0.0005 - 0.00005 * radius);
|
||||
+ }
|
||||
+ return lit / 12.0;
|
||||
+}
|
||||
+
|
||||
// `shadow` with a (2 * r + 1) square of taps: the floor under water takes one (the water dims and
|
||||
// blurs it anyway).
|
||||
fn shadow_taps(p: vec3<f32>, n: vec3<f32>, r: i32) -> f32 {
|
||||
@@ -249,13 +301,16 @@ fn shadow_taps(p: vec3<f32>, n: vec3<f32>, r: i32) -> f32 {
|
||||
let s = cascade_at(q, c);
|
||||
if any(s.xy < vec2<f32>(0.0)) || any(s.xy > vec2<f32>(1.0)) || s.z > 1.0 { return 1.0; }
|
||||
var lit = 0.0;
|
||||
+ let fade = clamp((frame.shadow.z - dist) / 16.0, 0.0, 1.0);
|
||||
+ if r == 1 && c == 0 {
|
||||
+ return mix(1.0, soft_shadow(s, texel), fade);
|
||||
+ }
|
||||
let step = 1.0 / 2048.0;
|
||||
for (var y = -r; y <= r; y++) {
|
||||
for (var x = -r; x <= r; x++) {
|
||||
lit += textureSampleCompareLevel(shadow_map, shadow_sampler, s.xy + vec2<f32>(f32(x), f32(y)) * step, c, s.z - 0.0005);
|
||||
}
|
||||
}
|
||||
- let fade = clamp((frame.shadow.z - dist) / 16.0, 0.0, 1.0);
|
||||
return mix(1.0, lit / f32((2 * r + 1) * (2 * r + 1)), fade);
|
||||
}
|
||||
|
||||
@@ -300,7 +355,31 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let halo_start = tiles[tile + 2u];
|
||||
let halo_all = tiles[tile + 3u] == 0xffffffffu;
|
||||
let halo_count = select(tiles[tile + 3u], count, halo_all);
|
||||
+ frag_px = in.position.xy;
|
||||
+ let atmosphere = frame.sky_sun.w > 0.5;
|
||||
var c = scene;
|
||||
+ // The sky: the atmosphere's, with what the world drew over vanilla's plain sky (sun, moon,
|
||||
+ // stars, clouds) kept on top.
|
||||
+ if d <= 0.0 && atmosphere {
|
||||
+ var kept = max(scene - vanilla_sky(view), vec3<f32>(0.0));
|
||||
+ var sky = atmosphere_sky(view);
|
||||
+ // The sun: vanilla's square sprite is taken out and a round disc with a soft haze round
|
||||
+ // it (warm, strongest low) is drawn in its place.
|
||||
+ let toward = dot(view, frame.sky_sun.xyz);
|
||||
+ kept *= 1.0 - smoothstep(0.965, 0.975, toward);
|
||||
+ let clear = 1.0 - frame.sky_extra.z;
|
||||
+ let shine = smoothstep(-0.04, 0.02, frame.sky_sun.y) * clear;
|
||||
+ let glow = max(toward, 0.0);
|
||||
+ let angle = acos(clamp(toward, -1.0, 1.0));
|
||||
+ sky += frame.sun_color.rgb * shine * (pow(glow, 24.0) * 0.07 + pow(glow, 400.0) * 0.4 + 6.0 * (1.0 - smoothstep(0.016, 0.022, angle)));
|
||||
+ c = sky + kept;
|
||||
+ }
|
||||
+ // Clouds (vanilla's, drawn into the world with depth but not into the G-buffer): lit by the low
|
||||
+ // sun's colour at dusk and dawn instead of vanilla's flat white.
|
||||
+ if atmosphere && !water && d > 0.0 && g <= 0.0 && p.y > 8.0 && dot(p, p) > 2304.0 {
|
||||
+ let low = 1.0 - smoothstep(0.05, 0.5, frame.sky_sun.y);
|
||||
+ c = scene * mix(vec3<f32>(1.0), mix(frame.sun_color.rgb, vec3<f32>(1.0), 0.45) * 1.1, low * 0.7 * (1.0 - frame.sky_extra.z));
|
||||
+ }
|
||||
let surface = water || (d > 0.0 && abs(d - g) <= max(d, g) * 0.002);
|
||||
if surface {
|
||||
let n = normalize(textureLoad(normal_tex, px, 0).xyz);
|
||||
@@ -323,7 +402,17 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
if frame.ambient.w > 0.0 && !water { occlusion = ao_at(px, length(p)); }
|
||||
let ao_ambient = mix(1.0, occlusion, frame.ambient.w);
|
||||
let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5);
|
||||
- var light = max(base * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct;
|
||||
+ // The sky's light is bluish from above and carries the horizon's colour from the sides;
|
||||
+ // faces turned up take more of it than the ones turned down.
|
||||
+ var sky_tint = vec3<f32>(1.0);
|
||||
+ if atmosphere {
|
||||
+ let reach = smoothstep(0.25, 0.9, lm.x);
|
||||
+ let up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
|
||||
+ let shape = mix(0.66, 1.1, smoothstep(0.1, 1.0, up));
|
||||
+ let hue = mix(vec3<f32>(1.0), sky_light_tint(n), 0.4 * (1.0 - 0.5 * frame.sun_color.a));
|
||||
+ sky_tint = mix(vec3<f32>(1.0), hue * shape, reach);
|
||||
+ }
|
||||
+ var light = max(base * sky_tint * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct;
|
||||
// The mods' lights.
|
||||
for (var k = 0u; k < surface_count; k++) {
|
||||
let l = lights[select(tiles[surface_start + k], k, surface_all)];
|
||||
@@ -340,7 +429,12 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let sph = length(p);
|
||||
let cyl = max(length(p.xz), abs(p.y));
|
||||
let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w));
|
||||
- c = mix(c, frame.fog_color.rgb, f * frame.fog_color.a);
|
||||
+ let haze = haze_colour(view);
|
||||
+ c = mix(c, haze, f * frame.fog_color.a);
|
||||
+ // Aerial perspective: far things take the horizon's colour a little before the fog does.
|
||||
+ if atmosphere && frame.fog.x > 1.0e6 {
|
||||
+ c = mix(c, haze, (1.0 - exp(-sph / 700.0)) * 0.5);
|
||||
+ }
|
||||
}
|
||||
// Light in the air: the sun's rays between the shadows, and halos round the mods' lights.
|
||||
let far = select(min(length(p), frame.shadow.z), frame.shadow.z, d <= 0.0);
|
||||
@@ -387,6 +481,7 @@ const REFLECT_RANGE: f32 = 96.0;
|
||||
@group(0) @binding(7) var shadow_sampler: sampler_comparison;
|
||||
@group(0) @binding(8) var lightmap: texture_2d<f32>;
|
||||
@group(0) @binding(9) var linear: sampler;
|
||||
+//SKY10
|
||||
|
||||
fn ign(px: vec2<f32>) -> f32 {
|
||||
let p = px + vec2<f32>(5.588238 * frame.params.w);
|
||||
@@ -417,7 +512,7 @@ fn texel(uv: vec2<f32>) -> vec2<i32> {
|
||||
// are whole cycles per 600 s, so the sum repeats every 256 blocks and 10 minutes and holds still
|
||||
// in the world as the camera moves. x, y: the vector in units of 2 pi / 256; z: the slope the
|
||||
// wave adds; w: its phase.
|
||||
-const WAVES = array<vec4<f32>, 10>(
|
||||
+const WAVES = array<vec4<f32>, 26>(
|
||||
vec4<f32>(3.0, 1.0, 0.040, 0.3),
|
||||
vec4<f32>(-2.0, 5.0, 0.045, 2.1),
|
||||
vec4<f32>(7.0, 2.0, 0.040, 4.4),
|
||||
@@ -428,17 +523,36 @@ const WAVES = array<vec4<f32>, 10>(
|
||||
vec4<f32>(9.0, 23.0, 0.030, 2.7),
|
||||
vec4<f32>(-27.0, 19.0, 0.025, 4.9),
|
||||
vec4<f32>(31.0, 30.0, 0.025, 1.8),
|
||||
+ // Fine ripples (wavelengths of 0.7 to 3 blocks) for the many small sun sparkles: slopes small,
|
||||
+ // faded out by distance and pixel size like the rest.
|
||||
+ vec4<f32>(83.0, 41.0, 0.042, 0.4),
|
||||
+ vec4<f32>(-97.0, 59.0, 0.042, 3.1),
|
||||
+ vec4<f32>(71.0, -113.0, 0.042, 5.0),
|
||||
+ vec4<f32>(131.0, 89.0, 0.040, 1.5),
|
||||
+ vec4<f32>(-157.0, -103.0, 0.040, 4.2),
|
||||
+ vec4<f32>(191.0, -67.0, 0.038, 2.4),
|
||||
+ vec4<f32>(-223.0, 149.0, 0.038, 0.8),
|
||||
+ vec4<f32>(101.0, 241.0, 0.036, 3.7),
|
||||
+ vec4<f32>(-263.0, -181.0, 0.034, 5.5),
|
||||
+ vec4<f32>(311.0, 127.0, 0.034, 2.0),
|
||||
+ // Finer still (0.35 to 0.6 blocks): the small sparkles.
|
||||
+ vec4<f32>(397.0, -263.0, 0.050, 1.1),
|
||||
+ vec4<f32>(-467.0, 331.0, 0.050, 4.6),
|
||||
+ vec4<f32>(521.0, 449.0, 0.045, 3.0),
|
||||
+ vec4<f32>(-613.0, -199.0, 0.045, 5.9),
|
||||
+ vec4<f32>(293.0, 577.0, 0.045, 0.2),
|
||||
+ vec4<f32>(-701.0, 263.0, 0.040, 2.8),
|
||||
);
|
||||
|
||||
// The gradient of the water's height at world x, z: the waves that a pixel `footprint` blocks
|
||||
// wide can still resolve (finer ones fade out, or they would shimmer).
|
||||
fn wave_gradient(xz: vec2<f32>, footprint: f32) -> vec2<f32> {
|
||||
var grad = vec2<f32>(0.0);
|
||||
- for (var i = 0; i < 10; i++) {
|
||||
+ for (var i = 0; i < 26; i++) {
|
||||
let w = WAVES[i];
|
||||
let k = w.xy * (2.0 * PI / 256.0);
|
||||
let len = length(k);
|
||||
- let fade = clamp((2.0 * PI / len) / max(footprint, 0.0001) * 0.25 - 0.5, 0.0, 1.0);
|
||||
+ let fade = clamp((2.0 * PI / len) / max(footprint, 0.0001) * 0.2 - 0.6, 0.0, 1.0);
|
||||
let cycles = round(sqrt(9.8 * len) * 0.5 * 600.0 / (2.0 * PI));
|
||||
let omega = cycles * 2.0 * PI / 600.0;
|
||||
let phase = dot(k, xz) - omega * frame.water.y + w.w;
|
||||
@@ -481,7 +595,9 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
// the zenith's, darker where the water lies under cover (`vis` is the sky light there).
|
||||
fn sky_along(r: vec3<f32>, vis: f32) -> vec3<f32> {
|
||||
var c = frame.fog_color.rgb;
|
||||
- if frame.sky_color.w > 0.5 {
|
||||
+ if frame.sky_sun.w > 0.5 {
|
||||
+ c = atmosphere_sky(r);
|
||||
+ } else if frame.sky_color.w > 0.5 {
|
||||
c = mix(frame.fog_color.rgb, frame.sky_color.rgb, smoothstep(0.0, 0.5, r.y));
|
||||
}
|
||||
return c * mix(0.12, 1.0, vis);
|
||||
@@ -531,7 +647,7 @@ fn reflection(p: vec3<f32>, r: vec3<f32>, jitter: f32) -> vec4<f32> {
|
||||
// The sun's glint on the waves (GGX, a rough lobe so it glitters instead of aliasing).
|
||||
fn glint(n: vec3<f32>, v: vec3<f32>, l: vec3<f32>) -> f32 {
|
||||
let h = normalize(l + v);
|
||||
- let a = 0.05;
|
||||
+ let a = 0.07;
|
||||
let nh = max(dot(n, h), 0.0);
|
||||
let d = a * a / (PI * pow(nh * nh * (a * a - 1.0) + 1.0, 2.0));
|
||||
let f = 0.02 + 0.98 * pow(1.0 - max(dot(v, h), 0.0), 5.0);
|
||||
@@ -565,7 +681,10 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
|
||||
// Light on the water: vanilla's lightmap at the surface, and the sun where it reaches.
|
||||
let levels = vec2<f32>(nt.a, tint.a);
|
||||
let sky_reach = smoothstep(0.35, 0.95, levels.x);
|
||||
- let ambient = textureSampleLevel(lightmap, linear, clamp(vec2<f32>(levels.y, levels.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2<f32>(0.5 / 16.0), vec2<f32>(15.5 / 16.0)), 0.0).rgb;
|
||||
+ var ambient = textureSampleLevel(lightmap, linear, clamp(vec2<f32>(levels.y, levels.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2<f32>(0.5 / 16.0), vec2<f32>(15.5 / 16.0)), 0.0).rgb;
|
||||
+ if frame.sky_sun.w > 0.5 {
|
||||
+ ambient *= mix(vec3<f32>(1.0), sky_light_tint(vec3<f32>(0.0, 1.0, 0.0)), 0.7 * sky_reach);
|
||||
+ }
|
||||
let block = textureSampleLevel(lightmap, linear, vec2<f32>(levels.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb;
|
||||
let sun = frame.sun.xyz;
|
||||
let sun_on = frame.sun.w;
|
||||
@@ -594,10 +713,10 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
|
||||
// A deep, dark water: the biome's colour darkened and a little greyed, so it reads as
|
||||
// depth rather than as bright paint.
|
||||
let biome = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
|
||||
- let colour = mix(vec3<f32>(dot(biome, vec3<f32>(0.333))), biome, 0.8) * vec3<f32>(0.55, 0.68, 0.8);
|
||||
+ let colour = mix(vec3<f32>(dot(biome, vec3<f32>(0.333))), biome, 0.85) * vec3<f32>(0.92, 1.0, 1.0);
|
||||
// The colours the water takes up fastest are the ones it has the least of.
|
||||
- let absorb = exp(-(vec3<f32>(1.0) - colour) * 1.1 * (thickness + 0.3));
|
||||
- let refracted = behind * absorb + colour * volume_light * 0.6 * (vec3<f32>(1.0) - absorb);
|
||||
+ let absorb = exp(-(vec3<f32>(1.0) - colour) * 0.8 * (thickness + 0.3));
|
||||
+ let refracted = behind * absorb + colour * volume_light * mix(0.85, 0.4, frame.sun_color.a) * (vec3<f32>(1.0) - absorb);
|
||||
|
||||
// Reflection: the screen where the ray stays on it, the sky where it leaves it or misses.
|
||||
var r = reflect(-v, n);
|
||||
@@ -617,7 +736,11 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let sph = dist;
|
||||
let cyl = max(length(p.xz), abs(p.y));
|
||||
let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w));
|
||||
- c = mix(c, frame.fog_color.rgb, f * frame.fog_color.a);
|
||||
+ let haze = haze_colour(-v);
|
||||
+ c = mix(c, haze, f * frame.fog_color.a);
|
||||
+ if frame.sky_sun.w > 0.5 && frame.fog.x > 1.0e6 {
|
||||
+ c = mix(c, haze, (1.0 - exp(-dist / 700.0)) * 0.5);
|
||||
+ }
|
||||
return vec4<f32>(max(c, vec3<f32>(0.0)), 1.0);
|
||||
}
|
||||
"#;
|
||||
@@ -1200,6 +1323,14 @@ pub struct Hd {
|
||||
/// The frame's translucent terrain exists (set by the renderer before `write`).
|
||||
pub water_geometry: bool,
|
||||
water_composited: bool,
|
||||
+ /// The sky-view texture (see `hd_sky`), the pass that marches it and its bind group.
|
||||
+ sky_lut: wgpu::TextureView,
|
||||
+ sky_pipeline: wgpu::RenderPipeline,
|
||||
+ sky_group: wgpu::BindGroup,
|
||||
+ /// The sun's height the sky-view texture was marched for (it depends on nothing else), and
|
||||
+ /// whether this frame's sun has moved enough to march it again.
|
||||
+ sky_drawn_for: f32,
|
||||
+ sky_stale: bool,
|
||||
started: std::time::Instant,
|
||||
/// TAA: the resolved frame, and the two histories (one read, one written, alternating).
|
||||
taa: wgpu::TextureView,
|
||||
@@ -1403,10 +1534,23 @@ fn ao_strength_default() -> f32 {
|
||||
.unwrap_or(AO_STRENGTH)
|
||||
}
|
||||
|
||||
+/// The shader's source: the common part, then the body with the sky-reading functions where it
|
||||
+/// has a marker (`//SKY15`: the sky-view texture is binding 15, `//SKY10`: binding 10).
|
||||
+fn assemble(body: &str) -> String {
|
||||
+ let mut body = body.to_owned();
|
||||
+ for binding in ["15", "10"] {
|
||||
+ body = body.replace(
|
||||
+ &format!("//SKY{binding}"),
|
||||
+ &hd_sky::SKY_FUNCS.replace("SKYBIND", binding),
|
||||
+ );
|
||||
+ }
|
||||
+ format!("{COMMON}{body}")
|
||||
+}
|
||||
+
|
||||
fn module(device: &wgpu::Device, label: &str, body: &str) -> wgpu::ShaderModule {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(label),
|
||||
- source: wgpu::ShaderSource::Wgsl(format!("{COMMON}{body}").into()),
|
||||
+ source: wgpu::ShaderSource::Wgsl(assemble(body).into()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1523,6 +1667,7 @@ impl Hd {
|
||||
},
|
||||
UNFILTERED,
|
||||
DEPTH,
|
||||
+ FLOAT,
|
||||
]),
|
||||
});
|
||||
let water_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -1542,8 +1687,13 @@ impl Hd {
|
||||
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
|
||||
FLOAT,
|
||||
SAMPLER,
|
||||
+ FLOAT,
|
||||
]),
|
||||
});
|
||||
+ let sky_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
+ label: Some("HD sky"),
|
||||
+ entries: &entries(&[UNIFORM]),
|
||||
+ });
|
||||
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("HD TAA"),
|
||||
entries: &entries(&[UNIFORM, UNFILTERED, DEPTH, FLOAT, SAMPLER]),
|
||||
@@ -1605,6 +1755,16 @@ impl Hd {
|
||||
let ao = module(device, "HD ambient occlusion", AO);
|
||||
let ao_blur = module(device, "HD ambient occlusion blur", AO_BLUR);
|
||||
let water = module(device, "HD water", WATER);
|
||||
+ let sky_module = module(device, "HD sky", hd_sky::SKY_PASS);
|
||||
+ let sky_pipeline = fullscreen(
|
||||
+ device,
|
||||
+ "HD sky",
|
||||
+ &sky_layout,
|
||||
+ &sky_module,
|
||||
+ "fs_sky_lut",
|
||||
+ hd_sky::SKY_LUT_FORMAT,
|
||||
+ None,
|
||||
+ );
|
||||
let water_pipeline = fullscreen(
|
||||
device,
|
||||
"HD water",
|
||||
@@ -1691,6 +1851,21 @@ impl Hd {
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
+ let sky_lut = target(
|
||||
+ device,
|
||||
+ "HD sky",
|
||||
+ hd_sky::SKY_LUT_SIZE,
|
||||
+ hd_sky::SKY_LUT_FORMAT,
|
||||
+ )
|
||||
+ .view;
|
||||
+ let sky_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
+ label: Some("HD sky"),
|
||||
+ layout: &sky_layout,
|
||||
+ entries: &[wgpu::BindGroupEntry {
|
||||
+ binding: 0,
|
||||
+ resource: frame.as_entire_binding(),
|
||||
+ }],
|
||||
+ });
|
||||
let lights = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("HD lights"),
|
||||
size: (MAX_LIGHTS * std::mem::size_of::<HdLight>()) as u64,
|
||||
@@ -1788,6 +1963,11 @@ impl Hd {
|
||||
water_active: false,
|
||||
water_geometry: false,
|
||||
water_composited: false,
|
||||
+ sky_lut,
|
||||
+ sky_pipeline,
|
||||
+ sky_group,
|
||||
+ sky_drawn_for: 2.0,
|
||||
+ sky_stale: true,
|
||||
started: std::time::Instant::now(),
|
||||
taa: target(device, "HD TAA", [1, 1], HDR).view,
|
||||
history: [
|
||||
@@ -1961,6 +2141,11 @@ impl Hd {
|
||||
if !self.taa_active || scene.camera_delta.length() > 64.0 {
|
||||
self.history_ready = false;
|
||||
}
|
||||
+ let sun_height = scene.sun.normalize_or(glam::Vec3::Y).y;
|
||||
+ self.sky_stale = (sun_height - self.sky_drawn_for).abs() > 0.001;
|
||||
+ if self.sky_stale {
|
||||
+ self.sky_drawn_for = sun_height;
|
||||
+ }
|
||||
let frame = HdFrame {
|
||||
prev_view_proj: scene.prev_view_proj.to_cols_array_2d(),
|
||||
taa: [
|
||||
@@ -1982,6 +2167,17 @@ impl Hd {
|
||||
scene.sky_color[2],
|
||||
if scene.sky { 1.0 } else { 0.0 },
|
||||
],
|
||||
+ sky_sun: {
|
||||
+ let sun = scene.sun.normalize_or(glam::Vec3::Y);
|
||||
+ [sun.x, sun.y, sun.z, if scene.sky { 1.0 } else { 0.0 }]
|
||||
+ },
|
||||
+ sky_extra: [
|
||||
+ scene.sky_fog_end,
|
||||
+ 0.0,
|
||||
+ (scene.rain / 0.7).clamp(0.0, 1.0) * 0.85,
|
||||
+ 0.0,
|
||||
+ ],
|
||||
+ sunrise: scene.sunrise,
|
||||
taa_params: [
|
||||
if self.upscaling() {
|
||||
UPSCALE_HISTORY
|
||||
@@ -2123,6 +2319,23 @@ impl Hd {
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
+ /// Marches the sky-view texture for this frame's sun (before the lighting and the water); skipped
|
||||
+ /// while the sun's height has moved less than a thousandth since the last march.
|
||||
+ pub fn sky(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
+ if !self.sky_stale {
|
||||
+ return;
|
||||
+ }
|
||||
+ Self::draw(
|
||||
+ encoder,
|
||||
+ "HD sky",
|
||||
+ &self.sky_lut,
|
||||
+ false,
|
||||
+ &self.sky_pipeline,
|
||||
+ &self.sky_group,
|
||||
+ stamp(&mut self.timing, "hd sky"),
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
/// Lights the world (its colour and depth) into `lit`.
|
||||
pub fn light(
|
||||
&mut self,
|
||||
@@ -2203,6 +2416,10 @@ impl Hd {
|
||||
binding: 14,
|
||||
resource: wgpu::BindingResource::TextureView(&self.water_depth),
|
||||
},
|
||||
+ wgpu::BindGroupEntry {
|
||||
+ binding: 15,
|
||||
+ resource: wgpu::BindingResource::TextureView(&self.sky_lut),
|
||||
+ },
|
||||
],
|
||||
});
|
||||
self.light_group = Some((key, group));
|
||||
@@ -2512,6 +2729,7 @@ impl Hd {
|
||||
wgpu::BindingResource::Sampler(&self.shadow_sampler),
|
||||
wgpu::BindingResource::TextureView(lightmap.0),
|
||||
wgpu::BindingResource::Sampler(&self.sampler),
|
||||
+ wgpu::BindingResource::TextureView(&self.sky_lut),
|
||||
],
|
||||
);
|
||||
self.water_group = Some((key, group));
|
||||
@@ -2608,9 +2826,13 @@ pub fn light_of(scene: &HdScene) -> (glam::Vec3, f32, glam::Vec3) {
|
||||
let clear = 1.0 - scene.rain * 0.85;
|
||||
if sun.y > -0.02 {
|
||||
let up = smooth(-0.02, 0.18, sun.y);
|
||||
- let warm = smooth(0.0, 0.45, sun.y);
|
||||
- let color = glam::Vec3::new(1.0, 0.62, 0.38).lerp(glam::Vec3::new(1.0, 0.95, 0.86), warm);
|
||||
- (sun, 0.6 * up * clear, color)
|
||||
+ // The atmosphere's transmittance along the sun's ray (orange when low, a clean warm white
|
||||
+ // high), kept at full brightness and tempered, with a slight warm tint on top.
|
||||
+ let t = hd_sky::sun_transmittance(sun.y);
|
||||
+ let hue = t / t.max_element().max(1e-4);
|
||||
+ let color = glam::Vec3::new(hue.x.powf(0.6), hue.y.powf(0.6), hue.z.powf(0.6))
|
||||
+ * glam::Vec3::new(1.0, 0.93, 0.78);
|
||||
+ (sun, 0.7 * up * clear, color)
|
||||
} else {
|
||||
let moon = -sun;
|
||||
let up = smooth(-0.02, 0.18, moon.y);
|
||||
@@ -2661,8 +2883,9 @@ mod tests {
|
||||
("ao", AO),
|
||||
("ao blur", AO_BLUR),
|
||||
("water", WATER),
|
||||
+ ("sky", hd_sky::SKY_PASS),
|
||||
] {
|
||||
- let source = format!("{COMMON}{body}");
|
||||
+ let source = assemble(body);
|
||||
let module = naga::front::wgsl::parse_str(&source).unwrap_or_else(|e| {
|
||||
panic!("{name}: {}", e.emit_to_string(&source));
|
||||
});
|
||||
diff --git a/crates/horizon-client/src/gfx/hd_sky.rs b/crates/horizon-client/src/gfx/hd_sky.rs
|
||||
new file mode 100644
|
||||
index 000000000..b5c231a7b
|
||||
--- /dev/null
|
||||
+++ b/crates/horizon-client/src/gfx/hd_sky.rs
|
||||
@@ -0,0 +1,324 @@
|
||||
+//! AirDef fork: the HD renderer's atmosphere. A small sky-view texture (azimuth from the sun by
|
||||
+//! elevation, plus a row of ambient samples) is marched each frame from single scattering of
|
||||
+//! Rayleigh and Mie particles and ozone absorption with a cheap multiple-scattering term; the
|
||||
+//! lighting and water shaders read it for the sky, the aerial-perspective haze, the water's
|
||||
+//! reflection fallback and the colour of the sky's light. The sunlight's colour is the same
|
||||
+//! atmosphere's transmittance at the sun's elevation (`sun_transmittance`).
|
||||
+//!
|
||||
+//! The coefficients, the density profiles (scale heights, the ozone layer), the Henyey-Greenstein
|
||||
+//! Mie parameters and the Chapman-function transmittance come from Photon by sixthsurge (MIT
|
||||
+//! licence, see THIRD_PARTY_PHOTON.md), shaders/include/sky/atmosphere.glsl at commit 26c2906.
|
||||
+
|
||||
+/// The sky-view texture: 128 azimuths by 64 elevations, then 8 rows for the ambient samples
|
||||
+/// (row 64: texel 0 the zenith, texel 1 the horizon's average).
|
||||
+pub const SKY_LUT_SIZE: [u32; 2] = [128, 72];
|
||||
+pub const SKY_LUT_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
+
|
||||
+/// The atmosphere's constants and the pass that marches the sky-view texture (after `COMMON`).
|
||||
+pub const SKY_PASS: &str = r#"
|
||||
+@group(0) @binding(0) var<uniform> frame: HdFrame;
|
||||
+
|
||||
+// Photon's atmosphere (sixthsurge, MIT): metres, Earth's size; the camera stands on the surface.
|
||||
+const PI: f32 = 3.14159265;
|
||||
+const PLANET_R: f32 = 6371000.0;
|
||||
+const ATMOSPHERE_TOP: f32 = 6481000.0;
|
||||
+const GROUND_R: f32 = 6370000.0;
|
||||
+const SCALE_HEIGHTS: vec2<f32> = vec2<f32>(8400.0, 1250.0);
|
||||
+const RAYLEIGH: vec3<f32> = vec3<f32>(8.059375432e-06, 1.671209429e-05, 4.080133294e-05);
|
||||
+const MIE: vec3<f32> = vec3<f32>(1.666442358e-06, 1.812685127e-06, 1.958927896e-06);
|
||||
+const OZONE: vec3<f32> = vec3<f32>(8.304280072e-07, 1.314911970e-06, 5.440679729e-08);
|
||||
+const MIE_ALBEDO: f32 = 0.9;
|
||||
+const MIE_G: f32 = 0.77;
|
||||
+// Sunlight in space (Photon's AM0 colour) and how bright the sun and the moon shine here.
|
||||
+const SUN_I: vec3<f32> = vec3<f32>(1.051, 0.985, 0.940) * 24.0;
|
||||
+const MOON_I: vec3<f32> = vec3<f32>(0.62, 0.78, 1.0) * 0.07;
|
||||
+// The share of the light scattered a second time, spread evenly (the haze's lift).
|
||||
+const MULTI_SCATTER: f32 = 0.6;
|
||||
+const STEPS: i32 = 32;
|
||||
+
|
||||
+// Photon's density: Rayleigh, Mie (exponential) and the ozone layer, at an altitude in metres.
|
||||
+fn density_at(altitude: f32) -> vec3<f32> {
|
||||
+ let rm = exp(-max(altitude, 0.0) / SCALE_HEIGHTS);
|
||||
+ let km = altitude * 1e-3;
|
||||
+ let o1 = 12.5 * exp(-km / 8.0);
|
||||
+ let o2 = 30.0 * exp((18.0 - km) * (km - 18.0) / 80.0);
|
||||
+ let o3 = 75.0 * exp((23.5 - km) * (km - 23.5) / 50.0);
|
||||
+ let o4 = 50.0 * exp((30.0 - km) * (km - 30.0) / 150.0);
|
||||
+ return vec3<f32>(rm, 7.428e-3 * (o1 + o2 + o3 + o4));
|
||||
+}
|
||||
+
|
||||
+// Chapman function approximation (thetenthplanet.de/archives/4519, as Photon uses it).
|
||||
+fn chapman(x: f32, cos_theta: f32) -> f32 {
|
||||
+ let c = sqrt(1.5707963 * x);
|
||||
+ if cos_theta >= 0.0 {
|
||||
+ return c / ((c - 1.0) * cos_theta + 1.0);
|
||||
+ }
|
||||
+ let sin_theta = sqrt(clamp(1.0 - cos_theta * cos_theta, 0.0, 1.0));
|
||||
+ return c / ((c - 1.0) * cos_theta - 1.0) + 2.0 * c * exp(min(x - x * sin_theta, 80.0)) * sqrt(sin_theta);
|
||||
+}
|
||||
+
|
||||
+// The light reaching a point r from the planet's centre (altitude h, whose sky direction makes
|
||||
+// cos mu with the up there) from the sun or the moon; 0 when the planet is in the way.
|
||||
+fn light_transmittance(mu: f32, r: f32, h: f32) -> vec3<f32> {
|
||||
+ if mu < 0.0 && r * r * (1.0 - mu * mu) < PLANET_R * PLANET_R { return vec3<f32>(0.0); }
|
||||
+ let dens = exp(-max(h, 0.0) / SCALE_HEIGHTS);
|
||||
+ var air = SCALE_HEIGHTS * dens;
|
||||
+ air.x *= chapman(r / SCALE_HEIGHTS.x, mu);
|
||||
+ air.y *= chapman(r / SCALE_HEIGHTS.y, mu);
|
||||
+ return clamp(exp(-(RAYLEIGH * air.x + MIE * air.y + OZONE * air.x)), vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
+}
|
||||
+
|
||||
+fn rayleigh_phase(nu: f32) -> f32 {
|
||||
+ return 3.0 / (16.0 * PI) * (1.0 + nu * nu);
|
||||
+}
|
||||
+
|
||||
+fn mie_phase(nu: f32) -> f32 {
|
||||
+ let g = MIE_G;
|
||||
+ return (1.0 - g * g) / (4.0 * PI * pow(1.0 + g * g - 2.0 * g * nu, 1.5));
|
||||
+}
|
||||
+
|
||||
+// The radiance the camera sees along `view` (y up) with the sun and the moon in the directions
|
||||
+// given: single scattering plus a flat multiple-scattering term.
|
||||
+fn march(view: vec3<f32>, sun: vec3<f32>, moon: vec3<f32>) -> vec3<f32> {
|
||||
+ let b = PLANET_R * view.y;
|
||||
+ let t_max = -b + sqrt(b * b + ATMOSPHERE_TOP * ATMOSPHERE_TOP - PLANET_R * PLANET_R);
|
||||
+ let nu_sun = dot(view, sun);
|
||||
+ let nu_moon = dot(view, moon);
|
||||
+ let phase_r_sun = rayleigh_phase(nu_sun);
|
||||
+ let phase_m_sun = mie_phase(nu_sun);
|
||||
+ let phase_r_moon = rayleigh_phase(nu_moon);
|
||||
+ let phase_m_moon = mie_phase(nu_moon);
|
||||
+ var od = vec3<f32>(0.0);
|
||||
+ var sum = vec3<f32>(0.0);
|
||||
+ var t0 = 0.0;
|
||||
+ for (var i = 0; i < STEPS; i++) {
|
||||
+ let u = (f32(i) + 1.0) / f32(STEPS);
|
||||
+ let t1 = t_max * u * u;
|
||||
+ let dt = t1 - t0;
|
||||
+ let t = 0.5 * (t0 + t1);
|
||||
+ t0 = t1;
|
||||
+ let r2 = PLANET_R * PLANET_R + 2.0 * PLANET_R * t * view.y + t * t;
|
||||
+ let r = sqrt(r2);
|
||||
+ // The altitude without the cancellation of r - planet radius.
|
||||
+ let altitude = (2.0 * PLANET_R * t * view.y + t * t) / (r + PLANET_R);
|
||||
+ let dens = density_at(altitude);
|
||||
+ let extinction = RAYLEIGH * dens.x + MIE * dens.y + OZONE * dens.z;
|
||||
+ let view_t = exp(-(od + 0.5 * extinction * dt));
|
||||
+ od += extinction * dt;
|
||||
+ let mu_sun = (PLANET_R * sun.y + t * nu_sun) / r;
|
||||
+ let mu_moon = (PLANET_R * moon.y + t * nu_moon) / r;
|
||||
+ let sun_t = light_transmittance(mu_sun, r, altitude);
|
||||
+ let moon_t = light_transmittance(mu_moon, r, altitude);
|
||||
+ let scatter_r = RAYLEIGH * dens.x;
|
||||
+ let scatter_m = MIE * MIE_ALBEDO * dens.y;
|
||||
+ let single = (scatter_r * phase_r_sun + scatter_m * phase_m_sun) * sun_t * SUN_I
|
||||
+ + (scatter_r * phase_r_moon + scatter_m * phase_m_moon) * moon_t * MOON_I;
|
||||
+ let multi = (scatter_r + scatter_m) * (1.0 / (4.0 * PI)) * MULTI_SCATTER
|
||||
+ * (sun_t * SUN_I + moon_t * MOON_I) * 0.5;
|
||||
+ sum += view_t * (single + multi) * dt;
|
||||
+ }
|
||||
+ return sum;
|
||||
+}
|
||||
+
|
||||
+@fragment
|
||||
+fn fs_sky_lut(in: FullOut) -> @location(0) vec4<f32> {
|
||||
+ let px = vec2<i32>(in.position.xy);
|
||||
+ // The texture's frame: the sun in the xy plane, the moon opposite.
|
||||
+ let sin_e = clamp(frame.sky_sun.y, -1.0, 1.0);
|
||||
+ let sun = vec3<f32>(sqrt(1.0 - sin_e * sin_e), sin_e, 0.0);
|
||||
+ let moon = -sun;
|
||||
+ // A faint night glow, so the night sky is not black.
|
||||
+ let night = vec3<f32>(0.0006, 0.0011, 0.0026);
|
||||
+ if px.y < 64 {
|
||||
+ let phi = f32(px.x) / 127.0 * PI;
|
||||
+ let v = f32(px.y) / 63.0;
|
||||
+ let l = v * v * PI * 0.5;
|
||||
+ let dir = vec3<f32>(cos(l) * cos(phi), sin(l), cos(l) * sin(phi));
|
||||
+ return vec4<f32>(march(dir, sun, moon) + night, 1.0);
|
||||
+ }
|
||||
+ if px.y == 64 && px.x == 0 {
|
||||
+ return vec4<f32>(march(vec3<f32>(0.0, 1.0, 0.0), sun, moon) + night, 1.0);
|
||||
+ }
|
||||
+ if px.y == 64 && px.x == 1 {
|
||||
+ var sum = vec3<f32>(0.0);
|
||||
+ for (var k = 0; k < 6; k++) {
|
||||
+ let phi = (f32(k) + 0.5) / 6.0 * PI;
|
||||
+ let l = 0.3;
|
||||
+ sum += march(vec3<f32>(cos(l) * cos(phi), sin(l), cos(l) * sin(phi)), sun, moon);
|
||||
+ }
|
||||
+ return vec4<f32>(sum / 6.0 + night, 1.0);
|
||||
+ }
|
||||
+ return vec4<f32>(0.0);
|
||||
+}
|
||||
+"#;
|
||||
+
|
||||
+/// Reading the sky-view texture, in the lighting and the water shaders (`SKYBIND` is replaced by
|
||||
+/// the binding of the texture; `frame` and the `linear` sampler come before it).
|
||||
+pub const SKY_FUNCS: &str = r#"
|
||||
+@group(0) @binding(SKYBIND) var sky_lut: texture_2d<f32>;
|
||||
+const SKY_PI: f32 = 3.14159265;
|
||||
+// How bright the sky is shown (the exposure of the physical radiance) and its gamma.
|
||||
+const SKY_EXPOSURE: f32 = 0.85;
|
||||
+
|
||||
+// Where the direction `dir` (world, y up) lies in the sky-view texture.
|
||||
+fn sky_uv(dir: vec3<f32>) -> vec2<f32> {
|
||||
+ let s = frame.sky_sun.xyz;
|
||||
+ let l = asin(clamp(dir.y, 0.0, 1.0));
|
||||
+ var cphi = 1.0;
|
||||
+ let dl = length(dir.xz);
|
||||
+ let sl = length(s.xz);
|
||||
+ if dl > 1e-4 && sl > 1e-4 { cphi = dot(dir.xz / dl, s.xz / sl); }
|
||||
+ let u = acos(clamp(cphi, -1.0, 1.0)) / SKY_PI;
|
||||
+ let v = sqrt(l / (SKY_PI * 0.5));
|
||||
+ return vec2<f32>((u * 127.0 + 0.5) / 128.0, (v * 63.0 + 0.5) / 72.0);
|
||||
+}
|
||||
+
|
||||
+// The physical sky's colour as it is shown: a soft shoulder, then the display's gamma.
|
||||
+fn sky_show(radiance: vec3<f32>) -> vec3<f32> {
|
||||
+ // Compressed by the brightest channel, so the hue stays as the atmosphere made it.
|
||||
+ let peak = max(max(radiance.r, radiance.g), max(radiance.b, 1e-6));
|
||||
+ let c = pow(radiance * ((1.0 - exp(-peak * SKY_EXPOSURE)) / peak), vec3<f32>(1.0 / 1.9));
|
||||
+ // A little more colour (Photon boosts the atmosphere's saturation too).
|
||||
+ // The low sun gets more, for orange and pink in place of pastel.
|
||||
+ let boost = 1.2 + 0.45 * (1.0 - smoothstep(0.0, 0.35, frame.sky_sun.y));
|
||||
+ return max(mix(vec3<f32>(dot(c, vec3<f32>(0.2126, 0.7152, 0.0722))), c, boost), vec3<f32>(0.0));
|
||||
+}
|
||||
+
|
||||
+// The vanilla sky's own gradient (the overcast and the rain keep it).
|
||||
+fn vanilla_gradient(dir: vec3<f32>) -> vec3<f32> {
|
||||
+ return mix(frame.fog_color.rgb, frame.sky_color.rgb, smoothstep(0.0, 0.5, max(dir.y, 0.0)));
|
||||
+}
|
||||
+
|
||||
+// The sky along `dir`: the atmosphere, greyed towards vanilla's colours in rain.
|
||||
+fn atmosphere_sky(dir: vec3<f32>) -> vec3<f32> {
|
||||
+ let lin = textureSampleLevel(sky_lut, linear, sky_uv(dir), 0.0).rgb;
|
||||
+ var c = sky_show(lin);
|
||||
+ let overcast = frame.sky_extra.z;
|
||||
+ if overcast > 0.0 { c = mix(c, vanilla_gradient(dir), overcast); }
|
||||
+ return c;
|
||||
+}
|
||||
+
|
||||
+// The colour distant things fade to: the sky just above the horizon in their direction (so the
|
||||
+// haze leans towards the sun), or vanilla's fog colour where the environment sets its own fog.
|
||||
+fn haze_colour(dir: vec3<f32>) -> vec3<f32> {
|
||||
+ if frame.sky_sun.w < 0.5 || frame.fog.x < 1.0e6 { return frame.fog_color.rgb; }
|
||||
+ let up = max(dir.y, 0.0) * 0.5 + 0.035;
|
||||
+ return atmosphere_sky(normalize(vec3<f32>(dir.x, 0.0, dir.z) + vec3<f32>(0.0, up, 0.0)));
|
||||
+}
|
||||
+
|
||||
+// The sky's light for a surface of normal n: the horizon's colour from the sides, the zenith's
|
||||
+// from above, as a hue (luminance 1) to tint vanilla's sky light with.
|
||||
+fn sky_light_tint(n: vec3<f32>) -> vec3<f32> {
|
||||
+ let zenith = sky_show(textureLoad(sky_lut, vec2<i32>(0, 64), 0).rgb);
|
||||
+ let horizon = sky_show(textureLoad(sky_lut, vec2<i32>(1, 64), 0).rgb);
|
||||
+ let up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
|
||||
+ let c = mix(horizon, zenith, smoothstep(0.35, 1.0, up));
|
||||
+ let luma = dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
+ return c / max(luma, 0.003);
|
||||
+}
|
||||
+
|
||||
+// vanilla's sky (terrain.wgsl: fs_sky), to tell what the world drew on the sky pixel (the sun and
|
||||
+// moon sprites, the stars, the clouds) from its plain gradient.
|
||||
+fn vanilla_disc_distance(d: vec3<f32>, y: f32) -> vec2<f32> {
|
||||
+ if d.y * y <= 0.0 { return vec2<f32>(-1.0); }
|
||||
+ let p = d * (y / d.y);
|
||||
+ let h = length(p.xz);
|
||||
+ let phi = atan2(p.z, p.x);
|
||||
+ let sector = SKY_PI / 4.0;
|
||||
+ let delta = phi - (floor(phi / sector) * sector + sector * 0.5);
|
||||
+ let edge = 512.0 * cos(sector * 0.5) / cos(delta);
|
||||
+ if h > edge { return vec2<f32>(-1.0); }
|
||||
+ let s = h / edge;
|
||||
+ let ay = abs(y);
|
||||
+ let rim = sqrt(512.0 * 512.0 + y * y);
|
||||
+ return vec2<f32>(ay + s * (rim - ay), ay + s * (512.0 - ay));
|
||||
+}
|
||||
+fn vanilla_linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
+ if d <= start { return 0.0; }
|
||||
+ if d >= end { return 1.0; }
|
||||
+ return (d - start) / (end - start);
|
||||
+}
|
||||
+fn vanilla_rotate_x90(v: vec3<f32>) -> vec3<f32> { return vec3<f32>(v.x, -v.z, v.y); }
|
||||
+fn vanilla_sunrise_weight(d: vec3<f32>) -> f32 {
|
||||
+ let alpha = frame.sunrise.a;
|
||||
+ if alpha <= 0.001 { return 0.0; }
|
||||
+ let flip = select(0.0, 180.0, frame.sky_sun.x > 0.0);
|
||||
+ let theta = radians(flip + 90.0);
|
||||
+ let c = cos(theta);
|
||||
+ let s = sin(theta);
|
||||
+ var best = 0.0;
|
||||
+ let apex = vanilla_rotate_x90(vec3<f32>(-100.0 * s, 100.0 * c, 0.0));
|
||||
+ for (var i = 0; i < 16; i++) {
|
||||
+ let a0 = f32(i) * 2.0 * SKY_PI / 16.0;
|
||||
+ let a1 = f32(i + 1) * 2.0 * SKY_PI / 16.0;
|
||||
+ let l0 = vec3<f32>(sin(a0) * 120.0, cos(a0) * 120.0, -cos(a0) * 40.0 * alpha);
|
||||
+ let l1 = vec3<f32>(sin(a1) * 120.0, cos(a1) * 120.0, -cos(a1) * 40.0 * alpha);
|
||||
+ let r0 = vanilla_rotate_x90(vec3<f32>(l0.x * c - l0.y * s, l0.x * s + l0.y * c, l0.z));
|
||||
+ let r1 = vanilla_rotate_x90(vec3<f32>(l1.x * c - l1.y * s, l1.x * s + l1.y * c, l1.z));
|
||||
+ let e1 = r0 - apex;
|
||||
+ let e2 = r1 - apex;
|
||||
+ let pv = cross(d, e2);
|
||||
+ let det = dot(e1, pv);
|
||||
+ if abs(det) < 1e-6 { continue; }
|
||||
+ let tv = -apex;
|
||||
+ let u = dot(tv, pv) / det;
|
||||
+ let qv = cross(tv, e1);
|
||||
+ let v = dot(d, qv) / det;
|
||||
+ let t = dot(e2, qv) / det;
|
||||
+ if u >= 0.0 && v >= 0.0 && u + v <= 1.0 && t > 0.0 {
|
||||
+ best = max(best, 1.0 - u - v);
|
||||
+ }
|
||||
+ }
|
||||
+ return best;
|
||||
+}
|
||||
+fn vanilla_sky(d: vec3<f32>) -> vec3<f32> {
|
||||
+ var color = frame.fog_color.rgb;
|
||||
+ let dist = vanilla_disc_distance(d, 16.0);
|
||||
+ if dist.x >= 0.0 {
|
||||
+ let end = frame.sky_extra.x;
|
||||
+ let f = max(vanilla_linear_fog(dist.x, 0.0, end), vanilla_linear_fog(dist.y, end, end));
|
||||
+ color = mix(frame.sky_color.rgb, frame.fog_color.rgb, f * frame.fog_color.a);
|
||||
+ }
|
||||
+ let w = vanilla_sunrise_weight(d);
|
||||
+ if w > 0.0 {
|
||||
+ color = mix(color, frame.sunrise.rgb, w * frame.sunrise.a);
|
||||
+ }
|
||||
+ return color;
|
||||
+}
|
||||
+"#;
|
||||
+
|
||||
+/// The sun's light at the ground: the atmosphere's transmittance along the sun's direction
|
||||
+/// (`sin_elevation`, the y of the unit vector towards it), linear, as a fraction of the light
|
||||
+/// outside the air. Same Chapman model as the sky pass.
|
||||
+pub fn sun_transmittance(sin_elevation: f32) -> glam::Vec3 {
|
||||
+ const PLANET: f32 = 6_371_000.0;
|
||||
+ const SCALE: [f32; 2] = [8400.0, 1250.0];
|
||||
+ const RAYLEIGH: glam::Vec3 = glam::Vec3::new(8.059_375e-6, 1.671_209_4e-5, 4.080_133_4e-5);
|
||||
+ const MIE: glam::Vec3 = glam::Vec3::new(1.666_442_4e-6, 1.812_685e-6, 1.958_927_9e-6);
|
||||
+ const OZONE: glam::Vec3 = glam::Vec3::new(8.304_28e-7, 1.314_912e-6, 5.440_679_7e-8);
|
||||
+ // A sun below the horizon keeps the horizon's colour (its strength is faded elsewhere).
|
||||
+ let mu = sin_elevation.clamp(0.0, 1.0);
|
||||
+ let chapman = |x: f32, c: f32| {
|
||||
+ let k = (std::f32::consts::FRAC_PI_2 * x).sqrt();
|
||||
+ k / ((k - 1.0) * c + 1.0)
|
||||
+ };
|
||||
+ let air = [
|
||||
+ SCALE[0] * chapman(PLANET / SCALE[0], mu),
|
||||
+ SCALE[1] * chapman(PLANET / SCALE[1], mu),
|
||||
+ ];
|
||||
+ let tau = RAYLEIGH * air[0] + MIE * air[1] + OZONE * air[0];
|
||||
+ glam::Vec3::new((-tau.x).exp(), (-tau.y).exp(), (-tau.z).exp())
|
||||
+}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn the_low_sun_is_redder_than_the_high_one() {
|
||||
+ let noon = sun_transmittance(1.0);
|
||||
+ let low = sun_transmittance(0.1);
|
||||
+ assert!(noon.x > 0.85 && noon.z > 0.6, "{noon:?}");
|
||||
+ assert!(low.z / low.x < noon.z / noon.x * 0.6, "{low:?} vs {noon:?}");
|
||||
+ }
|
||||
+}
|
||||
diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl
|
||||
index e892d5a0f..0a2a6cdba 100644
|
||||
--- a/crates/horizon-client/src/terrain.wgsl
|
||||
+++ b/crates/horizon-client/src/terrain.wgsl
|
||||
@@ -197,7 +197,7 @@ struct WaterOut {
|
||||
if layer != WATER_STILL && layer != WATER_FLOW { discard; }
|
||||
// The biome's colour at the texture's mean brightness, not the texture: the HD water draws
|
||||
// its own surface (waves, reflections), and vanilla's ripples on top would look painted on.
|
||||
- let color = vec3(0.62) * in.tint.rgb;
|
||||
+ let color = vec3(0.85) * in.tint.rgb;
|
||||
var out: WaterOut;
|
||||
out.tint = vec4(color, clamp(in.lm.y, 0.0, 1.0));
|
||||
out.normal = vec4(face_normal(in.pos) * 0.5 + 0.5, clamp(in.lm.x, 0.0, 1.0));
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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
|
||||
little higher
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx/hd.rs | 9 +++++----
|
||||
1 file changed, 5 insertions(+), 4 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index 031e9f2de..f078d423c 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -558,7 +558,8 @@ fn wave_gradient(xz: vec2<f32>, footprint: f32) -> vec2<f32> {
|
||||
let phase = dot(k, xz) - omega * frame.water.y + w.w;
|
||||
grad += (k / len) * (w.z * fade * cos(phase));
|
||||
}
|
||||
- return grad;
|
||||
+ // A little higher than the table's slopes.
|
||||
+ return grad * 1.25;
|
||||
}
|
||||
|
||||
fn cascade_at(p: vec3<f32>, c: i32) -> vec3<f32> {
|
||||
@@ -713,10 +714,10 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
|
||||
// A deep, dark water: the biome's colour darkened and a little greyed, so it reads as
|
||||
// depth rather than as bright paint.
|
||||
let biome = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
|
||||
- let colour = mix(vec3<f32>(dot(biome, vec3<f32>(0.333))), biome, 0.85) * vec3<f32>(0.92, 1.0, 1.0);
|
||||
+ let colour = mix(vec3<f32>(dot(biome, vec3<f32>(0.333))), biome, 0.85) * vec3<f32>(0.78, 0.86, 0.95);
|
||||
// The colours the water takes up fastest are the ones it has the least of.
|
||||
- let absorb = exp(-(vec3<f32>(1.0) - colour) * 0.8 * (thickness + 0.3));
|
||||
- let refracted = behind * absorb + colour * volume_light * mix(0.85, 0.4, frame.sun_color.a) * (vec3<f32>(1.0) - absorb);
|
||||
+ let absorb = exp(-(vec3<f32>(1.0) - colour) * 0.6 * (thickness + 0.3));
|
||||
+ let refracted = behind * absorb + colour * volume_light * mix(0.65, 0.3, frame.sun_color.a) * (vec3<f32>(1.0) - absorb);
|
||||
|
||||
// Reflection: the screen where the ray stays on it, the sky where it leaves it or misses.
|
||||
var r = reflect(-v, n);
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,992 @@
|
||||
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
|
||||
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
|
||||
air capped; mods: render.set-time-override (API level 13) so replays show
|
||||
their recorded time of day (#57)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/entity.wgsl | 2 +
|
||||
crates/horizon-client/src/environment.rs | 5 +
|
||||
crates/horizon-client/src/game.rs | 18 +-
|
||||
crates/horizon-client/src/gfx.rs | 52 ++-
|
||||
crates/horizon-client/src/gfx/hd.rs | 411 ++++++++++++++++-------
|
||||
crates/horizon-client/src/gfx/hd_sky.rs | 8 +
|
||||
crates/horizon-client/src/terrain.wgsl | 8 +
|
||||
crates/horizon-mod-sdk/wit/horizon.wit | 5 +
|
||||
crates/horizon-mods/src/api_level.rs | 6 +-
|
||||
crates/horizon-mods/src/runtime.rs | 12 +
|
||||
docs/api/mods-reference.md | 1 +
|
||||
11 files changed, 387 insertions(+), 141 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl
|
||||
index 61bc1f92a..b3fc9bc82 100644
|
||||
--- a/crates/horizon-client/src/entity.wgsl
|
||||
+++ b/crates/horizon-client/src/entity.wgsl
|
||||
@@ -20,6 +20,8 @@ struct Globals {
|
||||
crumbling_sprite: vec4<f32>,
|
||||
// Selection outline parameters (read by terrain.wgsl).
|
||||
outline_params: vec4<f32>,
|
||||
+ // AirDef fork. x: the HD water draws the water (fs_translucent leaves vanilla's out).
|
||||
+ hd_flags: vec4<f32>,
|
||||
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
|
||||
dynamic_lights: array<vec4<f32>, 16>,
|
||||
}
|
||||
diff --git a/crates/horizon-client/src/environment.rs b/crates/horizon-client/src/environment.rs
|
||||
index 20855b749..9b8acf13f 100644
|
||||
--- a/crates/horizon-client/src/environment.rs
|
||||
+++ b/crates/horizon-client/src/environment.rs
|
||||
@@ -141,6 +141,9 @@ pub struct Sky {
|
||||
pub fog_end: f32,
|
||||
/// `visual/cloud_fog_end_distance`.
|
||||
pub cloud_fog_end: f32,
|
||||
+ /// AirDef fork: the world's rain level (0..1), set by `apply_weather`. The HD sky reads this
|
||||
+ /// rather than the presence of rain geometry near the camera.
|
||||
+ pub rain: f32,
|
||||
}
|
||||
impl Environment {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
@@ -305,6 +308,7 @@ impl Environment {
|
||||
.base
|
||||
.get("minecraft:visual/fog_end_distance")
|
||||
.map_or(1024.0, |v| v[0]),
|
||||
+ rain: 0.0,
|
||||
cloud_fog_end: self
|
||||
.base
|
||||
.get("minecraft:visual/cloud_fog_end_distance")
|
||||
@@ -315,6 +319,7 @@ impl Environment {
|
||||
/// WeatherAttributes: rain then thunder layers lerped by their levels
|
||||
/// (rain counts only the part not covered by thunder).
|
||||
pub fn apply_weather(sky: &mut Sky, rain: f32, thunder: f32) {
|
||||
+ sky.rain = rain.clamp(0.0, 1.0);
|
||||
let grey = |c: [f32; 3]| c[0] * 0.3 + c[1] * 0.59 + c[2] * 0.11;
|
||||
let lerp3 = |t: f32, a: [f32; 3], b: [f32; 3]| -> [f32; 3] {
|
||||
std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t)
|
||||
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
|
||||
index 366be39c4..fad8de187 100644
|
||||
--- a/crates/horizon-client/src/game.rs
|
||||
+++ b/crates/horizon-client/src/game.rs
|
||||
@@ -8370,6 +8370,14 @@ impl Game<'_> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
+ /// The time of day the player sees: a mod's override (a replay's recorded time), else the world's.
|
||||
+ fn shown_day_time(&self) -> i64 {
|
||||
+ self.mods
|
||||
+ .mods
|
||||
+ .time_override()
|
||||
+ .map_or(self.world.day_time, |t| t as i64)
|
||||
+ }
|
||||
+
|
||||
fn sky_darken(&self) -> i32 {
|
||||
if self.dim == Dim::Nether {
|
||||
return 11;
|
||||
@@ -8378,12 +8386,12 @@ impl Game<'_> {
|
||||
let sky = self
|
||||
.res
|
||||
.end_environment()
|
||||
- .sample(self.world.day_time, None, None);
|
||||
+ .sample(self.shown_day_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.world.day_time, rain.to_bits(), thunder.to_bits());
|
||||
+ let key = (self.shown_day_time(), rain.to_bits(), thunder.to_bits());
|
||||
if let Some((k, darken)) = self.sky_darken_memo.get()
|
||||
&& k == key
|
||||
{
|
||||
@@ -8392,7 +8400,7 @@ impl Game<'_> {
|
||||
let mut sky = self
|
||||
.res
|
||||
.environment()
|
||||
- .sample(self.world.day_time, None, None);
|
||||
+ .sample(self.shown_day_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;
|
||||
@@ -8898,7 +8906,7 @@ impl Game<'_> {
|
||||
"first_quarter",
|
||||
"waxing_gibbous",
|
||||
];
|
||||
- let phase = phases[(self.world.day_time / 24000).rem_euclid(8) as usize];
|
||||
+ let phase = phases[(self.shown_day_time() / 24000).rem_euclid(8) as usize];
|
||||
body(
|
||||
&format!("environment/celestial/moon/{phase}"),
|
||||
sky.moon_angle,
|
||||
@@ -11608,7 +11616,7 @@ impl Game<'_> {
|
||||
);
|
||||
// visual/water_fog_color: no dimension sets it, its default is #050533.
|
||||
let water_fog = blend(|b| Some(b.water_fog), [0.0; 3]);
|
||||
- let mut sky = environment.sample(self.world.day_time, Some(biome_sky), Some(biome_fog));
|
||||
+ let mut sky = environment.sample(self.shown_day_time(), Some(biome_sky), Some(biome_fog));
|
||||
let (rain, thunder) = if self.dim == Dim::Overworld {
|
||||
self.world.weather.levels(alpha as f32)
|
||||
} else {
|
||||
diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs
|
||||
index 072090e33..a8031c1f6 100644
|
||||
--- a/crates/horizon-client/src/gfx.rs
|
||||
+++ b/crates/horizon-client/src/gfx.rs
|
||||
@@ -194,6 +194,9 @@ struct Globals {
|
||||
crumbling_sprite: [f32; 4],
|
||||
/// Framebuffer size, selection stroke width, perspective layering scale.
|
||||
outline_params: [f32; 4],
|
||||
+ /// AirDef fork. x: 1 while the HD water draws the water (fs_translucent then leaves vanilla's
|
||||
+ /// water out of the colour and keeps its depth).
|
||||
+ hd_flags: [f32; 4],
|
||||
/// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`).
|
||||
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
|
||||
}
|
||||
@@ -578,7 +581,7 @@ pub struct Renderer {
|
||||
hd_far: Option<(Mat4, glam::DVec3, Vec3, u32)>,
|
||||
/// HD's TAA: frames jittered so far, and the last one's unjittered view-projection and camera.
|
||||
taa_frame: u32,
|
||||
- taa_prev: Option<(Mat4, glam::DVec3)>,
|
||||
+ taa_prev: Option<(Mat4, glam::DVec3, Mat4)>,
|
||||
hd: Option<hd::Hd>,
|
||||
mod_params: (Vec<f32>, f32),
|
||||
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
|
||||
@@ -1207,17 +1210,15 @@ impl Renderer {
|
||||
if sprite_arrays > 1 {
|
||||
log::info!("Horizon: {layers} block textures in {sprite_arrays} arrays");
|
||||
}
|
||||
+ // AirDef fork, HD water: the sprite layers terrain.wgsl's fs_water takes for water (and
|
||||
+ // fs_translucent clears while the HD water draws it instead).
|
||||
let sprite_constants = [
|
||||
("SPRITE_ARRAYS", f64::from(sprite_arrays)),
|
||||
("SPRITE_SPLIT", f64::from(split)),
|
||||
- ];
|
||||
- // AirDef fork, HD water: the sprite layers terrain.wgsl's fs_water takes for water.
|
||||
- let water_constants = [
|
||||
- ("SPRITE_ARRAYS", f64::from(sprite_arrays)),
|
||||
- ("SPRITE_SPLIT", f64::from(split)),
|
||||
("WATER_STILL", f64::from(water_layers[0])),
|
||||
("WATER_FLOW", f64::from(water_layers[1])),
|
||||
];
|
||||
+ let water_constants = sprite_constants;
|
||||
let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon terrain bindings"),
|
||||
entries: &[
|
||||
@@ -2823,7 +2824,7 @@ impl Renderer {
|
||||
mip_levels,
|
||||
mipmaps: mip_levels - 1,
|
||||
terrain_layout: bindings,
|
||||
- terrain_sprites: sprite_constants.map(|(_, v)| v),
|
||||
+ terrain_sprites: [f64::from(sprite_arrays), f64::from(split)],
|
||||
sprite_sampler,
|
||||
lightmap_view,
|
||||
lightmap_sampler,
|
||||
@@ -4049,7 +4050,7 @@ impl Renderer {
|
||||
};
|
||||
let taa_prev = std::mem::replace(
|
||||
&mut self.taa_prev,
|
||||
- taa.then_some((unjittered, frame.camera)),
|
||||
+ taa.then_some((unjittered, frame.camera, view_proj)),
|
||||
);
|
||||
if frame.title {
|
||||
self.panorama.prepare(
|
||||
@@ -4135,6 +4136,7 @@ impl Renderer {
|
||||
frame.glint_ticks,
|
||||
],
|
||||
outline_params: outline_params(self.config.width, self.config.height),
|
||||
+ hd_flags: [0.0; 4],
|
||||
dynamic_lights: {
|
||||
let mut lights = [[0.0; 4]; crate::dynamic_light::MAX];
|
||||
for (to, from) in lights.iter_mut().zip(frame.dynamic_lights) {
|
||||
@@ -4260,11 +4262,14 @@ impl Renderer {
|
||||
taa: taa && taa_prev.is_some(),
|
||||
jitter_px,
|
||||
prev_view_proj: taa_prev.map_or(unjittered, |p| p.0),
|
||||
+ prev_view_proj_jittered: taa_prev.map_or(view_proj, |p| p.2),
|
||||
camera_delta: taa_prev.map_or(Vec3::ZERO, |p| (frame.camera - p.1).as_vec3()),
|
||||
// The world's sun: vanilla turns the sky -90° about Y, so it crosses from east to west.
|
||||
sun: Vec3::new(-sun_angle.sin(), sun_angle.cos(), 0.0),
|
||||
dark: 1.0 - frame.sky.sky_light_factor,
|
||||
- rain: if frame.weather.is_empty() { 0.0 } else { 0.7 },
|
||||
+ // The world's rain level (fades in and out with the weather), not the rain geometry
|
||||
+ // near the camera: hiding the particles or standing under a roof leaves the sky alone.
|
||||
+ rain: frame.sky.rain.clamp(0.0, 1.0) * 0.7,
|
||||
fog_color,
|
||||
fog,
|
||||
sky: frame.skybox && !frame.end_sky && !frame.nether_lighting && !frame.underwater,
|
||||
@@ -4827,7 +4832,20 @@ impl Renderer {
|
||||
+ shadow_quads
|
||||
+ translucent_particle_quads
|
||||
+ translucent_item_quads;
|
||||
- let cloud_quads = if let Some(input) = &frame.clouds {
|
||||
+ // AirDef fork: vanilla's clouds (HD's volumetric ones off) take the low sun's colour in their
|
||||
+ // own colour, so a mountain or glass behind them does not change it.
|
||||
+ let cloud_input = frame.clouds.as_ref().map(|c| {
|
||||
+ if !hd_wanted || !hd_scene.sky || hd::clouds_enabled() {
|
||||
+ return crate::clouds::Input { ..*c };
|
||||
+ }
|
||||
+ let [r, g, b, a] = c.color;
|
||||
+ let [tr, tg, tb] = hd::vanilla_cloud_tint(&hd_scene);
|
||||
+ crate::clouds::Input {
|
||||
+ color: [r * tr, g * tg, b * tb, a],
|
||||
+ ..*c
|
||||
+ }
|
||||
+ });
|
||||
+ let cloud_quads = if let Some(input) = &cloud_input {
|
||||
let (rebuilt, uniform) =
|
||||
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
|
||||
let vertices = self.cloud_mesh.vertices.len() as u64;
|
||||
@@ -5154,6 +5172,15 @@ impl Renderer {
|
||||
if let Some(settings) = frame.hd {
|
||||
hd.write(&self.queue, &hd_scene, frame.hd_lights, settings);
|
||||
}
|
||||
+ // vanilla's translucent water leaves the colour while the HD water draws it.
|
||||
+ let hd_flags_at = std::mem::offset_of!(Globals, hd_flags) as u64;
|
||||
+ let hd_flags: [f32; 4] = [if hd.water_active() { 1.0 } else { 0.0 }, 0.0, 0.0, 0.0];
|
||||
+ self.uploads.buffer(
|
||||
+ &mut encoder,
|
||||
+ &self.globals,
|
||||
+ hd_flags_at,
|
||||
+ bytemuck::bytes_of(&hd_flags),
|
||||
+ );
|
||||
hd.set_timing(device, &self.queue, self.pass_timing);
|
||||
hd.timing_begin(device, &self.queue);
|
||||
// The sun's (or moon's) shadow cascades: the world's globals swapped for the light's.
|
||||
@@ -5184,6 +5211,11 @@ impl Renderer {
|
||||
}
|
||||
self.queue
|
||||
.write_buffer(&hd.main_globals, 0, bytemuck::bytes_of(&globals));
|
||||
+ self.queue.write_buffer(
|
||||
+ &hd.main_globals,
|
||||
+ hd_flags_at,
|
||||
+ bytemuck::bytes_of(&hd_flags),
|
||||
+ );
|
||||
// Facing the light only: the nearest surface to it is always one that does.
|
||||
let toward = (hd_shadow_fast() && self.sides_on).then(|| shadow_dir * 1.0e5);
|
||||
for (c, cascade) in hd_cascades.iter().enumerate() {
|
||||
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
|
||||
index 3b9daabf4..e84a618a9 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd.rs
|
||||
@@ -133,6 +133,10 @@ pub struct HdScene {
|
||||
pub view_proj: Mat4,
|
||||
/// Last frame's view-projection without jitter, and the camera's move since (TAA).
|
||||
pub prev_view_proj: Mat4,
|
||||
+ /// The same frame's matrix as it was drawn, jitter included. History kept on the jittered
|
||||
+ /// pixel grid (`fs_taa`, scale 1) is looked up with this; `fs_taau` resolves the history on the
|
||||
+ /// unjittered output grid and uses `prev_view_proj`.
|
||||
+ pub prev_view_proj_jittered: Mat4,
|
||||
pub camera_delta: glam::Vec3,
|
||||
/// This frame is anti-aliased over time (the projection is jittered).
|
||||
pub taa: bool,
|
||||
@@ -403,31 +407,104 @@ fn fog_at(px: vec2<i32>, dist: f32) -> vec4<f32> {
|
||||
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).
|
||||
+ @location(0) lit: vec4<f32>,
|
||||
+ // Water: the air between the camera and the surface (scattered light, transmittance);
|
||||
+ // in front of the water: the floor lit (rgb); else nothing.
|
||||
+ @location(1) air: vec4<f32>,
|
||||
+}
|
||||
+
|
||||
+// An opaque surface lit: the ambient, the sun and the mods' lights over its albedo.
|
||||
+fn shade_surface(px: vec2<i32>, p: vec3<f32>, water: bool) -> vec3<f32> {
|
||||
+ let sun = frame.sun.xyz;
|
||||
+ let sun_on = frame.sun.w;
|
||||
+ let atmosphere = frame.sky_sun.w > 0.5;
|
||||
+ let count = u32(frame.params.x);
|
||||
+ let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u;
|
||||
+ let surface_start = tiles[tile];
|
||||
+ let surface_all = tiles[tile + 1u] == 0xffffffffu;
|
||||
+ let surface_count = select(tiles[tile + 1u], count, surface_all);
|
||||
+ let n = normalize(textureLoad(normal_tex, px, 0).xyz);
|
||||
+ let albedo = textureLoad(albedo_tex, px, 0);
|
||||
+ let lm = textureLoad(light_tex, px, 0).xy;
|
||||
+ // Vanilla's light (sky and blocks through the lightmap), shaded where the sun can't reach.
|
||||
+ let base = textureSampleLevel(lightmap, linear, clamp(vec2<f32>(lm.y, lm.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2<f32>(0.5 / 16.0), vec2<f32>(15.5 / 16.0)), 0.0).rgb;
|
||||
+ let block = textureSampleLevel(lightmap, linear, vec2<f32>(lm.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb;
|
||||
+ let sky_reach = smoothstep(0.35, 0.95, lm.x);
|
||||
+ var s = 1.0;
|
||||
+ let ndl = dot(n, sun);
|
||||
+ if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow_taps(p, n, select(1, 0, water)) * cloud_shadow(p); } else { s = 0.0; }
|
||||
+ // Shadowed sky light (ambient) keeps vanilla's look, blocks' light stays as is; the sun adds
|
||||
+ // direct light by the face's angle.
|
||||
+ let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
|
||||
+ // Contact shadows from the ambient occlusion: on the ambient and blocks' light fully, on
|
||||
+ // the mods' lights by half, not on the sun or on emission.
|
||||
+ var occlusion = 1.0;
|
||||
+ // (The water dims and blurs the floor under it: no occlusion there.)
|
||||
+ if frame.ambient.w > 0.0 && !water { occlusion = ao_at(px, length(p)); }
|
||||
+ let ao_ambient = mix(1.0, occlusion, frame.ambient.w);
|
||||
+ let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5);
|
||||
+ // The sky's light is bluish from above and carries the horizon's colour from the sides;
|
||||
+ // faces turned up take more of it than the ones turned down.
|
||||
+ var sky_tint = vec3<f32>(1.0);
|
||||
+ if atmosphere {
|
||||
+ let reach = smoothstep(0.25, 0.9, lm.x);
|
||||
+ let up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
|
||||
+ let shape = mix(0.66, 1.1, smoothstep(0.1, 1.0, up));
|
||||
+ let hue = mix(vec3<f32>(1.0), sky_light_tint(n), 0.4 * (1.0 - 0.5 * frame.sun_color.a));
|
||||
+ sky_tint = mix(vec3<f32>(1.0), hue * shape, reach);
|
||||
+ }
|
||||
+ var light = max(base * sky_tint * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct;
|
||||
+ // The mods' lights.
|
||||
+ for (var k = 0u; k < surface_count; k++) {
|
||||
+ let l = lights[select(tiles[surface_start + k], k, surface_all)];
|
||||
+ let to = l.pos.xyz - p;
|
||||
+ let dist = length(to);
|
||||
+ if dist >= l.pos.w { continue; }
|
||||
+ let window = pow(clamp(1.0 - pow(dist / l.pos.w, 4.0), 0.0, 1.0), 2.0);
|
||||
+ let falloff = l.color.w / (dist * dist + 1.0) * window;
|
||||
+ let k = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0);
|
||||
+ light += l.color.rgb * falloff * k * ao_lights;
|
||||
+ }
|
||||
+ return albedo.rgb * light + albedo.rgb * albedo.a * 1.5;
|
||||
+}
|
||||
+
|
||||
@fragment
|
||||
-fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
+fn fs_light(in: FullOut) -> LightOut {
|
||||
let px = vec2<i32>(in.position.xy);
|
||||
let scene = textureLoad(scene_color, px, 0).rgb;
|
||||
let d = textureLoad(scene_depth, px, 0);
|
||||
let g = textureLoad(gbuffer_depth, px, 0);
|
||||
- // Water in front of the opaque world with nothing translucent over it: here the floor is lit
|
||||
- // and the water composite lays the surface over it (it knows the pixel by the alpha 0.5).
|
||||
- var water = false;
|
||||
- if frame.water.x > 0.5 && g > 0.0 {
|
||||
- let wd = textureLoad(water_depth, px, 0);
|
||||
- water = wd > 0.0 && wd >= g && d - wd <= d * 0.002;
|
||||
- }
|
||||
- let dd = select(d, g, water);
|
||||
+ // Water over the opaque world (or over nothing): the water composite lays the surface over
|
||||
+ // the floor, which is lit here with no air. Something translucent in front of the water
|
||||
+ // (`front`: nearer than its surface) keeps the scene as drawn; the floor goes beside it.
|
||||
+ var wd = 0.0;
|
||||
+ var water_here = false;
|
||||
+ if frame.water.x > 0.5 {
|
||||
+ wd = textureLoad(water_depth, px, 0);
|
||||
+ water_here = wd > 0.0 && (g <= 0.0 || wd >= g);
|
||||
+ }
|
||||
+ let front = water_here && g > 0.0 && d - wd > d * 0.002;
|
||||
+ let water = water_here && !front;
|
||||
+ let has_floor = water && g > 0.0;
|
||||
+ let dd = select(d, g, has_floor);
|
||||
let ndc = vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(dd, 0.000001), 1.0);
|
||||
let h = frame.inverse_view_proj * ndc;
|
||||
let p = h.xyz / h.w;
|
||||
let view = normalize(p);
|
||||
+ // Where the air ends: the water's surface over water, else the pixel itself.
|
||||
+ var ps = p;
|
||||
+ if water {
|
||||
+ let hf = frame.inverse_view_proj * vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(wd, 0.000001), 1.0);
|
||||
+ ps = hf.xyz / hf.w;
|
||||
+ }
|
||||
+ let view_s = normalize(ps);
|
||||
let sun = frame.sun.xyz;
|
||||
let sun_on = frame.sun.w;
|
||||
let count = u32(frame.params.x);
|
||||
let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u;
|
||||
- let surface_start = tiles[tile];
|
||||
- let surface_all = tiles[tile + 1u] == 0xffffffffu;
|
||||
- let surface_count = select(tiles[tile + 1u], count, surface_all);
|
||||
let halo_start = tiles[tile + 2u];
|
||||
let halo_all = tiles[tile + 3u] == 0xffffffffu;
|
||||
let halo_count = select(tiles[tile + 3u], count, halo_all);
|
||||
@@ -455,113 +532,81 @@ fn fs_light(in: FullOut) -> @location(0) vec4<f32> {
|
||||
c = c * cl.a + cl.rgb;
|
||||
}
|
||||
}
|
||||
- // Clouds (vanilla's, drawn into the world with depth but not into the G-buffer): lit by the low
|
||||
- // sun's colour at dusk and dawn instead of vanilla's flat white.
|
||||
- if atmosphere && frame.clouds.z < 0.5 && !water && d > 0.0 && g <= 0.0 && p.y > 8.0 && dot(p, p) > 2304.0 {
|
||||
- let low = 1.0 - smoothstep(0.05, 0.5, frame.sky_sun.y);
|
||||
- c = scene * mix(vec3<f32>(1.0), mix(frame.sun_color.rgb, vec3<f32>(1.0), 0.45) * 1.1, low * 0.7 * (1.0 - frame.sky_extra.z));
|
||||
- }
|
||||
- let surface = water || (d > 0.0 && abs(d - g) <= max(d, g) * 0.002);
|
||||
+ // (Vanilla's clouds, when the volumetric ones are off, take the low sun's colour in their own
|
||||
+ // pass: `vanilla_cloud_tint`, applied to their colour on the CPU.)
|
||||
+ let surface = has_floor || (!water_here && d > 0.0 && abs(d - g) <= max(d, g) * 0.002);
|
||||
if surface {
|
||||
- let n = normalize(textureLoad(normal_tex, px, 0).xyz);
|
||||
- let albedo = textureLoad(albedo_tex, px, 0);
|
||||
- let lm = textureLoad(light_tex, px, 0).xy;
|
||||
- // Vanilla's light (sky and blocks through the lightmap), shaded where the sun can't reach.
|
||||
- let base = textureSampleLevel(lightmap, linear, clamp(vec2<f32>(lm.y, lm.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2<f32>(0.5 / 16.0), vec2<f32>(15.5 / 16.0)), 0.0).rgb;
|
||||
- let block = textureSampleLevel(lightmap, linear, vec2<f32>(lm.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb;
|
||||
- let sky_reach = smoothstep(0.35, 0.95, lm.x);
|
||||
- var s = 1.0;
|
||||
- let ndl = dot(n, sun);
|
||||
- if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow_taps(p, n, select(1, 0, water)) * cloud_shadow(p); } else { s = 0.0; }
|
||||
- // Shadowed sky light (ambient) keeps vanilla's look, blocks' light stays as is; the sun adds
|
||||
- // direct light by the face's angle.
|
||||
- let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach;
|
||||
- // Contact shadows from the ambient occlusion: on the ambient and blocks' light fully, on
|
||||
- // the mods' lights by half, not on the sun or on emission.
|
||||
- var occlusion = 1.0;
|
||||
- // (The water dims and blurs the floor under it: no occlusion there.)
|
||||
- if frame.ambient.w > 0.0 && !water { occlusion = ao_at(px, length(p)); }
|
||||
- let ao_ambient = mix(1.0, occlusion, frame.ambient.w);
|
||||
- let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5);
|
||||
- // The sky's light is bluish from above and carries the horizon's colour from the sides;
|
||||
- // faces turned up take more of it than the ones turned down.
|
||||
- var sky_tint = vec3<f32>(1.0);
|
||||
- if atmosphere {
|
||||
- let reach = smoothstep(0.25, 0.9, lm.x);
|
||||
- let up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
|
||||
- let shape = mix(0.66, 1.1, smoothstep(0.1, 1.0, up));
|
||||
- let hue = mix(vec3<f32>(1.0), sky_light_tint(n), 0.4 * (1.0 - 0.5 * frame.sun_color.a));
|
||||
- sky_tint = mix(vec3<f32>(1.0), hue * shape, reach);
|
||||
- }
|
||||
- var light = max(base * sky_tint * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct;
|
||||
- // The mods' lights.
|
||||
- for (var k = 0u; k < surface_count; k++) {
|
||||
- let l = lights[select(tiles[surface_start + k], k, surface_all)];
|
||||
- let to = l.pos.xyz - p;
|
||||
- let dist = length(to);
|
||||
- if dist >= l.pos.w { continue; }
|
||||
- let window = pow(clamp(1.0 - pow(dist / l.pos.w, 4.0), 0.0, 1.0), 2.0);
|
||||
- let falloff = l.color.w / (dist * dist + 1.0) * window;
|
||||
- let k = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0);
|
||||
- light += l.color.rgb * falloff * k * ao_lights;
|
||||
- }
|
||||
- c = albedo.rgb * light + albedo.rgb * albedo.a * 1.5;
|
||||
- // Vanilla's fog over it again.
|
||||
- let sph = length(p);
|
||||
- let cyl = max(length(p.xz), abs(p.y));
|
||||
- let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w));
|
||||
- let haze = haze_colour(view);
|
||||
- c = mix(c, haze, f * frame.fog_color.a);
|
||||
- // Aerial perspective: far things take the horizon's colour a little before the fog does.
|
||||
- if atmosphere && frame.fog.x > 1.0e6 {
|
||||
- c = mix(c, haze, (1.0 - exp(-sph / 700.0)) * 0.5);
|
||||
+ c = shade_surface(px, p, water);
|
||||
+ // Vanilla's fog over it again (the water composite does it at the surface).
|
||||
+ if !water {
|
||||
+ let sph = length(p);
|
||||
+ let cyl = max(length(p.xz), abs(p.y));
|
||||
+ let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w));
|
||||
+ let haze = haze_colour(view);
|
||||
+ c = mix(c, haze, f * frame.fog_color.a);
|
||||
+ // Aerial perspective: far things take the horizon's colour a little before the fog does.
|
||||
+ if atmosphere && frame.fog.x > 1.0e6 {
|
||||
+ c = mix(c, haze, (1.0 - exp(-sph / 700.0)) * 0.5);
|
||||
+ }
|
||||
}
|
||||
}
|
||||
- // The volumetric fog: the light the air scatters towards the camera (shafts through the trees,
|
||||
- // mist, haze), and how much of the pixel it hides.
|
||||
- let far = select(min(length(p), frame.shadow.z), frame.shadow.z, d <= 0.0);
|
||||
+ // What lies behind a translucent thing in front of the water: the floor, lit.
|
||||
+ var floor_lit = vec3<f32>(0.0);
|
||||
+ if front {
|
||||
+ let hg = frame.inverse_view_proj * vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(g, 0.000001), 1.0);
|
||||
+ floor_lit = shade_surface(px, hg.xyz / hg.w, true);
|
||||
+ }
|
||||
+ // The air between the camera and the pixel (the water's surface over water): the volumetric
|
||||
+ // fog (what it hides, what it scatters), the sun's rays between the shadows, and the halos
|
||||
+ // round the mods' lights. Over water they go to the composite, which lays them over the
|
||||
+ // finished water; elsewhere over the pixel here.
|
||||
+ var air_t = 1.0;
|
||||
+ var air_s = vec3<f32>(0.0);
|
||||
+ let far = select(min(length(ps), frame.shadow.z), frame.shadow.z, d <= 0.0);
|
||||
let density = frame.ambient.z;
|
||||
let volumetric = frame.vfog.x > 0.5 && atmosphere;
|
||||
if volumetric {
|
||||
var fog_len = 192.0;
|
||||
- if d > 0.0 {
|
||||
- var pf = p;
|
||||
- if water {
|
||||
- let hf = frame.inverse_view_proj * vec4<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(d, 0.000001), 1.0);
|
||||
- pf = hf.xyz / hf.w;
|
||||
- }
|
||||
- fog_len = min(length(pf), 192.0);
|
||||
- }
|
||||
+ if d > 0.0 { fog_len = min(length(ps), 192.0); }
|
||||
let fg = fog_at(px, fog_len);
|
||||
- c = c * fg.a + fg.rgb;
|
||||
+ air_t = fg.a;
|
||||
+ air_s = fg.rgb;
|
||||
}
|
||||
- // Light in the air (without the volumetric fog): the sun's rays between the shadows; always the
|
||||
- // halos round the mods' lights.
|
||||
if !volumetric && sun_on > 0.0 && density > 0.0 {
|
||||
let steps = 14;
|
||||
let jitter = ign(in.position.xy);
|
||||
var acc = 0.0;
|
||||
for (var i = 0; i < steps; i++) {
|
||||
let t = (f32(i) + jitter) / f32(steps) * far;
|
||||
- acc += lit_at(view * t);
|
||||
+ acc += lit_at(view_s * t);
|
||||
}
|
||||
// Henyey-Greenstein, forward scattering: brightest looking towards the sun.
|
||||
- let cos_t = dot(view, sun);
|
||||
+ let cos_t = dot(view_s, sun);
|
||||
let gk = 0.6;
|
||||
let phase = (1.0 - gk * gk) / pow(1.0 + gk * gk - 2.0 * gk * cos_t, 1.5) * 0.08;
|
||||
- c += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density));
|
||||
+ air_s += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density));
|
||||
}
|
||||
for (var k = 0u; k < halo_count; k++) {
|
||||
let l = lights[select(tiles[halo_start + k], k, halo_all)];
|
||||
// Nearest point of the view ray to the light, and the glow along the ray in front of it.
|
||||
- let t0 = dot(l.pos.xyz, view);
|
||||
- let hh = length(l.pos.xyz - view * t0) + 0.5;
|
||||
+ let t0 = dot(l.pos.xyz, view_s);
|
||||
+ let hh = length(l.pos.xyz - view_s * t0) + 0.5;
|
||||
if hh > l.pos.w * 0.5 { continue; }
|
||||
let a0 = atan((0.0 - t0) / hh);
|
||||
let a1 = atan((far - t0) / hh);
|
||||
- c += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + density * 300.0);
|
||||
+ // Thicker air glows more round a light, but only so far: the mist at dusk and many fires
|
||||
+ // at once would otherwise wash the picture out.
|
||||
+ air_s += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + min(density * 300.0, 0.9));
|
||||
+ }
|
||||
+ var out: LightOut;
|
||||
+ if water {
|
||||
+ out.lit = vec4<f32>(c, 0.5);
|
||||
+ out.air = vec4<f32>(air_s, air_t);
|
||||
+ } else {
|
||||
+ out.lit = vec4<f32>(c * air_t + air_s, select(1.0, 0.6, front));
|
||||
+ out.air = vec4<f32>(floor_lit, 0.0);
|
||||
}
|
||||
- return vec4<f32>(c, select(1.0, 0.5, water));
|
||||
+ return out;
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -580,6 +625,9 @@ const REFLECT_RANGE: f32 = 96.0;
|
||||
@group(0) @binding(8) var lightmap: texture_2d<f32>;
|
||||
@group(0) @binding(9) var linear: sampler;
|
||||
//SKY10
|
||||
+// What the lighting left beside the lit world: over water the air in front of the surface (scattered
|
||||
+// light, transmittance), in front of the water the lit floor.
|
||||
+@group(0) @binding(11) var air_tex: texture_2d<f32>;
|
||||
|
||||
fn ign(px: vec2<f32>) -> f32 {
|
||||
let p = px + vec2<f32>(5.588238 * frame.params.w);
|
||||
@@ -761,6 +809,9 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let px = vec2<i32>(in.position.xy);
|
||||
let base = textureLoad(lit_tex, px, 0);
|
||||
if base.a > 0.75 { return vec4<f32>(base.rgb, 1.0); }
|
||||
+ // Alpha 0.6: something translucent stands in front of the water here (base is the scene).
|
||||
+ let front = base.a > 0.55;
|
||||
+ let aux = textureLoad(air_tex, px, 0);
|
||||
let wd = textureLoad(water_depth, px, 0);
|
||||
let g = textureLoad(gbuffer_depth, px, 0);
|
||||
let tint = textureLoad(water_tint, px, 0);
|
||||
@@ -792,23 +843,35 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
|
||||
let volume_light = max(ambient * frame.ambient.x, block * frame.ambient.y) + frame.sun_color.rgb * sun_on * sunlit * 0.8;
|
||||
|
||||
// Refraction: where the ray through the waves meets the world, against where the flat surface
|
||||
- // would send it (so still water looks as vanilla's and only the waves distort).
|
||||
- let floor0 = position_at(in.uv, g);
|
||||
- let depth0 = max(length(floor0) - dist, 0.0);
|
||||
- let bent = refract(-v, n, 0.75) - refract(-v, geometric, 0.75);
|
||||
+ // would send it (so still water looks as vanilla's and only the waves distort). Not where
|
||||
+ // something translucent is in front of the water, nor onto texels that hold such a thing.
|
||||
+ let has_floor = g > 0.0;
|
||||
var uv_floor = in.uv;
|
||||
var floor_z = g;
|
||||
- let moved = project(floor0 + bent * min(depth0, 24.0));
|
||||
- if moved.z > 0.0 && on_screen(moved.xy) {
|
||||
- let z = textureLoad(gbuffer_depth, texel(moved.xy), 0);
|
||||
- // Only what lies behind the water: not the bank or an entity in front of it.
|
||||
- if z > 0.0 && z <= wd {
|
||||
- uv_floor = moved.xy;
|
||||
- floor_z = z;
|
||||
+ if has_floor && !front {
|
||||
+ let floor0 = position_at(in.uv, g);
|
||||
+ let depth0 = max(length(floor0) - dist, 0.0);
|
||||
+ let bent = refract(-v, n, 0.75) - refract(-v, geometric, 0.75);
|
||||
+ let moved = project(floor0 + bent * min(depth0, 24.0));
|
||||
+ if moved.z > 0.0 && on_screen(moved.xy) {
|
||||
+ let z = textureLoad(gbuffer_depth, texel(moved.xy), 0);
|
||||
+ // The four texels the filter blends: none may be a pixel with a translucent thing
|
||||
+ // in front of the water.
|
||||
+ let marks = textureGather(3, lit_tex, linear, moved.xy);
|
||||
+ let tainted = any((marks > vec4<f32>(0.55)) & (marks < vec4<f32>(0.75)));
|
||||
+ // Only what lies behind the water: not the bank or an entity in front of it.
|
||||
+ if z > 0.0 && z <= wd && !tainted {
|
||||
+ uv_floor = moved.xy;
|
||||
+ floor_z = z;
|
||||
+ }
|
||||
}
|
||||
}
|
||||
- let thickness = clamp(length(position_at(uv_floor, floor_z)) - dist, 0.0, 48.0);
|
||||
- let behind = textureSampleLevel(lit_tex, linear, uv_floor, 0.0).rgb;
|
||||
+ var thickness = 48.0;
|
||||
+ if has_floor {
|
||||
+ thickness = clamp(length(position_at(uv_floor, floor_z)) - dist, 0.0, 48.0);
|
||||
+ }
|
||||
+ var behind = textureSampleLevel(lit_tex, linear, uv_floor, 0.0).rgb;
|
||||
+ if front { behind = aux.rgb; }
|
||||
// A deep, dark water: the biome's colour darkened and a little greyed, so it reads as
|
||||
// depth rather than as bright paint.
|
||||
let biome = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
|
||||
@@ -840,6 +903,13 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
|
||||
if frame.sky_sun.w > 0.5 && frame.fog.x > 1.0e6 {
|
||||
c = mix(c, haze, (1.0 - exp(-dist / 700.0)) * 0.5);
|
||||
}
|
||||
+ if front {
|
||||
+ // The scene as drawn, with the water's change to the floor laid under the translucent thing.
|
||||
+ c = base.rgb + (c - aux.rgb) * 0.7;
|
||||
+ } else {
|
||||
+ // The air between the camera and the surface: volumetric fog, rays, halos.
|
||||
+ c = c * aux.a + aux.rgb;
|
||||
+ }
|
||||
return vec4<f32>(max(c, vec3<f32>(0.0)), 1.0);
|
||||
}
|
||||
"#;
|
||||
@@ -1413,6 +1483,8 @@ pub struct Hd {
|
||||
water_normal: wgpu::TextureView,
|
||||
water_depth: wgpu::TextureView,
|
||||
lit_water: wgpu::TextureView,
|
||||
+ /// The lighting's second target: the air over water, the floor beside translucent things.
|
||||
+ air: wgpu::TextureView,
|
||||
water_layout: wgpu::BindGroupLayout,
|
||||
water_pipeline: wgpu::RenderPipeline,
|
||||
water_group: Option<((wgpu::TextureView, wgpu::Sampler), wgpu::BindGroup)>,
|
||||
@@ -1849,6 +1921,7 @@ impl Hd {
|
||||
FLOAT,
|
||||
SAMPLER,
|
||||
FLOAT,
|
||||
+ UNFILTERED,
|
||||
]),
|
||||
});
|
||||
let sky_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -1961,15 +2034,39 @@ impl Hd {
|
||||
},
|
||||
alpha: wgpu::BlendComponent::REPLACE,
|
||||
};
|
||||
- let light_pipeline = fullscreen(
|
||||
- device,
|
||||
- "HD lighting",
|
||||
- &light_layout,
|
||||
- &light,
|
||||
- "fs_light",
|
||||
- HDR,
|
||||
- None,
|
||||
- );
|
||||
+ let light_pipeline = {
|
||||
+ let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
+ label: Some("HD lighting"),
|
||||
+ bind_group_layouts: &[Some(&light_layout)],
|
||||
+ ..Default::default()
|
||||
+ });
|
||||
+ let target = Some(wgpu::ColorTargetState {
|
||||
+ format: HDR,
|
||||
+ blend: None,
|
||||
+ write_mask: wgpu::ColorWrites::ALL,
|
||||
+ });
|
||||
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
+ label: Some("HD lighting"),
|
||||
+ layout: Some(&layout),
|
||||
+ vertex: wgpu::VertexState {
|
||||
+ module: &light,
|
||||
+ entry_point: Some("vs_full"),
|
||||
+ compilation_options: Default::default(),
|
||||
+ buffers: &[],
|
||||
+ },
|
||||
+ fragment: Some(wgpu::FragmentState {
|
||||
+ module: &light,
|
||||
+ entry_point: Some("fs_light"),
|
||||
+ compilation_options: Default::default(),
|
||||
+ targets: &[target.clone(), target],
|
||||
+ }),
|
||||
+ primitive: Default::default(),
|
||||
+ depth_stencil: None,
|
||||
+ multisample: Default::default(),
|
||||
+ multiview_mask: None,
|
||||
+ cache: None,
|
||||
+ })
|
||||
+ };
|
||||
let prefilter = fullscreen(
|
||||
device,
|
||||
"HD bloom prefilter",
|
||||
@@ -2108,6 +2205,7 @@ impl Hd {
|
||||
)
|
||||
.view,
|
||||
lit: target(device, "HD lit", [1, 1], HDR).view,
|
||||
+ air: target(device, "HD air", [1, 1], HDR).view,
|
||||
fx: target(device, "HD post", [1, 1], HDR).view,
|
||||
water_tint: target(device, "HD water colour", [1, 1], WATER_TINT).view,
|
||||
water_normal: target(device, "HD water normal", [1, 1], WATER_NORMAL).view,
|
||||
@@ -2199,6 +2297,7 @@ impl Hd {
|
||||
self.levels = target(device, "HD light levels", size, LIGHT_LEVELS).view;
|
||||
self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view;
|
||||
self.lit = target(device, "HD lit", size, HDR).view;
|
||||
+ self.air = target(device, "HD air", size, HDR).view;
|
||||
self.fx = target(device, "HD post", size, HDR).view;
|
||||
self.water_tint = target(device, "HD water colour", size, WATER_TINT).view;
|
||||
self.water_normal = target(device, "HD water normal", size, WATER_NORMAL).view;
|
||||
@@ -2322,7 +2421,12 @@ impl Hd {
|
||||
0.0
|
||||
};
|
||||
let frame = HdFrame {
|
||||
- prev_view_proj: scene.prev_view_proj.to_cols_array_2d(),
|
||||
+ prev_view_proj: if self.upscaling() {
|
||||
+ scene.prev_view_proj
|
||||
+ } else {
|
||||
+ scene.prev_view_proj_jittered
|
||||
+ }
|
||||
+ .to_cols_array_2d(),
|
||||
taa: [
|
||||
scene.camera_delta.x,
|
||||
scene.camera_delta.y,
|
||||
@@ -2685,15 +2789,28 @@ impl Hd {
|
||||
return;
|
||||
};
|
||||
self.water_composited = false;
|
||||
- Self::draw(
|
||||
- encoder,
|
||||
- "HD lighting",
|
||||
- &self.lit,
|
||||
- false,
|
||||
- &self.light_pipeline,
|
||||
- group,
|
||||
- stamp(&mut self.timing, "hd lighting"),
|
||||
- );
|
||||
+ let clear = |view| {
|
||||
+ Some(wgpu::RenderPassColorAttachment {
|
||||
+ view,
|
||||
+ resolve_target: None,
|
||||
+ depth_slice: None,
|
||||
+ ops: wgpu::Operations {
|
||||
+ load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
+ store: wgpu::StoreOp::Store,
|
||||
+ },
|
||||
+ })
|
||||
+ };
|
||||
+ {
|
||||
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
+ label: Some("HD lighting"),
|
||||
+ timestamp_writes: stamp(&mut self.timing, "hd lighting"),
|
||||
+ color_attachments: &[clear(&self.lit), clear(&self.air)],
|
||||
+ ..Default::default()
|
||||
+ });
|
||||
+ pass.set_pipeline(&self.light_pipeline);
|
||||
+ pass.set_bind_group(0, group, &[]);
|
||||
+ pass.draw(0..3, 0..1);
|
||||
+ }
|
||||
}
|
||||
|
||||
/// A bind group of consecutive bindings 0.., counted (`groups_last_frame`).
|
||||
@@ -2987,6 +3104,7 @@ impl Hd {
|
||||
wgpu::BindingResource::TextureView(lightmap.0),
|
||||
wgpu::BindingResource::Sampler(&self.sampler),
|
||||
wgpu::BindingResource::TextureView(&self.sky_lut),
|
||||
+ wgpu::BindingResource::TextureView(&self.air),
|
||||
],
|
||||
);
|
||||
self.water_group = Some((key, group));
|
||||
@@ -3098,6 +3216,20 @@ pub fn light_of(scene: &HdScene) -> (glam::Vec3, f32, glam::Vec3) {
|
||||
}
|
||||
}
|
||||
|
||||
+/// The factor on vanilla's cloud colour that lights the clouds with the low sun's colour at dusk
|
||||
+/// and dawn instead of a flat white. It is the clouds' own colour (the cloud pass gets it), not a
|
||||
+/// guess from the depth buffer, so what stands behind a cloud cannot change it.
|
||||
+pub fn vanilla_cloud_tint(scene: &HdScene) -> [f32; 3] {
|
||||
+ let sun = scene.sun.normalize_or(glam::Vec3::Y);
|
||||
+ let low = 1.0 - smooth(0.05, 0.5, sun.y);
|
||||
+ let overcast = (scene.rain / 0.7).clamp(0.0, 1.0) * 0.85;
|
||||
+ let (_, _, color) = light_of(scene);
|
||||
+ let warm = color.lerp(glam::Vec3::ONE, 0.45) * 1.1;
|
||||
+ let t = low * 0.7 * (1.0 - overcast);
|
||||
+ let tint = glam::Vec3::ONE.lerp(warm, t);
|
||||
+ [tint.x, tint.y, tint.z]
|
||||
+}
|
||||
+
|
||||
/// The x of the sun's direction: positive in the morning, negative in the evening.
|
||||
fn sun_dir_x(scene: &HdScene) -> f32 {
|
||||
scene.sun.normalize_or(glam::Vec3::Y).x
|
||||
@@ -3201,6 +3333,37 @@ mod tests {
|
||||
assert_eq!(seen.len(), 16);
|
||||
}
|
||||
|
||||
+ #[test]
|
||||
+ fn taa_history_lookup_follows_the_jittered_grid() {
|
||||
+ // A static point under two frames' different Halton jitters: fs_taa finds this frame's
|
||||
+ // point in the history by the previous frame's matrix *as drawn*, which lands on the pixel
|
||||
+ // the previous frame wrote it to; the unjittered one is off by that frame's jitter.
|
||||
+ let size = [1920u32, 1080];
|
||||
+ let unjittered = Mat4::perspective_rh(1.0, 16.0 / 9.0, 0.05, 1000.0);
|
||||
+ let to_uv = |m: Mat4, p: glam::Vec3| {
|
||||
+ let c = m * p.extend(1.0);
|
||||
+ [c.x / c.w * 0.5 + 0.5, 0.5 - c.y / c.w * 0.5]
|
||||
+ };
|
||||
+ let drawn = |frame: u32| {
|
||||
+ let j = jitter(frame, size, 16);
|
||||
+ Mat4::from_translation(glam::Vec3::new(j[0], j[1], 0.0)) * unjittered
|
||||
+ };
|
||||
+ let p = glam::Vec3::new(0.0, 0.0, -20.0);
|
||||
+ for frame in 1..16 {
|
||||
+ let prev = drawn(frame - 1);
|
||||
+ // The pixel (in uv) the previous frame drew the point at: what the history holds there.
|
||||
+ let wrote = to_uv(prev, p);
|
||||
+ let old = to_uv(unjittered, p);
|
||||
+ let err = [
|
||||
+ (old[0] - wrote[0]).abs() * size[0] as f32,
|
||||
+ (old[1] - wrote[1]).abs() * size[1] as f32,
|
||||
+ ];
|
||||
+ let pj = jitter(frame - 1, size, 16);
|
||||
+ assert!((err[0] - pj[0].abs() * size[0] as f32 * 0.5).abs() < 1e-3);
|
||||
+ assert!((err[1] - pj[1].abs() * size[1] as f32 * 0.5).abs() < 1e-3);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
#[test]
|
||||
fn jitter_covers_the_upscale_ratio() {
|
||||
assert_eq!(jitter_count(1.0), 16);
|
||||
diff --git a/crates/horizon-client/src/gfx/hd_sky.rs b/crates/horizon-client/src/gfx/hd_sky.rs
|
||||
index ec55de4c8..dbe8c8853 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd_sky.rs
|
||||
+++ b/crates/horizon-client/src/gfx/hd_sky.rs
|
||||
@@ -283,6 +283,14 @@ fn vanilla_sunrise_weight(d: vec3<f32>) -> f32 {
|
||||
let theta = radians(flip + 90.0);
|
||||
let c = cos(theta);
|
||||
let s = sin(theta);
|
||||
+ // Above the horizon the fan reaches only the sun's side (x of the same sign as the apex's, -s)
|
||||
+ // and rises at most alpha/3 per block of that distance (its rim stands 40*alpha high over a
|
||||
+ // 120-block radius and the points above the horizon lie at least 120 * the share of the rim
|
||||
+ // from the axis); anything else cannot hit it, so most of the sky skips the 16 triangles.
|
||||
+ if d.y > 0.0 {
|
||||
+ let side = -s * d.x;
|
||||
+ if side <= 0.0 || d.y > alpha * (1.0 / 3.0) * side * 1.02 + 0.001 { return 0.0; }
|
||||
+ }
|
||||
var best = 0.0;
|
||||
let apex = vanilla_rotate_x90(vec3<f32>(-100.0 * s, 100.0 * c, 0.0));
|
||||
for (var i = 0; i < 16; i++) {
|
||||
diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl
|
||||
index 0a2a6cdba..cb098a64a 100644
|
||||
--- a/crates/horizon-client/src/terrain.wgsl
|
||||
+++ b/crates/horizon-client/src/terrain.wgsl
|
||||
@@ -22,6 +22,8 @@ struct Globals {
|
||||
// Atlas rect of the current destroy stage (read by entity.wgsl).
|
||||
crumbling_sprite: vec4<f32>,
|
||||
outline_params: vec4<f32>,
|
||||
+ // AirDef fork. x: the HD water draws the water (fs_translucent leaves vanilla's out).
|
||||
+ hd_flags: vec4<f32>,
|
||||
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
|
||||
dynamic_lights: array<vec4<f32>, 16>,
|
||||
}
|
||||
@@ -211,6 +213,12 @@ struct WaterOut {
|
||||
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
if color.a < 0.01 { discard; }
|
||||
+ // The HD water composite draws the water itself: vanilla's stays out of the colour (alpha 0
|
||||
+ // blends to nothing) but keeps its depth, which the lighting reads.
|
||||
+ let layer = in.layer & 0x7fffu;
|
||||
+ if globals.hd_flags.x > 0.5 && (layer == WATER_STILL || layer == WATER_FLOW) {
|
||||
+ return vec4(0.0);
|
||||
+ }
|
||||
return apply_fog(color, in.spherical, in.cylindrical);
|
||||
}
|
||||
|
||||
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
index 8411ac9aa..5fa5e8a27 100644
|
||||
--- a/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
@@ -694,6 +694,11 @@ interface render {
|
||||
/// 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-lighting: func(lighting: lighting);
|
||||
+ /// 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.
|
||||
+ set-time-override: func(day-time: option<u64>);
|
||||
}
|
||||
|
||||
/// What the game calls. Only the hooks `init` asks for are ever called.
|
||||
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
|
||||
index 8cf350044..bc1ade067 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 = 12;
|
||||
+pub const LEVEL: u32 = 13;
|
||||
|
||||
/// 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); 66] = [
|
||||
+pub const FUNCTIONS: [(&str, u32); 67] = [
|
||||
("log.write", 0),
|
||||
("game.get-player", 0),
|
||||
("game.get-clock", 0),
|
||||
@@ -82,6 +82,8 @@ pub const FUNCTIONS: [(&str, u32); 66] = [
|
||||
("render.query-sections", 12),
|
||||
("render.sections", 12),
|
||||
("render.world-changes", 12),
|
||||
+ // AirDef fork: a replay shows its recorded time of day.
|
||||
+ ("render.set-time-override", 13),
|
||||
];
|
||||
|
||||
/// 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 2189a99e1..fb6180f1c 100644
|
||||
--- a/crates/horizon-mods/src/runtime.rs
|
||||
+++ b/crates/horizon-mods/src/runtime.rs
|
||||
@@ -507,6 +507,8 @@ struct State {
|
||||
/// `render.set-lights` and `render.set-lighting` (AirDef fork).
|
||||
lights: Vec<PointLight>,
|
||||
lighting: Option<ModLighting>,
|
||||
+ /// `render.set-time-override` (AirDef fork).
|
||||
+ time_override: Option<u64>,
|
||||
input: InputState,
|
||||
browser: BrowserState,
|
||||
/// Capability `storage`'s folder; made on first `set`.
|
||||
@@ -749,6 +751,7 @@ impl Mods {
|
||||
views: Vec::new(),
|
||||
lights: Vec::new(),
|
||||
lighting: None,
|
||||
+ time_override: None,
|
||||
input: InputState::default(),
|
||||
browser: BrowserState::default(),
|
||||
data_dir,
|
||||
@@ -1334,6 +1337,12 @@ impl Mods {
|
||||
.iter()
|
||||
.find_map(|e| e.run.as_ref()?.store.data().lighting)
|
||||
}
|
||||
+ /// The time of day the first running mod asked to show, if any.
|
||||
+ pub fn time_override(&self) -> Option<u64> {
|
||||
+ self.entries
|
||||
+ .iter()
|
||||
+ .find_map(|e| e.run.as_ref()?.store.data().time_override)
|
||||
+ }
|
||||
/// The columns each running mod asked the ground height of, taken.
|
||||
pub fn take_ground_queries(&mut self) -> Vec<(usize, Vec<(i32, i32)>)> {
|
||||
self.entries
|
||||
@@ -2237,6 +2246,9 @@ impl render::Host for State {
|
||||
};
|
||||
self.lighting = Some(lighting);
|
||||
}
|
||||
+ fn set_time_override(&mut self, day_time: Option<u64>) {
|
||||
+ self.time_override = day_time.map(|t| t.min(1 << 40));
|
||||
+ }
|
||||
fn query_ground(&mut self, columns: Vec<(i32, i32)>) {
|
||||
let room = 512usize.saturating_sub(self.ground_queries.len());
|
||||
self.ground_queries.extend(columns.into_iter().take(room));
|
||||
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
|
||||
index 2afb5bf62..b58f863c2 100644
|
||||
--- a/docs/api/mods-reference.md
|
||||
+++ b/docs/api/mods-reference.md
|
||||
@@ -646,6 +646,7 @@ The HD lighting (AirDef fork): the world lit per pixel by the mods' lights, in h
|
||||
| `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-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. |
|
||||
|
||||
## `events`
|
||||
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
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
|
||||
65536-block wrap, so the clouds and their shadows no longer jump at x or z =
|
||||
0
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/gfx/hd/cloud_common.wgsl | 12 +++++++-----
|
||||
1 file changed, 7 insertions(+), 5 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
|
||||
index 393ea564b..e2352f008 100644
|
||||
--- a/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
|
||||
+++ b/crates/horizon-client/src/gfx/hd/cloud_common.wgsl
|
||||
@@ -21,6 +21,8 @@ fn dampen(x: f32) -> f32 {
|
||||
}
|
||||
|
||||
// Where a camera-relative point lies in the world's cloud pattern (the wind moves the pattern).
|
||||
+// The camera's x, z come wrapped to 65536 blocks: every noise scale below must divide that (powers
|
||||
+// of two), or the pattern jumps where the wrap happens (at x or z = 0).
|
||||
fn cloud_xz(pos: vec3<f32>) -> vec2<f32> {
|
||||
return vec2<f32>(pos.x + frame.cloud_cam.x, pos.z + frame.cloud_cam.y)
|
||||
+ vec2<f32>(0.8, 0.6) * (CLOUD_WIND * frame.clouds.w);
|
||||
@@ -33,8 +35,8 @@ fn cloud_density(pos: vec3<f32>, detail: f32) -> f32 {
|
||||
let h = (pos.y - frame.clouds.x) / frame.clouds.y;
|
||||
if h <= 0.0 || h >= 1.0 { return 0.0; }
|
||||
let w = cloud_xz(pos);
|
||||
- let cover = textureSampleLevel(noise2, repeat_s, w / 16000.0, 0.0).r;
|
||||
- let shape = textureSampleLevel(noise2, repeat_s, w / 3000.0, 0.0).g;
|
||||
+ let cover = textureSampleLevel(noise2, repeat_s, w / 16384.0, 0.0).r;
|
||||
+ let shape = textureSampleLevel(noise2, repeat_s, w / 4096.0, 0.0).g;
|
||||
let cu = mix(0.56, 0.80, cover) + frame.cloud_cam.z;
|
||||
var d = linear_step(1.0 - cu, 1.0, shape);
|
||||
// Carve an egg shape, thin out the bottom.
|
||||
@@ -43,9 +45,9 @@ fn cloud_density(pos: vec3<f32>, detail: f32) -> f32 {
|
||||
if d < 0.002 { return 0.0; }
|
||||
if detail > 0.0 {
|
||||
let p3 = vec3<f32>(w.x, pos.y, w.y);
|
||||
- let warp = (textureSampleLevel(noise3, repeat_s, p3 / 1100.0, 0.0).b - 0.5) * 0.4;
|
||||
- let w0 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 420.0 + vec3<f32>(warp), 0.0).r;
|
||||
- let w1 = 1.0 - textureSampleLevel(noise3, repeat_s, p3 / 90.0 + vec3<f32>(warp * 3.0), 0.0).g;
|
||||
+ 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;
|
||||
let fade = 0.20 * smoothstep(0.85, 1.0, 1.0 - h) - 0.35 * smoothstep(0.05, 0.5, h) + 0.6;
|
||||
d -= 0.55 * w0 * w0 * dampen(1.0 - d);
|
||||
d -= 0.45 * w1 * w1 * dampen(1.0 - d) * fade;
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Графический полигон 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 не мешаем).
|
||||
Для каждой сцены: время и погода заморожены (`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 декодирование PNG быстрее.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import operator
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import zlib
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
BENCH = ROOT / "run-home-bench"
|
||||
SOCK = BENCH / "c.sock" # короткий путь: лимит Unix-сокета около 104 байт
|
||||
LOG = SOCK.with_suffix(".log") # лог игры рядом с сокетом (как ctl.log в run-home)
|
||||
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_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
|
||||
class Shot:
|
||||
name: str
|
||||
world: str
|
||||
time: int # время суток: 0 рассвет, 1000 утро, 6000 полдень, 12000 закат, 18000 полночь
|
||||
pose: tuple # x, y, z, yaw, pitch (телепорт `tp @s`)
|
||||
note: str
|
||||
|
||||
|
||||
# Позы проверены в игре: полигон с техникой, река Hills (-26.5 72 0.5, взгляд на реку и холм), лес Hills на восток
|
||||
# перед сравнением и поправь здесь, если кадр не то, что написано в note.
|
||||
SHOTS = [
|
||||
Shot("polygon_noon", "Polygon", 6000, (40.5, 76.0, 40.5, 135.0, 25.0),
|
||||
"полигон с техникой, полдень, белые материалы"),
|
||||
Shot("hills_river_noon", "Hills", 6000, (-26.5, 72.0, 0.5, -150.0, 8.0),
|
||||
"река на Hills, полдень: вода, преломление, блики"),
|
||||
Shot("hills_river_sunset", "Hills", 12000, (-26.5, 72.0, 0.5, -150.0, 8.0),
|
||||
"река на Hills, закат: тинт неба и воды"),
|
||||
Shot("hills_river_night", "Hills", 18000, (-26.5, 72.0, 0.5, -150.0, 8.0),
|
||||
"река на Hills, ночь: луна, туман"),
|
||||
Shot("forest_morning_sun", "Hills", 1000, (-26.5, 71.0, 0.5, -90.0, 3.0),
|
||||
"лес на Hills, утро, взгляд на восток (солнце ~15 градусов над горизонтом)"),
|
||||
Shot("sky_noon", "Polygon", 6000, (40.5, 76.0, 40.5, 0.0, -40.0),
|
||||
"небо и облака, полдень, взгляд вверх"),
|
||||
]
|
||||
|
||||
|
||||
class BenchError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def die(msg, code=2):
|
||||
print(f"hd_bench: {msg}", file=sys.stderr)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
# --- процессы и игра -------------------------------------------------------------------------
|
||||
|
||||
def pgrep(pattern):
|
||||
return subprocess.run(["pgrep", "-f", pattern], capture_output=True).returncode == 0
|
||||
|
||||
|
||||
def ctl_ok(*args):
|
||||
r = subprocess.run([str(CTL), "--socket", str(SOCK), "--timeout", "3", *args],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
return r.returncode == 0
|
||||
|
||||
|
||||
def bench_running():
|
||||
"""Наша игра на c.sock: по командной строке (--control-socket) или по живому сокету."""
|
||||
if pgrep(str(SOCK.relative_to(ROOT))):
|
||||
return True
|
||||
if SOCK.exists():
|
||||
if ctl_ok("status"):
|
||||
return True
|
||||
SOCK.unlink() # мёртвый сокет от прошлого прогона
|
||||
return False
|
||||
|
||||
|
||||
def ctl(*args, timeout=30.0):
|
||||
cmd = [str(CTL), "--socket", str(SOCK), "--timeout", f"{timeout:g}", *map(str, args)]
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 60)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise BenchError(f"horizon-ctl {' '.join(map(str, args))}: таймаут") from e
|
||||
if r.returncode != 0:
|
||||
raise BenchError(f"horizon-ctl {' '.join(map(str, args))}: "
|
||||
f"{(r.stdout or r.stderr).strip()[:300]}")
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
def game_env(extra, timing):
|
||||
env = dict(os.environ)
|
||||
env.update({
|
||||
"HORIZON_HOME": str(HOME),
|
||||
"HORIZON_NO_AUDIO": "1",
|
||||
"HORIZON_LAUNCHER_NO_SELF_UPDATE": "1",
|
||||
})
|
||||
if timing:
|
||||
env["HORIZON_SHADER_PACK_TIMING"] = "1"
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
|
||||
def prepare_home():
|
||||
if HOME.exists():
|
||||
shutil.rmtree(HOME)
|
||||
(HOME / "saves").mkdir(parents=True)
|
||||
for world in sorted({s.world for s in SHOTS}):
|
||||
src = SAVES / world
|
||||
if not src.is_dir():
|
||||
die(f"нет мира {src}")
|
||||
shutil.copytree(src, HOME / "saves" / world,
|
||||
ignore=shutil.ignore_patterns("session.lock", "*.lock"))
|
||||
|
||||
|
||||
def install_mod(env):
|
||||
subprocess.run([sys.executable, str(ROOT / "tools" / "airdef.py"), "install"],
|
||||
env={**env, "HORIZON_HOME": str(HOME)}, check=True, cwd=ROOT)
|
||||
|
||||
|
||||
def launch(world, env, game, resources):
|
||||
if SOCK.exists():
|
||||
SOCK.unlink()
|
||||
cmd = [str(CTL), "--socket", str(SOCK), "launch", "--world", world, "--offscreen", "--wait",
|
||||
"--game", str(game), "--resources", str(resources)]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, env=env, cwd=HOME, timeout=600)
|
||||
if r.returncode != 0:
|
||||
raise BenchError(f"launch {world}: {(r.stdout or r.stderr).strip()[:400]}")
|
||||
ctl("command", "gamemode creative")
|
||||
ctl("key", "t", "tap") # чат, как игрок: /airdef shader hd
|
||||
ctl("type", "/airdef shader hd")
|
||||
ctl("key", "return", "tap")
|
||||
time.sleep(0.5)
|
||||
ctl("key", "f1", "tap") # скрыть HUD (один раз на запуск)
|
||||
|
||||
|
||||
def stop_game():
|
||||
try:
|
||||
ctl("quit", timeout=20)
|
||||
except BenchError:
|
||||
pass
|
||||
for _ in range(100):
|
||||
if not ctl_ok("status"):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
if SOCK.exists() and not ctl_ok("status"):
|
||||
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):
|
||||
p = a + b - c
|
||||
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
|
||||
return a if pa <= pb and pa <= pc else (b if pb <= pc else c)
|
||||
|
||||
|
||||
def read_png_rgb(path):
|
||||
"""(w, h, bytes RGB). Только 8-бит RGB/RGBA без чередования (так пишет игра)."""
|
||||
data = Path(path).read_bytes()
|
||||
if data[:8] != b"\x89PNG\r\n\x1a\n":
|
||||
raise BenchError(f"{path}: не PNG")
|
||||
pos, idat, w = 8, [], 0
|
||||
while pos < len(data):
|
||||
(ln,) = struct.unpack(">I", data[pos:pos + 4])
|
||||
kind = data[pos + 4:pos + 8]
|
||||
body = data[pos + 8:pos + 8 + ln]
|
||||
pos += 12 + ln
|
||||
if kind == b"IHDR":
|
||||
w, h, depth, ctype, _, _, interlace = struct.unpack(">IIBBBBB", body)
|
||||
elif kind == b"IDAT":
|
||||
idat.append(body)
|
||||
elif kind == b"IEND":
|
||||
break
|
||||
if depth != 8 or interlace or ctype not in (2, 6):
|
||||
raise BenchError(f"{path}: нужен 8-бит RGB/RGBA без чередования")
|
||||
bpp = 3 if ctype == 2 else 4
|
||||
stride = w * bpp
|
||||
raw = zlib.decompress(b"".join(idat))
|
||||
out = bytearray(w * h * 3)
|
||||
prev = bytearray(stride)
|
||||
p = 0
|
||||
for y in range(h):
|
||||
ft = raw[p]
|
||||
line = raw[p + 1:p + 1 + stride]
|
||||
p += 1 + stride
|
||||
cur = bytearray(stride)
|
||||
for i in range(stride):
|
||||
x = line[i]
|
||||
a = cur[i - bpp] if i >= bpp else 0
|
||||
b = prev[i]
|
||||
c = prev[i - bpp] if i >= bpp else 0
|
||||
if ft == 1:
|
||||
x += a
|
||||
elif ft == 2:
|
||||
x += b
|
||||
elif ft == 3:
|
||||
x += (a + b) >> 1
|
||||
elif ft == 4:
|
||||
x += _paeth(a, b, c)
|
||||
cur[i] = x & 255
|
||||
base = y * w * 3
|
||||
if bpp == 3:
|
||||
out[base:base + w * 3] = cur
|
||||
else:
|
||||
out[base:base + w * 3:3] = cur[0::4]
|
||||
out[base + 1:base + w * 3:3] = cur[1::4]
|
||||
out[base + 2:base + w * 3:3] = cur[2::4]
|
||||
prev = cur
|
||||
return w, h, bytes(out)
|
||||
|
||||
|
||||
def write_png_rgb(path, w, h, rgb):
|
||||
raw = b"".join(b"\x00" + rgb[y * w * 3:(y + 1) * w * 3] for y in range(h))
|
||||
|
||||
def chunk(kind, body):
|
||||
return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF)
|
||||
|
||||
Path(path).write_bytes(b"\x89PNG\r\n\x1a\n"
|
||||
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
||||
+ chunk(b"IDAT", zlib.compress(raw, 6))
|
||||
+ chunk(b"IEND", b""))
|
||||
|
||||
|
||||
try: # необязательно: быстрее декодирование
|
||||
from PIL import Image
|
||||
except ImportError: # pragma: no cover
|
||||
Image = None
|
||||
|
||||
|
||||
def load_rgb(path):
|
||||
if Image is not None:
|
||||
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:
|
||||
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]}
|
||||
|
||||
|
||||
# --- тайминги -----------------------------------------------------------------------------------
|
||||
|
||||
def read_from(path, offset):
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
f.seek(offset)
|
||||
return f.read().decode("utf-8", "replace")
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
|
||||
|
||||
def parse_pass_reports(text):
|
||||
reports = []
|
||||
for line in text.splitlines():
|
||||
m = TIMING_RE.search(line)
|
||||
if not m:
|
||||
continue
|
||||
passes = {}
|
||||
for part in m.group(1).split(", "):
|
||||
name, _, ms = part.rpartition(" ")
|
||||
try:
|
||||
passes[name] = float(ms)
|
||||
except ValueError:
|
||||
pass
|
||||
reports.append((passes, float(m.group(2))))
|
||||
return reports
|
||||
|
||||
|
||||
def average_passes(reports):
|
||||
if not reports:
|
||||
return {}, None
|
||||
sums, counts = {}, {}
|
||||
for passes, _ in reports:
|
||||
for name, ms in passes.items():
|
||||
sums[name] = sums.get(name, 0.0) + ms
|
||||
counts[name] = counts.get(name, 0) + 1
|
||||
avg = {n: sums[n] / counts[n] for n in sums}
|
||||
total = sum(avg.values())
|
||||
return avg, total
|
||||
|
||||
|
||||
def bench_gpu_avg(reply):
|
||||
try:
|
||||
return float(json.loads(reply)["gpu"]["avg_ms"])
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# --- сцена ------------------------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
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)
|
||||
(out / f"{shot.name}_bench.json").write_text(reply + "\n")
|
||||
result["bench_json"] = f"{shot.name}_bench.json"
|
||||
result["gpu_avg_ms"] = bench_gpu_avg(reply)
|
||||
if timing:
|
||||
time.sleep(1.0) # последний отчёт о проходах
|
||||
reports = parse_pass_reports(read_from(LOG, offset))
|
||||
result["passes"], result["pass_total_ms"] = average_passes(reports)
|
||||
if not reports:
|
||||
result["warning"] = "нет строк проходов в логе (мало таймед-кадров?)"
|
||||
return result
|
||||
|
||||
|
||||
def group_by_world(shots):
|
||||
order = []
|
||||
for s in shots:
|
||||
if s.world not in order:
|
||||
order.append(s.world)
|
||||
return [(w, [s for s in shots if s.world == w]) for w in order]
|
||||
|
||||
|
||||
def git_rev():
|
||||
r = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"],
|
||||
capture_output=True, text=True)
|
||||
return r.stdout.strip() or "?"
|
||||
|
||||
|
||||
def judge(entry, args):
|
||||
reasons = []
|
||||
if entry.get("error"):
|
||||
reasons.append("ошибка: " + entry["error"])
|
||||
gpu = entry.get("gpu_avg_ms")
|
||||
if gpu is not None and gpu > args.budget_ms:
|
||||
reasons.append(f"GPU {gpu:.2f} мс > бюджета {args.budget_ms:g}")
|
||||
if gpu is None and not entry.get("error"):
|
||||
reasons.append("нет GPU-таймингов (bench)")
|
||||
fl = entry.get("flicker")
|
||||
if fl and args.max_flicker is not None and fl["pct"] > args.max_flicker:
|
||||
reasons.append(f"мерцание статичных пикселей {fl['pct']:.2f}% > {args.max_flicker:g}%")
|
||||
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}")
|
||||
if base["pct"] > args.max_pct:
|
||||
reasons.append(f"статичных пикселей >20/255 {base['pct']:.2f}% > {args.max_pct:g}%")
|
||||
return reasons
|
||||
|
||||
|
||||
def write_report(out, entries, args, env_extra, size, baseline, meta):
|
||||
lines = []
|
||||
add = lines.append
|
||||
add(f"# HD-полигон: {out.name}")
|
||||
add("")
|
||||
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("- время и погода: " + ("заморожены (`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}, "
|
||||
f"пикселей >20/255 <= {args.max_pct:g}%)")
|
||||
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} | {mv} | {bm} | {bp} | {status} |")
|
||||
passes = []
|
||||
for e in entries:
|
||||
for p in e.get("passes", {}):
|
||||
if p not in passes:
|
||||
passes.append(p)
|
||||
if passes:
|
||||
add("")
|
||||
add("## Проходы GPU, среднее по отчётам лога, мс")
|
||||
add("")
|
||||
add("| проход | " + " | ".join(e["name"] for e in entries) + " |")
|
||||
add("|---|" + "---|" * len(entries))
|
||||
for p in passes:
|
||||
add(f"| {p} | " + " | ".join(f"{e['passes'][p]:.3f}" if p in e.get("passes", {}) else "-" for e in entries) + " |")
|
||||
totals = [f"{e['pass_total_ms']:.3f}" if e.get("pass_total_ms") is not None else "-" for e in entries]
|
||||
add("| **итого** | " + " | ".join(totals) + " |")
|
||||
add("")
|
||||
add("## Кадры")
|
||||
for e in entries:
|
||||
add("")
|
||||
add(f"### {e['name']}: {e['note']}")
|
||||
add("")
|
||||
add(f"![{e['name']}]({e['frame']})")
|
||||
if e.get("diff_image"):
|
||||
add("")
|
||||
add(f"Разница с базой (x{DIFF_GAIN}, синим — движущиеся пиксели, не сравниваются): ")
|
||||
if e.get("motion_image"):
|
||||
add("")
|
||||
add(f"Движущиеся пиксели (белым): ")
|
||||
if e.get("warning"):
|
||||
add("")
|
||||
add(f"> {e['warning']}")
|
||||
if e.get("error"):
|
||||
add("")
|
||||
add(f"> ошибка: {e['error']}")
|
||||
(out / "report.md").write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
p = argparse.ArgumentParser(description="Графический полигон HD: кадры, мерцание, GPU-бюджет.")
|
||||
p.add_argument("--list", action="store_true", help="показать сцены и выйти")
|
||||
p.add_argument("--only", action="append", default=[], metavar="СЦЕНА", help="только эти сцены (можно несколько раз)")
|
||||
p.add_argument("--out", help="папка вывода (по умолчанию run-home-bench/out/<время>)")
|
||||
p.add_argument("--baseline", help="папка прошлого прогона для сравнения кадров")
|
||||
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=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")
|
||||
args = p.parse_args(argv)
|
||||
if args.stills < 2:
|
||||
p.error("--stills: нужно не меньше 2 кадров, иначе нет ни мерцания, ни движения")
|
||||
return args
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = parse_args(argv)
|
||||
if args.list:
|
||||
for s in SHOTS:
|
||||
print(f"{s.name:22} {s.world:8} время {s.time:5} поза {s.pose} {s.note}")
|
||||
return 0
|
||||
shots = [s for s in SHOTS if not args.only or s.name in args.only]
|
||||
unknown = set(args.only) - {s.name for s in SHOTS}
|
||||
if unknown:
|
||||
die(f"нет сцен: {', '.join(sorted(unknown))} (см. --list)")
|
||||
env_extra = {}
|
||||
for item in args.env:
|
||||
if "=" not in item:
|
||||
die(f"--env ждёт КЛЮЧ=ЗНАЧЕНИЕ, получено: {item}")
|
||||
k, v = item.split("=", 1)
|
||||
env_extra[k] = v
|
||||
if bench_running():
|
||||
die("уже запущена игра на run-home-bench/c.sock: один прогон за раз")
|
||||
if pgrep(OTHER_SOCK):
|
||||
if len(shots) > 1:
|
||||
die(f"работает чужая игра на {OTHER_SOCK}: запусти одну сцену (--only) или дождись конца теста")
|
||||
print(f"hd_bench: внимание: работает чужая игра на {OTHER_SOCK}, снимок будет под нагрузкой",
|
||||
file=sys.stderr)
|
||||
out = Path(args.out) if args.out else BENCH / "out" / datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
out = out.resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import airdef # tools/airdef.py: ресурсы и путь к игре
|
||||
|
||||
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()
|
||||
install_mod(env)
|
||||
|
||||
entries = []
|
||||
baseline = Path(args.baseline).resolve() if args.baseline else None
|
||||
try:
|
||||
for world, group in group_by_world(shots):
|
||||
try:
|
||||
launch(world, env, game, resources)
|
||||
except BenchError as e:
|
||||
for s in group:
|
||||
entries.append({**asdict(s), "error": str(e), "fail": [f"ошибка: {e}"]})
|
||||
stop_game()
|
||||
continue
|
||||
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, 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)
|
||||
entry["fail"] = judge(entry, args)
|
||||
entries.append(entry)
|
||||
stop_game()
|
||||
finally:
|
||||
stop_game()
|
||||
|
||||
size = None
|
||||
for e in entries:
|
||||
if e.get("vs_baseline") and "size" in e["vs_baseline"]:
|
||||
size = e["vs_baseline"]["size"]
|
||||
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, "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, 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}% движение {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'}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except BenchError as e:
|
||||
die(str(e), code=2)
|
||||
Reference in New Issue
Block a user