0f5c4136f3
build / mod (push) Successful in 3m49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
742 lines
39 KiB
Python
742 lines
39 KiB
Python
#!/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}, синим — движущиеся пиксели, не сравниваются): ")
|
||
if e.get("motion_image"):
|
||
add("")
|
||
add(f"Движущиеся пиксели (белым): ")
|
||
if e.get("warning"):
|
||
add("")
|
||
add(f"> {e['warning']}")
|
||
if e.get("error"):
|
||
add("")
|
||
add(f"> ошибка: {e['error']}")
|
||
(out / "report.md").write_text("\n".join(lines) + "\n")
|
||
|
||
|
||
def parse_args(argv):
|
||
p = argparse.ArgumentParser(description="Графический полигон HD: кадры, мерцание, GPU-бюджет.")
|
||
p.add_argument("--list", action="store_true", help="показать сцены и выйти")
|
||
p.add_argument("--only", action="append", default=[], metavar="СЦЕНА", help="только эти сцены (можно несколько раз)")
|
||
p.add_argument("--out", help="папка вывода (по умолчанию run-home-bench/out/<время>)")
|
||
p.add_argument("--baseline", help="папка прошлого прогона для сравнения кадров")
|
||
p.add_argument("--env", action="append", default=[], metavar="КЛЮЧ=ЗНАЧЕНИЕ", help="переменная для игры, например HORIZON_HD_TAA=0")
|
||
p.add_argument("--budget-ms", type=float, default=18.0, help="бюджет GPU на кадр, мс (по умолчанию 18)")
|
||
p.add_argument("--bench-secs", type=float, default=4.0, help="сколько секунд bench (по умолчанию 4)")
|
||
p.add_argument("--settle", type=float, default=12.0, help="пауза после телепорта, с (по умолчанию 12: за 4 с TAA и экспозиция ещё сходятся, мерцание 20%% -> 6%%)")
|
||
p.add_argument("--stills", type=int, default=3, help="кадров подряд на сцену, не меньше 2 (по умолчанию 3)")
|
||
p.add_argument("--still-gap", type=float, default=0.5, help="пауза между кадрами, с (по умолчанию 0.5)")
|
||
p.add_argument("--warmup-secs", type=float, default=1.0, help="прогрев перед замером GPU, с; не в отчёте (по умолчанию 1)")
|
||
p.add_argument("--motion-level", type=int, default=MOTION_LEVEL,
|
||
help=f"размах между кадрами выше этого (из 255) — движение, не сравнивается с базой (по умолчанию {MOTION_LEVEL})")
|
||
p.add_argument("--flicker-level", type=int, default=FLICKER_LEVEL,
|
||
help=f"размах статичного пикселя выше этого (из 255) — мерцание (по умолчанию {FLICKER_LEVEL})")
|
||
p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы статичных пикселей с базой, 0..255")
|
||
p.add_argument("--max-pct", type=float, default=5.0, help="порог статичных пикселей >20/255 с базой, %%")
|
||
p.add_argument("--max-flicker", type=float, default=None, help="порог мерцания статичных пикселей, %%; без него мерцание только в отчёте (порог пока не откалиброван двумя одинаковыми прогонами)")
|
||
p.add_argument("--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)
|