Skip to content

Panorama Budget and LOD

M T edited this page Oct 4, 2026 · 1 revision

Panorama Budget and LOD

Every panorama bearing repeats Halo's whole scene traversal, so the engine thread's cost grows with the number of bearings drawn. Engine time, not GPU time, limits this port. The bearing budget (panorama_budget.h) decides each frame which of the nine bearing passes to run. It draws what the viewer is looking at every frame and spends measured spare time on the rest of the sphere. Under load it steps through four "tiers" that refresh the periphery more slowly and eventually share one forward picture between both eyes.

The panorama LOD (panorama_lod.h) is a native replacement for Halo's per-object pixel-size routine 0050F740. It makes an object's level of detail, shadow and lighting cadence identical in every bearing that draws it, so the joins do not pop.

Both run inside the 0050BEA0 panorama hook described on Panorama System. The budget's busy-time target is supplied by the frame pacer when pacing is enabled (Frame Pacing).

Source files

File Role
native/EngineHost/panorama_budget.h Header-only budget controller: planning, measurement, tiers, motion fill, scene entry, pacer coupling.
native/EngineHost/panorama_lod.h Native 0050F740 with a frame-wide depth and scale; three modes.
native/EngineHost/panorama_hooks.inc Calls the budget once per frame, applies its mask in the pass loop, feeds back timings, and dispatches the LOD routine.
native/EngineHost/panorama.h host_panorama_budget_extra/tier, host_panorama_busy_seconds for reports.
native/EngineHost/halo_settings.c HALO_PANORAMA_TARGET_FPS seeding and the live target.
native/EngineHost/shims_kernel32.c Presenting-thread idle accounting (host_yield_spin_ns) that busy time is derived from.

Inputs: busy time and gaze

Busy time. At each panorama entry the hook measures the frame period since the previous entry and subtracts the presenting thread's idle accumulated in host_yield_spin_ns over that period (panorama_hooks.inc:462-470). Idle includes:

  • Halo's limiter spinning in Sleep(0);
  • Sleep(n) on the presenting thread;
  • the frame pacer's blocking wait (host_frame_idle_wait).

The Sleep comment explains why sleep must count as idle. Left out, "the bearing budget read 24-30 ms of busy time at Halo's 30 fps cap whatever the engine did ... and could never step back down to stereo" (shims_kernel32.c:295-299).

Pass timing. Every pass is timed with CLOCK_UPTIME_RAW around the original 0050BEA0 (panorama_hooks.inc:651-656). After a non-aborted frame, halo_panorama_budget_note_passes(total, count) updates:

  • pass_ema, the cost of one bearing pass;
  • ppf_ema, passes per frame.

Both are EMAs with weight 0.1. They are only updated once every layer is ready, because the full sphere after a load "would price every pass at its texture-upload cost". Samples of 0 s or above 1 s are ignored.

Gaze. halo_settings_head_yaw() and head_pitch() are radians from the recentred forward direction. The compositor writes them every frame (EngineImmersive.swift:772).

Target. host_frame_pacer_budget_target(1 / panorama_target_fps()) gives the target. Without pacing, it is the target FPS period. While pacing, it is the rung's period less jitter headroom (see Frame Pacing).

Data structures

HaloPanoramaView (panorama_budget.h:8) is { int layer; float yaw, pitch; int eye; }. The hook's static schedule[] holds nine entries in draw order: 0, 2, 5, 6, 7, 8, 9, 4, 1.

HaloPanoramaBudget (panorama_budget.h:25-63):

Field Meaning
last_drawn[10] Frame number each layer was last actually drawn. Used for staleness.
previous_yaw, yaw_rate, previous_pitch, pitch_rate, have_previous Gaze history. Rates are per frame, clamped to +/-0.35 rad.
extra_half Optional extra passes per two frames. 1 is the floor (one distant bearing every other frame).
busy_ema Smoothed busy time (EMA weight 0.2).
since_adjust, tier_dwell Seconds of measured frame period since the last adjustment / tier change. Seconds rather than frames, because frame counts "made every wait three times longer at 11 fps than at 30".
tier, tier_pressure, tier_relief Heavy tier 0..3 and the consecutive-adjustment counters that move it.
pass_ema, ppf_ema Cost of one pass, passes per frame.
level_tier, have_level_tier Tier the last level settled at, used as the next level's start.
scene_epoch Advances on scene entry. The frame pacer resets when it changes.
distant_phase Tier-3 slot (1 or 3 of 8) for the distant refresh, latched once per eight-frame cycle.

Constants (panorama_budget.h:65-79):

Constant Value Use
HALO_PANORAMA_BUDGET_LOOKAHEAD 3 frames Gaze prediction lead.
HALO_PANORAMA_TIER_MAX 3 Lightest tier.
HALO_PANORAMA_TIER_UP_PERIODS 3 Consecutive over-target adjustments before a heavier tier.
HALO_PANORAMA_TIER_DOWN_PERIODS 6 Consecutive relief adjustments before a lighter tier.
HALO_PANORAMA_BUDGET_ADJUST_SECONDS 0.25 Adjustment interval.
HALO_PANORAMA_TIER_UP_SECONDS 1.0 Minimum dwell before stepping heavier.
HALO_PANORAMA_TIER_DOWN_SECONDS 3.0 Minimum dwell before stepping lighter.
HALO_PANORAMA_STEREO_DWELL_SECONDS 6.0 Minimum dwell for the visible step from tier 2 (mono) back to tier 1 (stereo).
halo_panorama_tier_floor_ppf[] {4.5, 3.5, 2.6, 1.625} Passes per frame each tier's floor draws looking ahead (tier 3: 13 passes in 8 frames).
HALO_PANORAMA_GAZE_SEAM_RADIANS 0.21 (about 12 deg) Gaze this far off the bearing's centre puts the neighbouring join in view.

The tiers

Tier Centre Ring neighbours within 90 deg of gaze Caps (when head pitched toward them, more than 0.5 rad) Seam neighbour (join in view) Distant extras
0 stereo pair, every frame every frame every frame (covered by the neighbours) budgeted, extra_half can grow
1 stereo pair, every frame alternate: right side on even frames, left on odd odd frames every frame floor: 1 every other frame
2 mono (one centre for both eyes) as tier 1 odd frames every frame floor
3 mono right neighbour at frame%4==0, left at ==2; none if a join is in view up at frame%4==1, down at ==3 even frames exactly one in 8 frames, in the latched odd slot

halo_panorama_budget_mono() is tier >= 2. The hook draws stereo only when stereo is enabled and the budget is not mono (with the tight budget active) (panorama_hooks.inc:497).

The header comment gives the motivation for the heavy tiers. On the headset one pass of Silent Cartographer is 15 ms. The floor at tier 0 (centre pair + two neighbours + half an extra) is 4.7 passes, about 9 fps. "The forward view keeps its frame rate; the sides refresh slower."

Planning a frame: halo_panorama_budget_plan

Source: panorama_budget.h:201-320.

flowchart TD
    A["Gaze yaw/pitch from the presenter"] --> B["yaw_rate = wrapped delta, clamped to 0.35 rad/frame, ahead = yaw + 3 * rate"]
    B --> C["score each active view = cos(angle to (ahead, pitch)), layer 4 inactive in mono"]
    C --> D["Mandatory: layer 1, layer 4 if stereo, best-scoring view(s)"]
    D --> E["Ring views within 90 deg of ahead, caps if pitch beyond 0.5 rad"]
    E --> F{"tier"}
    F -- "0" --> G["draw all of them"]
    F -- "1-2" --> H["alternate sides by frame parity, caps on odd frames"]
    F -- "3" --> I["one per 4-frame phase, ring neighbours dropped if a join is in view"]
    G --> J
    H --> J
    I --> J["Seam: if tier at least 1 and gaze more than 0.21 rad off the gaze bearing, add the neighbour on that side (tier 3: even frames only)"]
    J --> K["Rest: sort by min(age,64) * (0.7 + 0.3 * nearness)"]
    K --> L["Clamp extra_half to 2 * rest (anti-windup)"]
    L --> M{"tier 3?"}
    M -- yes --> N["take 1 only when frame%8 equals distant_phase (latched at frame%8==0: 3 if pitch above 0.5 else 1)"]
    M -- no --> O["take = extra_half/2, plus 1 on odd frames if extra_half is odd"]
    N --> P["add to mask the first take views"]
    O --> P
    P --> Q{"camera moving or head rate above 0.003?"}
    Q -- yes --> R["motion_fill: up to 2 more visible stale views if they fit under 0.9 * target"]
    Q -- no --> S["final mask"]
    R --> S
Loading

Details:

  1. Prediction. Only yaw is predicted. ahead = yaw + rate * 3. Pitch scoring uses the current pitch. pitch_rate is stored only so motion fill can trigger.
  2. Score. The score is cos(pitch) cos(vpitch) cos(dyaw) + sin(pitch) sin(vpitch), the cosine of the angle between the view centre and the gaze (panorama_budget.h:91-94). In mono, layer 4 is excluded from active entirely.
  3. Gaze bearing. The highest-scoring ring view (pitch 0) gives gaze_off, the wrapped yaw offset of ahead from that view's centre. seam_in_view is true when |gaze_off| > 0.21.
  4. Mandatory set. This is layer 1, plus layer 4 if stereo, plus every view within 1e-5 of the best score. It also includes the "beside" views, subject to the tier rules in the table:
    • ring views within 90 deg of ahead;
    • UP only if pitch > 0.5;
    • DOWN only if pitch < -0.5.
  5. Seam rule. At tier at least 1 with a join in view, the ring view at gaze yaw +/- 60 deg (on the side of gaze_off) is drawn every frame, or on even frames at tier 3. The comment explains that taking the seam neighbour in turns was up to three frames behind at tier 3, "which at a brisk stick turn is several degrees of misalignment and a flash of stale lighting across the join" (panorama_budget.h:267-280).
  6. Distant extras. The remaining active views are insertion-sorted by min(frames since drawn, 64) * (0.7 + 0.3 * (score+1)/2). Age dominates, with a bounded nearness weight, so "the bearing behind the head still comes round within about seventeen frames at the tier-zero floor budget".
  7. Anti-windup. extra_half is clamped to 2 * rest (minimum 1) before it is spent. Credits above the number of optional views "made overload adjustments do no real work for up to seconds" (panorama_budget.h:300-306).
  8. Tier-3 distant slot. The odd slot (1 or 3 of every 8 frames) is chosen at frame % 8 == 0 and kept for the cycle. The slot without the cap toward the gaze is preferred, so the distant pass lands on a lighter frame. Latching prevents "a moving head from postponing every distant refresh by switching slots".

The caller records the actually drawn mask with halo_panorama_budget_drawn after the frame, so staleness counts real pictures.

Motion fill

halo_panorama_budget_motion_fill (panorama_budget.h:322-361) runs when host_panorama_camera_moving() is set, or when the yaw or pitch rate exceeds 0.003 rad/frame (panorama_hooks.inc:509-512).

  1. It needs valid, finite pass_ema, busy_ema and ppf_ema. Without timings it returns the mask unchanged.
  2. It estimates non-pass time as other = busy - pass * ppf.
  3. It admits at most two more views. Each is admitted only while other + pass * (passes + 1) <= 0.9 * target.
  4. The candidate is the highest-scoring undrawn view against the current gaze with score above 0.35. Ties go to the older last_drawn.
  5. Caps are a special case. Looking roughly ahead, a cap's centre is about 90 deg away, but its edges overlap the visible ring. So when |pitch| > 1.5 and the score is between -0.15 and 0.36, the cap's score is lifted to 0.36. That puts it just above the threshold and below the front neighbours (cos 60 deg = 0.5). The opposite cap is not drawn when looking steeply up or down.

Motion fill "does not enable geometric warping or lower the frame-rate target". It spends measured spare time on visible stale neighbours rather than leaving it idle during a heavy tier's relief dwell.

Legacy and override schedules

The pass loop applies the mask only after host_panorama_all_layers_ready() (panorama_hooks.inc:540-558). Before that, the full sphere is drawn.

  • HALO_PANORAMA_ALL_VIEWS=1 draws every active layer every frame.
  • HALO_PANORAMA_TIGHT_BUDGET=0 restores the older schedule. It draws every bearing within 90 deg of the current gaze (caps when pitch is beyond +/-0.5), plus one rotating layer per frame from {7, 8, 9, 5, 6} (panorama_rotation % 5). In this mode the budget never forces mono.

Adapting: halo_panorama_budget_observe_timed

Called once per frame with busy time, measured period, target, and an extra ceiling of 2 * view_count = 18 (panorama_hooks.inc:483-485). Source: panorama_budget.h:126-193.

  1. Guard. Busy time of 0 or over 1 s is ignored ("a load, not a frame"). An invalid period is replaced by max(busy, target).
  2. Smoothing. busy_ema += 0.2 * (busy - busy_ema). Both dwell clocks advance by the period.
  3. Interval. Adjustments happen every 0.25 s of measured period.
  4. Extras. The target is a ceiling, not a centre.
    • Grow. At tier 0 only, when busy_ema < 0.8 * target, extra_half++ (up to the ceiling).
    • Shrink. When busy_ema > target and extra_half > 1, the excess is priced in measured passes: drop = ceil(2 * (busy - 0.9 * target) / pass_ema) half-passes, clamped to [1, extra_half - 1]. This keeps 10% headroom. Without pass timing, it halves when busy > 1.25 * target and steps by 1 otherwise. Only optional views are shed this way.
  5. Pressure and relief.
    • Pressure is "at the floor (extra_half == 1) and still over target".
    • Relief means one more pass fits under nine tenths of the target: busy + pass_ema < 0.9 * target. Without timing it means busy < 0.6 * target. The comment explains why the pass-priced test exists: at Halo's 30 fps cap, busy time read 24-30 ms whatever the pass count, so "once a heavy scene took the budget to mono it stayed mono for the rest of the session".
    • Each counter resets the other.
  6. Overload jump. At the floor with busy > 1.5 * target, after a dwell of at least 0.25 s, the tier jumps to the lightest one predicted to fit: busy - pass_ema * (ppf_ema - floor_ppf[t]) <= target. Without timing it jumps to tier 3.
  7. Step up. 3 pressure adjustments and 1 s dwell at the current tier move one tier heavier.
  8. Step down. 6 relief adjustments and a dwell of 3 s (6 s for 2 to 1, the return to stereo) move one tier lighter.

The struct comment still says "Each step waits ninety frames". The constants and code use seconds of measured period.

Scene entry and level memory

When the shell closes or a frame gap exceeds 1 s in play, the hook calls halo_panorama_budget_scene_entry (panorama_budget.h:104-117), followed by host_panorama_invalidate(). Scene entry:

  • increments scene_epoch;
  • starts at the tier the last level settled at, or 3 if none, but never lighter than 2;
  • sets extra_half = 1;
  • zeroes busy_ema, pass_ema and ppf_ema, and the pressure, relief and dwell counters.

The comment records the bug this prevents: Build74 "carried the menu's tier 0 and full extras into Silent Cartographer and drew nine passes a frame at 3-11 fps".

halo_panorama_budget_note_level runs on every non-shell frame and remembers the current tier as level_tier.

Pacer coupling

Function Purpose
halo_panorama_budget_floor_busy(b, busy) busy - pass_ema * (ppf_ema - 1.625), floored at 0. This is what the frame would cost at the lightest tier's floor. The pacer chooses its cadence from this, "so it never settles on a slower cadence only because the budget filled a faster one with extra bearings" (panorama_budget.h:369-379).
halo_panorama_budget_retarget(b, target) When the pacer moves to a faster cadence or stops pacing, jump to the lightest tier predicted to fit the new target and reset extras to 1. Only ever heavier (panorama_budget.h:381-396).

Reporting

host_panorama_budget_extra(), host_panorama_budget_tier() and host_panorama_busy_seconds() feed EngineVisionDrawProfile. They appear in diagnostics as panoramaBudgetTier, panoramaBudgetExtraHalf and panoramaBusyMilliseconds (EngineVisionRuntime.m:259-261).

Panorama LOD: one pixel size per object per frame

The original routine

0050F740 computes an object's on-screen pixel size as 2 * r * S / depth (panorama_lod.h:11-23):

  • r is the bounding radius (obj+0xAC), halved or quartered by the LOD switch at 0x689450;
  • S is the view's pixels per unit at unit depth (0x7C32F0);
  • depth is the object's distance along the view's forward axis, clamped to at least 0.1.

That single number drives a lot:

  • the model LOD and the draw cutoff (004D6FC0);
  • shadow admission above 30 px and the shadow fade-in up to 45 px (0050EBA0);
  • the shadow silhouette LOD at 0.3 of the value;
  • the lighting record and how often it is refreshed (0050EA00, 0050F150, 0050F270).

Why it breaks at joins

Each bearing has its own forward axis, so one object gets a different depth, and therefore a different pixel size, in every bearing and in each eye. Where bearings overlap (the band/cap belt between 37.5 and 52.5 deg of elevation, the ring's guard columns and cross-fades), the same object could be drawn with two LODs, a shadow in one and none in the other, and lighting refreshed on two cadences: "a pop at the seam".

Modes

panorama_lod_mode() reads HALO_PANORAMA_LOD on each frame entry through a per-site cached HOST_ENV (panorama_hooks.inc:63-72).

Mode Value Depth used Behaviour
HALO_PANORAMA_LOD_DISTANCE default Straight-line distance from the head centre Pixel size is the object's true angular size times the centre view's S. On the centre axis it equals the original. Off-axis it is cos(angle) of the old value: 0.87 at a band's side edge, 0.79 at 37.5 deg up. It is identical in every bearing and both eyes, and does not change as the view turns.
HALO_PANORAMA_LOD_NEAREST nearest Largest of the depths along all bearing axes Equals the original in the bearing whose axis is nearest the object, and never exceeds it elsewhere. Changes as the view turns, like the original.
HALO_PANORAMA_LOD_BEARING bearing or 0 The original per-bearing planar depth For comparison. Bit-identical to the translated routine.

Per-frame state: HaloPanoramaLod

At frame entry, halo_panorama_lod_begin is called for each renderer view with that view's saved central pose (panorama_hooks.inc:500-506, panorama_lod.h:98-111). It does three things:

  • copies the head position from the pose;
  • for NEAREST, computes up to 12 bearing axes (one per schedule entry) with the same float operations the pass loop uses (halo_panorama_lod_bearing_axis);
  • clears scale.

scale is latched on the first hooked call of the frame from 0x7C32F0. Every bearing has the same S: 0050CC40 stores S = h / (2 tan(vfov/2)) from the raster height and vertical field, which the loop gives every bearing alike (DENSE narrows only the width). So it is "the centre view's S; holding it for the frame only makes that true by construction".

panorama_lod_reach is set to the eye separation for the frame.

Dispatch conditions

panorama_lod_dispatch (panorama_hooks.inc:73-90) replaces 0050F740 only when all of these hold:

  • the call is inside a panorama frame (panorama_nested);
  • the current view index (0x007C3108) is below the record count and below 4;
  • the mode is not BEARING;
  • the x87 stack has room for the original's three temporaries (fp_valid & 0xE0 clear). Otherwise the original runs "and is left to fail as it would";
  • the current camera (0x007C3114) is within |separation| * 1.01 + 1e-4 of the head centre. This excludes the mirror pass (0050BA80 renders it through 0050BFB0 as view -1 from a reflected camera) and any foreign camera, while admitting both eyes.

The native routine

halo_panorama_lod_object_pixels (panorama_lod.h:150-231) reproduces the original in order:

  1. the 0x400000 special-case flag (returns FLT_MAX);
  2. the radius switch;
  3. the 12-byte stack copy of the position;
  4. the sign test and the 0.1 clamp (a NaN takes the clamp);
  5. the same x87 operations and stack depth, the same EAX/ECX/EDX, EFLAGS and status word.

Only the depth term and S differ. Depth arithmetic goes through engine_fp_arithmetic in the guest's rounding mode, and the square root honours the guest rounding mode as FSQRT would (halo_panorama_lod_sqrt). With the frame's depth, only ST0 and the condition bits that compared it (C0/C2/C3, and AX, which holds the status word) can differ from the original.

Environment variables

Variable Default Effect Read at
HALO_PANORAMA_TARGET_FPS 30 (accepted 10..60) Seeds the live "Keep at least" target. The budget keeps busy time under 1/target. The settings slider offers 20..30. An invalid value set at runtime falls back to 27. halo_settings.c:63, halo_settings.c:111-112
HALO_PANORAMA_TIGHT_BUDGET on "0": legacy near-gaze plus one rotating layer schedule; no budget-forced mono. panorama_hooks.inc:459-460
HALO_PANORAMA_ALL_VIEWS unset "1": draw every active layer every frame (ignore the mask). panorama_hooks.inc:451-452
HALO_PANORAMA_FORCE_TIER unset 0..3: outside the shell, hold that tier and extra_half = 1 every frame. "Mac measurement only", so two builds draw the same passes. panorama_hooks.inc:489-494
HALO_PANORAMA_LOD distance nearest or bearing/0 (see modes). panorama_hooks.inc:68
HALO_FRAME_PACING off When on, the pacer supplies the budget target (see Frame Pacing). halo_settings.c:79-81

Threading

All budget and LOD state is static to the engine thread. The gaze, roll and target are read from halo_settings atomics once per frame at entry. The three report getters are plain reads of engine-thread fields, called from the diagnostics thread.

Tests

Test What it asserts
test_panorama_budget.c No phantom extras: 18 is clamped to 10. A cheap corridor followed by a costly room fits again within 0.8 s with fewer than 20 misses. For every yaw/pitch: the mask stays inside the validity mask, the centre pair is always drawn, and the best view is always drawn. Tier-0 stereo floor is 540 passes per 120 frames, and every layer refreshes within 12 frames. Extras grow and shrink. A head turn reaches the right bearing early. With one extra per frame, the five distant layers rotate fairly. Tiers climb to 3, with fewer passes per tier and mono from tier 2, then descend one dwell at a time with extras held until tier 0. Tier 3 averages 104 passes per 64 frames with at most 2 per frame. Caps and pitch changes do not stack passes. No layer goes unrefreshed for more than 64 frames at any tier. Relief comes from pass-priced room. Extras halve when well over. Seam neighbours are covered. Scene entry starts heavy and remembers the level tier. Waits are in seconds. The overload jump works.
test_panorama_motion_budget.c No timings means no fill. Both front neighbours are filled when room exists. A costly scene keeps the floor. Exactly one view is admitted when only one fits. NaN timing is a no-op. Caps are filled when looking ahead; only the oldest cap when room is short; never the opposite cap. Yaw and pitch rates are measured.
test_panorama_lod.c (1) Against the translated 0050F740 over random and adversarial inputs (NaN, infinities, signed zeros, subnormals, clamp, FLT_MAX case, every radius switch, every rounding mode), BEARING is bit-identical across all guest state. DISTANCE/NEAREST differ only in ST0 and the compare bits, and ST0 matches `2 r S / max(
test_frame_pacer.c budget_checks floor_busy arithmetic. The pacer's target earns more extras than the plain 1/30 target for the same busy time.

Related pages

Clone this wiki locally