Centralize world and viewport game loops
Some checks failed
CI / rust-skia (Rust only) (push) Has been cancelled
CI / required (push) Has been cancelled

This commit is contained in:
2026-08-23 13:42:01 +02:00
parent cb9233c59c
commit 33d1cf6827
18 changed files with 2541 additions and 287 deletions

View File

@@ -1,100 +1,116 @@
# Renderer and viewport performance
# Agent runtime and viewport performance
Measured on 2026-08-23 with the primary account in Broceliande, a 1024 m x
1024 m varregion, using the default 64 m view distance. The visible scene had
211 objects, 427,829 triangles, and five unavailable grid textures. Release
numbers are the performance baseline; debug timings are intentionally omitted.
1024 m varregion, using the default 64 m view distance. The rendered scene had
211 objects, 427,253 triangles, and five unavailable grid textures. All timing
numbers below are optimized release builds.
## Current capture path
## Runtime ownership
| Phase | Warm release time | Can run ahead? | Required change |
|---|---:|---|---|
| Read current simulator objects and discover assets | 8 ms | Yes | Apply incoming object/avatar/terrain events to a persistent world instead of scanning the simulator maps per capture. |
| Fetch cached/missing assets | 151 ms | Yes | Bounded asset workers continuously fetch visible dirty assets. Raw immutable assets remain in the 2 GiB disk LRU. |
| Decode textures and derive scene geometry | 176 ms warm; 8.03 s on the first scene | Yes | Retain decoded visible textures and derived meshes in memory. Add a versioned persistent derived-mesh cache only for the measured 8 s cold-start cost; decoded RGBA textures should normally remain memory-only because they expand substantially. |
| Collect and sort renderables | 1 ms | Yes | Persistent renderer entities remove this per-frame list build. |
| Validate scene | 1 ms | Yes | Validate assets and changes when admitted, not every frame. |
| Copy render command | 21 ms | Yes | Send stable IDs and dirty updates; do not clone the complete scene for a frame. |
| Re-identify cached textures by content hash | 30 ms | Yes | Key immutable texture resources by grid asset UUID. |
| Re-identify cached meshes by content hash | 14 ms | Yes | Key derived meshes by stable object/asset signature and LOD. |
| Recreate frame entities | <1 ms | Yes | Keep Bevy entities and material handles alive; update only dirty components. |
| Render plus synchronous readback | 46 ms | Partly | Render continuously into a persistent target. A GUI presents that target directly; readback happens only for CPU consumers. |
| JPEG encoding | 41 ms | Yes, and not part of viewport FPS | A separate latest-frame worker encodes only when an LLM/image consumer asks for it. |
- `metacrate-grid-world` owns native OpenSim/Second Life event subscriptions,
distance filtering, attachment world transforms, terse movement application,
event-drop accounting, and immutable published world snapshots.
- `metacrate-game-loop` owns the fixed update schedule, renderer lifetime,
persistent GPU scene, fixed-rate viewport loop, completed-frame buffers, and
timing signals. Readers receive an immutable `Arc<[u8]>`; they never read a
render target while Bevy writes it.
- `metacrate-rendering-wgpu` owns Bevy/wgpu resources and supports separate
scene loading and camera/frame rendering. Shared immutable scene arrays cross
the renderer thread without copying their contents.
- `metacrate-grid-agent` supplies policy, conversation, tools, and lifecycle
triggers. It consumes published world/viewport state instead of synchronizing
render buffers or individual native event channels.
The existing end-to-end warm capture takes 525 ms. A repeated renderer call
alone takes 113 ms (8.8 FPS), including 67 ms of avoidable copying, hashing,
and cache synchronization. Its measured render/readback segment is 46 ms
(21.9 FPS equivalent). A persistent GPU scene therefore makes the initial
10 FPS viewport target realistic on the measured hardware.
Grid callbacks never perform asset I/O or render work. The world loop drains a
bounded 4,096-event queue in batches, coalesces dirty IDs, and publishes only
complete snapshots. Object, avatar, attachment, and terrain admission uses the
configured view distance; 64 m is the default. The live reference contained
280 visible prims, one avatar, and 51 nearby terrain patches, with zero dropped
events.
The 46 ms value still includes synchronous CPU readback. It is not a pure GPU
timestamp. Continuous viewport rendering without readback should be faster and
must be measured separately once the persistent render target exists.
## Readiness
## Startup
Transport connection is not agent readiness. A generation remains degraded and
chat, tools, movement, and autonomous behavior remain fenced while the terminal
reports:
The optimized cold-start profile was:
```text
INITIALIZING generation=N waiting_for=world_state,viewport target_fps=10
```
| Phase | Time | Scheduling |
The framework gate opens only after the region-scoped world state exists, the
first scene/assets have converged, and at least two complete viewport frames
have been published without render errors. The terminal then emits
`INITIALIZED ...` followed by one unambiguous `READY ...` line when the session
and agent services are released. The default readiness allowance is two
minutes; the measured scene convergence window was about 30.05 seconds.
## Measured steady state
| Phase | Measured time |
|---|---:|
| Bevy/wgpu initialization | 476 ms |
| First persistent GPU scene load and frame | 1.02 s |
| Cached complete scene refresh | 86 ms |
| Stable warm render plus coherent CPU readback | 3136 ms |
| JPEG encoding on demand | 37 ms |
| Repeated capture using the already published viewport, including JPEG | 45 ms |
The viewport uses a 90 ms scheduling interval, providing margin above the
minimum 10 FPS requirement. The live two-second soak completed 22 frames at
10.99 FPS with zero missed deadlines and zero render errors. JPEG encoding and
LLM transfer are not in this loop.
A cached full-scene refresh completed in 86 ms, inside the 90 ms interval:
validation 1.08 ms, shared command handoff 0.001 ms, texture cache sync 30.47
ms, mesh cache sync 15.21 ms, scene setup 0.47 ms, and render/readback 35.70 ms.
Unchanged frames skip validation, command transfer, texture sync, and mesh sync.
## Startup work
| Phase | Reference time | Scheduling |
|---|---:|---|
| Client owner initialization | 2 ms | Startup thread |
| Grid login | 3.37 s | Parallel with renderer initialization |
| wgpu/Bevy initialization | 452 ms | Parallel with grid login; completed before agent readiness |
| Test scene convergence window | 30.04 s | World events continue asynchronously; this is not a system-readiness gate. |
| First full visible-scene build from persistent raw assets | 8.35 s | Background asset/scene workers; publish partial complete frames as content converges. |
| Bevy/wgpu initialization | 0.49 s | Completed before agent readiness |
| Initial world/asset convergence | 30.05 s | Asynchronous Grid and asset workers |
| First persistent GPU scene and frame | 1.02 s | Before the readiness gate opens |
System readiness must require configuration, renderer initialization (or an
explicitly reported fallback), grid login, region dimensions, and the running
world/update loops. It must not wait until every user-created asset inside the
view radius has decoded: that would turn missing or slow grid assets into a
permanent login stall. Visual readiness is a separate completeness signal.
The slower first start is intentional: the agent does not claim readiness until
it can provide stable world and visual state. Raw immutable assets persist in
the configured 2 GiB LRU under the platform cache directory
(`$HOME/.cache/metacrate` on Linux), so later starts reuse them.
## Game loop
## Frame publication and future GUI path
The agent needs four independent paths:
The current agent path continuously renders and publishes the latest complete
RGBA frame. A requested snapshot reads that immutable frame and performs JPEG
encoding independently. Camera or scene submissions replace stale pending
input; consumers never block the viewport loop.
1. Grid callbacks enqueue compact object, avatar, terrain, region, and camera
changes immediately. They never perform asset I/O or rendering.
2. A fixed update loop drains those events, updates the authoritative CPU world,
recalculates 64 m visibility, and emits stable dirty IDs. Grid event handling
must remain faster than the render tick.
3. Bounded asset workers fetch, decode, and derive only dirty visible content.
Completed resources update the CPU world and enqueue GPU changes.
4. A render loop applies GPU changes and updates a persistent viewport at an
initial 10 FPS target. It retains the latest complete frame. GUI presentation
uses the GPU target directly; snapshot readback, resize, JPEG encoding, and
LLM upload are independent latest-frame consumers.
The current Bevy screenshot API still performs synchronous CPU readback, which
accounts for most of the 3136 ms warm frame cost. This meets the current
headless-agent target, but a GUI viewer should present the GPU target directly.
For higher-rate CPU capture, use a ring of three staging buffers: render, enqueue
GPU copy, map asynchronously, and publish only after completion. If all staging
buffers are occupied, skip that readback instead of stalling rendering.
Simulation/update and rendering use separate clocks. Slow asset downloads,
JPEG encoding, LLM requests, and subscribers must never hold either loop.
## Instrumentation and observed limits
### Frame publication and readback
Hot paths record monotonic timestamps and fixed-cardinality counters only.
World event drops, completed frames, missed deadlines, render errors, and last
render duration are available without formatting or I/O on the loop threads.
The persistent viewport renders into a GPU texture. A GUI presents it without
CPU readback. When a CPU image is requested, the render graph schedules a copy
after that frame's render commands into the next free staging buffer. GPU queue
ordering makes the copied frame coherent even when the render target is reused
for the following frame.
Observed limiting factors:
Use a ring of three staging buffers. Mapping and CPU consumption happen
asynchronously; a buffer is reused only after its completion signal. If all
three are busy, skip that readback rather than stall rendering. Publish only the
latest completed immutable CPU frame, tagged with frame sequence, camera pose,
and observation time. JPEG and LLM workers use that published frame and discard
superseded work. The current synchronous Bevy screenshot plus `device.poll(Wait)`
path is only a diagnostic bridge and must not remain in the game loop.
## Instrumentation
Hot paths record only monotonic start/end timestamps and emit fixed-size,
fixed-cardinality timing signals with `try_send`. A bounded receiver owned by a
dedicated profiling worker aggregates counts, totals, maxima, and percentile
histograms and publishes observability records. A full queue drops profiling
signals and increments a drop counter; it never backpressures grid or rendering.
Formatting, serialization, journal I/O, and subscriber delivery remain outside
the measured thread.
Required fixed phases are startup/config, client creation, renderer creation,
login, grid readiness, event ingest, update tick, visibility, asset disk read,
asset network fetch, texture decode, geometry derive, GPU change application,
render submission, GPU completion, optional readback, and optional JPEG encode.
- Initial scene/asset convergence, about 30 seconds, dominates readiness.
- First GPU admission is about one second; it occurs before `READY`.
- A cached full-scene refresh is 86 ms and is currently the closest operation to
the 90 ms frame budget. Incremental per-entity GPU updates are the next useful
optimization if denser or highly animated regions exceed that budget.
- Synchronous readback costs roughly 3136 ms per frame. A direct GUI path or
asynchronous staging ring removes it from presentation.
- Five referenced textures were unavailable from the test grid. Missing assets
do not deadlock readiness; completeness remains explicit.
- The successful live run used 280 visible prims, one avatar, 51 terrain
patches, 427,253 render triangles, 4,096 event slots, and dropped zero events.