hd_bench: time and weather frozen per shot, several stills per shot, moving pixels (water, clouds, leaves) kept out of the baseline score, warm-up, engine hash in the report (#71)
build / mod (push) Successful in 3m18s
build / mod (push) Successful in 3m18s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+209
-87
@@ -1,24 +1,37 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Графический полигон HD (#44): фиксированные сцены, сравнение кадров, мерцание и GPU-бюджет.
|
"""Графический полигон HD (#44, #71): фиксированные сцены, сравнение кадров, мерцание и GPU-бюджет.
|
||||||
|
|
||||||
python3 tools/hd_bench.py --list # сцены и их параметры
|
python3 tools/hd_bench.py --list # сцены и их параметры
|
||||||
python3 tools/hd_bench.py # все сцены -> run-home-bench/out/<время>/
|
python3 tools/hd_bench.py # все сцены -> run-home-bench/out/<время>/
|
||||||
python3 tools/hd_bench.py --only polygon_noon # одна сцена (годится и при занятой игре)
|
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 --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 --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,
|
Каждый прогон: свежая копия миров Polygon и Hills из run-home/saves в run-home-bench/home, мод airdef,
|
||||||
игра офскрин на сокете run-home-bench/c.sock (одна за раз; чужой игре run-home/ctl.sock не мешаем).
|
игра офскрин на сокете run-home-bench/c.sock (одна за раз; чужой игре run-home/ctl.sock не мешаем).
|
||||||
Для каждой сцены: время суток, ясная погода, телепорт, пауза на устаканивание, кадр, ещё один кадр
|
Для каждой сцены: время и погода заморожены (`gamerule advance_time/advance_weather false`), время суток,
|
||||||
(мерцание), `bench 4` (GPU avg, мс) и средние проходы из run-home-bench/c.log при HORIZON_SHADER_PACK_TIMING=1.
|
ясная погода, телепорт, пауза на устаканивание, подряд --stills кадров (по умолчанию 3), прогрев, `bench 4`
|
||||||
|
(GPU avg, мс) и средние проходы из run-home-bench/c.log при HORIZON_SHADER_PACK_TIMING=1.
|
||||||
Итог: report.md и results.json в папке вывода.
|
Итог: 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 — ошибка запуска или аргументов.
|
Коды выхода: 0 — всё в порогах и бюджете; 1 — есть превышение; 2 — ошибка запуска или аргументов.
|
||||||
Только стандартная библиотека; при установленном Pillow сравнение быстрее.
|
Только стандартная библиотека; при установленном Pillow декодирование PNG быстрее.
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import datetime
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import itertools
|
||||||
import json
|
import json
|
||||||
|
import operator
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@@ -38,9 +51,12 @@ HOME = BENCH / "home" # HORIZON_HOME одного прогона, пересо
|
|||||||
CTL = ROOT / "horizon" / "target" / "fast" / "horizon-ctl"
|
CTL = ROOT / "horizon" / "target" / "fast" / "horizon-ctl"
|
||||||
SAVES = ROOT / "run-home" / "saves"
|
SAVES = ROOT / "run-home" / "saves"
|
||||||
OTHER_SOCK = "run-home/ctl.sock" # чужая игра (другой агент): только читаем, не трогаем
|
OTHER_SOCK = "run-home/ctl.sock" # чужая игра (другой агент): только читаем, не трогаем
|
||||||
DIFF_LEVEL = 20 # порог «заметной» разницы по каналу, из 255
|
DIFF_LEVEL = 20 # порог «заметной» разницы по каналу, из 255 (сравнение с базой)
|
||||||
DIFF_GAIN = 6 # усиление разницы в diff-картинках
|
DIFF_GAIN = 6 # усиление разницы в diff-картинках
|
||||||
|
MOTION_LEVEL = 16 # размах значения между кадрами одного прогона выше этого — движение (вода, облака, листва)
|
||||||
|
FLICKER_LEVEL = 6 # размах выше этого на статичных пикселях — мерцание
|
||||||
TIMING_RE = re.compile(r"shader pack GPU time per pass .*?: (.*) = ([\d.]+)\s*$")
|
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
|
@dataclass
|
||||||
@@ -173,6 +189,14 @@ def stop_game():
|
|||||||
SOCK.unlink()
|
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 и с ним -------------------------------------------------------------
|
# --- кадры: PNG без Pillow и с ним -------------------------------------------------------------
|
||||||
|
|
||||||
def _paeth(a, b, c):
|
def _paeth(a, b, c):
|
||||||
@@ -248,51 +272,103 @@ def write_png_rgb(path, w, h, rgb):
|
|||||||
+ chunk(b"IEND", b""))
|
+ chunk(b"IEND", b""))
|
||||||
|
|
||||||
|
|
||||||
try: # необязательно: быстрее декодирование и сравнение
|
try: # необязательно: быстрее декодирование
|
||||||
from PIL import Image, ImageChops, ImageStat
|
from PIL import Image
|
||||||
except ImportError: # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
Image = None
|
Image = None
|
||||||
|
|
||||||
|
|
||||||
def compare_frames(a, b, diff_out=None):
|
def load_rgb(path):
|
||||||
"""Средняя разница по каналам (0..255) и доля пикселей с разницей > DIFF_LEVEL, в %."""
|
|
||||||
if Image is not None:
|
if Image is not None:
|
||||||
A = Image.open(a).convert("RGB")
|
im = Image.open(path).convert("RGB")
|
||||||
B = Image.open(b).convert("RGB")
|
return im.size[0], im.size[1], im.tobytes()
|
||||||
if A.size != B.size:
|
return read_png_rgb(path)
|
||||||
raise BenchError(f"размеры кадров разные: {A.size} и {B.size}")
|
|
||||||
d = ImageChops.difference(A, B)
|
|
||||||
mean = sum(ImageStat.Stat(d).mean) / 3
|
def load_frames(paths):
|
||||||
r, g, bl = d.split()
|
"""(w, h, [bytes RGB каждого кадра]); размеры должны совпадать."""
|
||||||
mx = ImageChops.lighter(ImageChops.lighter(r, g), bl)
|
w = h = None
|
||||||
hist = mx.histogram()
|
rgbs = []
|
||||||
total = A.size[0] * A.size[1]
|
for p in paths:
|
||||||
pct = 100.0 * sum(hist[DIFF_LEVEL + 1:]) / total
|
pw, ph, px = load_rgb(p)
|
||||||
if diff_out:
|
if w is None:
|
||||||
d.point(lambda v: min(255, v * DIFF_GAIN)).save(diff_out)
|
w, h = pw, ph
|
||||||
return {"mean": mean, "pct": pct, "size": list(A.size)}
|
elif (pw, ph) != (w, h):
|
||||||
wa, ha, pa = read_png_rgb(a)
|
raise BenchError(f"размеры кадров разные: {w}x{h} и {pw}x{ph} ({Path(p).name})")
|
||||||
wb, hb, pb = read_png_rgb(b)
|
rgbs.append(px)
|
||||||
if (wa, ha) != (wb, hb):
|
return w, h, rgbs
|
||||||
raise BenchError(f"размеры кадров разные: {wa}x{ha} и {wb}x{hb}")
|
|
||||||
total_abs = 0
|
|
||||||
over = 0
|
def shot_frames(folder, name, n=None):
|
||||||
diff = bytearray(len(pa)) if diff_out else None
|
"""Кадры сцены по порядку: <имя>.png, <имя>_still2.png, ... до n или пока файлы есть."""
|
||||||
for i in range(0, len(pa), 3):
|
paths = []
|
||||||
dr = abs(pa[i] - pb[i])
|
while n is None or len(paths) < n:
|
||||||
dg = abs(pa[i + 1] - pb[i + 1])
|
k = len(paths) + 1
|
||||||
db = abs(pa[i + 2] - pb[i + 2])
|
p = Path(folder) / (f"{name}.png" if k == 1 else f"{name}_still{k}.png")
|
||||||
total_abs += dr + dg + db
|
if not p.exists():
|
||||||
if max(dr, dg, db) > DIFF_LEVEL:
|
break
|
||||||
over += 1
|
paths.append(p)
|
||||||
if diff is not None:
|
return paths
|
||||||
diff[i] = min(255, dr * DIFF_GAIN)
|
|
||||||
diff[i + 1] = min(255, dg * DIFF_GAIN)
|
|
||||||
diff[i + 2] = min(255, db * DIFF_GAIN)
|
# --- сравнение кадров с учётом анимации (#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:
|
if diff_out:
|
||||||
write_png_rgb(diff_out, wa, ha, bytes(diff))
|
img = bytearray(min(255, v * DIFF_GAIN) for v in d)
|
||||||
npx = wa * ha
|
for i, s in enumerate(static):
|
||||||
return {"mean": total_abs / (3.0 * npx), "pct": 100.0 * over / npx, "size": [wa, ha]}
|
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
|
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", f"time set {shot.time}")
|
||||||
ctl("command", "weather clear")
|
ctl("command", "weather clear")
|
||||||
ctl("command", f"tp @s {x} {y} {z} {yaw} {pitch}")
|
ctl("command", f"tp @s {x} {y} {z} {yaw} {pitch}")
|
||||||
ctl("wait-ready", 120, timeout=150)
|
ctl("wait-ready", 120, timeout=150)
|
||||||
time.sleep(settle)
|
time.sleep(settle)
|
||||||
first = out / f"{shot.name}.png"
|
frames = []
|
||||||
ctl("screenshot", str(first.resolve()))
|
for k in range(stills):
|
||||||
time.sleep(0.5)
|
if k:
|
||||||
second = out / f"{shot.name}_still2.png"
|
time.sleep(gap)
|
||||||
ctl("screenshot", str(second.resolve()))
|
path = out / (f"{shot.name}.png" if k == 0 else f"{shot.name}_still{k + 1}.png")
|
||||||
result = {"frame": first.name, "still2": second.name, "gpu_avg_ms": None, "passes": {},
|
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}
|
"pass_total_ms": None, "bench_json": None}
|
||||||
offset = LOG.stat().st_size if LOG.exists() else 0
|
offset = LOG.stat().st_size if LOG.exists() else 0
|
||||||
reply = ctl("bench", bench_secs, timeout=bench_secs + 60)
|
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"):
|
if gpu is None and not entry.get("error"):
|
||||||
reasons.append("нет GPU-таймингов (bench)")
|
reasons.append("нет GPU-таймингов (bench)")
|
||||||
fl = entry.get("flicker")
|
fl = entry.get("flicker")
|
||||||
if fl and fl["pct"] > args.max_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}%")
|
reasons.append(f"мерцание статичных пикселей {fl['pct']:.2f}% > {args.max_flicker:g}%")
|
||||||
base = entry.get("vs_baseline")
|
base = entry.get("vs_baseline")
|
||||||
if base:
|
if base:
|
||||||
if base.get("error"):
|
if base.get("error"):
|
||||||
reasons.append(base["error"])
|
reasons.append(base["error"])
|
||||||
else:
|
else:
|
||||||
if base["mean"] > args.max_mean:
|
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:
|
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
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
def write_report(out, entries, args, env_extra, size, baseline):
|
def write_report(out, entries, args, env_extra, size, baseline, meta):
|
||||||
lines = []
|
lines = []
|
||||||
add = lines.append
|
add = lines.append
|
||||||
add(f"# HD-полигон: {out.name}")
|
add(f"# HD-полигон: {out.name}")
|
||||||
add("")
|
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"- разрешение кадра: {size[0]}x{size[1]}" if size else "- разрешение кадра: ?")
|
||||||
add(f"- окружение игры: " + (", ".join(f"`{k}={v}`" for k, v in sorted(env_extra.items())) or "по умолчанию"))
|
add("- время и погода: " + ("заморожены (`gamerule advance_time/advance_weather false`)" if meta["freeze"]
|
||||||
add(f"- бюджет GPU: {args.budget_ms:g} мс (bench {args.bench_secs:g} с), мерцание <= {args.max_flicker:g}%")
|
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:
|
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("")
|
||||||
add("| сцена | мир | время | GPU avg, мс | бюджет | мерцание %>20 | к базе: mean | к базе: %>20 | итог |")
|
add("| сцена | мир | время | GPU avg, мс | бюджет | мерцание % | движение % | к базе (статика): mean | %>20 | итог |")
|
||||||
add("|---|---|---|---|---|---|---|---|---|")
|
add("|---|---|---|---|---|---|---|---|---|---|")
|
||||||
for e in entries:
|
for e in entries:
|
||||||
gpu = f"{e['gpu_avg_ms']:.2f}" if e.get("gpu_avg_ms") is not None else "-"
|
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 "-"
|
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 {}
|
b = e.get("vs_baseline") or {}
|
||||||
bm = f"{b['mean']:.2f}" if "mean" in b else "-"
|
bm = f"{b['mean']:.2f}" if "mean" in b else "-"
|
||||||
bp = f"{b['pct']:.2f}" if "pct" in b else "-"
|
bp = f"{b['pct']:.2f}" if "pct" in b else "-"
|
||||||
status = "OK" if not e["fail"] else "FAIL: " + "; ".join(e["fail"])
|
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 "-")
|
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 = []
|
passes = []
|
||||||
for e in entries:
|
for e in entries:
|
||||||
for p in e.get("passes", {}):
|
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']})")
|
add(f"![{e['name']}]({e['frame']})")
|
||||||
if e.get("diff_image"):
|
if e.get("diff_image"):
|
||||||
add("")
|
add("")
|
||||||
add(f"Разница с базой (x{DIFF_GAIN}): ")
|
add(f"Разница с базой (x{DIFF_GAIN}, синим — движущиеся пиксели, не сравниваются): ")
|
||||||
|
if e.get("motion_image"):
|
||||||
|
add("")
|
||||||
|
add(f"Движущиеся пиксели (белым): ")
|
||||||
if e.get("warning"):
|
if e.get("warning"):
|
||||||
add("")
|
add("")
|
||||||
add(f"> {e['warning']}")
|
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("--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("--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("--bench-secs", type=float, default=4.0, help="сколько секунд bench (по умолчанию 4)")
|
||||||
p.add_argument("--settle", type=float, default=4.0, help="пауза после телепорта, с (по умолчанию 4)")
|
p.add_argument("--settle", type=float, default=12.0, help="пауза после телепорта, с (по умолчанию 12: за 4 с TAA и экспозиция ещё сходятся, мерцание 20%% -> 6%%)")
|
||||||
p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы с базой, 0..255")
|
p.add_argument("--stills", type=int, default=3, help="кадров подряд на сцену, не меньше 2 (по умолчанию 3)")
|
||||||
p.add_argument("--max-pct", type=float, default=5.0, help="порог пикселей >20/255 с базой, %%")
|
p.add_argument("--still-gap", type=float, default=0.5, help="пауза между кадрами, с (по умолчанию 0.5)")
|
||||||
p.add_argument("--max-flicker", type=float, default=5.0, help="порог мерцания (два кадра подряд), %%")
|
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("--no-pass-timing", action="store_true", help="не включать HORIZON_SHADER_PACK_TIMING")
|
||||||
p.add_argument("--game", help="horizon-client (по умолчанию как в tools/airdef.py)")
|
p.add_argument("--game", help="horizon-client (по умолчанию как в tools/airdef.py)")
|
||||||
p.add_argument("--resources", help="подготовленные ресурсы Minecraft 26.3")
|
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):
|
def main(argv=None):
|
||||||
@@ -518,6 +626,14 @@ def main(argv=None):
|
|||||||
|
|
||||||
game = Path(args.game) if args.game else airdef.built_game()
|
game = Path(args.game) if args.game else airdef.built_game()
|
||||||
resources = Path(args.resources) if args.resources else airdef.resources()
|
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
|
timing = not args.no_pass_timing
|
||||||
env = game_env(env_extra, timing)
|
env = game_env(env_extra, timing)
|
||||||
prepare_home()
|
prepare_home()
|
||||||
@@ -537,23 +653,28 @@ def main(argv=None):
|
|||||||
for s in group:
|
for s in group:
|
||||||
entry = {**asdict(s), "frame": f"{s.name}.png"}
|
entry = {**asdict(s), "frame": f"{s.name}.png"}
|
||||||
try:
|
try:
|
||||||
entry.update(run_shot(s, out, args.settle, args.bench_secs, timing))
|
entry.update(run_shot(s, out, args.settle, args.bench_secs, timing, args.stills,
|
||||||
fl = compare_frames(out / f"{s.name}.png", out / f"{s.name}_still2.png")
|
args.still_gap, args.warmup_secs, not args.no_freeze))
|
||||||
entry["flicker"] = fl
|
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:
|
except BenchError as e:
|
||||||
entry["error"] = str(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)
|
entry["fail"] = judge(entry, args)
|
||||||
entries.append(entry)
|
entries.append(entry)
|
||||||
stop_game()
|
stop_game()
|
||||||
@@ -567,15 +688,16 @@ def main(argv=None):
|
|||||||
break
|
break
|
||||||
if size is None and entries and entries[0].get("flicker"):
|
if size is None and entries and entries[0].get("flicker"):
|
||||||
size = entries[0]["flicker"]["size"]
|
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}
|
"baseline": str(baseline) if baseline else None, "shots": entries}
|
||||||
(out / "results.json").write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n")
|
(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"]]
|
failed = [e for e in entries if e["fail"]]
|
||||||
for e in entries:
|
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)} мс "
|
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"]:
|
for r in e["fail"]:
|
||||||
print(f" FAIL: {r}")
|
print(f" FAIL: {r}")
|
||||||
print(f"отчёт: {out / 'report.md'}")
|
print(f"отчёт: {out / 'report.md'}")
|
||||||
|
|||||||
Reference in New Issue
Block a user