Files
benilla-octo/scripts/water_perf.py
T
gromandClaude Opus 5 624ccec808 water: graft deepthought84's Improved Water onto this fork
The whole workspace compiles with his water in place of ours. Nothing is
verified beyond that yet -- no capture, no gates -- so this lands on
`water-port`, not on main.

Method: scripts/water-port-merge.sh stitches the common ancestor our squashed
history hides (both forks descend from samwhosung/benilla) and runs a real
three-way merge. 134 of his 168 files land clean; the 34 that conflict were
resolved by hand, 82 hunks, and the log is in docs/WATER_PORT.md.

What went, per grom's call (full swap, drop the extras): enhanced_water.wgsl,
liquid/{reflect,scene_depth,waves,lod}.rs, water_fx/bob.rs and the Water Quality
dropdown, with our LiquidExt bindings and the WaterDepth prepass they fed.

What was kept and re-applied onto his tree: the Ironforge magma palette
(MagmaTone, now multiplying the body before his stylised grade so a tinted pool's
crust and glow are its own colour) and our shader hooks -- fog, emissive,
monkey_frame, wind, wet, moonlight -- which his liquid.wgsl knew nothing about.

Four things needed real work rather than a side:
  * static_gx now keys its pipelines per VIEW, not per world camera, because his
    mirror and probe faces carry their own samples and format; our torch and
    light groups and the shadow-filter def ride along, and the depth-only twin
    his Hi-Z prepass needs is built beside each colour pipeline.
  * the booth layer ladder moved past the liquid render layers. His seam claims
    30 and 31; our pane block grew from UI_MODELS_LAYER with nothing stopping it
    reaching them, and the const assert fired exactly as the doc predicted.
  * two SystemParam tuples were already at the 16 ceiling (the minimap's blip
    inputs, the capture driver) and had to nest to take his.
  * sky.rs grew `dome_uniforms`, the one law his water march and the dome now
    share, without losing our Enhanced sky inputs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-09 10:57:18 -07:00

183 lines
7.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Water perf (INS-4): the cost of each golden water scene, read from a `water-shots.sh` perf run.
WATER_PERF=1200 scripts/water-shots.sh <dir> # <scene>.perf.log/.fps.csv, both lanes
scripts/water_perf.py <dir> [<stem> ...] # one line per shot
scripts/water_perf.py <dir> --json out.json # also write the numbers
A perf shot builds and ages its scene like a still, then runs its frames with vsync off and the
loop continuous (the capture's `WOW_FPS_PROBE`), so the GPU stays busy and its clock stays up;
under a 60 Hz pace a light frame lets the clock idle down and every pass reads slower. Per shot,
from <stem>.perf.log:
- the FPS_PROBE line: wall ms per frame, the whole-frame GPU meter (`WOW_GPU_MS`, gpu_mean), CPU
ms per frame over every thread;
- each probe second's GPU_PASSES line (ms per frame per pass), after the log's "probe: scene aged"
line and without its first SETTLE seconds, which hold frozen-clock frames and the warm-up; the
median of each pass over the rest;
- main_ms, the main thread's CPU per frame, the median over the journal's rows of the same seconds
(<stem>.perf.fps.csv, one row per GPU_PASSES line).
FIXED_PASS does the same work on every scene and lane, so its time moves only with the GPU's
clock (`fixed_ratio`, against the run's median). A shot where it reads more than CLOCK_SLACK times
the median ran at a lower clock, as a light frame whose CPU is the limit can, and its passes read
slower by up to that factor ("clock?"). A probe whose wall frame sits at 1/60 s while the GPU idles
part of it was paced, not uncapped ("paced"), and its clock idled too.
"""
from __future__ import annotations
import argparse
import csv
import json
import pathlib
import re
import statistics
import sys
# The UI's gamma decode: one full-window pass, the same work in every shot.
FIXED_PASS = "ui_gamma_decode"
CLOCK_SLACK = 1.25
# A wall frame this close to 1/60 s with the GPU busy under PACED_GPU of it is a 60 Hz pace.
PACE_MS = 1000.0 / 60.0
PACED_GPU = 0.9
# Probe seconds dropped after the probe starts: the first flush still holds frozen-clock frames,
# the next can hold the rest of the 60-frame warm-up.
SETTLE = 2
PROBE_MARK = "probe: scene aged"
ANSI = re.compile(r"\x1b\[[0-9;]*m")
# `name=value` pairs; a pass name can hold a space ("early prepass").
PAIR = re.compile(r"(\S(?:[^=]*\S)?)=(\S+)")
# Columns of the summary, from the shot's record.
SHOWN = ["frames", "mean_ms", "gpu_mean", "gpu_passes", "gpu_water", "water_hiz_seed",
"water_hiz_reduce", "water_snapshot", "water_compute", "main_transparent_pass_3d",
"main_opaque_pass_3d", "cpu_ms", "main_ms", FIXED_PASS]
def pairs(text: str) -> dict[str, str]:
return {k: v for k, v in PAIR.findall(text)}
def num(v):
try:
return float(v)
except (TypeError, ValueError):
return v
def parse_log(text: str) -> dict:
"""The probe's numbers from one perf shot's log: the FPS_PROBE fields and the per-pass
medians over the probe seconds kept (`seconds`)."""
lines = [ANSI.sub("", l) for l in text.splitlines()]
start = next((i for i, l in enumerate(lines) if PROBE_MARK in l), None)
rec: dict = {"probe": start is not None}
probe = next((l for l in lines if l.startswith("FPS_PROBE ")), None)
if probe:
rec.update({k: num(v) for k, v in pairs(probe[len("FPS_PROBE "):]).items()})
if start is None:
return rec
seconds = []
for l in lines[start + 1:]:
if l.startswith("GPU_PASSES "):
seconds.append({k: float(v) for k, v in pairs(l[len("GPU_PASSES "):]).items()})
kept = seconds[SETTLE:]
rec["seconds"] = len(kept)
passes: dict[str, float] = {}
for name in sorted({k for s in kept for k in s} - {"frames"}):
passes[name] = statistics.median(s.get(name, 0.0) for s in kept)
rec["passes"] = passes
rec["gpu_passes"] = sum(passes.values())
rec["gpu_water"] = sum(v for k, v in passes.items() if k.startswith("water_"))
return rec
def journal_main_ms(text: str, seconds: int) -> float | None:
"""The median main-thread ms over the journal's last `seconds` rows that carry GPU cells."""
rows = [l for l in text.splitlines() if l and not l.startswith("#")]
if len(rows) < 2 or seconds <= 0:
return None
body = [r for r in csv.DictReader(rows) if r.get("gpu_ms")]
vals = [float(r["main_ms"]) for r in body[-seconds:] if r.get("main_ms")]
return statistics.median(vals) if vals else None
def measure(directory: pathlib.Path, stems: list[str]) -> dict:
"""{stem: record} for every <stem>.perf.log in the directory, or the stems given."""
logs = sorted(directory.glob("*.perf.log"))
found = {p.name[: -len(".perf.log")]: p for p in logs}
out: dict = {}
for stem in stems or sorted(found):
if stem not in found:
print(f"{stem}: no {stem}.perf.log in {directory}", file=sys.stderr)
continue
rec = parse_log(found[stem].read_text(errors="replace"))
journal = directory / f"{stem}.perf.fps.csv"
if journal.exists():
rec["main_ms"] = journal_main_ms(journal.read_text(), rec.get("seconds", 0))
out[stem] = rec
fixed = [r["passes"][FIXED_PASS] for r in out.values() if FIXED_PASS in r.get("passes", {})]
ref = statistics.median(fixed) if fixed else None
for r in out.values():
f = r.get("passes", {}).get(FIXED_PASS)
r["fixed_ratio"] = f / ref if f is not None and ref else None
r["clock_ok"] = None if r["fixed_ratio"] is None else r["fixed_ratio"] <= CLOCK_SLACK
return out
def paced(r: dict) -> bool:
"""The probe ran at the 60 Hz pace instead of its frame cost."""
wall, gpu = r.get("mean_ms"), r.get("gpu_mean")
if not isinstance(wall, float):
return False
return abs(wall - PACE_MS) < 0.35 and (not isinstance(gpu, float) or gpu < PACED_GPU * wall)
def value(r: dict, key: str):
return r.get(key, r.get("passes", {}).get(key))
def fmt(v) -> str:
if v is None:
return "-"
return f"{v:.3f}" if isinstance(v, float) else str(v)
def report(results: dict) -> int:
"""One line per shot; the count of shots that are not a clean saturated probe."""
bad = 0
heads = ["frames", "wall", "gpu", "passes", "water", "seed", "reduce", "snap", "compute",
"transp", "opaque", "cpu", "main", "fixed"]
print(f"{'shot':34s} " + " ".join(f"{h:>7s}" for h in heads))
for stem, r in results.items():
why = []
if not r.get("probe") or "mean_ms" not in r:
why.append("no probe")
elif r.get("seconds", 0) < 3:
why.append(f"{r.get('seconds', 0)} probe seconds")
if r.get("present") not in (None, "AutoNoVsync", "Immediate", "Mailbox"):
why.append(f"present {r.get('present')}")
if paced(r):
why.append("paced")
if r.get("clock_ok") is False:
why.append(f"clock? ({FIXED_PASS} {r['fixed_ratio']:.2f}x the run's)")
bad += bool(why)
cells = " ".join(f"{fmt(value(r, k)):>7s}" for k in SHOWN)
print(f"{stem:34s} {cells} {' '.join(why)}")
return bad
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("dir", type=pathlib.Path, help="directory of a WATER_PERF run")
ap.add_argument("stems", nargs="*", help="shots, e.g. water-loch-modan water-loch-modan-ref")
ap.add_argument("--json", type=pathlib.Path, help="write the records as JSON")
a = ap.parse_args(argv)
results = measure(a.dir, a.stems)
bad = report(results)
if a.json:
a.json.write_text(json.dumps(results, indent=1))
return 1 if bad or not results else 0
if __name__ == "__main__":
sys.exit(main())