Files
settled-reach/docs/workshops/body-map-viewer/measurements/t1180-imagetexture.md
T
jpmschweitzerandClaude Fable 5 473b654787 docs(meta): body-map-viewer measurement results + brief appendix (T-1177/T-1178/T-1154/T-1179/T-1180)
Four results docs under docs/workshops/body-map-viewer/measurements/ and the
brief's measurement appendix rewritten from owners/effort to MEASURED
headlines: settled hydrology viable (~24 ms/body, 273 bodies ~0.8 s);
parallel derive throughput holds 330K-8.3M cells (~64 ms / ~1.8 s); block+tile
cost-cleared (deepest step 83K cells ~17 ms); PNG-per-field smallest and
fastest with the tagged-envelope migration foreclosed by byte math (21x the
ceiling at the smallest size); texture upload a non-issue (8.3M px ~3.2 ms,
L8 4-9x cheaper). All numbers post-date the background-load closure with
stability re-runs. The workshop round-1 gate is satisfied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 19:23:02 +02:00

10 KiB
Raw Blame History

title, ticket, owner, workshop, status
title ticket owner workshop status
Measurement ⑤: Godot ImageTexture upload cost per step-cross (330K–8.3M px) T-1180 Stig body-map-viewer complete

Measurement ⑤ — ImageTexture upload cost per step-cross

What this answers

The body-map-viewer render baseline is per-step data canvas → render-to-texture terrain layer. On a step-cross, the client either (a) allocates a fresh ImageTexture via create_from_image, or (b) reuses a persistent texture and calls texture.update(image). Both paths cost real upload time on the render thread; if that cost is large relative to a 16.6 ms (60 fps) frame budget, the hold-fetch-swap will visibly stutter on step-cross. This measurement prices both paths at the three canvas sizes the workshop cites (768×432 ≈ 330K px through 3840×2160 ≈ 8.3M px), in both RGBA8 and L8, and checks directly whether the worst case (8.3M px) blows a frame budget.

Method

Must run windowed, not headless — Godot's headless build swaps in a dummy rendering driver that does not perform real GPU texture uploads, so create_from_image/texture.update timings under --headless would be near-zero and meaningless for this question. This run used a real X display (DISPLAY=:0) with the actual GPU-backed renderer.

  • Driver: a temporary SceneTree script (client/tmp_drive_t1180.gd, deleted after this measurement — not committed).
  • Launch: godot --path client --script res://tmp_drive_t1180.gd, run from the worktree so res:// resolves against client/.
  • Before any measurement: DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED), Engine.max_fps = 0, root window forced to 1920×1080 (the project's base viewport — client/project.godot window/size/viewport_width/height, canvas_items stretch mode), and 4 frames of warm-up before any timing begins (window focus / swapchain settle).
  • Each op timed with Time.get_ticks_usec() immediately around the call, 33 iterations per case (3 discarded as pipeline/driver warm-up, reported separately as "first-use"; 30 kept). Median and p95 computed over the 30 kept samples.
  • texture.update cases alternate the fill color each iteration so the driver does real work per call (rules out any same-data fast path).
  • Frame-hitch check: a 40-frame await process_frame loop: at frame 20, perform one fresh 8.3M px create_from_image and record both the op's direct Time.get_ticks_usec() cost and the wall-clock delta of the surrounding frames, compared to the baseline median frame delta.

Environment note

A background CPU/GPU load (Factorio) was running on this machine for part of the session and was closed partway through. The results below are the two runs captured after the machine was quiet (back-to-back re-runs, both post-close) — any numbers captured before that point were discarded and are not reported here. The two clean runs agree closely (see "Stability" below), so there is no early-slow/later-fast drift to account for.

Hardware / GPU

Printed by Godot at startup on every run:

Renderer: AMD Radeon RX 9070 (radeonsi, gfx1201, ACO, DRM 3.64, 7.0.9-ogc3.2.fc44.x86_64)
Vendor: AMD
OpenGL API 4.6 (Core Profile) Mesa 26.1.3 - Compatibility
Godot Engine v4.6.stable.official.89cea1439

This is a desktop discrete GPU (RX 9070) — a reasonable stand-in for target hardware, but not a lower-bound guarantee; integrated/laptop GPUs should be expected to run slower, especially on the 8.3M px cases.

Results — run A (first clean run, post Factorio-close)

All times in ms. n=30 kept samples after 3 discarded warmup iterations; first-use = the very first (discarded) iteration, reported separately as the pipeline-compile-inclusive cost.

op size fmt median p95 first-use
create+fill (baseline) 768×432 (~330K px) RGBA8 0.053 0.059 0.372
create+fill (baseline) 1920×1080 (~2.07M px) RGBA8 0.364 0.430 3.260
create+fill (baseline) 3840×2160 (~8.3M px) RGBA8 2.156 2.816 11.315
create_from_image (fresh) 768×432 RGBA8 0.211 0.265 0.269
create_from_image (fresh) 768×432 L8 0.053 0.094 0.340
create_from_image (fresh) 1920×1080 RGBA8 0.471 1.351 2.369
create_from_image (fresh) 1920×1080 L8 0.320 0.454 0.404
create_from_image (fresh) 3840×2160 RGBA8 3.159 4.073 5.235
create_from_image (fresh) 3840×2160 L8 0.745 1.183 0.593
texture.update (reuse) 768×432 RGBA8 0.105 0.262 0.102
texture.update (reuse) 768×432 L8 0.031 0.036 0.064
texture.update (reuse) 1920×1080 RGBA8 0.724 1.257 0.897
texture.update (reuse) 1920×1080 L8 0.157 0.214 0.160
texture.update (reuse) 3840×2160 RGBA8 4.624 5.214 5.255
texture.update (reuse) 3840×2160 L8 0.754 1.195 0.719

Results — run B (repeat, stability confirmation)

Same driver, back-to-back re-run, same quiet machine. Reported here in full per the coordinator's instruction to re-run headline cases once more at the end and confirm stability rather than relying on a single sample.

op size fmt median p95 first-use
create+fill (baseline) 768×432 RGBA8 0.055 0.059 0.370
create+fill (baseline) 1920×1080 RGBA8 0.386 0.537 2.590
create+fill (baseline) 3840×2160 RGBA8 2.340 3.379 11.855
create_from_image (fresh) 768×432 RGBA8 0.196 0.259 0.334
create_from_image (fresh) 768×432 L8 0.053 0.100 0.405
create_from_image (fresh) 1920×1080 RGBA8 0.541 1.287 0.991
create_from_image (fresh) 1920×1080 L8 0.130 0.567 0.511
create_from_image (fresh) 3840×2160 RGBA8 3.273 4.649 4.578
create_from_image (fresh) 3840×2160 L8 0.524 1.540 1.600
texture.update (reuse) 768×432 RGBA8 0.106 0.385 0.109
texture.update (reuse) 768×432 L8 0.034 0.042 0.078
texture.update (reuse) 1920×1080 RGBA8 0.730 0.868 1.153
texture.update (reuse) 1920×1080 L8 0.172 0.875 0.146
texture.update (reuse) 3840×2160 RGBA8 4.276 5.681 4.380
texture.update (reuse) 3840×2160 L8 0.704 1.092 0.719

Stability

Run A and Run B agree within ~0.1–0.5 ms on every headline case (8.3M px RGBA8 create: 3.16 vs 3.27 ms; 8.3M px RGBA8 update: 4.62 vs 4.28 ms). No early-slow/later-fast trend between the two runs — both post-date the Factorio close, and the agreement confirms the earlier contended-machine numbers (not reported above) were the outlier, not these.

Frame-hitch check (8.3M px create_from_image mid-frame-loop)

40-frame loop, await process_frame per frame, op performed at frame 20:

frame      delta_ms        op_ms
17           0.191        0.000
18           0.197        0.000
19           0.194        0.000
20           0.192        2.092   <-- create_from_image here
21           2.364        0.000
22           2.851        0.000
23           0.581        0.000

Baseline median frame delta (excl. hitch frame + successor): 0.202 ms
Hitch frame delta:      0.192 ms  (op direct cost: 2.092 ms)
Successor frame delta:  2.364 ms
16.6ms budget exceeded on hitch frame: no
16.6ms budget exceeded on successor frame: no

Reading this: the op's direct CPU-side cost (Time.get_ticks_usec around the call) is ~2–3 ms, matching the table above. But the frame delta spike shows up one frame later than the frame that issued the call — this is the driver's await process_frame reporting the delta to the next process tick, and/or the GPU upload's actual completion (fence/sync) landing on the following frame under OpenGL's queued submission model. Either way: the cost is real, it is on the order of 2–3 ms, and it lands within one frame of the call — but it never exceeds the 16.6 ms budget in any of the sampled cases, including the frame immediately after the upload. There is headroom, not a cliff.

Headline numbers

case median p95
768×432 create_from_image, RGBA8 ~0.20 ms ~0.26 ms
768×432 create_from_image, L8 ~0.05 ms ~0.10 ms
768×432 texture.update, RGBA8 ~0.11 ms ~0.26–0.39 ms
768×432 texture.update, L8 ~0.03 ms ~0.04 ms
3840×2160 create_from_image, RGBA8 ~3.2 ms ~4.1–4.6 ms
3840×2160 create_from_image, L8 ~0.5–0.7 ms ~1.2–1.5 ms
3840×2160 texture.update, RGBA8 ~4.3–4.6 ms ~5.2–5.7 ms
3840×2160 texture.update, L8 ~0.7 ms ~1.1–1.2 ms

Frame-hitch verdict: no budget break. The single most expensive case measured (8.3M px RGBA8 create_from_image) costs ~3 ms direct / shows up as a ~2.4–2.9 ms frame-delta spike one frame later — comfortably under the 16.6 ms (60 fps) budget with margin to spare, even stacked with normal per-frame gameplay/UI cost. texture.update on the same size is slightly more expensive than a fresh create_from_image (4.3–4.6 ms vs 3.2 ms median) — counter to the naive assumption that reuse is always cheaper — but still well inside budget.

Implication for the hold-fetch-swap design

At every canvas size the workshop cites, both textured-swap strategies (fresh create_from_image and reused texture.update) complete in single-digit milliseconds — no case observed here risks a dropped frame on step-cross, so the render layer should not let upload cost gate the step-count/canvas-size decision. Prefer texture.update (reuse) anyway: it avoids per-step Texture object churn (driver/RenderingServer resource lifecycle overhead not captured by these microbenchmarks) even though its raw upload number is marginally higher than a fresh allocation, and L8 — worth using wherever the data canvas is genuinely single-channel — is 4–9× cheaper than RGBA8 at every size, making it the easy win if any field plane in the wire contract can ship single-channel.

Launch command (exact)

cd /var/mnt/data/projects/settled-reach/.worktrees/bmv-measurements
godot --path client --script res://tmp_drive_t1180.gd

Driver script: client/tmp_drive_t1180.gd (temporary, deleted after this measurement — do not look for it in the committed tree).