Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0b9498aaf | |||
| 8065f2225c | |||
| a8551b5c28 | |||
| f443f0a94a | |||
| e33daac636 | |||
| 661c852bbf |
@@ -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`), иначе падает с ошибкой; затем выкачивает ровно коммит этого тега (а не ветку, с которой запущен),
|
||||
сверяет его с коммитом релиза и дописывает архивы только в этот релиз.
|
||||
|
||||
## Как играть
|
||||
|
||||
|
||||
@@ -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,9 @@ 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,
|
||||
pub ground_batches: u64,
|
||||
pub tick_p50: Duration,
|
||||
pub tick_p95: Duration,
|
||||
@@ -163,6 +173,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 +224,8 @@ 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.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 +267,8 @@ 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",
|
||||
name = self.name,
|
||||
seed = self.seed,
|
||||
units = self.units,
|
||||
@@ -276,6 +291,8 @@ impl Report {
|
||||
nan = self.nan_positions,
|
||||
stuck = self.stuck_trucks,
|
||||
busy = self.truck_busy_ticks,
|
||||
crashes = self.crashes,
|
||||
world = self.world,
|
||||
)
|
||||
}
|
||||
pub fn json(&self) -> String {
|
||||
|
||||
+27
-2
@@ -1061,6 +1061,25 @@ 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)));
|
||||
@@ -1069,6 +1088,10 @@ impl Mod for AirDef {
|
||||
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 {
|
||||
@@ -1451,12 +1474,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()),
|
||||
};
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -352,7 +356,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 +416,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 +676,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 +709,7 @@ impl Player {
|
||||
let c = match e.kind {
|
||||
EventKind::Kill { .. } => 0xFFFFE040,
|
||||
EventKind::Hit { .. } => 0xFFFF3020,
|
||||
EventKind::Crash { .. } => 0xFF903020,
|
||||
EventKind::SamLost { .. } => 0xFFFF00FF,
|
||||
EventKind::Split { .. } => 0xFFFF9020,
|
||||
};
|
||||
|
||||
@@ -28,7 +28,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]);
|
||||
|
||||
@@ -19,6 +19,7 @@ mod logistics;
|
||||
mod orders;
|
||||
mod units;
|
||||
mod why;
|
||||
pub mod world;
|
||||
#[cfg(test)]
|
||||
mod regress;
|
||||
pub use ew::EW_RADIUS;
|
||||
@@ -211,6 +212,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 +254,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1172,6 +1197,8 @@ 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,
|
||||
@@ -1225,6 +1252,7 @@ impl Sim {
|
||||
tick: 0,
|
||||
kills: 0,
|
||||
hits: 0,
|
||||
crashes: 0,
|
||||
launched: 0,
|
||||
damage: true,
|
||||
shader: false,
|
||||
@@ -1289,6 +1317,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);
|
||||
@@ -1373,7 +1403,7 @@ impl Sim {
|
||||
self.update_sight(ground);
|
||||
self.step_lights(out);
|
||||
self.step_sams(out);
|
||||
self.step_missiles(out);
|
||||
self.step_missiles(ground, out);
|
||||
self.step_fx(ground, out);
|
||||
self.draw_targets(out);
|
||||
self.step_wrecks(out);
|
||||
@@ -1394,10 +1424,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, 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 +1446,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 +1477,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;
|
||||
@@ -1546,10 +1595,22 @@ impl Sim {
|
||||
let speed = if d.flight == Flight::Dive { spec.speed * 1.35 } else { spec.speed };
|
||||
d.vel = new * speed;
|
||||
d.pos += d.vel;
|
||||
let sweep = ground.sweep(from, d.pos, d.age <= 3, now);
|
||||
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 +1641,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 +1666,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);
|
||||
@@ -2061,9 +2135,11 @@ 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 mut blocked = 0u32;
|
||||
let sams_fit: HashMap<u32, f64> = self.sams.iter().map(|s| (s.id, s.fitness())).collect();
|
||||
for m in &mut self.missiles {
|
||||
m.age += 1;
|
||||
@@ -2091,13 +2167,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 +2186,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 +2237,7 @@ impl Sim {
|
||||
t.missed += missed;
|
||||
t.expired += expired;
|
||||
t.orphaned += orphaned;
|
||||
t.blocked += blocked;
|
||||
});
|
||||
for (mid, target, kill, sam) in done {
|
||||
if let Some(i) = self.missiles.iter().position(|m| m.id == mid) {
|
||||
|
||||
@@ -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,8 @@ 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,
|
||||
}
|
||||
|
||||
impl Tally {
|
||||
@@ -71,6 +75,7 @@ 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),
|
||||
}
|
||||
}
|
||||
/// The non-zero counts as a line, or nothing.
|
||||
@@ -81,6 +86,7 @@ impl Tally {
|
||||
(self.orphaned, "ракет остались без цели"),
|
||||
(self.tracks_lost, "потерь трека"),
|
||||
(self.ammo_waits, "ожиданий БК"),
|
||||
(self.blocked, "ракет разбились о препятствие"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|p| p.0 > 0)
|
||||
@@ -312,7 +318,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]
|
||||
@@ -222,3 +223,281 @@ 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();
|
||||
sim.add_drone(kind, DVec3::new(0.0, 80.0, 0.0), DVec3::X, DVec3::new(600.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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
//! 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,
|
||||
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, true, 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)
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
+49
-9
@@ -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` (аппаратная
|
||||
трассировка, отдельно).
|
||||
@@ -19,6 +20,8 @@ Horizon действует правило clean-room. Пишем по знани
|
||||
| Что | Где |
|
||||
|---|---|
|
||||
| Весь HD-конвейер: цели, проходы, WGSL-шейдеры (строками), тайлы света, AO, тени, bloom, tonemap, TAA | `horizon/crates/horizon-client/src/gfx/hd.rs` |
|
||||
| Объёмные облака (рэймарч слоя, тени облаков на землю) и объёмный туман (лучи света через тени каскадов), их цели и проходы; CPU-шум (2D, 3D Worley) | `horizon/crates/horizon-client/src/gfx/hd_volumes.rs`, `hd_noise.rs` |
|
||||
| Атмосфера: сборка текстуры неба (WGSL), чтение неба/дымки/тона света в шейдерах, цвет солнца (`sun_transmittance`) | `horizon/crates/horizon-client/src/gfx/hd_sky.rs` (+ `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` |
|
||||
@@ -43,6 +46,7 @@ cd .. && rm -f horizon-patches/*.patch && git -C horizon format-patch -q $(cat H
|
||||
(`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-трюк) и потом
|
||||
@@ -59,7 +63,8 @@ cd .. && rm -f horizon-patches/*.patch && git -C horizon format-patch -q $(cat H
|
||||
землёй решают потребители). Пайплайны `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): пиксель — вода, если `water_depth >= g` и перед водой в мировой глубине ничего нет
|
||||
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 слова —
|
||||
@@ -71,7 +76,7 @@ cd .. && rm -f horizon-patches/*.patch && git -C horizon format-patch -q $(cat H
|
||||
по глубине: не берутся банк/сущности перед водой); толщина = расстояние от поверхности до дна; поглощение `exp(-(1-цвет)·0.8·(t+0.3))`
|
||||
+ рассеяние цветом воды при свете «объёма» (лайтмап + солнце с тенью). Отражение: SSR по глубине G-буфера (24 шага, растущие,
|
||||
джиттер IGN по кадру, 5 шагов бинарного уточнения, толщина `0.4+0.08·s`), при промахе — небо/туман (`sky_color`, `fog_color`, тусклее
|
||||
под навесом). Френель Шлика F0=0.02 (SSR пропускается при F<0.04: взгляд вниз), блик солнца GGX (a=0.05) × тень, туман поверх.
|
||||
под навесом). Волны: к 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).
|
||||
@@ -105,12 +110,15 @@ Bind groups кэшируются и пересоздаются только пр
|
||||
| 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, тайлов в строке), `prev_view_proj`, `taa`, `taa_params`, `view_proj` (с джиттером, для лучей воды), `water` (x: эффект идёт, y: секунды волн mod 600, zw: камера x, z mod 256), `sky_color` (w: есть небо).
|
||||
`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 сэмплер.
|
||||
Композит воды (`WATER`, group 0): 0 `HdFrame`, 1 `lit`, 2 глубина G-буфера, 3 цвет воды, 4 нормаль воды, 5 глубина воды, 6–7 тени, 8 лайтмап, 9 сэмплер, 10 текстура неба. Проход неба (`SKY_PASS`, group 0): только `HdFrame`.
|
||||
|
||||
## Как проверять
|
||||
|
||||
@@ -165,7 +173,12 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс
|
||||
| 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 |
|
||||
| Вода: проход `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 |
|
||||
|
||||
## План (по порядку) и ТЗ
|
||||
@@ -181,10 +194,10 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс
|
||||
2. **Вода** — СДЕЛАНО (патч 19, см. «Вода» ниже). Исходное ТЗ: отдельный G-буфер-путь для воды (маска + нормаль с анимированными волнами), SSR (трассировка по
|
||||
глубине, откат на небо/туман), преломление (смещение по нормали), затухание по глубине воды (цвет биома),
|
||||
блики солнца (GGX-подобный), френель. Подводный вид не ломать (`frame.underwater`).
|
||||
3. **Небо и атмосфера**: рассеяние Рэлея/Ми (предвычисленная LUT пропускания и небесного цвета в маленькой
|
||||
3. **Небо и атмосфера** — СДЕЛАНО (патч 22, см. «Небо и свет» ниже). Исходное ТЗ: рассеяние Рэлея/Ми (предвычисленная LUT пропускания и небесного цвета в маленькой
|
||||
текстуре), солнце/луна дисками, звёзды из ванили; туман и лучи берут цвет из той же модели. Учесть дождь
|
||||
(`weather`), закат, Незер/Энд (там — ванильное небо).
|
||||
4. **Объёмные облака** (рэймарч слоя с шумом) + их тени на земле (в карту/маску солнца).
|
||||
4. **Объёмные облака** — СДЕЛАНО (патч 24, см. «Закат, облака и туман»). Осталось: перистые/высококучевые слои Photon, облака в отражении воды.
|
||||
5. **Блики и материалы**: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из
|
||||
мода), мокрые поверхности и лужи в дождь.
|
||||
6. **PCSS** (мягкость тени по расстоянию до тенеотбрасывателя) и цветные тени через стекло/листву.
|
||||
@@ -208,8 +221,18 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс
|
||||
пена у берега, подводная каустика не сделаны; ночью вода слегка ярче неба (нужно привязать к модели неба — п. 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`).
|
||||
@@ -217,3 +240,20 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс
|
||||
- Коммиты: в форке, затем патчи; подпись `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` (записывается из встроенного, если файла нет) — правки видны после перезапуска игры без пересборки; готовое переносится в константы вручную.
|
||||
- **Слабые места.** Облака не отражаются в воде (SSR берёт небо без облаков); не закрывают далёкую землю выше слоя (только небо); карта теней облаков ±1200 блоков и почти не работает при солнце ниже ~10°; туман не знает рельефа (только высоту над y=62); нет перистых облаков; на быстром повороте камеры история облаков отбрасывается (кратковременный шум).
|
||||
|
||||
@@ -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/19] Mod API (AirDef fork): world models, client particles,
|
||||
Subject: [PATCH 01/24] 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/19] cargo-horizon-mod: report particles and sounds
|
||||
Subject: [PATCH 02/24] 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/19] Mod API (AirDef fork): full-screen post pass for mods
|
||||
Subject: [PATCH 03/24] 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/19] Mod models: draw far models as scaled impostors past
|
||||
Subject: [PATCH 04/24] 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/19] Mod cameras: render.set-views draws the world from
|
||||
Subject: [PATCH 05/24] 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/19] Mods: more fuel per tick and per event (AirDef plans
|
||||
Subject: [PATCH 06/24] 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/19] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
Subject: [PATCH 07/24] 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/19] Mod cameras: distant terrain tiles, fog measured from
|
||||
Subject: [PATCH 08/24] 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/19] Mods: game.world-id, so mods keep state per world (API
|
||||
Subject: [PATCH 09/24] 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/19] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
Subject: [PATCH 10/24] 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/19] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
Subject: [PATCH 11/24] 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/19] HD lighting stage 2: sun and moon shadow cascades, the
|
||||
Subject: [PATCH 12/24] 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/19] HD: bind groups cached until a resize or a new source,
|
||||
Subject: [PATCH 13/24] 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/19] HD: lights culled per 16x16 tile (surface and halo
|
||||
Subject: [PATCH 14/24] 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/19] HD: GPU time per pass in the F3 timing list,
|
||||
Subject: [PATCH 15/24] 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/19] HD: shadows draw only sun-facing quads and skip buried
|
||||
Subject: [PATCH 16/24] 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/19] Mods: model handles (render.model-id, model-generation,
|
||||
Subject: [PATCH 17/24] Mods: model handles (render.model-id, model-generation,
|
||||
set-models-by-id; API level 11) so mods send no model names each tick;
|
||||
clear-post and set-lighting documented
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:19:57 +0300
|
||||
Subject: [PATCH 18/19] HD: temporal upscaling (HORIZON_HD_SCALE: world,
|
||||
Subject: [PATCH 18/24] HD: temporal upscaling (HORIZON_HD_SCALE: world,
|
||||
G-buffer and lighting at a lower size, rebuilt to the window in the TAA
|
||||
pass); fix flickering shadows near the player (the near cascade lost its
|
||||
sections on frames the far one was held, the held far matrix ignored the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:47:52 +0300
|
||||
Subject: [PATCH 19/19] HD water: its own pass (colour, normal, depth), the
|
||||
Subject: [PATCH 19/24] 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)
|
||||
|
||||
@@ -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/24] 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/24] 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/24] 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,589 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Графический полигон HD (#44): фиксированные сцены, сравнение кадров, мерцание и 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
|
||||
|
||||
Каждый прогон: свежая копия миров Polygon и Hills из run-home/saves в run-home-bench/home, мод airdef,
|
||||
игра офскрин на сокете run-home-bench/c.sock (одна за раз; чужой игре run-home/ctl.sock не мешаем).
|
||||
Для каждой сцены: время суток, ясная погода, телепорт, пауза на устаканивание, кадр, ещё один кадр
|
||||
(мерцание), `bench 4` (GPU avg, мс) и средние проходы из run-home-bench/c.log при HORIZON_SHADER_PACK_TIMING=1.
|
||||
Итог: report.md и results.json в папке вывода.
|
||||
|
||||
Коды выхода: 0 — всё в порогах и бюджете; 1 — есть превышение; 2 — ошибка запуска или аргументов.
|
||||
Только стандартная библиотека; при установленном Pillow сравнение быстрее.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
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-картинках
|
||||
TIMING_RE = re.compile(r"shader pack GPU time per pass .*?: (.*) = ([\d.]+)\s*$")
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
|
||||
# --- кадры: 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, ImageChops, ImageStat
|
||||
except ImportError: # pragma: no cover
|
||||
Image = None
|
||||
|
||||
|
||||
def compare_frames(a, b, diff_out=None):
|
||||
"""Средняя разница по каналам (0..255) и доля пикселей с разницей > DIFF_LEVEL, в %."""
|
||||
if Image is not None:
|
||||
A = Image.open(a).convert("RGB")
|
||||
B = Image.open(b).convert("RGB")
|
||||
if A.size != B.size:
|
||||
raise BenchError(f"размеры кадров разные: {A.size} и {B.size}")
|
||||
d = ImageChops.difference(A, B)
|
||||
mean = sum(ImageStat.Stat(d).mean) / 3
|
||||
r, g, bl = d.split()
|
||||
mx = ImageChops.lighter(ImageChops.lighter(r, g), bl)
|
||||
hist = mx.histogram()
|
||||
total = A.size[0] * A.size[1]
|
||||
pct = 100.0 * sum(hist[DIFF_LEVEL + 1:]) / total
|
||||
if diff_out:
|
||||
d.point(lambda v: min(255, v * DIFF_GAIN)).save(diff_out)
|
||||
return {"mean": mean, "pct": pct, "size": list(A.size)}
|
||||
wa, ha, pa = read_png_rgb(a)
|
||||
wb, hb, pb = read_png_rgb(b)
|
||||
if (wa, ha) != (wb, hb):
|
||||
raise BenchError(f"размеры кадров разные: {wa}x{ha} и {wb}x{hb}")
|
||||
total_abs = 0
|
||||
over = 0
|
||||
diff = bytearray(len(pa)) if diff_out else None
|
||||
for i in range(0, len(pa), 3):
|
||||
dr = abs(pa[i] - pb[i])
|
||||
dg = abs(pa[i + 1] - pb[i + 1])
|
||||
db = abs(pa[i + 2] - pb[i + 2])
|
||||
total_abs += dr + dg + db
|
||||
if max(dr, dg, db) > DIFF_LEVEL:
|
||||
over += 1
|
||||
if diff is not None:
|
||||
diff[i] = min(255, dr * DIFF_GAIN)
|
||||
diff[i + 1] = min(255, dg * DIFF_GAIN)
|
||||
diff[i + 2] = min(255, db * DIFF_GAIN)
|
||||
if diff_out:
|
||||
write_png_rgb(diff_out, wa, ha, bytes(diff))
|
||||
npx = wa * ha
|
||||
return {"mean": total_abs / (3.0 * npx), "pct": 100.0 * over / npx, "size": [wa, ha]}
|
||||
|
||||
|
||||
# --- тайминги -----------------------------------------------------------------------------------
|
||||
|
||||
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):
|
||||
x, y, z, yaw, pitch = shot.pose
|
||||
ctl("command", f"time set {shot.time}")
|
||||
ctl("command", "weather clear")
|
||||
ctl("command", f"tp @s {x} {y} {z} {yaw} {pitch}")
|
||||
ctl("wait-ready", 120, timeout=150)
|
||||
time.sleep(settle)
|
||||
first = out / f"{shot.name}.png"
|
||||
ctl("screenshot", str(first.resolve()))
|
||||
time.sleep(0.5)
|
||||
second = out / f"{shot.name}_still2.png"
|
||||
ctl("screenshot", str(second.resolve()))
|
||||
result = {"frame": first.name, "still2": second.name, "gpu_avg_ms": None, "passes": {},
|
||||
"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 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):
|
||||
lines = []
|
||||
add = lines.append
|
||||
add(f"# HD-полигон: {out.name}")
|
||||
add("")
|
||||
add(f"- коммит: `{git_rev()}`, дата: {datetime.datetime.now():%Y-%m-%d %H:%M}")
|
||||
add(f"- разрешение кадра: {size[0]}x{size[1]}" if size else "- разрешение кадра: ?")
|
||||
add(f"- окружение игры: " + (", ".join(f"`{k}={v}`" for k, v in sorted(env_extra.items())) or "по умолчанию"))
|
||||
add(f"- бюджет GPU: {args.budget_ms:g} мс (bench {args.bench_secs:g} с), мерцание <= {args.max_flicker:g}%")
|
||||
if baseline:
|
||||
add(f"- сравнение с: `{baseline}` (средняя разница <= {args.max_mean:g}, пикселей >20/255 <= {args.max_pct:g}%)")
|
||||
add("")
|
||||
add("| сцена | мир | время | GPU avg, мс | бюджет | мерцание %>20 | к базе: 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 "-"
|
||||
b = e.get("vs_baseline") or {}
|
||||
bm = f"{b['mean']:.2f}" if "mean" in b else "-"
|
||||
bp = f"{b['pct']:.2f}" if "pct" in b else "-"
|
||||
status = "OK" if not e["fail"] else "FAIL: " + "; ".join(e["fail"])
|
||||
budget = "да" if e.get("gpu_avg_ms") is not None and e["gpu_avg_ms"] <= args.budget_ms else ("нет" if e.get("gpu_avg_ms") is not None else "-")
|
||||
add(f"| {e['name']} | {e['world']} | {e['time']} | {gpu} | {budget} | {fl} | {bm} | {bp} | {status} |")
|
||||
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("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=4.0, help="пауза после телепорта, с (по умолчанию 4)")
|
||||
p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы с базой, 0..255")
|
||||
p.add_argument("--max-pct", type=float, default=5.0, help="порог пикселей >20/255 с базой, %%")
|
||||
p.add_argument("--max-flicker", type=float, default=5.0, help="порог мерцания (два кадра подряд), %%")
|
||||
p.add_argument("--no-pass-timing", action="store_true", help="не включать HORIZON_SHADER_PACK_TIMING")
|
||||
p.add_argument("--game", help="horizon-client (по умолчанию как в tools/airdef.py)")
|
||||
p.add_argument("--resources", help="подготовленные ресурсы Minecraft 26.3")
|
||||
return p.parse_args(argv)
|
||||
|
||||
|
||||
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()
|
||||
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))
|
||||
fl = compare_frames(out / f"{s.name}.png", out / f"{s.name}_still2.png")
|
||||
entry["flicker"] = fl
|
||||
except BenchError as e:
|
||||
entry["error"] = str(e)
|
||||
if baseline and not entry.get("error"):
|
||||
base_png = baseline / f"{s.name}.png"
|
||||
if base_png.exists():
|
||||
try:
|
||||
diff_name = f"{s.name}_diff.png"
|
||||
m = compare_frames(base_png, out / f"{s.name}.png", out / diff_name)
|
||||
entry["vs_baseline"] = m
|
||||
entry["diff_image"] = diff_name
|
||||
except BenchError as e:
|
||||
entry["vs_baseline"] = {"error": str(e)}
|
||||
else:
|
||||
entry["vs_baseline"] = {"error": f"в базе нет {base_png.name}"}
|
||||
entry["fail"] = judge(entry, args)
|
||||
entries.append(entry)
|
||||
stop_game()
|
||||
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,
|
||||
"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)
|
||||
|
||||
failed = [e for e in entries if e["fail"]]
|
||||
for e in entries:
|
||||
print(f"{e['name']:22} GPU {e.get('gpu_avg_ms') if e.get('gpu_avg_ms') is None else round(e['gpu_avg_ms'], 2)} мс "
|
||||
f"мерцание {e['flicker']['pct']:.2f}%" if e.get("flicker") else f"{e['name']:22} {e.get('error', '')}")
|
||||
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