#!/usr/bin/env python3 """Графический полигон HD (#44, #71): фиксированные сцены, сравнение кадров, мерцание и GPU-бюджет. python3 tools/hd_bench.py --list # сцены и их параметры python3 tools/hd_bench.py # все сцены -> run-home-bench/out/<время>/ python3 tools/hd_bench.py --only polygon_noon # одна сцена (годится и при занятой игре) python3 tools/hd_bench.py --env HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off python3 tools/hd_bench.py --baseline run-home-bench/out/before --out run-home-bench/out/after python3 tools/hd_bench.py --no-freeze # живой замер производительности: время и погода идут Каждый прогон: свежая копия миров Polygon и Hills из run-home/saves в run-home-bench/home, мод airdef, игра офскрин на сокете run-home-bench/c.sock (одна за раз; чужой игре run-home/ctl.sock не мешаем). Для каждой сцены: время и погода заморожены (`gamerule advance_time/advance_weather false`), время суток, ясная погода, телепорт, пауза на устаканивание, подряд --stills кадров (по умолчанию 3), прогрев, `bench 4` (GPU avg, мс) и средние проходы из run-home-bench/c.log при HORIZON_SHADER_PACK_TIMING=1. Итог: report.md и results.json в папке вывода. Анимация (#71). Игровое время и погоду мы замораживаем, но вода (волны, HD `water.y`) и листва (ветер, `wind_seconds`) идут по часам стены, поэтому два прогона одного бинарника разные и без изменений кода. Поэтому кадры не сравниваются целиком: по кадрам одного прогона пиксель, у которого размах значений между кадрами больше --motion-level (16 из 255), считается движущимся (вода, облака, листва). Такие пиксели не участвуют в сравнении с базой (ни в одном из двух прогонов). Мерцание считается по остальным пикселям: размах между кадрами больше --flicker-level (6 из 255) и не больше --motion-level. Сильное мерцание по кадрам одного прогона от движения не отличить, поэтому «движение» само ограничено: доля непроверенных пикселей (--max-animated), сумма «неустойчивых» (--max-flicker), пиксели, ставшие движущимися против БАЗЫ (--max-new-animated: маска берётся из базы, не из проверяемого прогона), и доля проверяемых пикселей (--min-static: ноль статичных — не «ноль ошибки», а провал). Для движущейся части выводится отдельная средняя разница, только для информации. Картинка `<сцена>_motion.png` показывает маску. Коды выхода: 0 — всё в порогах и бюджете; 1 — есть превышение; 2 — ошибка запуска или аргументов. Только стандартная библиотека; при установленном Pillow декодирование PNG быстрее. """ import argparse import datetime import hashlib import itertools import json import operator import os import re import shutil import struct import subprocess import sys import time import zlib from dataclasses import asdict, dataclass from pathlib import Path ROOT = Path(__file__).resolve().parent.parent BENCH = ROOT / "run-home-bench" SOCK = BENCH / "c.sock" # короткий путь: лимит Unix-сокета около 104 байт LOG = SOCK.with_suffix(".log") # лог игры рядом с сокетом (как ctl.log в run-home) HOME = BENCH / "home" # HORIZON_HOME одного прогона, пересоздаётся каждый раз CTL = ROOT / "horizon" / "target" / "fast" / "horizon-ctl" SAVES = ROOT / "run-home" / "saves" OTHER_SOCK = "run-home/ctl.sock" # чужая игра (другой агент): только читаем, не трогаем DIFF_LEVEL = 20 # порог «заметной» разницы по каналу, из 255 (сравнение с базой) DIFF_GAIN = 6 # усиление разницы в diff-картинках MOTION_LEVEL = 16 # размах значения между кадрами одного прогона выше этого — движение (вода, облака, листва) FLICKER_LEVEL = 6 # размах выше этого на статичных пикселях — мерцание TIMING_RE = re.compile(r"shader pack GPU time per pass .*?: (.*) = ([\d.]+)\s*$") FREEZE_RULES = ("advance_time", "advance_weather") # имена игровых правил в horizon-client-core/gamerules.rs @dataclass class Shot: name: str world: str time: int # время суток: 0 рассвет, 1000 утро, 6000 полдень, 12000 закат, 18000 полночь pose: tuple # x, y, z, yaw, pitch (телепорт `tp @s`) note: str # Позы проверены в игре: полигон с техникой, река Hills (-26.5 72 0.5, взгляд на реку и холм), лес Hills на восток # перед сравнением и поправь здесь, если кадр не то, что написано в note. SHOTS = [ Shot("polygon_noon", "Polygon", 6000, (40.5, 76.0, 40.5, 135.0, 25.0), "полигон с техникой, полдень, белые материалы"), Shot("hills_river_noon", "Hills", 6000, (-26.5, 72.0, 0.5, -150.0, 8.0), "река на Hills, полдень: вода, преломление, блики"), Shot("hills_river_sunset", "Hills", 12000, (-26.5, 72.0, 0.5, -150.0, 8.0), "река на Hills, закат: тинт неба и воды"), Shot("hills_river_night", "Hills", 18000, (-26.5, 72.0, 0.5, -150.0, 8.0), "река на Hills, ночь: луна, туман"), Shot("forest_morning_sun", "Hills", 1000, (-26.5, 71.0, 0.5, -90.0, 3.0), "лес на Hills, утро, взгляд на восток (солнце ~15 градусов над горизонтом)"), Shot("sky_noon", "Polygon", 6000, (40.5, 76.0, 40.5, 0.0, -40.0), "небо и облака, полдень, взгляд вверх"), ] class BenchError(RuntimeError): pass def die(msg, code=2): print(f"hd_bench: {msg}", file=sys.stderr) sys.exit(code) # --- процессы и игра ------------------------------------------------------------------------- def pgrep(pattern): return subprocess.run(["pgrep", "-f", pattern], capture_output=True).returncode == 0 def ctl_ok(*args): r = subprocess.run([str(CTL), "--socket", str(SOCK), "--timeout", "3", *args], capture_output=True, text=True, timeout=20) return r.returncode == 0 def bench_running(): """Наша игра на c.sock: по командной строке (--control-socket) или по живому сокету.""" if pgrep(str(SOCK.relative_to(ROOT))): return True if SOCK.exists(): if ctl_ok("status"): return True SOCK.unlink() # мёртвый сокет от прошлого прогона return False def ctl(*args, timeout=30.0): cmd = [str(CTL), "--socket", str(SOCK), "--timeout", f"{timeout:g}", *map(str, args)] try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 60) except subprocess.TimeoutExpired as e: raise BenchError(f"horizon-ctl {' '.join(map(str, args))}: таймаут") from e if r.returncode != 0: raise BenchError(f"horizon-ctl {' '.join(map(str, args))}: " f"{(r.stdout or r.stderr).strip()[:300]}") return r.stdout.strip() def game_env(extra, timing): env = dict(os.environ) env.update({ "HORIZON_HOME": str(HOME), "HORIZON_NO_AUDIO": "1", "HORIZON_LAUNCHER_NO_SELF_UPDATE": "1", }) if timing: env["HORIZON_SHADER_PACK_TIMING"] = "1" env.update(extra) return env def prepare_home(): if HOME.exists(): shutil.rmtree(HOME) (HOME / "saves").mkdir(parents=True) for world in sorted({s.world for s in SHOTS}): src = SAVES / world if not src.is_dir(): die(f"нет мира {src}") shutil.copytree(src, HOME / "saves" / world, ignore=shutil.ignore_patterns("session.lock", "*.lock")) def install_mod(env): subprocess.run([sys.executable, str(ROOT / "tools" / "airdef.py"), "install"], env={**env, "HORIZON_HOME": str(HOME)}, check=True, cwd=ROOT) def launch(world, env, game, resources): if SOCK.exists(): SOCK.unlink() cmd = [str(CTL), "--socket", str(SOCK), "launch", "--world", world, "--offscreen", "--wait", "--game", str(game), "--resources", str(resources)] r = subprocess.run(cmd, capture_output=True, text=True, env=env, cwd=HOME, timeout=600) if r.returncode != 0: raise BenchError(f"launch {world}: {(r.stdout or r.stderr).strip()[:400]}") ctl("command", "gamemode creative") ctl("key", "t", "tap") # чат, как игрок: /airdef shader hd ctl("type", "/airdef shader hd") ctl("key", "return", "tap") time.sleep(0.5) ctl("key", "f1", "tap") # скрыть HUD (один раз на запуск) def stop_game(): try: ctl("quit", timeout=20) except BenchError: pass for _ in range(100): if not ctl_ok("status"): break time.sleep(0.2) if SOCK.exists() and not ctl_ok("status"): SOCK.unlink() def file_sha256(path): h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() # --- кадры: PNG без Pillow и с ним ------------------------------------------------------------- def _paeth(a, b, c): p = a + b - c pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) return a if pa <= pb and pa <= pc else (b if pb <= pc else c) def read_png_rgb(path): """(w, h, bytes RGB). Только 8-бит RGB/RGBA без чередования (так пишет игра).""" data = Path(path).read_bytes() if data[:8] != b"\x89PNG\r\n\x1a\n": raise BenchError(f"{path}: не PNG") pos, idat, w = 8, [], 0 while pos < len(data): (ln,) = struct.unpack(">I", data[pos:pos + 4]) kind = data[pos + 4:pos + 8] body = data[pos + 8:pos + 8 + ln] pos += 12 + ln if kind == b"IHDR": w, h, depth, ctype, _, _, interlace = struct.unpack(">IIBBBBB", body) elif kind == b"IDAT": idat.append(body) elif kind == b"IEND": break if depth != 8 or interlace or ctype not in (2, 6): raise BenchError(f"{path}: нужен 8-бит RGB/RGBA без чередования") bpp = 3 if ctype == 2 else 4 stride = w * bpp raw = zlib.decompress(b"".join(idat)) out = bytearray(w * h * 3) prev = bytearray(stride) p = 0 for y in range(h): ft = raw[p] line = raw[p + 1:p + 1 + stride] p += 1 + stride cur = bytearray(stride) for i in range(stride): x = line[i] a = cur[i - bpp] if i >= bpp else 0 b = prev[i] c = prev[i - bpp] if i >= bpp else 0 if ft == 1: x += a elif ft == 2: x += b elif ft == 3: x += (a + b) >> 1 elif ft == 4: x += _paeth(a, b, c) cur[i] = x & 255 base = y * w * 3 if bpp == 3: out[base:base + w * 3] = cur else: out[base:base + w * 3:3] = cur[0::4] out[base + 1:base + w * 3:3] = cur[1::4] out[base + 2:base + w * 3:3] = cur[2::4] prev = cur return w, h, bytes(out) def write_png_rgb(path, w, h, rgb): raw = b"".join(b"\x00" + rgb[y * w * 3:(y + 1) * w * 3] for y in range(h)) def chunk(kind, body): return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF) Path(path).write_bytes(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b"")) try: # необязательно: быстрее декодирование from PIL import Image except ImportError: # pragma: no cover Image = None def load_rgb(path): if Image is not None: im = Image.open(path).convert("RGB") return im.size[0], im.size[1], im.tobytes() return read_png_rgb(path) def load_frames(paths): """(w, h, [bytes RGB каждого кадра]); размеры должны совпадать.""" w = h = None rgbs = [] for p in paths: pw, ph, px = load_rgb(p) if w is None: w, h = pw, ph elif (pw, ph) != (w, h): raise BenchError(f"размеры кадров разные: {w}x{h} и {pw}x{ph} ({Path(p).name})") rgbs.append(px) return w, h, rgbs def shot_frames(folder, name, n=None): """Кадры сцены по порядку: <имя>.png, <имя>_still2.png, ... до n или пока файлы есть.""" paths = [] while n is None or len(paths) < n: k = len(paths) + 1 p = Path(folder) / (f"{name}.png" if k == 1 else f"{name}_still{k}.png") if not p.exists(): break paths.append(p) return paths # --- сравнение кадров с учётом анимации (#71) --------------------------------------------------- def _per_pixel_max(ch): """Из байтов по каналам (RGB, по 3 на пиксель) — максимум по каналам на пиксель.""" return bytes(map(max, ch[0::3], ch[1::3], ch[2::3])) def _expand3(mask): """Маска на пиксель -> маска на байт (по 3 на пиксель).""" m = bytearray(3 * len(mask)) m[0::3] = mask m[1::3] = mask m[2::3] = mask return m def motion_range(rgbs): """Размах значения каждого пикселя между кадрами одного прогона (максимум по каналам, 0..255).""" lo = hi = rgbs[0] for f in rgbs[1:]: lo = bytes(map(min, lo, f)) hi = bytes(map(max, hi, f)) return _per_pixel_max(bytes(map(operator.sub, hi, lo))) def flicker_stats(rng, w, h, n_frames, motion_level, flicker_level): """По размаху каждого пикселя между кадрами одного прогона. `pct` — мерцание: размах в (flicker_level, motion_level]; `animated_pct` — выше motion_level (движение: вода, облака, листва; ИЛИ очень сильное мерцание — по одному прогону их не различить). `unsteady_pct` — сумма обоих: все пиксели с размахом выше flicker_level, она не падает, когда мерцание усиливается и переходит из одной корзины в другую. `unverified_pct` — доля пикселей, которые этот прогон ничем не проверяет (то же, что animated_pct); `static_pct` — доля проверяемых.""" npx = w * h flick = sum(1 for r in rng if flicker_level < r <= motion_level) moving = sum(1 for r in rng if r > motion_level) return {"pct": 100.0 * flick / npx, "animated_pct": 100.0 * moving / npx, "unsteady_pct": 100.0 * (flick + moving) / npx, "unverified_pct": 100.0 * moving / npx, "static_pct": 100.0 * (npx - moving) / npx, "frames": n_frames, "size": [w, h]} def compare_runs(cur_rgb, cur_rng, base_rgb, base_rng, w, h, motion_level, diff_out=None, motion_out=None): """Первый кадр текущего прогона против первого кадра базы. Из сравнения исключаются только пиксели, которые двигались уже в БАЗЕ (маска воспроизводимая, не зависит от того, что показал проверяемый прогон): mean и pct — по остальным; для движущихся — отдельная средняя разница (информация, не порог). Пиксель, ставший «движущимся» только в текущем прогоне (мерцание, призраки TAA), из сравнения не исключается и считается в new_animated_pct. Доля проверяемых пикселей — static_pct: при нуле mean не «0 ошибки», а отсутствие проверки (см. judge).""" npx = w * h static = bytes(1 if b <= motion_level else 0 for b in base_rng) new_anim = sum(1 for a, b in zip(cur_rng, base_rng) if a > motion_level and b <= motion_level) d = bytes(map(abs, map(operator.sub, cur_rgb[0], base_rgb[0]))) dpix = _per_pixel_max(d) n_static = sum(static) n_moving = npx - n_static mean = sum(itertools.compress(d, _expand3(static))) / (3.0 * n_static) if n_static else 0.0 over = sum(1 for p, s in zip(dpix, static) if s and p > DIFF_LEVEL) moving_mean = (sum(itertools.compress(d, _expand3(bytes(1 - s for s in static)))) / (3.0 * n_moving) if n_moving else 0.0) cur_anim = sum(1 for a in cur_rng if a > motion_level) if diff_out: img = bytearray(min(255, v * DIFF_GAIN) for v in d) for i, s in enumerate(static): if not s: # движущиеся пиксели — синим, чтобы не путать с регрессией j = 3 * i img[j], img[j + 1], img[j + 2] = 0, 0, 80 write_png_rgb(diff_out, w, h, bytes(img)) if motion_out: write_png_rgb(motion_out, w, h, bytes(_expand3(bytes(0 if s else 255 for s in static)))) return {"mean": mean, "pct": 100.0 * over / npx, "animated_pct": 100.0 * n_moving / npx, "animated_mean": moving_mean, "static_pct": 100.0 * n_static / npx, "new_animated_pct": 100.0 * new_anim / npx, "cur_animated_pct": 100.0 * cur_anim / npx, "size": [w, h]} # --- тайминги ----------------------------------------------------------------------------------- def read_from(path, offset): try: with open(path, "rb") as f: f.seek(offset) return f.read().decode("utf-8", "replace") except FileNotFoundError: return "" def parse_pass_reports(text): reports = [] for line in text.splitlines(): m = TIMING_RE.search(line) if not m: continue passes = {} for part in m.group(1).split(", "): name, _, ms = part.rpartition(" ") try: passes[name] = float(ms) except ValueError: pass reports.append((passes, float(m.group(2)))) return reports def average_passes(reports): if not reports: return {}, None sums, counts = {}, {} for passes, _ in reports: for name, ms in passes.items(): sums[name] = sums.get(name, 0.0) + ms counts[name] = counts.get(name, 0) + 1 avg = {n: sums[n] / counts[n] for n in sums} total = sum(avg.values()) return avg, total def bench_gpu_avg(reply): try: return float(json.loads(reply)["gpu"]["avg_ms"]) except (ValueError, KeyError, TypeError): return None # --- сцена ------------------------------------------------------------------------------------ def run_shot(shot, out, settle, bench_secs, timing, stills, gap, warmup, freeze): x, y, z, yaw, pitch = shot.pose if freeze: # игровое время и погода стоят: облачные часы и погода не уходят между снимками for rule in FREEZE_RULES: ctl("command", f"gamerule {rule} false") ctl("command", f"time set {shot.time}") ctl("command", "weather clear") ctl("command", f"tp @s {x} {y} {z} {yaw} {pitch}") ctl("wait-ready", 120, timeout=150) time.sleep(settle) frames = [] for k in range(stills): if k: time.sleep(gap) path = out / (f"{shot.name}.png" if k == 0 else f"{shot.name}_still{k + 1}.png") ctl("screenshot", str(path.resolve())) frames.append(path.name) if warmup > 0: # прогрев перед замером, не попадает в отчёт ctl("bench", warmup, timeout=warmup + 60) result = {"frame": frames[0], "frames": frames, "gpu_avg_ms": None, "passes": {}, "pass_total_ms": None, "bench_json": None} offset = LOG.stat().st_size if LOG.exists() else 0 reply = ctl("bench", bench_secs, timeout=bench_secs + 60) (out / f"{shot.name}_bench.json").write_text(reply + "\n") result["bench_json"] = f"{shot.name}_bench.json" result["gpu_avg_ms"] = bench_gpu_avg(reply) if timing: time.sleep(1.0) # последний отчёт о проходах reports = parse_pass_reports(read_from(LOG, offset)) result["passes"], result["pass_total_ms"] = average_passes(reports) if not reports: result["warning"] = "нет строк проходов в логе (мало таймед-кадров?)" return result def group_by_world(shots): order = [] for s in shots: if s.world not in order: order.append(s.world) return [(w, [s for s in shots if s.world == w]) for w in order] def git_rev(): r = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"], capture_output=True, text=True) return r.stdout.strip() or "?" def judge(entry, args): reasons = [] if entry.get("error"): reasons.append("ошибка: " + entry["error"]) gpu = entry.get("gpu_avg_ms") if gpu is not None and gpu > args.budget_ms: reasons.append(f"GPU {gpu:.2f} мс > бюджета {args.budget_ms:g}") if gpu is None and not entry.get("error"): reasons.append("нет GPU-таймингов (bench)") fl = entry.get("flicker") if fl and args.max_flicker is not None and fl["pct"] > args.max_flicker: reasons.append(f"мерцание статичных пикселей {fl['pct']:.2f}% > {args.max_flicker:g}%") if fl and fl["animated_pct"] > args.max_animated: reasons.append(f"не проверено (размах > {args.motion_level}/255) {fl['animated_pct']:.1f}% кадра > {args.max_animated:g}%") base = entry.get("vs_baseline") if base: if base.get("error"): reasons.append(base["error"]) else: if base["static_pct"] < args.min_static: reasons.append(f"проверяемых пикселей {base['static_pct']:.1f}% < {args.min_static:g}%: сравнение с базой ничего не доказывает") if base["new_animated_pct"] > args.max_new_animated: reasons.append(f"новых движущихся пикселей против базы {base['new_animated_pct']:.2f}% > {args.max_new_animated:g}%") if base["mean"] > args.max_mean: reasons.append(f"разница с базой (статика) {base['mean']:.2f} > {args.max_mean:g}") if base["pct"] > args.max_pct: reasons.append(f"статичных пикселей >20/255 {base['pct']:.2f}% > {args.max_pct:g}%") return reasons def write_report(out, entries, args, env_extra, size, baseline, meta): lines = [] add = lines.append add(f"# HD-полигон: {out.name}") add("") add(f"- движок: `{meta['game']}`, sha256 `{meta['game_sha256'][:16]}`; коммит мода (не движка): `{git_rev()}`") add(f"- ресурсы: `{meta['resources']}`") add(f"- дата: {datetime.datetime.now():%Y-%m-%d %H:%M}") add(f"- разрешение кадра: {size[0]}x{size[1]}" if size else "- разрешение кадра: ?") add("- время и погода: " + ("заморожены (`gamerule advance_time/advance_weather false`)" if meta["freeze"] else "идут (`--no-freeze`, живой замер)")) add(f"- кадров на сцену: {meta['stills']} через {meta['still_gap']:g} с; прогрев {meta['warmup_secs']:g} с") add(f"- движение: размах между кадрами > {meta['motion_level']}/255 (вода, облака, листва) не сравнивается с базой; " f"мерцание: статичные пиксели с размахом > {meta['flicker_level']}/255") add("- окружение игры: " + (", ".join(f"`{k}={v}`" for k, v in sorted(env_extra.items())) or "по умолчанию")) add(f"- бюджет GPU: {args.budget_ms:g} мс (bench {args.bench_secs:g} с), мерцание: " + (f"<= {args.max_flicker:g}%" if args.max_flicker is not None else "только в отчёте (порог не задан)")) if baseline: add(f"- сравнение с: `{baseline}` (статичные пиксели: средняя разница <= {args.max_mean:g}, " f"пикселей >20/255 <= {args.max_pct:g}%)") add("") add("| сцена | мир | время | GPU avg, мс | бюджет | мерцание % | движение % | к базе (статика): mean | %>20 | итог |") add("|---|---|---|---|---|---|---|---|---|---|") for e in entries: gpu = f"{e['gpu_avg_ms']:.2f}" if e.get("gpu_avg_ms") is not None else "-" fl = f"{e['flicker']['pct']:.2f}" if e.get("flicker") else "-" mv = f"{e['flicker']['animated_pct']:.2f}" if e.get("flicker") else "-" b = e.get("vs_baseline") or {} if "new_animated_pct" in b: # what the baseline comparison covers and what grew mv += f" (проверено {b['static_pct']:.0f}%, новых {b['new_animated_pct']:.2f}%)" bm = f"{b['mean']:.2f}" if "mean" in b else "-" bp = f"{b['pct']:.2f}" if "pct" in b else "-" status = "OK" if not e["fail"] else "FAIL: " + "; ".join(e["fail"]) budget = "да" if e.get("gpu_avg_ms") is not None and e["gpu_avg_ms"] <= args.budget_ms else ("нет" if e.get("gpu_avg_ms") is not None else "-") add(f"| {e['name']} | {e['world']} | {e['time']} | {gpu} | {budget} | {fl} | {mv} | {bm} | {bp} | {status} |") passes = [] for e in entries: for p in e.get("passes", {}): if p not in passes: passes.append(p) if passes: add("") add("## Проходы GPU, среднее по отчётам лога, мс") add("") add("| проход | " + " | ".join(e["name"] for e in entries) + " |") add("|---|" + "---|" * len(entries)) for p in passes: add(f"| {p} | " + " | ".join(f"{e['passes'][p]:.3f}" if p in e.get("passes", {}) else "-" for e in entries) + " |") totals = [f"{e['pass_total_ms']:.3f}" if e.get("pass_total_ms") is not None else "-" for e in entries] add("| **итого** | " + " | ".join(totals) + " |") add("") add("## Кадры") for e in entries: add("") add(f"### {e['name']}: {e['note']}") add("") add(f"![{e['name']}]({e['frame']})") if e.get("diff_image"): add("") add(f"Разница с базой (x{DIFF_GAIN}, синим — движущиеся пиксели, не сравниваются): ![diff]({e['diff_image']})") if e.get("motion_image"): add("") add(f"Движущиеся пиксели (белым): ![motion]({e['motion_image']})") if e.get("warning"): add("") add(f"> {e['warning']}") if e.get("error"): add("") add(f"> ошибка: {e['error']}") (out / "report.md").write_text("\n".join(lines) + "\n") def parse_args(argv): p = argparse.ArgumentParser(description="Графический полигон HD: кадры, мерцание, GPU-бюджет.") p.add_argument("--list", action="store_true", help="показать сцены и выйти") p.add_argument("--only", action="append", default=[], metavar="СЦЕНА", help="только эти сцены (можно несколько раз)") p.add_argument("--out", help="папка вывода (по умолчанию run-home-bench/out/<время>)") p.add_argument("--baseline", help="папка прошлого прогона для сравнения кадров") p.add_argument("--env", action="append", default=[], metavar="КЛЮЧ=ЗНАЧЕНИЕ", help="переменная для игры, например HORIZON_HD_TAA=0") p.add_argument("--budget-ms", type=float, default=18.0, help="бюджет GPU на кадр, мс (по умолчанию 18)") p.add_argument("--bench-secs", type=float, default=4.0, help="сколько секунд bench (по умолчанию 4)") p.add_argument("--settle", type=float, default=12.0, help="пауза после телепорта, с (по умолчанию 12: за 4 с TAA и экспозиция ещё сходятся, мерцание 20%% -> 6%%)") p.add_argument("--stills", type=int, default=3, help="кадров подряд на сцену, не меньше 2 (по умолчанию 3)") p.add_argument("--still-gap", type=float, default=0.5, help="пауза между кадрами, с (по умолчанию 0.5)") p.add_argument("--warmup-secs", type=float, default=1.0, help="прогрев перед замером GPU, с; не в отчёте (по умолчанию 1)") p.add_argument("--motion-level", type=int, default=MOTION_LEVEL, help=f"размах между кадрами выше этого (из 255) — движение, не сравнивается с базой (по умолчанию {MOTION_LEVEL})") p.add_argument("--flicker-level", type=int, default=FLICKER_LEVEL, help=f"размах статичного пикселя выше этого (из 255) — мерцание (по умолчанию {FLICKER_LEVEL})") p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы статичных пикселей с базой, 0..255") p.add_argument("--max-pct", type=float, default=5.0, help="порог статичных пикселей >20/255 с базой, %%") p.add_argument("--max-flicker", type=float, default=None, help="порог мерцания статичных пикселей, %%; без него мерцание только в отчёте (порог пока не откалиброван двумя одинаковыми прогонами)") p.add_argument("--max-animated", type=float, default=60.0, help="порог доли кадра, которую прогон не проверяет (размах выше --motion-level), %%; не откалиброван") p.add_argument("--max-new-animated", type=float, default=2.0, help="порог пикселей, статичных в базе и движущихся в прогоне, %%; не откалиброван") p.add_argument("--min-static", type=float, default=10.0, help="наименьшая доля проверяемых (статичных в базе) пикселей, %%") p.add_argument("--no-freeze", action="store_true", help="не замораживать игровое время и погоду (живой замер производительности, не снимки)") p.add_argument("--no-pass-timing", action="store_true", help="не включать HORIZON_SHADER_PACK_TIMING") p.add_argument("--game", help="horizon-client (по умолчанию как в tools/airdef.py)") p.add_argument("--resources", help="подготовленные ресурсы Minecraft 26.3") args = p.parse_args(argv) if args.stills < 2: p.error("--stills: нужно не меньше 2 кадров, иначе нет ни мерцания, ни движения") return args def main(argv=None): args = parse_args(argv) if args.list: for s in SHOTS: print(f"{s.name:22} {s.world:8} время {s.time:5} поза {s.pose} {s.note}") return 0 shots = [s for s in SHOTS if not args.only or s.name in args.only] unknown = set(args.only) - {s.name for s in SHOTS} if unknown: die(f"нет сцен: {', '.join(sorted(unknown))} (см. --list)") env_extra = {} for item in args.env: if "=" not in item: die(f"--env ждёт КЛЮЧ=ЗНАЧЕНИЕ, получено: {item}") k, v = item.split("=", 1) env_extra[k] = v if bench_running(): die("уже запущена игра на run-home-bench/c.sock: один прогон за раз") if pgrep(OTHER_SOCK): if len(shots) > 1: die(f"работает чужая игра на {OTHER_SOCK}: запусти одну сцену (--only) или дождись конца теста") print(f"hd_bench: внимание: работает чужая игра на {OTHER_SOCK}, снимок будет под нагрузкой", file=sys.stderr) out = Path(args.out) if args.out else BENCH / "out" / datetime.datetime.now().strftime("%Y%m%d-%H%M%S") out = out.resolve() out.mkdir(parents=True, exist_ok=True) sys.path.insert(0, str(ROOT / "tools")) import airdef # tools/airdef.py: ресурсы и путь к игре game = Path(args.game) if args.game else airdef.built_game() resources = Path(args.resources) if args.resources else airdef.resources() try: game_sha = file_sha256(game) except OSError: game_sha = "?" * 64 meta = {"game": str(game), "game_sha256": game_sha, "resources": str(resources), "freeze": not args.no_freeze, "stills": args.stills, "still_gap": args.still_gap, "warmup_secs": args.warmup_secs, "motion_level": args.motion_level, "flicker_level": args.flicker_level} timing = not args.no_pass_timing env = game_env(env_extra, timing) prepare_home() install_mod(env) entries = [] baseline = Path(args.baseline).resolve() if args.baseline else None try: for world, group in group_by_world(shots): try: launch(world, env, game, resources) except BenchError as e: for s in group: entries.append({**asdict(s), "error": str(e), "fail": [f"ошибка: {e}"]}) stop_game() continue for s in group: entry = {**asdict(s), "frame": f"{s.name}.png"} try: entry.update(run_shot(s, out, args.settle, args.bench_secs, timing, args.stills, args.still_gap, args.warmup_secs, not args.no_freeze)) w, h, rgbs = load_frames(shot_frames(out, s.name, args.stills)) rng = motion_range(rgbs) entry["flicker"] = flicker_stats(rng, w, h, len(rgbs), args.motion_level, args.flicker_level) if baseline and not entry.get("error"): base_paths = shot_frames(baseline, s.name) # все кадры базы, сколько есть if not base_paths: entry["vs_baseline"] = {"error": f"в базе нет {s.name}.png"} else: bw, bh, brgbs = load_frames(base_paths) if (bw, bh) != (w, h): raise BenchError(f"размеры кадров разные: база {bw}x{bh}, прогон {w}x{h}") brng = motion_range(brgbs) diff_name, motion_name = f"{s.name}_diff.png", f"{s.name}_motion.png" entry["vs_baseline"] = compare_runs(rgbs, rng, brgbs, brng, w, h, args.motion_level, out / diff_name, out / motion_name) entry["vs_baseline"]["base_frames"] = len(brgbs) entry["diff_image"] = diff_name entry["motion_image"] = motion_name except BenchError as e: entry["error"] = str(e) entry["fail"] = judge(entry, args) entries.append(entry) stop_game() finally: stop_game() size = None for e in entries: if e.get("vs_baseline") and "size" in e["vs_baseline"]: size = e["vs_baseline"]["size"] break if size is None and entries and entries[0].get("flicker"): size = entries[0]["flicker"]["size"] results = {"out": str(out), "env": env_extra, "budget_ms": args.budget_ms, "meta": meta, "baseline": str(baseline) if baseline else None, "shots": entries} (out / "results.json").write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n") write_report(out, entries, args, env_extra, size, str(baseline) if baseline else None, meta) failed = [e for e in entries if e["fail"]] for e in entries: print(f"{e['name']:22} GPU {e.get('gpu_avg_ms') if e.get('gpu_avg_ms') is None else round(e['gpu_avg_ms'], 2)} мс " f"мерцание {e['flicker']['pct']:.2f}% движение {e['flicker']['animated_pct']:.2f}%" if e.get("flicker") else f"{e['name']:22} {e.get('error', '')}") for r in e["fail"]: print(f" FAIL: {r}") print(f"отчёт: {out / 'report.md'}") return 1 if failed else 0 if __name__ == "__main__": try: sys.exit(main()) except BenchError as e: die(str(e), code=2)