Orkige Help Orkige

Performance architecture — static mobility, sprite-run batching, instancing

How the engine turns knowledge about content (what never moves, what can share a draw) into each render backend's native fast path — and how the resulting structure is guarded so it cannot silently regress.

The governing rule (also in Docs/render-abstraction.md): a facade contract must be expressible as each backend's native fast path. The facade names the intent (static, batchable run); each backend realizes it with its own machinery; neither backend is pessimized to serve the other.


The three mechanisms at a glance

Facade conceptWhere declaredclassic OGRE realizationOgre-Next realization
Static mobility flagTransformComponent.static (reflected bool) → RenderNode::setStaticentities on static nodes bake into shared Ogre::StaticGeometry regions (fewer draw calls; membership bookkeeping in StaticBakeClassic.cpp)node + attached content migrate into the SCENE_STATIC memory managers (no per-frame transform derivation / cull prep — the static managers' whole point)
Sprite-run batchingautomatic per frame (SpriteBatcher over the pure core_util/SpriteRunPlanner)one facade SpriteBatch per run = one ManualObject draw under the run's shared Sprite/<tex>#<sampler> materialsame facade path over the v2 ManualObject + shared HlmsUnlit datablock
Same-mesh instancing(no facade surface)not built — gated out (see the verdict below)native: the Hlms auto-instances identical Items with one shared vao/datablock — nothing to declare

Toggles (live cvars, both default on; the honest escape hatches and the levers the identity tests flip):

  • r.staticScene — apply the static flag to the renderer. The **editor

    boots it off** (edit mode: gizmo moves must never fight the mobility contract; the flag still round-trips through the inspector).

  • r.spriteBatching — merge sprite runs. Off releases every run live and

    sprites draw individually again.

---

Pillar 1 — the static mobility flag

The flag and its home

TransformComponent carries the reflected static bool (inspector, serialization, prefab overrides, Lua weak-handle getStaticFlag/setStaticFlag, debug protocol and MCP all ride the ONE property registry; the schema declares it after position/orientation/ scale so a scene load places an object before freezing it).

The flag lives on TransformComponent, not ModelComponent, deliberately:

  1. The flag is a statement about the transform — "this object's world

    pose never changes". TransformComponent owns that pose.

  2. One node-level seam catches every runtime move path (component

    setters, Lua handles, the debug protocol, teleports) — a mesh-level flag could not see its parent node moving.

  3. It benefits everything attached to the object — mesh and sprite

    and vector shape (on next, Node::setStatic migrates attached movables along with the node; sprite/vector content on static objects stops paying per-frame transform/AABB updates too).

  4. Ogre-Next's own API agrees: static is a node property that cascades to

    attached objects.

The component cascades to the content nodes its sibling components own (facade children without a user pointer — only TransformComponent tags nodes, the documented back-mapping convention) and stops at child objects' transforms: children flag themselves. Nodes created after the flag inherit it (RenderNode::createChild); content attached late aligns on attach.

The hierarchy rule (validated, honest error)

A static node's frozen world transform embeds every ancestor's pose, so:

  • static = true requires a static (or absent) parent;
  • static = false is refused while static children depend on it.

TransformComponent::staticFlagChangeError is the pure rule (StaticFlagTests); the setter logs an oDebugError and refuses. The scene loader parents children after parents, so legitimate content always loads.

THE MOBILITY CONTRACT

Static means static. A runtime mutation of a static object stays correct but is a contract violation: the backend logs one warning per node and lands the change through its repair path —

  • next: SceneManager::notifyStaticDirty re-derives the frozen

    transforms next frame. Correct but costly (the exact cost you opted out of paying per frame).

  • classic: the object's baked entities demote out of their

    StaticGeometry region (one rebuild, coalesced to the frame boundary) and render individually, following the node again. Draw count goes up by the demoted entities.

Visibility changes are gentler: on next a static object shows/hides freely (visibility is not a transform); on classic the bake re-filters its regions at the next frame boundary (membership follows entity->getVisible()) — correct, but a blinking static object rebuilds regions every toggle, so flag blinkers dynamic.

Editor gizmo moves are exempt by construction: the editor boots r.staticScene=0, so edit mode never applies the flag to the backend.

The classic bake, in one paragraph

StaticBakeClassic.cpp owns only membership bookkeeping around OGRE's own StaticGeometry: registration follows the facade flag and mesh attach/detach; every change just marks the bake dirty; renderOneFrame flushes at most one rebuild per frame (a scene load registering dozens of entities pays a single build). Two buckets split by the entity's castShadows flag (shadow casting is region-level in OGRE, so per-object cast flags survive the bake). Baked sources are suppressed via visibility flags 0 — never the visible flag, which remains the game's own show/hide state and keeps flowing through node cascades.

The trade asymmetry (when to flag content static)

draw-cost winrebuild/repair cost
classiclarge: N same-bucket entities → ~1 region draw (fixture_static: 9 → 5 batches; a real scene's whole prop set collapses)region rebuild copies vertex buffers — fine at scene-load/edit frequency, never per frame
nextnone directly (the Hlms already auto-instances identical items) — the win is CPU: static items skip per-frame transform derivation and cull prep entirelynotifyStaticDirty is cheap-ish but defeats the point when spammed

Flag: terrain, buildings, props, backdrops — anything placed once. Don't flag: anything scripted/tweened/physics-driven, pooled objects that activate/deactivate per frame (the benchmark's ramped field cubes stay dynamic for exactly that reason), blinking decorations on classic.

Content shipped flagged: the benchmark's terrain (all three terrain vignettes), the vista props, Flatland's backdrop sprites. The roller project ships unflagged on purpose: its walls ride sliding tiles (the "move world" mechanic) — mobile by design, so the honest answer is the dynamic path plus sprite-run batching.

Pixel identity

player_static_contract (per flavor) renders fixture_static.oscene with the gate on and off and compares framebuffers: byte-identical on next (SCENE_STATIC only skips recomputation — the GPU sees the same matrices); classic allows isolated 1-pixel float-rounding differences (measured 2 px of 921600, worst channel delta 64) because StaticGeometry re-expresses vertices relative to the region origin. The same test moves a static object mid-run and asserts: exactly one warning, the move lands, the classic batch delta is exactly +1, and the moved frame matches a fully-dynamic run of the same move (the repair path is correct, not just warned).

Pillar 2 — sprite-run batching

The facade contract (grouping is ours, drawing is the backend's)

The pure core_util/SpriteRunPlanner owns the painter's contract:

  • sprites sort by zOrder, stable — registration order (= scene load

    order) breaks ties, so co-planar overlap keeps creation order;

  • only contiguous same-(texture,sampler) neighbours in that order form

    a run — an interleaved different-material sprite splits the run, because merging across it would reorder alpha-blended output;

  • runs of one stay solo (their quad already costs exactly one draw);
  • dirty tracking: a run whose members and state hashes are unchanged does

    not re-upload; one moved member re-uploads only its own run.

The engine-side SpriteBatcher (created by the player, ticked once per frame after all gameplay/debug mutations, right before rendering; the editor never creates one) realizes each run as ONE facade SpriteBatch on the world root, filled with the members' world-space quads (SpriteComponent::buildWorldQuad — position + orientation · (scale · corner), the exact composition the backend applies to the individual quad). Batched members hide their individual quad (object-level flag only; node visibility stays the game's state). The run's batch binds the same per-(texture,sampler) material the individual quads use — merging changes nothing but the draw count. Per-sprite tint rides the batch's vertex colours, so a tinted sprite stays inside its run (no material clone, no demotion — unlike a 3D mesh accent, which costs one material variant per accented instance, see Docs/materials.md).

Why the classic adapter is the facade SpriteBatch (the BillboardSet audit)

Classic OGRE's BillboardSet is built-in batched quads, but it cannot carry this contract: a billboard is position + size + one rotation angle about the facing axis — a sprite's full node transform (hierarchy-composed orientation, non-uniform scale) is not expressible per billboard, and its texcoord rotation semantics diverge from the quad path. The facade SpriteBatch already exists on both backends as the proven one-draw N-quad primitive with byte-compatible vertex rules (same winding, same generated materials, same zOrder window — the 2D particle system ships on it), so the batcher feeds it and each backend keeps its native realization. That is the facade-groups/backends-decide boundary in practice — and it means a future backend gets sprite batching for free by implementing SpriteBatch once.

Costs and behavior

  • An unmoved scene uploads nothing per frame; the per-frame cost is reading

    each registered sprite's world transform for the dirty hash.

  • A flipbook or continuously-moving member re-uploads its run each frame —

    one buffer refill for the run, which replaces N per-quad refills; heavy independent movers in one huge run can be split by giving them distinct zOrders if profiling ever demands it.

  • player_spritebatch pins the structure exactly against

    fixture_sprites.oscene (painter sequence A,A,A,B,A,A,A,A,A + two more at z=1 → 3 runs + 1 solo): classic draws 4 vs 11, the live toggle releases and re-forms the runs, a moved member re-uploads, and batching on/off renders identical pixels (byte-exact on classic; a 1-LSB edge fringe of ~10 px allowed on next, where the merged path bakes the world transform on the CPU while the per-quad path applies it in the vertex shader — float32 sums round differently).

Pillar 3 — classic 3D auto-instancing: GATED OUT (the verdict)

The measured gap (Instance Field, ~190 ramped same-mesh cubes, Debug, Apple M-series): classic ~187 draw batches vs next ~4 — Ogre-Next's Hlms auto-instances identical Items natively, classic draws each entity. The InstanceManager promote/demote seam was not built, on evidence:

  1. RTSS cannot derive the instanced vertex path. OGRE 14.5's

    InstanceManager techniques (ShaderBased/VTF/HW) all require materials whose vertex programs read per-instance world matrices; the shipped RTSS surface (RTShaderSystem/ headers, FFPLib_Transform.glsl) generates no such path, and our classic materials are exclusively RTSS-generated Cook-Torrance. Hand-writing per-material instanced shaders would break the generated-materials discipline (CLAUDE.md) for one flavor.

  2. The numbers don't justify it for real content. The 187-draw scene

    costs ~1.7 ms/frame in a Debug build on desktop classic — inside any frame budget. The classic flavor's real 3D scenes (vista: 16 batches before the static bake) never approach the pathological count; classic's shipping role is the 2D tier (web/GLES2), where pillar 2 applies.

  3. Static overlap. Immobile same-mesh fields are already collapsed by

    the pillar-1 StaticGeometry bake on classic; the residual gap is moving same-mesh crowds, which no shipped or planned content has.

Revisit trigger: a real classic-flavor game with hundreds of moving same-mesh instances. The facade seam sketched for that day: promote N same-(mesh,material) MeshInstances above a threshold, demote on any individualization — but it earns its complexity only with the content.

The structural budget gate (the standing guard)

run_benchmark_budget_test.py + the checked-in tests/integration_driver/benchmark_budgets.json walk every benchmark scene per flavor (deterministic boots: ramps at ceiling, camera frozen) and gate the structure:

  • draw batches above batchesMax → fail: a batching/facade regression;
  • draw batches below batchesMin → fail: something stopped rendering

    (the silent-black class — dramatically under budget is a bug, not a win);

  • triangles above trisMax → fail: content/LOD regression;
  • frame-ms: reported, never gated (machine noise).

Budgets change deliberately: the commit that moves a number edits the table in the same change, with the reason — the pixel-probe-baseline discipline. The current table's numbers are the measured post-optimization values with headroom for legitimate jitter (ramp pacing, HUD lines).

Known metric caveat: the next flavor's FrameStats.batchCount (Metal RenderingMetrics) compresses absolute counts (auto-instanced draws count once), so next budgets are tighter in absolute terms and mostly guard deltas; classic counts are true draw calls.

Measured results (macOS, Apple M-series, Debug trees, 240-frame deterministic boots)

Both columns come from the SAME tree — "before" runs with r.staticScene=0,r.spriteBatching=0 (the exact pre-optimization path; the identity tests prove the pixels match either way), "after" with the defaults. Draw batches, avg over the run (the structural number the gate holds):

Sceneclassic beforeclassic afternext beforenext after
Terrace Vista16.18.17.17.1
Still Water (lake)15.18.17.17.1
Night Lumens11.14.03.13.1
Ember Swarm10.09.96.67.0
Instance Field185.3185.34.14.1
Flatland6.56.56.16.1
Cascade32.13.03.13.0
roller (projects/roller main)18.05.06.05.0
fixture_static (9 meshes)9.05.02.02.0
fixture_sprites (11 sprites)11.04.04.03.0

Notes for the reader of the deltas:

  • Vista/lake/lumens classic 16→8 / 15→8 / 11→4: the static bake

    collapses terrain + props into region buckets. Next's count is unchanged — its win is the CPU side (static items leave the per-frame transform/cull pipeline), which vsync-clamped Debug frame times cannot resolve; the structural numbers are the honest measure on this rig.

  • Cascade classic 32→3, roller 18→5: the 2D headline — pooled cascade

    bodies and the roller's tile frames/walls share textures within their z-layers, so sprite-run batching folds the per-sprite draws into a few run batches. The runs stay merged while tiles slide (dirty tracking re-uploads the moved run; the roller selfcheck still passes end to end).

  • Flatland unchanged: honest — its authored sprites are all distinct

    parallax layers (one sprite per zOrder), so no run of two ever forms. The fixture (and cascade) show the batching win; flatland shows the painter's contract refusing to merge what may not merge.

  • Ember Swarm ~unchanged: particles already batch (one draw per

    emitter, the SpriteBatch contract from day one).

  • Instance Field: untouched by design — the cubes are pooled/ramped

    (activation churn would thrash the classic bake) and the classic instancing pillar is gated out (verdict above). The 185-vs-4 gap is the documented flavor difference.

  • Frame-ms on this rig: classic Debug runs 0.5–2.5 ms/scene (real deltas

    drown in jitter), next is vsync-clamped near 8.3 ms — which is why the gate holds structure, not milliseconds, and why frame-ms stays reported-only.

  • The web flavor (classic GLES2→WebGL) is CI-verified, not measured

    here: the wasm tree builds and runs its suite on the CI Linux web job. The classic desktop numbers are the structural proxy; draw-call savings matter most on web, where each call crosses the JS/WebGL boundary.

Test map

ConcernTest
grouping contract + dirty tracking (pure)SpriteRunPlannerTests (unit)
static hierarchy rule + schema + round-trip (pure/headless)StaticFlagTests (unit)
static toggle pixel identity + draw win + mobility contractplayer_static_contract[_next] (integration, per flavor)
sprite batching exact counts + live toggle + moved member + pixel identityplayer_spritebatch[_next] (integration, per flavor)
per-scene structural budgets over the whole tourbenchmark_budget[_next] (integration, per flavor)
generator fixtures stay contract-shapedmake_benchmark_assets_selftest (unit)