diff --git a/tools/hd_bench.py b/tools/hd_bench.py index b46b585..1b7a625 100644 --- a/tools/hd_bench.py +++ b/tools/hd_bench.py @@ -1,24 +1,37 @@ #!/usr/bin/env python3 -"""Графический полигон HD (#44): фиксированные сцены, сравнение кадров, мерцание и GPU-бюджет. +"""Графический полигон HD (#44, #71): фиксированные сцены, сравнение кадров, мерцание и GPU-бюджет. python3 tools/hd_bench.py --list # сцены и их параметры python3 tools/hd_bench.py # все сцены -> run-home-bench/out/<время>/ python3 tools/hd_bench.py --only polygon_noon # одна сцена (годится и при занятой игре) python3 tools/hd_bench.py --env HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off python3 tools/hd_bench.py --baseline run-home-bench/out/before --out run-home-bench/out/after + python3 tools/hd_bench.py --no-freeze # живой замер производительности: время и погода идут Каждый прогон: свежая копия миров Polygon и Hills из run-home/saves в run-home-bench/home, мод airdef, игра офскрин на сокете run-home-bench/c.sock (одна за раз; чужой игре run-home/ctl.sock не мешаем). -Для каждой сцены: время суток, ясная погода, телепорт, пауза на устаканивание, кадр, ещё один кадр -(мерцание), `bench 4` (GPU avg, мс) и средние проходы из run-home-bench/c.log при HORIZON_SHADER_PACK_TIMING=1. +Для каждой сцены: время и погода заморожены (`gamerule advance_time/advance_weather false`), время суток, +ясная погода, телепорт, пауза на устаканивание, подряд --stills кадров (по умолчанию 3), прогрев, `bench 4` +(GPU avg, мс) и средние проходы из run-home-bench/c.log при HORIZON_SHADER_PACK_TIMING=1. Итог: report.md и results.json в папке вывода. +Анимация (#71). Игровое время и погоду мы замораживаем, но вода (волны, HD `water.y`) и листва (ветер, +`wind_seconds`) идут по часам стены, поэтому два прогона одного бинарника разные и без изменений кода. +Поэтому кадры не сравниваются целиком: по кадрам одного прогона пиксель, у которого размах значений между +кадрами больше --motion-level (16 из 255), считается движущимся (вода, облака, листва). Такие пиксели не +участвуют в сравнении с базой (ни в одном из двух прогонов). Мерцание считается по остальным пикселям: +размах между кадрами больше --flicker-level (6 из 255) и не больше --motion-level. Для движущейся части +выводится отдельная средняя разница, только для информации. Картинка `<сцена>_motion.png` показывает маску. + Коды выхода: 0 — всё в порогах и бюджете; 1 — есть превышение; 2 — ошибка запуска или аргументов. -Только стандартная библиотека; при установленном Pillow сравнение быстрее. +Только стандартная библиотека; при установленном Pillow декодирование PNG быстрее. """ import argparse import datetime +import hashlib +import itertools import json +import operator import os import re import shutil @@ -38,9 +51,12 @@ HOME = BENCH / "home" # HORIZON_HOME одного прогона, пересо CTL = ROOT / "horizon" / "target" / "fast" / "horizon-ctl" SAVES = ROOT / "run-home" / "saves" OTHER_SOCK = "run-home/ctl.sock" # чужая игра (другой агент): только читаем, не трогаем -DIFF_LEVEL = 20 # порог «заметной» разницы по каналу, из 255 +DIFF_LEVEL = 20 # порог «заметной» разницы по каналу, из 255 (сравнение с базой) DIFF_GAIN = 6 # усиление разницы в diff-картинках +MOTION_LEVEL = 16 # размах значения между кадрами одного прогона выше этого — движение (вода, облака, листва) +FLICKER_LEVEL = 6 # размах выше этого на статичных пикселях — мерцание TIMING_RE = re.compile(r"shader pack GPU time per pass .*?: (.*) = ([\d.]+)\s*$") +FREEZE_RULES = ("advance_time", "advance_weather") # имена игровых правил в horizon-client-core/gamerules.rs @dataclass @@ -173,6 +189,14 @@ def stop_game(): SOCK.unlink() +def file_sha256(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + # --- кадры: PNG без Pillow и с ним ------------------------------------------------------------- def _paeth(a, b, c): @@ -248,51 +272,103 @@ def write_png_rgb(path, w, h, rgb): + chunk(b"IEND", b"")) -try: # необязательно: быстрее декодирование и сравнение - from PIL import Image, ImageChops, ImageStat +try: # необязательно: быстрее декодирование + from PIL import Image except ImportError: # pragma: no cover Image = None -def compare_frames(a, b, diff_out=None): - """Средняя разница по каналам (0..255) и доля пикселей с разницей > DIFF_LEVEL, в %.""" +def load_rgb(path): if Image is not None: - A = Image.open(a).convert("RGB") - B = Image.open(b).convert("RGB") - if A.size != B.size: - raise BenchError(f"размеры кадров разные: {A.size} и {B.size}") - d = ImageChops.difference(A, B) - mean = sum(ImageStat.Stat(d).mean) / 3 - r, g, bl = d.split() - mx = ImageChops.lighter(ImageChops.lighter(r, g), bl) - hist = mx.histogram() - total = A.size[0] * A.size[1] - pct = 100.0 * sum(hist[DIFF_LEVEL + 1:]) / total - if diff_out: - d.point(lambda v: min(255, v * DIFF_GAIN)).save(diff_out) - return {"mean": mean, "pct": pct, "size": list(A.size)} - wa, ha, pa = read_png_rgb(a) - wb, hb, pb = read_png_rgb(b) - if (wa, ha) != (wb, hb): - raise BenchError(f"размеры кадров разные: {wa}x{ha} и {wb}x{hb}") - total_abs = 0 - over = 0 - diff = bytearray(len(pa)) if diff_out else None - for i in range(0, len(pa), 3): - dr = abs(pa[i] - pb[i]) - dg = abs(pa[i + 1] - pb[i + 1]) - db = abs(pa[i + 2] - pb[i + 2]) - total_abs += dr + dg + db - if max(dr, dg, db) > DIFF_LEVEL: - over += 1 - if diff is not None: - diff[i] = min(255, dr * DIFF_GAIN) - diff[i + 1] = min(255, dg * DIFF_GAIN) - diff[i + 2] = min(255, db * DIFF_GAIN) + im = Image.open(path).convert("RGB") + return im.size[0], im.size[1], im.tobytes() + return read_png_rgb(path) + + +def load_frames(paths): + """(w, h, [bytes RGB каждого кадра]); размеры должны совпадать.""" + w = h = None + rgbs = [] + for p in paths: + pw, ph, px = load_rgb(p) + if w is None: + w, h = pw, ph + elif (pw, ph) != (w, h): + raise BenchError(f"размеры кадров разные: {w}x{h} и {pw}x{ph} ({Path(p).name})") + rgbs.append(px) + return w, h, rgbs + + +def shot_frames(folder, name, n=None): + """Кадры сцены по порядку: <имя>.png, <имя>_still2.png, ... до n или пока файлы есть.""" + paths = [] + while n is None or len(paths) < n: + k = len(paths) + 1 + p = Path(folder) / (f"{name}.png" if k == 1 else f"{name}_still{k}.png") + if not p.exists(): + break + paths.append(p) + return paths + + +# --- сравнение кадров с учётом анимации (#71) --------------------------------------------------- + +def _per_pixel_max(ch): + """Из байтов по каналам (RGB, по 3 на пиксель) — максимум по каналам на пиксель.""" + return bytes(map(max, ch[0::3], ch[1::3], ch[2::3])) + + +def _expand3(mask): + """Маска на пиксель -> маска на байт (по 3 на пиксель).""" + m = bytearray(3 * len(mask)) + m[0::3] = mask + m[1::3] = mask + m[2::3] = mask + return m + + +def motion_range(rgbs): + """Размах значения каждого пикселя между кадрами одного прогона (максимум по каналам, 0..255).""" + lo = hi = rgbs[0] + for f in rgbs[1:]: + lo = bytes(map(min, lo, f)) + hi = bytes(map(max, hi, f)) + return _per_pixel_max(bytes(map(operator.sub, hi, lo))) + + +def flicker_stats(rng, w, h, n_frames, motion_level, flicker_level): + npx = w * h + flick = sum(1 for r in rng if flicker_level < r <= motion_level) + moving = sum(1 for r in rng if r > motion_level) + return {"pct": 100.0 * flick / npx, "animated_pct": 100.0 * moving / npx, + "frames": n_frames, "size": [w, h]} + + +def compare_runs(cur_rgb, cur_rng, base_rgb, base_rng, w, h, motion_level, diff_out=None, motion_out=None): + """Первый кадр текущего прогона против первого кадра базы. Движущиеся пиксели (в любом из двух + прогонов) в сравнении не участвуют: mean и pct — только по статичным; для движущихся — отдельная + средняя разница (информация, не порог).""" + npx = w * h + static = bytes(1 if (a <= motion_level and b <= motion_level) else 0 for a, b in zip(cur_rng, base_rng)) + d = bytes(map(abs, map(operator.sub, cur_rgb[0], base_rgb[0]))) + dpix = _per_pixel_max(d) + n_static = sum(static) + n_moving = npx - n_static + mean = sum(itertools.compress(d, _expand3(static))) / (3.0 * n_static) if n_static else 0.0 + over = sum(1 for p, s in zip(dpix, static) if s and p > DIFF_LEVEL) + moving_mean = (sum(itertools.compress(d, _expand3(bytes(1 - s for s in static)))) / (3.0 * n_moving) + if n_moving else 0.0) if diff_out: - write_png_rgb(diff_out, wa, ha, bytes(diff)) - npx = wa * ha - return {"mean": total_abs / (3.0 * npx), "pct": 100.0 * over / npx, "size": [wa, ha]} + img = bytearray(min(255, v * DIFF_GAIN) for v in d) + for i, s in enumerate(static): + if not s: # движущиеся пиксели — синим, чтобы не путать с регрессией + j = 3 * i + img[j], img[j + 1], img[j + 2] = 0, 0, 80 + write_png_rgb(diff_out, w, h, bytes(img)) + if motion_out: + write_png_rgb(motion_out, w, h, bytes(_expand3(bytes(0 if s else 255 for s in static)))) + return {"mean": mean, "pct": 100.0 * over / npx, "animated_pct": 100.0 * n_moving / npx, + "animated_mean": moving_mean, "size": [w, h]} # --- тайминги ----------------------------------------------------------------------------------- @@ -345,19 +421,26 @@ def bench_gpu_avg(reply): # --- сцена ------------------------------------------------------------------------------------ -def run_shot(shot, out, settle, bench_secs, timing): +def run_shot(shot, out, settle, bench_secs, timing, stills, gap, warmup, freeze): x, y, z, yaw, pitch = shot.pose + if freeze: # игровое время и погода стоят: облачные часы и погода не уходят между снимками + for rule in FREEZE_RULES: + ctl("command", f"gamerule {rule} false") ctl("command", f"time set {shot.time}") ctl("command", "weather clear") ctl("command", f"tp @s {x} {y} {z} {yaw} {pitch}") ctl("wait-ready", 120, timeout=150) time.sleep(settle) - first = out / f"{shot.name}.png" - ctl("screenshot", str(first.resolve())) - time.sleep(0.5) - second = out / f"{shot.name}_still2.png" - ctl("screenshot", str(second.resolve())) - result = {"frame": first.name, "still2": second.name, "gpu_avg_ms": None, "passes": {}, + frames = [] + for k in range(stills): + if k: + time.sleep(gap) + path = out / (f"{shot.name}.png" if k == 0 else f"{shot.name}_still{k + 1}.png") + ctl("screenshot", str(path.resolve())) + frames.append(path.name) + if warmup > 0: # прогрев перед замером, не попадает в отчёт + ctl("bench", warmup, timeout=warmup + 60) + result = {"frame": frames[0], "frames": frames, "gpu_avg_ms": None, "passes": {}, "pass_total_ms": None, "bench_json": None} offset = LOG.stat().st_size if LOG.exists() else 0 reply = ctl("bench", bench_secs, timeout=bench_secs + 60) @@ -397,43 +480,53 @@ def judge(entry, args): if gpu is None and not entry.get("error"): reasons.append("нет GPU-таймингов (bench)") fl = entry.get("flicker") - if fl and fl["pct"] > args.max_flicker: - reasons.append(f"мерцание {fl['pct']:.2f}% > {args.max_flicker:g}%") + if fl and args.max_flicker is not None and fl["pct"] > args.max_flicker: + reasons.append(f"мерцание статичных пикселей {fl['pct']:.2f}% > {args.max_flicker:g}%") base = entry.get("vs_baseline") if base: if base.get("error"): reasons.append(base["error"]) else: if base["mean"] > args.max_mean: - reasons.append(f"разница с базой {base['mean']:.2f} > {args.max_mean:g}") + reasons.append(f"разница с базой (статика) {base['mean']:.2f} > {args.max_mean:g}") if base["pct"] > args.max_pct: - reasons.append(f"пикселей >20/255 {base['pct']:.2f}% > {args.max_pct:g}%") + reasons.append(f"статичных пикселей >20/255 {base['pct']:.2f}% > {args.max_pct:g}%") return reasons -def write_report(out, entries, args, env_extra, size, baseline): +def write_report(out, entries, args, env_extra, size, baseline, meta): lines = [] add = lines.append add(f"# HD-полигон: {out.name}") add("") - add(f"- коммит: `{git_rev()}`, дата: {datetime.datetime.now():%Y-%m-%d %H:%M}") + add(f"- движок: `{meta['game']}`, sha256 `{meta['game_sha256'][:16]}`; коммит мода (не движка): `{git_rev()}`") + add(f"- ресурсы: `{meta['resources']}`") + add(f"- дата: {datetime.datetime.now():%Y-%m-%d %H:%M}") add(f"- разрешение кадра: {size[0]}x{size[1]}" if size else "- разрешение кадра: ?") - add(f"- окружение игры: " + (", ".join(f"`{k}={v}`" for k, v in sorted(env_extra.items())) or "по умолчанию")) - add(f"- бюджет GPU: {args.budget_ms:g} мс (bench {args.bench_secs:g} с), мерцание <= {args.max_flicker:g}%") + add("- время и погода: " + ("заморожены (`gamerule advance_time/advance_weather false`)" if meta["freeze"] + else "идут (`--no-freeze`, живой замер)")) + add(f"- кадров на сцену: {meta['stills']} через {meta['still_gap']:g} с; прогрев {meta['warmup_secs']:g} с") + add(f"- движение: размах между кадрами > {meta['motion_level']}/255 (вода, облака, листва) не сравнивается с базой; " + f"мерцание: статичные пиксели с размахом > {meta['flicker_level']}/255") + add("- окружение игры: " + (", ".join(f"`{k}={v}`" for k, v in sorted(env_extra.items())) or "по умолчанию")) + add(f"- бюджет GPU: {args.budget_ms:g} мс (bench {args.bench_secs:g} с), мерцание: " + + (f"<= {args.max_flicker:g}%" if args.max_flicker is not None else "только в отчёте (порог не задан)")) if baseline: - add(f"- сравнение с: `{baseline}` (средняя разница <= {args.max_mean:g}, пикселей >20/255 <= {args.max_pct:g}%)") + add(f"- сравнение с: `{baseline}` (статичные пиксели: средняя разница <= {args.max_mean:g}, " + f"пикселей >20/255 <= {args.max_pct:g}%)") add("") - add("| сцена | мир | время | GPU avg, мс | бюджет | мерцание %>20 | к базе: mean | к базе: %>20 | итог |") - add("|---|---|---|---|---|---|---|---|---|") + add("| сцена | мир | время | GPU avg, мс | бюджет | мерцание % | движение % | к базе (статика): mean | %>20 | итог |") + add("|---|---|---|---|---|---|---|---|---|---|") for e in entries: gpu = f"{e['gpu_avg_ms']:.2f}" if e.get("gpu_avg_ms") is not None else "-" fl = f"{e['flicker']['pct']:.2f}" if e.get("flicker") else "-" + mv = f"{e['flicker']['animated_pct']:.2f}" if e.get("flicker") else "-" b = e.get("vs_baseline") or {} bm = f"{b['mean']:.2f}" if "mean" in b else "-" bp = f"{b['pct']:.2f}" if "pct" in b else "-" status = "OK" if not e["fail"] else "FAIL: " + "; ".join(e["fail"]) budget = "да" if e.get("gpu_avg_ms") is not None and e["gpu_avg_ms"] <= args.budget_ms else ("нет" if e.get("gpu_avg_ms") is not None else "-") - add(f"| {e['name']} | {e['world']} | {e['time']} | {gpu} | {budget} | {fl} | {bm} | {bp} | {status} |") + add(f"| {e['name']} | {e['world']} | {e['time']} | {gpu} | {budget} | {fl} | {mv} | {bm} | {bp} | {status} |") passes = [] for e in entries: for p in e.get("passes", {}): @@ -458,7 +551,10 @@ def write_report(out, entries, args, env_extra, size, baseline): add(f"![{e['name']}]({e['frame']})") if e.get("diff_image"): add("") - add(f"Разница с базой (x{DIFF_GAIN}): ![diff]({e['diff_image']})") + 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']}") @@ -477,14 +573,26 @@ def parse_args(argv): p.add_argument("--env", action="append", default=[], metavar="КЛЮЧ=ЗНАЧЕНИЕ", help="переменная для игры, например HORIZON_HD_TAA=0") p.add_argument("--budget-ms", type=float, default=18.0, help="бюджет GPU на кадр, мс (по умолчанию 18)") p.add_argument("--bench-secs", type=float, default=4.0, help="сколько секунд bench (по умолчанию 4)") - p.add_argument("--settle", type=float, default=4.0, help="пауза после телепорта, с (по умолчанию 4)") - p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы с базой, 0..255") - p.add_argument("--max-pct", type=float, default=5.0, help="порог пикселей >20/255 с базой, %%") - p.add_argument("--max-flicker", type=float, default=5.0, help="порог мерцания (два кадра подряд), %%") + p.add_argument("--settle", type=float, default=12.0, help="пауза после телепорта, с (по умолчанию 12: за 4 с TAA и экспозиция ещё сходятся, мерцание 20%% -> 6%%)") + p.add_argument("--stills", type=int, default=3, help="кадров подряд на сцену, не меньше 2 (по умолчанию 3)") + p.add_argument("--still-gap", type=float, default=0.5, help="пауза между кадрами, с (по умолчанию 0.5)") + p.add_argument("--warmup-secs", type=float, default=1.0, help="прогрев перед замером GPU, с; не в отчёте (по умолчанию 1)") + p.add_argument("--motion-level", type=int, default=MOTION_LEVEL, + help=f"размах между кадрами выше этого (из 255) — движение, не сравнивается с базой (по умолчанию {MOTION_LEVEL})") + p.add_argument("--flicker-level", type=int, default=FLICKER_LEVEL, + help=f"размах статичного пикселя выше этого (из 255) — мерцание (по умолчанию {FLICKER_LEVEL})") + p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы статичных пикселей с базой, 0..255") + p.add_argument("--max-pct", type=float, default=5.0, help="порог статичных пикселей >20/255 с базой, %%") + p.add_argument("--max-flicker", type=float, default=None, help="порог мерцания статичных пикселей, %%; без него мерцание только в отчёте (порог пока не откалиброван двумя одинаковыми прогонами)") + p.add_argument("--no-freeze", action="store_true", + help="не замораживать игровое время и погоду (живой замер производительности, не снимки)") p.add_argument("--no-pass-timing", action="store_true", help="не включать HORIZON_SHADER_PACK_TIMING") p.add_argument("--game", help="horizon-client (по умолчанию как в tools/airdef.py)") p.add_argument("--resources", help="подготовленные ресурсы Minecraft 26.3") - return p.parse_args(argv) + args = p.parse_args(argv) + if args.stills < 2: + p.error("--stills: нужно не меньше 2 кадров, иначе нет ни мерцания, ни движения") + return args def main(argv=None): @@ -518,6 +626,14 @@ def main(argv=None): game = Path(args.game) if args.game else airdef.built_game() resources = Path(args.resources) if args.resources else airdef.resources() + try: + game_sha = file_sha256(game) + except OSError: + game_sha = "?" * 64 + meta = {"game": str(game), "game_sha256": game_sha, "resources": str(resources), + "freeze": not args.no_freeze, "stills": args.stills, "still_gap": args.still_gap, + "warmup_secs": args.warmup_secs, "motion_level": args.motion_level, + "flicker_level": args.flicker_level} timing = not args.no_pass_timing env = game_env(env_extra, timing) prepare_home() @@ -537,23 +653,28 @@ def main(argv=None): for s in group: entry = {**asdict(s), "frame": f"{s.name}.png"} try: - entry.update(run_shot(s, out, args.settle, args.bench_secs, timing)) - fl = compare_frames(out / f"{s.name}.png", out / f"{s.name}_still2.png") - entry["flicker"] = fl + entry.update(run_shot(s, out, args.settle, args.bench_secs, timing, args.stills, + args.still_gap, args.warmup_secs, not args.no_freeze)) + w, h, rgbs = load_frames(shot_frames(out, s.name, args.stills)) + rng = motion_range(rgbs) + entry["flicker"] = flicker_stats(rng, w, h, len(rgbs), args.motion_level, args.flicker_level) + if baseline and not entry.get("error"): + base_paths = shot_frames(baseline, s.name) # все кадры базы, сколько есть + if not base_paths: + entry["vs_baseline"] = {"error": f"в базе нет {s.name}.png"} + else: + bw, bh, brgbs = load_frames(base_paths) + if (bw, bh) != (w, h): + raise BenchError(f"размеры кадров разные: база {bw}x{bh}, прогон {w}x{h}") + brng = motion_range(brgbs) + diff_name, motion_name = f"{s.name}_diff.png", f"{s.name}_motion.png" + entry["vs_baseline"] = compare_runs(rgbs, rng, brgbs, brng, w, h, args.motion_level, + out / diff_name, out / motion_name) + entry["vs_baseline"]["base_frames"] = len(brgbs) + entry["diff_image"] = diff_name + entry["motion_image"] = motion_name except BenchError as e: entry["error"] = str(e) - if baseline and not entry.get("error"): - base_png = baseline / f"{s.name}.png" - if base_png.exists(): - try: - diff_name = f"{s.name}_diff.png" - m = compare_frames(base_png, out / f"{s.name}.png", out / diff_name) - entry["vs_baseline"] = m - entry["diff_image"] = diff_name - except BenchError as e: - entry["vs_baseline"] = {"error": str(e)} - else: - entry["vs_baseline"] = {"error": f"в базе нет {base_png.name}"} entry["fail"] = judge(entry, args) entries.append(entry) stop_game() @@ -567,15 +688,16 @@ def main(argv=None): break if size is None and entries and entries[0].get("flicker"): size = entries[0]["flicker"]["size"] - results = {"out": str(out), "env": env_extra, "budget_ms": args.budget_ms, + results = {"out": str(out), "env": env_extra, "budget_ms": args.budget_ms, "meta": meta, "baseline": str(baseline) if baseline else None, "shots": entries} (out / "results.json").write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n") - write_report(out, entries, args, env_extra, size, str(baseline) if baseline else None) + write_report(out, entries, args, env_extra, size, str(baseline) if baseline else None, meta) failed = [e for e in entries if e["fail"]] for e in entries: print(f"{e['name']:22} GPU {e.get('gpu_avg_ms') if e.get('gpu_avg_ms') is None else round(e['gpu_avg_ms'], 2)} мс " - f"мерцание {e['flicker']['pct']:.2f}%" if e.get("flicker") else f"{e['name']:22} {e.get('error', '')}") + f"мерцание {e['flicker']['pct']:.2f}% движение {e['flicker']['animated_pct']:.2f}%" if e.get("flicker") + else f"{e['name']:22} {e.get('error', '')}") for r in e["fail"]: print(f" FAIL: {r}") print(f"отчёт: {out / 'report.md'}")