ecd87ac96d
Phase 0 of the handoff plan, as a marketplace rather than a flat skills/ directory. Content moved out of the LyraResearch archive and depersonalised: addresses stay in the archive, recipes ship. - plugins/ue-design-skills: 17 skills, 232 failure-mode entries, each with the six required fields; catalog.json as the harness-neutral source of truth and .claude-plugin/ as one adapter over it. - _gate: 16 rules, one poisoned fixture per rule, plus surface coverage so a declared file cannot silently miss the line rules. - ADR-0002 (harness-neutral bundle behind a marketplace) and ADR-0003 (split licensing: CC BY-ND 4.0 prose, Apache-2.0 code and metadata). - LICENSE files at both levels, CONTRIBUTING.md, docs/licensing-options.md as the material the licence decision grew from. Verified: gate.py 0 violations; test_gate.py 16/16 rules redden on their fixtures with a clean baseline and 2 root files reaching the line rules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
299 lines
12 KiB
Markdown
299 lines
12 KiB
Markdown
---
|
|
name: ue-asset-loading-and-memory
|
|
description: >-
|
|
Design or audit asset loading and CPU memory residency in Unreal Engine: the
|
|
asset manager and primary asset identifiers, asset bundles and cook rules,
|
|
hard versus soft versus weak reference semantics, streamable handle ownership,
|
|
synchronous load hitches, startup job sequencing, garbage-collection
|
|
reachability, retention leaks and unload paths. Use when reducing load times
|
|
or memory, choosing reference types, adding content-pulling definitions,
|
|
diagnosing hitches, or investigating objects that are never collected.
|
|
---
|
|
|
|
# UE asset loading and CPU memory
|
|
|
|
The invariant:
|
|
|
|
> An asset stays in memory because something reachable **owns** it. Loading is a
|
|
> policy decision; residency is an ownership fact. They are set by different code
|
|
> and must be reviewed separately.
|
|
|
|
Measured patterns from a reference product: [patterns](references/patterns.md).
|
|
Detection recipes: [failure modes](references/failure-modes.md).
|
|
|
|
Related skills: `ue-data-driven-architecture`, `ue-modular-gameplay`,
|
|
`ue-streaming-and-platform-budgets`, `ue-runtime-allocation-and-caching`.
|
|
|
|
Method, not architecture — how to check any claim in this bundle before repeating it: `ue-evidence-discipline`.
|
|
|
|
---
|
|
|
|
## 1. Separate the five budgets before optimizing anything
|
|
|
|
| Budget | What it is | Measured by |
|
|
|---|---|---|
|
|
| A. Disk / cook | package bytes shipped | build size, chunk sizes |
|
|
| B. CPU memory | object graph plus bulk data | memory report, object list |
|
|
| C. GPU memory | live render resources | platform GPU profiler |
|
|
| D. Streaming pool | a **limit**, not consumption | streaming stats |
|
|
| E. Net objects | actors, channels, bandwidth | network profiler |
|
|
|
|
This skill owns A and B. C and D belong to
|
|
`ue-streaming-and-platform-budgets`; E to `ue-runtime-allocation-and-caching`.
|
|
|
|
**The most common analytical error is treating D as B.** A pool setting grants
|
|
permission to use memory. It never reports use.
|
|
|
|
---
|
|
|
|
## 2. Reference semantics decide load cost
|
|
|
|
Three mechanisms, routinely conflated:
|
|
|
|
| Mechanism | Effect |
|
|
|---|---|
|
|
| reflected property, manual reference collection, a retained streamable handle | **owns** — keeps resident |
|
|
| weak pointer, object key | **observes** — keeps nothing |
|
|
| soft object or class pointer | **a path, not a reference** |
|
|
|
|
The third line is where most reasoning fails. **A soft pointer saves nothing by
|
|
itself.** Residency is decided by where the loaded result is stored — a soft
|
|
reference resolved into a reflected property is a hard reference with extra
|
|
steps.
|
|
|
|
### Measure the transitive closure, not the direct count
|
|
|
|
Direct reference counts are misleading. What matters is how many packages load
|
|
when this one loads. In the audited reference the same architectural layer
|
|
spanned three orders of magnitude depending on reference type alone: an archetype
|
|
holding hard references pulled over a thousand packages, while a catalog entry
|
|
addressing by identifier pulled two.
|
|
|
|
That single contrast is the argument for treating reference type as
|
|
architecture rather than style. Recipe: AL-01.
|
|
|
|
### Choosing
|
|
|
|
| Need | Reference |
|
|
|---|---|
|
|
| address before load; cook and bundle identity | primary asset identifier |
|
|
| optional, feature-scoped or async content | soft object or class pointer |
|
|
| required whenever the owner is loaded, and small | hard pointer |
|
|
| data selects an implementation class | class reference |
|
|
| owner-exclusive polymorphic fragment | instanced object |
|
|
|
|
Rule of thumb: a definition that is a **catalog entry** references by identifier
|
|
or softly; a definition that is an **archetype** may reference hard — but its
|
|
closure must be measured and budgeted, not assumed.
|
|
|
|
---
|
|
|
|
## 3. Bundles express role, not grouping
|
|
|
|
Declare the load role beside the reference, then request bundles per runtime role
|
|
at load time.
|
|
|
|
Gates:
|
|
|
|
- every soft reference carries an intentional bundle tag;
|
|
- server-irrelevant presentation is client-only;
|
|
- prediction-relevant gameplay classes are client and server;
|
|
- **a missing bundle tag means "always loaded with the owner"** — decide it, do
|
|
not default into it;
|
|
- cook rules control budget **A**, never budget **B**.
|
|
|
|
A label asset with thousands of soft references and no hard references determines
|
|
what ships and retains nothing at runtime. Do not read cook rules as residency.
|
|
|
|
Watch for a declared bundle name that no property ever uses: it is requested at
|
|
load time, matches nothing, and looks like a working tier. Recipe: AL-02.
|
|
|
|
---
|
|
|
|
## 4. Startup discipline
|
|
|
|
### Never synchronously load a graph you have not measured
|
|
|
|
The dominant boot hitch in a data-driven project is one synchronous load whose
|
|
hard closure is large. An elegant async bundle pipeline that runs *after* it is
|
|
decoration.
|
|
|
|
- [ ] every synchronous load on the boot path is justified;
|
|
- [ ] its transitive closure is measured, not assumed;
|
|
- [ ] an async alternative was considered, with an explicit wait state;
|
|
- [ ] the loading screen carries a reason string;
|
|
- [ ] cancellation is distinguishable from completion.
|
|
|
|
### Startup jobs must own handles and report truthfully
|
|
|
|
A progress bar that never moves is a bug, not a cosmetic issue — it hides which
|
|
job is slow. Three independent layers can each break it: a macro that never
|
|
fills the handle, a throttle comparison with swapped operands, and an empty
|
|
progress receiver. Any one of them is enough. Recipe: AL-03.
|
|
|
|
### Global data assets need bundles too
|
|
|
|
Soft class pointers on a globally-loaded data asset with **no** bundle tags load
|
|
synchronously at first use — typically in the frame of first damage. Tag them and
|
|
preload them with the mode. Recipe: AL-04.
|
|
|
|
---
|
|
|
|
## 5. Retention: what actually keeps objects alive
|
|
|
|
Audit six owners explicitly: reflected containers on long-lived subsystems;
|
|
manual reference collection in native code; retained streamable handles; delegate
|
|
bindings capturing strong references; component and actor ownership chains; class
|
|
default objects reached through class references.
|
|
|
|
### A convenience loader must not default to permanent
|
|
|
|
The worst shape in this area is a helper that loads an asset and adds it to a
|
|
reflected set, with the keep-in-memory parameter **defaulting to true** and no
|
|
release API anywhere. Every casual call then synchronously loads *and permanently
|
|
roots* the asset. The soft pointer was chosen for deferred loading; the API turns
|
|
it into a process-lifetime hard reference.
|
|
|
|
Required design: the flag is explicit at every call site or defaults to false; a
|
|
paired release exists and is used; the retained set is inspectable from a console
|
|
command; retention has an owner and a scope. Recipe: AL-05.
|
|
|
|
### Strong keys pin object graphs — and raw pointers lose them
|
|
|
|
```text
|
|
map<strong actor pointer, strong value> one missed unregister pins the actor,
|
|
its meshes, materials, sounds, effects
|
|
|
|
map<raw actor pointer, non-reflected struct of object pointers>
|
|
garbage collection cannot see the
|
|
values; the key can dangle
|
|
```
|
|
|
|
Both errors can coexist in one codebase. **Audit direction as well as presence.**
|
|
Prefer object keys with explicitly-owned values plus a periodic prune. Recipe:
|
|
AL-06.
|
|
|
|
---
|
|
|
|
## 6. Unloading must exist before you claim modularity
|
|
|
|
If a project cannot answer "what is released when a mode ends", its memory
|
|
profile only grows. Either implement release, or state plainly that mode changes
|
|
require map travel — that is a legitimate scope decision, and leaving it
|
|
undefined is not.
|
|
|
|
The measurement is blunt and takes one command: count unload calls, bundle
|
|
releases and explicit collection requests across the whole project. In the
|
|
audited reference every one of those counts was **zero**. Recipe: AL-07.
|
|
|
|
### Avoid collection ping-pong
|
|
|
|
A forced collection at a transition boundary must not collect what the next
|
|
transition immediately needs. Options: keep transition-critical classes rooted,
|
|
defer collection past the next load, or do not force it and let the budget drive
|
|
it.
|
|
|
|
---
|
|
|
|
## 7. Failure handling changes memory behaviour
|
|
|
|
- a cancelled load must not run the success path;
|
|
- asynchronous results must be read, not discarded;
|
|
- partially loaded state must be releasable;
|
|
- waiting without a timeout converts a stall into a hang;
|
|
- a permanent loading screen with no reason string is not error handling.
|
|
|
|
The sharpest instance: binding the cancel delegate to the **same callback** as
|
|
completion. A cancelled load then reports success, downstream code dereferences
|
|
unloaded assets, and each of those dereferences is individually silent. Recipes:
|
|
AL-08, AL-09.
|
|
|
|
---
|
|
|
|
## 8. Review checklist
|
|
|
|
### References
|
|
|
|
- [ ] reference type chosen per lifecycle and documented in metadata;
|
|
- [ ] transitive hard closure measured for every archetype-level definition;
|
|
- [ ] no soft reference resolved into an unbounded reflected cache;
|
|
- [ ] bundle tags on all soft references, and every requested bundle is used;
|
|
- [ ] cross-plugin edges intentional and acyclic.
|
|
|
|
### Loading
|
|
|
|
- [ ] no unmeasured synchronous load on boot or in a hot path;
|
|
- [ ] async loads own their handles and can be cancelled;
|
|
- [ ] progress reporting is verified to move, not merely implemented;
|
|
- [ ] cancellation and failure paths are distinct from success;
|
|
- [ ] global data assets are bundled, not lazily loaded at first use.
|
|
|
|
### Retention
|
|
|
|
- [ ] every long-lived container has an owner, a bound and a prune policy;
|
|
- [ ] strong keys justified; object keys used where identity suffices;
|
|
- [ ] non-reflected object pointers eliminated or documented as weak by design;
|
|
- [ ] register and unregister pairs verified across teardown paths;
|
|
- [ ] a release API exists for anything deliberately kept resident.
|
|
|
|
---
|
|
|
|
## 9. Measurement plan
|
|
|
|
Static first — it is cheap and finds structural problems:
|
|
|
|
1. build the dependency graph from the asset registry: direct hard and soft
|
|
counts, transitive hard closure per definition, cross-plugin edges;
|
|
2. rank definitions by closure and compare against their architectural layer;
|
|
3. search for synchronous loads on boot and hot paths;
|
|
4. inventory retention owners by type.
|
|
|
|
Runtime, once a packaged build exists: memory reports before and after a mode
|
|
cycle; object listings on suspected roots — root-graph evidence, not process
|
|
memory; fifty to a hundred spawn and travel cycles looking for monotonic growth;
|
|
an allocation profiler for attribution.
|
|
|
|
### Do not measure residency in the editor
|
|
|
|
Editor builds commonly load **both** client and server bundles, so an in-editor
|
|
memory profile represents neither shipping role. Residency numbers require a
|
|
packaged build; use the editor for relative structural comparison only. Recipe:
|
|
AL-10.
|
|
|
|
---
|
|
|
|
## 10. When to stop
|
|
|
|
Optimize loading and residency when boot or transition time is a product problem,
|
|
a platform ceiling is real and near, growth is monotonic across a session, or a
|
|
specific closure is measurably oversized.
|
|
|
|
Do not restructure references on aesthetics. A fourteen-package closure is not a
|
|
problem; a thousand-package synchronous closure on the boot path is. Measure
|
|
first, and **keep the measurement** so the next change can be compared against
|
|
it.
|
|
|
|
---
|
|
|
|
## Provenance
|
|
|
|
The findings come from a source and dependency-graph audit of Epic's Lyra Starter
|
|
Game on Unreal Engine 5.6. The engine's own asset manager and streamable manager
|
|
are not part of that project, so every claim about their internals is inferred
|
|
from call sites and marked as such rather than presented as measured.
|
|
|
|
No profiler was run and no timing was captured: this material is structural by
|
|
construction, and it deliberately contains no performance numbers. What it
|
|
contains is counts — of call sites, of unload paths, of packages in a closure —
|
|
which is a different kind of evidence and a more portable one.
|
|
|
|
Source addresses stay in the research archive. Each entry carries a stable
|
|
identifier (`AL-01` and up) resolving back to the audited location there.
|
|
|
|
## Evidence boundary
|
|
|
|
One project, one engine version. Binary assets were not read, so the contents of
|
|
any specific closure are structural rather than byte-measured. Re-run the recipes
|
|
against your own tree; the counts quoted show the shape of a contrast, not a
|
|
target.
|