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>
10 KiB
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
SceneTreescript (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 sores://resolves againstclient/. - 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.godotwindow/size/viewport_width/height,canvas_itemsstretch 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.updatecases 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_frameloop: at frame 20, perform one fresh 8.3M pxcreate_from_imageand record both the op's directTime.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).