Skip to content

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 on UDomainDungeonConfig caps environment spawners. Together these let dungeons run dozens of enemies without frame drops.

Table of Contents


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 from AEternalEnemy::BeginPlay() (server-only)
  • UnregisterEnemy() — called from EndPlay() and Multicast_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

  1. Distance: player enters WakeDistance (default 5500u) — hysteresis prevents re-freeze oscillation
  2. Damage: delegate on Health attribute change fires NotifyEnemyDamaged() — immediate wake
  3. Master toggle: CVar Eternal.AI.Hibernation set to 0 wakes everything next pass

Never-Hibernate Exemptions

  • AEternalBoss class or ThreatTier == 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


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