Files
qbz/scripts/qt-batches.sh
T
vicrodh 931cb06d38 feat(qt): ease the visualizers on the pulse, and make Plasma react
TWO FINDINGS, both of which falsified something that was being assumed.

FLUIDITY IS NOT FRAME RATE. Driven at QBZ_PULSE_MS=10 the shell presented ~100
frames/s — qt-batches confirmed it, and the script now prints the pulse it ran
with so two logs cannot be confused — and the bars looked exactly as they had at
30. They would: every published frame was applied as a STEP, so the extra
presents repainted the same discrete values three times over. What the Slint
build does differently is not rate, it is `animate bar-h { 90ms }` — it moves
BETWEEN values.

So the interpolation is back, ON THE PULSE, and it costs nothing. The old
interpolator was expensive because of its private 16 ms Timer, not because it
interpolated: it presented at its own rate, unsynchronised with everything else.
Easing on the pulse presents exactly as often as stepping on the pulse did — one
repaint per period either way, only the value differs. Self-parking survives: a
converged ease writes nothing and schedules no repaint. The price is lag, and it
is the honest one — k = 0.45 is ~83% there in three pulses (~100 ms), matching
the reference's 90 ms, against 46 ms of FFT window and 77 ms of Rust smoothing
already in the chain.

The from/to/progress triple is gone with it, and its consumers moved to `cur`.
Note for the next reader: `SpectrumBand` drove all three of its modes from
`Connections` on those properties, and a Connections handler naming a property
that no longer exists is NOT a compile error — QML warns at runtime and simply
never fires. That is how the whole band went dead at once; grep for
`on<Name>Changed` when removing a property, not only for direct reads.

PLASMA WAS DRIVEN BY THE SLOWEST SIGNALS IN THE PIPELINE while the fast ones
travelled beside it unconnected. `transientAmp`, `bandsLo` and `bandsHi` were
published, parsed and stored, and never handed to the item — the shader declared
them and received zeros — so everything ran off the energy bands, which decay
x0.85 per frame (~473 ms) against the bars' x0.65 (~178 ms).

Worse, `beat` stops being a beat on dense material: it decays x0.88 per tick, so
above ~3 hits/s it never falls far enough to come back as a spike and settles
into a shallow ripple around 0.7. A splat multiplied by it becomes a steady
glow, and the busier the music the flatter the picture — the most frenetic input
produced the least motion. `beatAc` subtracts a slow density floor, so a hit
reads as a hit at any tempo.

The scene itself was rebuilt around the reference the owner filmed (Butterchurn
on a percussion-heavy track). Its recipe is three parts: one thin annulus
injected per frame that the feedback warp marches inward, so the concentric
rings ARE the last N frames of audio laid out in space; retention high enough
for that to stack (this held 0.85, where a trail died in ~15 frames and nothing
ever accumulated); and angular structure from reading the spectrum BY ANGLE.

But a CLOSED ring was wrong, and the owner named why: a perfect inner
circumference reads as a drawn shape, hard-edges the field, and at this
retention successive rings pack into one solid disc — which is what killed the
ambient feel. The annulus is gated by angle now, so a hit paints arcs the
conveyor carries inward, the circle never closes, and the gaps stay open for the
background. Noise bends, frays and thins those arcs so they are made of plasma
rather than of compass.

The worms are back at full strength with tighter cores and a contrast curve — a
raw gaussian is all shoulder and reads as haze — and their orbits now reach past
the arc band instead of being trapped inside it. Colour comes from ONE
album-derived triad, so everything used to be a shade of the same hue; a
Rodrigues hue turn splits it into worms, arcs and an ambient wash that sit apart
whatever album is playing. That wash is what stops the background being void for
the foreground to be a cut-out against. A soft limiter replaces the hard clamp:
at 0.945 retention a clamp pins the middle to white and the structure vanishes.

Release build clean, offscreen smoke green, 421/421.

cc
2026-08-19 13:53:57 -06:00

169 lines
7.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# Count Qt Quick scene-graph BATCHES for a scene you have driven to by hand.
#
# WHY THIS EXISTS. GPU utilisation is a terrible optimisation target: it moved by
# nothing across four different hypotheses (shader resolution, pane layering,
# animation tick, duplicated clocks) because none of them touched the thing that
# actually costs. QSG_RENDER_TIMING then showed render=2ms / sync=0 / polish=0 —
# the scene is cheap to COMPUTE — and QSG_RENDERER_DEBUG=render showed
# "Alpha: 533 nodes in 449 batches", i.e. 1.2 nodes per batch and ~28k draw calls
# a second. Batch count is the number that moves when the tree improves, and it
# is deterministic: same view, same batches. No thermal ramp, no measurement
# window to argue about, no 20% spread between consecutive readings.
#
# YOU DRIVE, THEN IT SAMPLES. The first cut of this script launched the app and
# killed it after a fixed timeout, which meant it sampled whatever was on screen
# during startup — and since the dynamic background only mounts once a track is
# loaded, the "reference scene" it measured was the one with the background OFF.
# It now waits for you: get to the view, start playback, let it settle, press
# Enter, and only then does the sample window open.
#
# Usage:
# ./scripts/qt-batches.sh # launch, you drive, Enter, 8s sample
# SECS=15 ./scripts/qt-batches.sh # longer sample window
# KEEP=1 ./scripts/qt-batches.sh # leave the app running afterwards
# ATLAS_OVERLAY=1 ./scripts/qt-batches.sh # tint atlased textures (diagnostic)
# ATLAS=8192 ./scripts/qt-batches.sh # force a bigger texture atlas
# BIN=crates/target/debug/qbz ./scripts/qt-batches.sh
# QBZ_PULSE_MS=25 ./scripts/qt-batches.sh # 40 Hz shell pulse instead of 30
#
# QBZ_PULSE_MS is the shared repaint clock (settings_qt::shell_pulse_ms,
# default 33). It is THE knob this script's "presents/s" line measures, so the
# summary below prints the value it ran with: two logs whose pulse cannot be
# told apart are not an A/B.
#
# THE REFERENCE SCENE, so two runs are comparable: album page, NPB Large, the
# spectrum band up (the eye toggle on), a track PLAYING, dynamic background on.
# The count is per-scene — comparing Home against an album page tells you
# nothing, and comparing background-on against background-off tells you only
# that translucency moved the tree into the alpha pass.
set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.."
BIN="${BIN:-crates/target/release/qbz}"
SECS="${SECS:-8}"
LOG="$(mktemp "${TMPDIR:-/tmp}/qbz-batches-XXXXXX.log")"
[[ -x "$BIN" ]] || { echo "no binary at $BIN — build first" >&2; exit 1; }
# ~1.2 MB/s of renderer chatter, so a long drive makes a big file. Harmless, but
# say so rather than let it be a surprise.
# ATLAS_OVERLAY=1 tints every texture that made it INTO the shared atlas.
# Untinted covers mean the atlas filled up: there is one Atlas instance, and
# once allocate() fails each subsequent image becomes a standalone texture with
# a unique comparisonKey() and is permanently unmergeable. That is a bigger
# lever than any clip, so it is worth ruling in or out before reading a delta.
# It is NOT on by default: the tint makes the UI unusable to look at, and this
# script's main job is the batch count.
atlas_env=()
if [[ "${ATLAS_OVERLAY:-0}" == 1 ]]; then
atlas_env+=(QSG_ATLAS_OVERLAY=1)
echo "[batches] ATLAS OVERLAY ON — tinted = in the atlas, untinted = its own texture"
fi
# ATLAS=8192 raises the atlas past the window-derived default, the actual fix
# if the overlay shows overflow.
if [[ -n "${ATLAS:-}" ]]; then
atlas_env+=(QSG_ATLAS_WIDTH="$ATLAS" QSG_ATLAS_HEIGHT="$ATLAS")
echo "[batches] atlas forced to ${ATLAS}x${ATLAS}"
fi
echo "[batches] shell pulse = ${QBZ_PULSE_MS:-33} ms (~$((1000 / ${QBZ_PULSE_MS:-33})) Hz)"
echo "[batches] launching $BIN (renderer debug is ~1 MB/s of log -> $LOG)"
env "${atlas_env[@]}" QSG_RENDERER_DEBUG=render QSG_RENDER_TIMING=1 "$BIN" > "$LOG" 2>&1 &
pid=$!
cleanup() { [[ "${KEEP:-0}" == 1 ]] || kill "$pid" 2>/dev/null || true; }
trap cleanup EXIT
echo
echo "[batches] Drive the app to the scene you want to measure:"
echo " album page · NPB Large · band ON · track PLAYING · background on"
echo "[batches] Let it settle, then press Enter to open the ${SECS}s sample window."
read -r _ || true
# Everything written before this instant is startup and navigation. The sample
# is only what lands after — that is the whole point of waiting for you.
mark=$(stat -c %s "$LOG" 2>/dev/null || stat -f %z "$LOG")
echo "[batches] sampling ${SECS}s..."
sleep "$SECS"
sample="$(mktemp "${TMPDIR:-/tmp}/qbz-batches-sample-XXXXXX.log")"
tail -c "+$((mark + 1))" "$LOG" > "$sample"
if ! kill -0 "$pid" 2>/dev/null; then
echo "[batches] WARNING: the app exited during the sample window — this is a" >&2
echo " real exit, not the script; check the tail of $LOG" >&2
fi
# The LAST block in the window: the settled tree, after any delegate churn from
# scrolling or a track change has finished.
opaque=$(grep -a 'Opaque:' "$sample" | tail -1 || true)
alpha=$(grep -a 'Alpha:' "$sample" | tail -1 || true)
if [[ -z "$alpha" ]]; then
echo "[batches] no renderer output in the window — did the scene stop repainting?" >&2
echo "[batches] (a paused player with no animation legitimately emits nothing)" >&2
echo "[batches] log: $LOG" >&2
exit 1
fi
echo
echo " $opaque"
echo " $alpha"
echo
# nodes-per-batch is the health metric: 1.0 means every node pays its own draw
# call, higher is better. Under ~2 says the tree is fighting the batcher, and
# clip state is the usual reason (Qt cannot merge across different clip roots).
python3 - "$alpha" "$opaque" <<'PY'
import re, sys
def parse(s):
m = re.search(r'(\d+)\s+nodes in\s+(\d+)\s+batches', s or '')
return (int(m.group(1)), int(m.group(2))) if m else None
a, o = parse(sys.argv[1]), parse(sys.argv[2])
if a:
print(f" alpha nodes/batch = {a[0]/max(a[1],1):.2f} (1.0 = no batching at all)")
if o:
print(f" opaque nodes/batch = {o[0]/max(o[1],1):.2f}")
if a and o:
print(f" TOTAL batches per frame = {a[1] + o[1]} <- the number to drive down")
PY
# PRESENT RATE — the number that decides whether hunting repaint sources is even
# the right hunt. Qt Quick redraws and presents the WHOLE window whenever
# anything dirties the scene, and on a hybrid laptop whose external monitor
# hangs off the dGPU every present is also a frame KWin composites and scans out
# there. So the GPU cost is presents/s x window area, and presents/s is
# MEASURABLE instead of guessable: `polishAndSync: elapsed since last call` is
# the frame period. Read this before proposing any mechanism. ~30/s = one
# source. ~60/s = two unsynchronised ones. 120+/s = something runs at display
# rate. Five hypotheses died in this campaign for want of this one number.
python3 - "$sample" <<'PYFPS'
import re, sys, statistics, os
txt = open(sys.argv[1], errors="ignore").read()
per = [int(m) for m in re.findall(r"elapsed since last call: (\d+) ms", txt)]
per = [p for p in per if p > 0]
if per:
med = statistics.median(per)
fast = sum(1 for p in per if p <= 8)
print(f" presents: {len(per)} sampled, median period {med:.0f} ms -> ~{1000/max(med,1):.0f}/s"
f" (pulse was {os.environ.get('QBZ_PULSE_MS', '33')} ms)")
print(f" arriving <=8ms apart: {fast} ({100*fast/len(per):.0f}%) <- display-rate source if high")
else:
print(" presents: no timing lines (QSG_RENDER_TIMING not honoured?)")
PYFPS
# Per-frame composition of the last block, which is what a clip audit targets.
last_start=$(grep -an 'Rendering:' "$sample" | tail -1 | cut -d: -f1 || true)
if [[ -n "$last_start" ]]; then
block=$(tail -n "+$last_start" "$sample")
echo
printf ' last frame: clip=%s noclip=%s unmerged=%s\n' \
"$(grep -c '\[ clip\]' <<<"$block" || true)" \
"$(grep -c '\[noclip\]' <<<"$block" || true)" \
"$(grep -c '\[unmerged\]' <<<"$block" || true)"
fi
echo
echo " sample: $sample"
echo " full log: $LOG"