fix(rendering): bound portal resource lifetime
Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
3971997689
commit
749e8ceeb1
225 changed files with 29107 additions and 3914 deletions
|
|
@ -0,0 +1,208 @@
|
|||
# Retail texture and per-object material lifetime
|
||||
|
||||
## Scope
|
||||
|
||||
This note records the retail ownership rules needed to fix the repeated-recall
|
||||
resource buildup in acdream. It does not attempt to reproduce Direct3D 9 or
|
||||
retail's whole database cache. The modern renderer keeps shared atlas textures,
|
||||
but per-object palette/texture composites must follow the same owner lifetime as
|
||||
retail's `CSurface`/`ImgTex` pair.
|
||||
|
||||
## Retail oracle
|
||||
|
||||
Named Sept-2013 client symbols and pseudo-C:
|
||||
|
||||
- `CSurface::SetTextureAndPalette` `0x00535FB0`
|
||||
- `CSurface::Destroy` `0x005361F0`
|
||||
- `CSurface::UseTextureMap(ImgTex*, SurfaceHandlerEnum)` `0x00536350`
|
||||
- `CSurface::InitEnd` `0x00536400`
|
||||
- `CSurface::RestorePalShiftSurface` `0x00536530`
|
||||
- `ImgTex::PurgeResource` `0x0053E550`
|
||||
- `ImgTex::GetD3DTexture` `0x0053E5B0`
|
||||
- `ImgTex::CreateD3DTexture` `0x0053EDB0`
|
||||
- `DBCache::UseTime` `0x00414090`
|
||||
- `DBCache::FlushFreeObjects` `0x004141E0`
|
||||
- `GraphicsResource::PurgeOldResources` `0x00446C60`
|
||||
- `SceneTool::PurgeOldGraphicsResources` `0x0043E5C0`
|
||||
|
||||
Modern reference seam:
|
||||
|
||||
- WorldBuilder's extracted `TextureAtlasManager` reference-counts a texture
|
||||
layer and releases it when its last mesh user disappears.
|
||||
- acdream's `ObjectMeshManager.UnloadObject` already disposes an empty atlas
|
||||
and removes it from `_globalAtlases`; the missing lifetime is the separate
|
||||
`TextureCache` used by live-object composites.
|
||||
|
||||
## Pseudocode
|
||||
|
||||
### A database surface owns its current image texture
|
||||
|
||||
```text
|
||||
CSurface::UseTextureMap(newTexture):
|
||||
if current base1map exists:
|
||||
current base1map.Release()
|
||||
current base1map = null
|
||||
|
||||
current base1map = newTexture
|
||||
newTexture.AddRef()
|
||||
```
|
||||
|
||||
### Replacing a palette composite releases the previous composite
|
||||
|
||||
```text
|
||||
CSurface::SetTextureAndPalette(sourceTexture, palette):
|
||||
combined = ImgTex::CreateCombinedTexture(sourceTexture, palette, clipmap)
|
||||
if combined is null:
|
||||
fail
|
||||
|
||||
if current base1map exists:
|
||||
current base1map.Release()
|
||||
current base1map = null
|
||||
|
||||
current base1map = combined
|
||||
```
|
||||
|
||||
`RestorePalShiftSurface` follows the same order: release the old `base1map`,
|
||||
create the replacement combined texture, then install it.
|
||||
|
||||
### Object destruction releases its material resources immediately
|
||||
|
||||
```text
|
||||
CSurface::Destroy():
|
||||
if base1map exists:
|
||||
base1map.Release()
|
||||
base1map = null
|
||||
|
||||
if base1pal exists:
|
||||
Palette::releasePalette(base1pal)
|
||||
base1pal = null
|
||||
|
||||
reset surface fields
|
||||
```
|
||||
|
||||
This is the important portal/recall rule: a palette-shifted texture is not a
|
||||
session-global retain-forever entry. Its owning surface contributes a reference,
|
||||
and destroying/replacing that surface releases the reference.
|
||||
|
||||
### Shared image resources are separately purgeable
|
||||
|
||||
```text
|
||||
ImgTex::GetD3DTexture():
|
||||
if resource is lost:
|
||||
RestoreResource()
|
||||
TimeUsed = Timer.local_time
|
||||
FrameUsed = render_device.frame_stamp
|
||||
return D3D texture
|
||||
|
||||
SceneTool::PurgeOldGraphicsResources():
|
||||
every 5 seconds:
|
||||
if available video memory is low:
|
||||
GraphicsResource::PurgeOldResources(unused-age threshold)
|
||||
|
||||
GraphicsResource::PurgeOldResources(age):
|
||||
for each registered graphics resource:
|
||||
if not lost
|
||||
and not used this frame
|
||||
and it is thrashable
|
||||
and TimeUsed is older than age:
|
||||
resource.PurgeResource()
|
||||
mark resource lost
|
||||
```
|
||||
|
||||
Shared DAT image residency and owner-scoped composites are therefore two
|
||||
different lifetimes. The modern atlas is acdream's shared-image adaptation;
|
||||
palette and original-texture overrides remain owner-scoped.
|
||||
|
||||
## acdream port contract
|
||||
|
||||
```text
|
||||
ResolveTexture(entity, meshBatch):
|
||||
if no original-texture override
|
||||
and no applicable palette override:
|
||||
use meshBatch's existing shared atlas handle and layer
|
||||
|
||||
if entity has a palette override
|
||||
and the resolved RenderSurface is P8 or INDEX16:
|
||||
get/create one owner-scoped palette composite
|
||||
record (entity local id -> composite key)
|
||||
use composite handle, layer 0
|
||||
|
||||
else if entity has an original-texture override:
|
||||
get/create one owner-scoped override texture
|
||||
record (entity local id -> override key)
|
||||
use override handle, layer 0
|
||||
|
||||
else:
|
||||
use shared atlas handle and layer
|
||||
```
|
||||
|
||||
```text
|
||||
ReleaseTextureOwner(entity local id):
|
||||
for each composite key first acquired by that owner:
|
||||
decrement the key's owner count
|
||||
if count becomes zero:
|
||||
mark the cache entry unowned and therefore evictable
|
||||
|
||||
EvictUnownedTexture(key):
|
||||
remove key from every public lookup first
|
||||
enqueue its handle/resource against the current GPU frame serial
|
||||
after the retirement fence signals:
|
||||
make bindless handle non-resident
|
||||
delete GL texture or recycle the array layer
|
||||
remove physical diagnostic metadata
|
||||
```
|
||||
|
||||
The registry is idempotent per `(owner, key)`, so classifying the same animated
|
||||
object every frame does not add references. Appearance replacement resolves and
|
||||
acquires the complete desired set first, publishes that exact incarnation, then
|
||||
releases resources no longer used by the old description. Logical entity
|
||||
teardown withdraws publication and releases the same set exactly once.
|
||||
|
||||
Keeping a bounded unowned cache is the modern equivalent of retail's separately
|
||||
purgeable `GraphicsResource`: it does not change the owner contract. Current
|
||||
production budgets are 32 MiB / 256 unowned standalone textures and 64 MiB
|
||||
unowned / 128 MiB physical composite arrays. Physical destruction/reuse waits
|
||||
for the three-frame fence owner so the GPU cannot still be sampling a resource
|
||||
whose logical owner has disappeared.
|
||||
|
||||
## Captured failure evidence
|
||||
|
||||
The connected C95B -> 3032 -> F682 -> 0904 -> C95B sequence retained normal
|
||||
world ownership (`139` live network objects, `13` animated objects, `1,705`
|
||||
particle emitters) but `TextureCache` held `1,945` GL textures:
|
||||
|
||||
- `527` legacy palette composites pre-warmed by `EntitySpawnAdapter` but never
|
||||
consumed by the modern draw path;
|
||||
- `366` base bindless textures duplicating textures already resident in the
|
||||
WorldBuilder atlas;
|
||||
- `587` bindless palette composites, including stale objects from earlier
|
||||
destinations and duplicates for non-indexed surfaces.
|
||||
|
||||
The process reached about 2.7 GB private memory, about 891 MB dedicated GPU
|
||||
memory, and 5-8 FPS. A managed CPU trace spent nearly the entire render thread
|
||||
inside native rendering, while a GC snapshot reduced working set but did not
|
||||
recover frame rate. That combination isolates retained GL texture residency,
|
||||
not live-entity, particle, or managed-update counts, as the remaining
|
||||
recall-triggered buildup.
|
||||
|
||||
## Final connected conformance evidence
|
||||
|
||||
The integrated build ran Caul → Sawato → Rynthid → Aerlinthe → Sawato →
|
||||
Holtburg → Caul with 25–30 second destination dwells and a 60-second final
|
||||
reclamation dwell. Compared with the failing build on the same route:
|
||||
|
||||
| Metric | Failing build | Integrated build |
|
||||
|---|---:|---:|
|
||||
| Peak working set | 2,954.5 MiB | 1,493.4 MiB |
|
||||
| Peak private bytes | 3,502.3 MiB | 1,969.5 MiB |
|
||||
| Final Caul working set | 1,826.2 MiB | 1,030.6 MiB |
|
||||
| Final Caul private bytes | 2,433.7 MiB | 1,638.2 MiB |
|
||||
|
||||
Final local GPU residency was 831.6 MiB and stable through the dwell. The
|
||||
composite cache ended at 65,780,224 unowned bytes and 122,437,632 physical
|
||||
bytes, below its 64 MiB policy modulo one entry's admission granularity and
|
||||
below the strict 128 MiB physical ceiling. Emitter and binding indexes balanced
|
||||
at 1,715 each. Caul held 125–153 FPS (141.8 average) on the local display.
|
||||
There was no application WER event or AMD display-driver reset. The old run's
|
||||
32 FPS was imposed by the RDP monitor's 32 Hz refresh and is not a performance
|
||||
comparison; its process-memory measurements remain valid.
|
||||
Loading…
Add table
Add a link
Reference in a new issue