Web performance: light animation, gestures on a compositor layer, no forced layouts
Deploy web Studio / deploy (push) Successful in 3m46s

Measured with the new tools/bench_web.py on the web Studio's house demo, CPU slowed 6x:

- Idle: running CSS animations repainted the whole drawing every frame (99% of the main
  thread in a window). Animations now step on about ten times a second by default
  ("light", 5% idle); the new toolbar button switches light / smooth / off and the browser
  remembers it; [build] animation sets the default for built pages; view.animation().
  Pan and zoom gestures pause them. Status LEDs pause off-screen and with the rest now.
- Pan 28 -> 10 ms, zoom 69 -> 11 ms per step: gestures move the drawing as one compositor
  layer (transform) and set the viewBox once they end; --iso-k is set on the tags group
  only and only when it changes; the stage box is read once per frame.
- Hover 22 -> 15 ms per move: the tooltip is placed once per frame on its own layer and
  measured only when its text changes; the Studio's x/y readout updates ten times a second
  in a contained box; hover work in the Studio runs once per frame.
- Long object lists skip layout of rows out of sight (content-visibility).
- WASM built with opt-level 3: engine render 11.7 -> 9.4 ms for 10% more download.

Checks: Studio 102/102 (animation modes, pan as a layer, contained readout), web 87/87.
This commit is contained in:
grabowski committed 2026-10-24 20:46:35 +07:00
1 parent 19f4c83ae8
commit a202283a60
15 files changed
+476 -31

No files matched your search

+251
View File
@@ -0,0 +1,251 @@
"""How the web Studio and a static page run on a slow machine.
Serves dist/web (build it first: python tools/build_web.py) and opens it and
an example's static page (isokit build examples/house) in headless Edge with
the CPU slowed down (CDP Emulation.setCPUThrottlingRate; 6x is roughly a
low-power laptop next to a desktop). For each page it prints the load time,
DOM size, the main thread's busy share while idle (animations), frames per
main-thread time per input step and long frames (over 50 ms) while panning,
zooming and hovering, and how long a
rotation, a level switch and an edit take until painted.
uv run --with playwright python tools/bench_web.py [--rate 6] [--headed] [--anim light|smooth|off]
[--json out.json] [--only studio|page]
Use --headed for the idle figures: headless Edge renders no frames for CSS
animations when nothing else changes, so their cost only shows in a window.
"""
import argparse
import json
import subprocess
import sys
import time
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parent.parent
DIST = ROOT / "dist" / "web"
PAGE = ROOT / "examples" / "house" / "dist" / "index.html"
PORT = 7941
ANIM = None
# in the page: collect long animation frames (over 50 ms, Chromium's LoAF API);
# no requestAnimationFrame loop, which would make headless Chrome render flat out.
# JS_TIMED times an action until the view's SVG has new content and two frames
# have been painted.
JS_SETUP = """() => {
if (window.__bench) return;
window.__bench = { loaf: [] };
try {
new PerformanceObserver(l => l.getEntries().forEach(e => window.__bench.loaf.push(e.duration)))
.observe({ type: 'long-animation-frame', buffered: false });
} catch (e) { /* not supported */ }
}"""
JS_TIMED = """async (code) => {
const svg = document.querySelector('.iso-stage .iso-svg');
const t0 = performance.now();
const changed = new Promise(r => { const mo = new MutationObserver(() => { mo.disconnect(); r(); }); mo.observe(svg, { childList: true }); });
(0, eval)(code);
const late = await Promise.race([changed.then(() => false), new Promise(r => setTimeout(() => r(true), 30000))]);
if (late) return -1;
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
return performance.now() - t0;
}"""
T0 = time.perf_counter()
def log(what):
print(f" [{time.perf_counter() - T0:6.1f}s] {what}", flush=True)
def metrics(cdp):
m = {x["name"]: x["value"] for x in cdp.send("Performance.getMetrics")["metrics"]}
return m
def busy(cdp, pg, run, steps=1):
"""Run `run()` (`steps` input events); answer a dict: wall seconds, the main
thread's busy share, its ms per step, and the long frames (count, longest)."""
pg.evaluate(JS_SETUP)
pg.evaluate("window.__bench.loaf = []")
m0, t0 = metrics(cdp), time.perf_counter()
run()
pg.wait_for_timeout(300)
m1, t1 = metrics(cdp), time.perf_counter()
loaf = pg.evaluate("window.__bench.loaf")
wall, task = t1 - t0, m1["TaskDuration"] - m0["TaskDuration"]
return {"s": wall, "busy": task / wall, "ms_step": 1000 * task / steps, "long": len(loaf), "max_ms": max(loaf or [0])}
def gestures(pg, cdp, stage, out, wheel_mod=None):
b = stage.bounding_box()
cx, cy = b["x"] + b["width"] / 2, b["y"] + b["height"] / 2
out["nodes"] = pg.evaluate("document.querySelectorAll('.iso-stage .iso-svg *').length")
log("idle")
out["idle_busy"] = busy(cdp, pg, lambda: pg.wait_for_timeout(4000))["busy"]
def pan():
# the middle button: always the camera, in the Studio and on a page
pg.mouse.move(cx - 150, cy)
pg.mouse.down(button="middle")
for i in range(90):
pg.mouse.move(cx - 150 + 300 * (i % 45) / 45, cy + 60 * ((i // 45) % 2))
pg.wait_for_timeout(16)
pg.mouse.up(button="middle")
def zoom():
pg.mouse.move(cx, cy)
if wheel_mod:
pg.keyboard.down(wheel_mod)
for i in range(24):
pg.mouse.wheel(0, -60 if i < 12 else 60)
pg.wait_for_timeout(30)
if wheel_mod:
pg.keyboard.up(wheel_mod)
def hover():
for i in range(90):
pg.mouse.move(b["x"] + b["width"] * (0.2 + 0.6 * i / 90), cy + 40 * ((i // 10) % 2))
pg.wait_for_timeout(16)
pg.evaluate("__isokit_view().focus([0, 0, 0], 2.2)")
pg.wait_for_timeout(600)
for name, fn, n in [("pan", pan, 90), ("zoom", zoom, 24), ("hover", hover, 90)]:
log(name)
r = busy(cdp, pg, fn, n)
for k, v in r.items():
out[name + "_" + k] = v
pg.wait_for_timeout(500)
log("rotate")
out["rotate_ms"] = pg.evaluate(JS_TIMED, "__isokit_view().setRotation((__isokit_view().getRotation() + 1) % 4)")
pg.wait_for_timeout(400)
log("level")
# to another level and back
lv0 = pg.evaluate("__isokit_view().getLevel()")
other = pg.evaluate("[...document.querySelectorAll('.iso-seg button')].map(b => b.dataset.level).find(l => l !== __isokit_view().getLevel())")
out["level_ms"] = pg.evaluate(JS_TIMED, f"__isokit_view().setLevel('{other}')")
pg.wait_for_timeout(400)
pg.evaluate(JS_TIMED, f"__isokit_view().setLevel('{lv0}')")
pg.wait_for_timeout(400)
def open_page(b, rate):
pg = b.new_page(viewport={"width": 1366, "height": 768})
if ANIM:
pg.add_init_script(f"try {{ localStorage.setItem('isokit-anim', '{ANIM}'); }} catch (e) {{}}")
pg.bring_to_front()
cdp = pg.context.new_cdp_session(pg)
cdp.send("Performance.enable")
cdp.send("Emulation.setCPUThrottlingRate", {"rate": rate})
errs = []
pg.on("pageerror", lambda e: errs.append(str(e)))
return pg, cdp, errs
def bench_studio(b, rate):
out = {}
pg, cdp, errs = open_page(b, rate)
t0 = time.perf_counter()
pg.goto(f"http://127.0.0.1:{PORT}/")
pg.wait_for_selector(".iso-svg [data-obj]", timeout=120000)
out["load_s"] = time.perf_counter() - t0
pg.add_init_script("window.__isokit_view = () => window.__studio.view")
pg.evaluate("window.__isokit_view = () => window.__studio.view")
pg.locator("#wb-projects").click()
t0 = time.perf_counter()
pg.locator("#wb-nhouse").click()
pg.wait_for_load_state()
pg.wait_for_function("window.__studio && __studio.S.doc && __studio.S.doc.objects.length > 50 && document.querySelector('.iso-svg [data-obj]')", timeout=120000)
out["open_house_s"] = time.perf_counter() - t0
pg.evaluate("window.__isokit_view = () => window.__studio.view")
pg.wait_for_timeout(1000)
gestures(pg, cdp, pg.locator(".iso-stage"), out)
out["edit_ms"] = pg.evaluate(JS_TIMED, "__studio.op({op: 'add', type: 'tree', x: 14, y: -5})")
pg.wait_for_timeout(300)
out["select_ms"] = pg.evaluate("""() => { const t0 = performance.now(); __studio.select('sofa'); return new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(performance.now() - t0)))); }""")
out["errors"] = errs
pg.close()
return out
def bench_page(b, rate):
out = {}
pg, cdp, errs = open_page(b, rate)
t0 = time.perf_counter()
pg.goto(PAGE.as_uri())
pg.wait_for_selector(".iso-svg [data-obj]", timeout=120000)
out["load_s"] = time.perf_counter() - t0
pg.evaluate("window.__isokit_view = () => window.__isokit.view")
pg.wait_for_timeout(1000)
gestures(pg, cdp, pg.locator(".iso-stage"), out, wheel_mod="Control")
out["errors"] = errs
pg.close()
return out
ROWS = [
("load_s", "load (s)", "{:.2f}"),
("open_house_s", "open house demo (s)", "{:.2f}"),
("nodes", "SVG nodes", "{:.0f}"),
("idle_busy", "idle: main thread busy", "{:.0%}"),
("pan_ms_step", "pan: main thread ms per move", "{:.1f}"),
("pan_busy", "pan: busy", "{:.0%}"),
("pan_long", "pan: frames over 50 ms", "{:.0f}"),
("pan_max_ms", "pan: longest frame (ms)", "{:.0f}"),
("zoom_ms_step", "zoom: ms per wheel step", "{:.1f}"),
("zoom_long", "zoom: frames over 50 ms", "{:.0f}"),
("zoom_max_ms", "zoom: longest frame (ms)", "{:.0f}"),
("hover_ms_step", "hover: ms per move", "{:.1f}"),
("hover_long", "hover: frames over 50 ms", "{:.0f}"),
("rotate_ms", "rotate until painted (ms)", "{:.0f}"),
("level_ms", "level switch until painted (ms)", "{:.0f}"),
("edit_ms", "edit until painted (ms)", "{:.0f}"),
("select_ms", "select until painted (ms)", "{:.0f}"),
]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--rate", type=float, default=6.0)
ap.add_argument("--json")
ap.add_argument("--only", choices=["studio", "page"])
ap.add_argument("--headed", action="store_true")
ap.add_argument("--anim", choices=["light", "smooth", "off"])
a = ap.parse_args()
global ANIM
ANIM = a.anim
if not (DIST / "index.html").exists():
sys.exit("no dist/web: run python tools/build_web.py first")
srv = subprocess.Popen([sys.executable, "-m", "http.server", str(PORT), "--bind", "127.0.0.1", "--directory", str(DIST)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
res = {}
try:
time.sleep(0.8)
with sync_playwright() as p:
b = p.chromium.launch(channel="msedge", headless=not a.headed, args=["--window-position=0,0"])
if a.only != "page":
res["studio"] = bench_studio(b, a.rate)
if a.only != "studio" and PAGE.exists():
res["page"] = bench_page(b, a.rate)
b.close()
finally:
srv.terminate()
cols = list(res)
print(f"CPU slowed {a.rate:g}x, {'window' if a.headed else 'headless'}, animation {a.anim or 'default'}")
print(f"{'':34}" + "".join(f"{c:>12}" for c in cols))
for k, lab, fmt in ROWS:
if any(k in res[c] for c in cols):
print(f"{lab:34}" + "".join(f"{(fmt.format(res[c][k]) if k in res[c] else '-'):>12}" for c in cols))
for c in cols:
if res[c].get("errors"):
print(c, "errors:", res[c]["errors"])
if a.json:
Path(a.json).write_text(json.dumps(res, indent=1))
if __name__ == "__main__":
main()
+39
View File
@@ -345,6 +345,45 @@ with sync_playwright() as p:
pg.wait_for_timeout(600)
pg.evaluate("__studio.select(null)")
# performance: animations step about ten times a second by default (no CSS animation runs on its
# own), the toolbar button switches smooth / off / light; a pan moves the drawing as one layer and
# draws it again (viewBox) only when the drag ends
pg.evaluate("__studio.select(null)")
pg.evaluate("localStorage.removeItem('isokit-anim')")
an = lambda: pg.evaluate("__studio.view.animation()")
running = lambda: pg.evaluate("document.querySelector('.iso-svg').getAnimations({subtree: true}).filter(a => a.playState === 'running').length")
t0 = pg.evaluate("(() => { const a = document.querySelector('.iso-svg').getAnimations({subtree: true}); return a.length ? a[0].currentTime : null; })()")
pg.wait_for_timeout(600)
t1 = pg.evaluate("(() => { const a = document.querySelector('.iso-svg').getAnimations({subtree: true}); return a.length ? a[0].currentTime : null; })()")
check(an() == "light" and running() == 0 and t0 is not None and 300 < t1 - t0 < 900,
f"animations are light by default: none runs on its own, they step on ({t0} -> {t1})")
btn = pg.locator('.iso-bar [data-a="anim"]')
btn.click()
pg.wait_for_timeout(500)
check(an() == "smooth" and running() > 0 and "smooth" in btn.get_attribute("title"), f"the button switches to smooth ({running()} running)")
btn.click()
pg.wait_for_timeout(300)
check(an() == "off" and running() == 0, "then off")
btn.click()
pg.wait_for_timeout(300)
check(an() == "light" and pg.evaluate("localStorage.getItem('isokit-anim')") == "light", "then light again, remembered")
vb0 = pg.evaluate("document.querySelector('.iso-svg').getAttribute('viewBox')")
bx = pg.locator(".iso-stage").bounding_box()
cx2, cy2 = bx["x"] + bx["width"] / 2, bx["y"] + bx["height"] / 2
pg.mouse.move(cx2, cy2)
pg.mouse.down(button="middle")
for i in range(1, 9):
pg.mouse.move(cx2 + 10 * i, cy2 + 4 * i)
pg.wait_for_timeout(20)
mid = pg.evaluate("[document.querySelector('.iso-svg').getAttribute('viewBox'), document.querySelector('.iso-svg').style.transform, document.querySelector('.iso-gesture') !== null]")
pg.mouse.up(button="middle")
pg.wait_for_timeout(300)
end = pg.evaluate("[document.querySelector('.iso-svg').getAttribute('viewBox'), document.querySelector('.iso-svg').style.transform, document.querySelector('.iso-gesture') !== null]")
check(mid[0] == vb0 and "translate" in mid[1] and mid[2] and end[0] != vb0 and end[1] == "" and not end[2],
f"a pan moves the layer (viewBox kept, transform {mid[1][:40]}), the release draws it again")
check(pg.evaluate("getComputedStyle(document.getElementById('cursor')).contain").startswith("strict") or "size" in pg.evaluate("getComputedStyle(document.getElementById('cursor')).contain"),
"the pointer readout is a contained box (its text does not lay the page out again)")
# the middle mouse button only moves the camera: it never drags, selects or places anything
pg.locator('#objects li[data-id="truck"]').click()
pg.evaluate("__studio.view.focus('truck', 3)")
+8
View File
@@ -300,6 +300,14 @@ try:
check(pg.evaluate("() => __studio.S.warnings.length") == 0 and pg.locator('.st-tile[data-type="@room"]').count() == 1,
"it draws without warnings, the palette has the Room tool")
shot(pg, "web-house.jpg")
# low-power default: the pond's fish (and every other animation) step on about ten times a
# second instead of running as CSS animations that repaint the drawing every frame
an = pg.evaluate("""() => { const a = document.querySelector('.iso-svg').getAnimations({subtree: true});
return [__studio.view.animation(), a.length, a.filter(x => x.playState === 'running').length, a.length ? a[0].currentTime : 0]; }""")
pg.wait_for_timeout(500)
t1 = pg.evaluate("() => { const a = document.querySelector('.iso-svg').getAnimations({subtree: true}); return a.length ? a[0].currentTime : 0; }")
check(an[0] == "light" and an[1] >= 6 and an[2] == 0 and t1 > an[3] and pg.locator('.iso-bar [data-a="anim"]').is_visible(),
f"animations are light by default: {an[1]} stepped, none running, time moves on; the toolbar can switch")
# ---------------------------------------------------------------- folder project (OPFS stands in for a picked folder)
demo = {f.relative_to(ROOT / "examples" / "demo").as_posix(): f.read_text(encoding="utf-8")