Enemy Performance Tiering¶
Summary: A three-tier LOD scheme (
UEnemySignificanceSubsystem) reduces per-enemy simulation cost at distance: Full Detail → Low Detail → Hibernated. A spawn-side density policy onUDomainDungeonConfigcaps environment spawners. Together these let dungeons run dozens of enemies without frame drops.
Table of Contents¶
- Why Tiering
- Architecture Overview
- Distance Tiers
- Hibernation
- Density Policy
- Flat Optimizations
- Configuration
- Scalability Profiles
- Steam Deck Device Profile
- Benchmark Harness
- Source References
- Related Systems
- Recent Changes
Why Tiering¶
Unreal's default skeletal-mesh tick, cloth sim, and AI perception scale linearly with enemy count. A 30-enemy dungeon room at full fidelity costs ~28ms on the game thread. The tiering system brought per-enemy cost from ~0.94ms to ~0.35ms for distant enemies, making dense encounters viable.
Key Tradeoffs¶
| Decision | Benefit | Cost |
|---|---|---|
| Three tiers instead of two | Cloth/URO off at mid-range, full freeze at far range — smooth degradation | More state transitions to manage |
| Combat-state override | Engaged enemies always look good | A ranged enemy duelling at 4000u still pays full cost |
| Death unregisters immediately | Ragdolls and despawn VFX never freeze or jank | Corpse animation is full-cost until despawn |
| Spawn-side density cap | Hard ceiling on active enemies | Content rooms exempt — cap only affects filler spawners |
Architecture Overview¶
Spawn
│
├── Density Policy (FEnemyDensityPolicy on UDomainDungeonConfig)
│ Gates which environment-tile spawners fire
│ Content-room spawners always fire
│
▼
Enemy alive, registered with UEnemySignificanceSubsystem
│
├── Full Detail (Awake): dist ≤ DetailDistance OR in combat
│ No URO, cloth active, full anim graph eval
│
├── Low Detail (Awake): DetailDistance < dist ≤ SleepDistance, not in combat
│ URO on all skeletal meshes, cloth suspended
│
├── Hibernated: dist > SleepDistance, not exempt
│ Everything off: tick, AI, BT, CMC, mesh, poise, perception
│
├── Wake triggers: dist < WakeDistance, or any damage
│ Restores to Full Detail (low-detail re-evaluated next pass)
│
└── Death: immediate unregister → full detail restored
Ragdoll and VFX never frozen or URO-throttled
UEnemySignificanceSubsystem is a UTickableWorldSubsystem (server-only). It maintains a TMap<TWeakObjectPtr<AEternalEnemy>, FTrackedEnemy> of all registered enemies.
Registration Lifecycle¶
RegisterEnemy()— called fromAEternalEnemy::BeginPlay()(server-only)UnregisterEnemy()— called fromEndPlay()andMulticast_HandleDeath()(death unregisters early)- On unregister, hibernated enemies are woken and low-detail enemies are restored
Distance Tiers¶
A distance pass (RunDistancePass) runs every CVarPassInterval seconds (default 0.25s). For each tracked enemy, the minimum squared distance to any player pawn is computed.
Tier Table¶
| Tier | Condition | URO | Cloth | AI / BT | Tick |
|---|---|---|---|---|---|
| Full Detail | dist ≤ 3000 OR in combat state | Off | Active | Active | Active |
| Low Detail | 3000 < dist ≤ 7000, not in combat | On (all skeletal meshes) | Suspended | Active | Active |
| Hibernated | dist > 7000, awake > 2s, not exempt | N/A (mesh tick off) | Off | Paused | Disabled |
Combat-State Override¶
IsInCombatState() checks the AI controller's EEnemyState:
| State | Counts as Combat |
|---|---|
| Engaged | Yes |
| Attacking | Yes |
| HitReact | Yes |
| PhaseTransition | Yes |
| All others | No |
An engaged enemy at 4000u still gets full animation fidelity. Combat state also exempts from hibernation via ShouldNeverHibernate.
URO on the ALS Driver Mesh¶
The ALS driver mesh is always hidden (bHiddenInGame=true). When URO is enabled, the engine treats it as non-rendered and applies maximum throttle — the animation graph evaluates at a heavily interpolated skip rate. This is fine at distance but catastrophic at melee range (janky combat anims) and on death (URO interpolation fights the physics-driven ragdoll). The combat-state and death-unregister overrides exist specifically to prevent this.
Hibernation¶
What Gets Disabled¶
| System | How |
|---|---|
| Actor tick (ALS/pawn) | SetActorTickEnabled(false) |
| CharacterMovementComponent | Tick disabled + StopMovementImmediately |
| All skeletal mesh components | SetComponentTickEnabled(false) |
| PoiseSystemComponent | Tick disabled |
| AI Controller | Tick disabled |
| BrainComponent (BT) | PauseLogic("EnemyHibernation") |
| AI movement | Stopped |
| Perception senses | Sight + Hearing disabled |
| Detour Crowd agent | SetCrowdSimulationSuspended(true) — crowd agents otherwise register for component lifetime, so a frozen enemy would still cost CrowdManager neighbour queries and a MaxAgents slot |
Wake Triggers¶
- Distance: player enters
WakeDistance(default 5500u) — hysteresis prevents re-freeze oscillation - Damage: delegate on
Healthattribute change firesNotifyEnemyDamaged()— immediate wake - Master toggle: CVar
Eternal.AI.Hibernationset to 0 wakes everything next pass
Never-Hibernate Exemptions¶
AEternalBossclass orThreatTier == Boss- Any combat state (Engaged, Attacking, HitReact, PhaseTransition)
Safety Guards¶
| Guard | Purpose |
|---|---|
| Mid-air check | IsMovingOnGround() must be true — prevents freeze mid-jump |
| Max sleeps per pass | Capped at 8 (wakes uncapped) — prevents frame spike from mass-hibernate |
| Min-awake window | 2s after spawn or wake — prevents re-freeze before init completes |
| Death unregister | Immediate — corpse anims/ragdolls/VFX never frozen |
Density Policy¶
FEnemyDensityPolicy is authored on UDomainDungeonConfig::EnemyDensity. Two knobs:
| Knob | Type | Default | Purpose |
|---|---|---|---|
EnvironmentSpawnerChance |
float 0–1 | 1.0 | Per-spawner activation roll |
MaxEnvironmentEnemies |
int32 | 0 (uncapped) | Hard cap on environment-tile enemies |
Only environment-tile spawners (filler/edge tiles from the Phase B space-fill) are policed; content-room spawners always fire. The activation pass runs in UDungeonSubsystem::MarkRoomsReady(), spawners sorted by path name for determinism, using a seeded FRandomStream from the dungeon seed. Non-dungeon maps keep immediate spawn unchanged.
Flat Optimizations¶
These apply to all enemies unconditionally, independent of distance tier:
| Optimization | Location | Effect |
|---|---|---|
Visual-mesh OnlyTickPoseWhenRendered |
PostInitializeComponents |
Overlay and weapon meshes skip pose eval when off-screen |
| HitTrace tick gating | HitTraceActorComponent |
bStartWithTickEnabled = false; tick enabled only during active trace windows |
| Physics interaction disabled | Constructor | bEnablePhysicsInteraction = false on movement component |
| AI perception server-only | Not replicated | Perception component is server-only |
The ALS driver mesh keeps AlwaysTickPoseAndRefreshBones because it drives server-side animation state. All other skeletal meshes (retarget overlay, weapons) use OnlyTickPoseWhenRendered.
Configuration¶
| Mechanism | Location | Type |
|---|---|---|
| Density policy | UDomainDungeonConfig.EnemyDensity |
Data asset (FEnemyDensityPolicy) |
| Hibernation distances | CVars Eternal.AI.Hibernation.* |
Runtime CVars |
| Detail distance | CVar Eternal.AI.Hibernation.DetailDistance |
Runtime CVar |
| Debug dump | Console Eternal.AI.Hibernation.Dump |
Console command |
CVars¶
| CVar | Default | Purpose |
|---|---|---|
Eternal.AI.Hibernation |
1 | Master toggle (0 wakes all) |
Eternal.AI.Hibernation.SleepDistance |
7000 | Hibernate threshold (uu) |
Eternal.AI.Hibernation.WakeDistance |
5500 | Wake threshold — hysteresis gap |
Eternal.AI.Hibernation.DetailDistance |
3000 | Low-detail threshold (uu) |
Eternal.AI.Hibernation.Interval |
0.25 | Seconds between distance passes |
Eternal.AI.Hibernation.MaxSleepsPerPass |
8 | Cap on hibernate transitions per tick |
Console Commands / Launch Args¶
| Command / Arg | Purpose |
|---|---|
Eternal.AI.Hibernation.Dump |
Dump tracked-enemy tiers |
Eternal.Perf.Run [WarmupSecs=15] [CaptureSecs=30] [NodeID] |
Benchmark: wait for dungeon + pawn, optional force-travel, warmup, CsvProfile + ProfileGPU capture, screenshot (see Benchmark Harness) |
-ScalabilityLevel=<0..3> |
Pin the graphics profile; wins over everything (see Scalability Profiles) |
-ResolutionScale=<pct> |
Override the profile's resolution scale |
Scalability Profiles¶
Enemy tiering is the CPU side; the GPU side is a pair of curated, look-identical profiles rather than the
engine's auto-scalability (which once landed machines on High and visibly changed the art-approved look).
UEternalGameInstance::Init (client only) picks the overall level and applies it through
UEternalDisplaySettingsSubsystem::ApplyProfile:
| Level | Meaning |
|---|---|
| 1 Medium | Default. Art-approved look. |
| 0 Low | Same look; EffectsQuality=0 (coarse fog), VSM page bias, lower resolution scale — for GPUs that can't hold 60fps on Medium. Built as Medium-with-overrides, not engine overall-0. |
| 2+ High/Epic | Engine defaults; reachable only via explicit -ScalabilityLevel=. |
Per-level cvars live in the @N buckets of Config/DefaultScalability.ini; the overall presets are just sg.* levels
set by code.
Selection order: explicit -ScalabilityLevel= → PIE always Medium (GIsEditor; the benchmark, the Low profile
and its sticky cvar overrides must never leak into the session artists review in) → the user's quality pick from the
settings menu (UEternalDisplaySettingsSubsystem::GetUserQualityOverride, persisted — see
UI Architecture) → a one-time SynthBenchmark (RunHardwareBenchmark, result
persisted in GameUserSettings so later boots skip it). The benchmark only ever chooses between Low and Medium:
GPU perf index below GpuPerfIndexMediumThreshold = 320 → Low, else Medium. The cutoff is calibrated ~25% above a
GTX 1070 (index 255, 47fps on Medium in the forest domain) and biased toward Low because the failure costs are
asymmetric — a strong GPU wrongly on Low just renders softer, a weak GPU wrongly on Medium stutters. A stale
placeholder result of exactly 50.0 (seeded by an older DefaultGameUserSettings.ini) is treated as "never run".
-ResolutionScale= overrides the profile's scale in every path.
Steam Deck Device Profile¶
The Deck runs the Windows build under Proton, so the engine's WindowsDeviceProfileSelector cannot tell it from a
PC. Plugins/EternalDeviceProfile (registered via Config/Windows/WindowsEngine.ini
[DeviceProfileManager] DeviceProfileSelectionModule) answers SteamDeck when the process environment carries
Steam's SteamDeck=1 (verified on hardware: Proton forwards it) or the build was launched with -DP=SteamDeck; in
every other case it defers to the engine Windows selector, so Windows_<RHI> / WindowsEditor keep working.
FEternalDeviceProfile::IsSteamDeck() exposes the same answer to game code.
Cvar priority is SetByScalability < SetByGameSetting < SetByDeviceProfile < SetByCommandline, so
[SteamDeck DeviceProfile] in Config/DefaultDeviceProfiles.ini is the single home for every Deck cvar — it
beats the buckets and ApplyProfile, and survives the user switching tiers. It currently pins
r.MaterialQualityLevel=0 (materials take their QualitySwitch Low branch — M_Foliage_Grass drops WPO and shades
DefaultLit instead of TwoSidedFoliage there, M_StaticMesh_Layered_RVTBlend bypasses its UsePOM_LayerN parallax),
r.LightFunctionQuality=0, r.LumenScene.Radiosity=0, r.ViewDistanceScale=0.6, pcg.Quality=0, an Effects
texture cap of 512, and the Prismatiscape sizes via project cvars:
| CVar | PC default | Deck | Consumer |
|---|---|---|---|
Eternal.Water.SimResolution |
384 | 16 (floor) | ApplyPrismatiscapeCVarsToManagerDefaults → manager BP WaterResolution |
Eternal.Grass.GridResolution |
256 | 128 | same → manager BP Deform Resolution |
Eternal.Wind.GridResolution |
256 | 256 (128 measured ≈0 ms total — not pinned) | same → manager BP WindResolution |
The manager is a Blueprint chain (Prismatiscape_Manager_BP → BP_EternalPrismatiscapeManager) that sizes its
Niagara grids from those variables in BeginPlay, so UEternalGameInstance::Init writes the cvar values into the
class defaults by name before any world spawns it. Cvar-less knobs stay in C++ gated on IsSteamDeck(): Low's
resolution scale is 60 on the Deck (70 elsewhere), the GPU benchmark is skipped and Low forced, and the boot log
prints the active device profile. pcg.Quality also lives in the [FoliageQuality@N] buckets so PCG Quality
Branch/Select nodes follow the tier on PC. Test on PC with -DP=SteamDeck; inspect with dumpdeviceprofile.
The material QualitySwitches were authored headless with the editor console commands in
Source/ProjectEternalEditor/Private/Authoring/MaterialQualityAuthoringCommands.cpp — Eternal.Material.DumpGraph
(links + property inputs to a text file), Eternal.Material.ShadingModelQualitySwitch <Material> <Default> <Low>,
Eternal.Material.StaticSwitchLowBypass <Material> <StaticSwitchParam>... — driven with the editor closed via
UnrealEditor-Cmd.exe <proj> -run=pythonscript -script=Tools/run_editor_console.py (commands listed one per line
in Saved/MaterialDump/cmds.txt). Dump before and after; the diff is the proof of wiring.
Benchmark Harness¶
Tools/perf_benchmark.ps1 launches UnrealEditor.exe -game into L_Dungeon per config in a cvar matrix, arms
Eternal.Perf.Run (Private/Debug/PerfBenchmarkCommands.cpp), and parses Saved/Profiling/CSV into a per-config
median/p95 table. It always pins a profile (-ScalabilityLevel=1 -ResolutionScale=75 by default) so configs measure
the same baseline on every machine, and moves SaveGames aside for the session so the dungeon seed
(hash(NodeID, ClearCount)) is identical each run.
The command's optional third argument force-travels to a world-map node via
UWorldMapSubsystem::DebugForceTravelToNode (!UE_BUILD_SHIPPING). That call unlocks the node for real —
the unlock persists into the save and replicates — so only use it on throwaway saves (the harness runs on a wiped
SaveGames); for a lock-safe spawn on a live save use the preset landing-node override instead. See
World Map System.
Steam Deck per-room tour¶
Tools/deck_perf.ps1 runs the same idea unattended on a devkit-paired Steam Deck. It pushes the packaged
Development build (-Push, rsync through the devkit client's cygwin binaries, Saved/ excluded), writes the run's
command line to ~/devkit-game/ProjectEternal/perf_cmdline.txt (the game loads it via -CmdLineFile=, which
sidesteps Steam/Proton argv quoting), launches via steam-devkit-rpc run-game, waits for the EternalTour: RESULT=
marker, pulls Saved/PerfBench/<RunID>/ back and reduces it to a config × room table
(Saved/PerfBench/Deck/<stamp>/summary.{csv,json,md}).
Eternal.Perf.Tour [Settle] [Capture] [NodeID|-] [RunID] [Room...] (Private/Debug/PerfTourCommands.cpp) is the
in-game half: after the same ready gate as Perf.Run it sets god mode through the PlayerController's console path
(engine Exec never reaches UCheatManager), then per room teleports, settles, CSV-captures with -csvGpuStats
(GPU/<Pass> columns), dumps one ProfileGPU frame to the log, screenshots, and finally writes manifest.json.
Rooms are numeric IDs or slot/data substrings; -Discover (no rooms) logs the table. Capture=0 makes it a pure
screenshot A/B pass. Plan + Deck findings: ImplementationDocs/archive/DeckPerfBench.plan.md.
Source References¶
| Class | File |
|---|---|
| UEnemySignificanceSubsystem | Source/ProjectEternal/Public/AI/Significance/EnemySignificanceSubsystem.h |
| FTrackedEnemy | Source/ProjectEternal/Public/AI/Significance/EnemySignificanceSubsystem.h |
| FEnemyDensityPolicy | Source/ProjectEternal/Public/Dungeon/DataAssets/DomainDungeonConfig.h |
| AEternalEnemy (registration) | Source/ProjectEternal/Private/AI/Enemy/EternalEnemy.cpp |
| UDungeonSubsystem (density activation) | Source/ProjectEternal/Private/Dungeon/DungeonSubsystem.cpp |
UEternalGameInstance (profile selection, GpuPerfIndexMediumThreshold) |
Source/ProjectEternal/Private/GameMode/EternalGameInstance.cpp |
UEternalDisplaySettingsSubsystem (ApplyProfile, user quality override) |
Source/ProjectEternal/Public/Settings/EternalDisplaySettingsSubsystem.h |
| Eternal.Perf.Run | Source/ProjectEternal/Private/Debug/PerfBenchmarkCommands.cpp |
| perf_benchmark.ps1 | Tools/perf_benchmark.ps1 |
| Eternal.Perf.Tour | Source/ProjectEternal/Private/Debug/PerfTourCommands.cpp |
| deck_perf.ps1 | Tools/deck_perf.ps1 |
| Scalability buckets | Config/DefaultScalability.ini |
| FEternalDeviceProfile (Deck detection + selector) | Plugins/EternalDeviceProfile/Source/EternalDeviceProfile/Public/EternalDeviceProfile.h |
| Eternal.Water/Grass cvars + manager CDO push | Source/ProjectEternal/Private/Rendering/EternalRenderingCVars.cpp |
| SteamDeck device profile | Config/DefaultDeviceProfiles.ini |
Related Systems¶
- Enemy AI — behavior trees, combat states, perception
- Dungeon System — environment tiles, spawner activation
- Spawning System — encounter spawner lifecycle
Recent Changes¶
| Date | Change | Impact |
|---|---|---|
| 2026-08-21 | SteamDeck device profile + Plugins/EternalDeviceProfile selector, Eternal.Water.SimResolution / Eternal.Grass.GridResolution cvars pushed into the Prismatiscape manager defaults, M_Foliage_Grass QualitySwitch (no WPO on Low), Deck Low resolution scale 60, pcg.Quality in the foliage buckets |
Deck tuning has one greppable home that survives tier changes; grass/water/radiosity/light-function costs trimmed on the Deck only |
| 2026-08-21 | Eternal.Perf.Tour per-room CSV/ProfileGPU/screenshot tour + Tools/deck_perf.ps1 Steam Deck harness (devkit SSH, -CmdLineFile, rsync push, config × room table); replaces the screenshot-only Eternal.Perf.Shots |
Deck perf iteration no longer needs hands on the device |
| 2026-08-20 | Documented the scalability profiles (Low/Medium, one-time SynthBenchmark pick at threshold 320, PIE pinned to Medium, -ScalabilityLevel= / -ResolutionScale=), the Eternal.Perf.Run benchmark command + Tools/perf_benchmark.ps1, and DebugForceTravelToNode's real-state warning |
GPU-side perf work had no doc home; harness was only discoverable from the script header |
| 2026-08-09 | Detour crowd agent suspended with hibernation (SetCrowdSimulationSuspended) |
A frozen enemy no longer costs CrowdManager neighbour queries or a MaxAgents slot |
| 2026-07-29 | Full-detail override for combat state and corpses; URO becomes a distance tier instead of spawn-time default | Close/engaged enemies and ragdolls no longer jank from URO interpolation |
| 2026-07-29 | Per-enemy cost cut 0.94ms → 0.35ms (URO, visual-mesh tick gating, cloth distance tier) | Dense encounters viable without frame drops |
| 2026-07-29 | Enemy density policy + significance hibernation for many-enemy dungeons | Environment-tile spawner cap, three-tier distance LOD, damage-wake |