tools/hd_bench.py: a graphics test range — fixed shots in Polygon and Hills, before/after diffs against a baseline, still-frame flicker, GPU time and per-pass budget, a Markdown report (#44)
build / mod (push) Successful in 3m27s
build / mod (push) Successful in 3m27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,3 +5,4 @@ airdef-mod/target/
|
||||
__pycache__/
|
||||
/out/
|
||||
.DS_Store
|
||||
run-home-bench/
|
||||
|
||||
@@ -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