From 749e8ceeb108a1772eac576e80ca05ecbb3bbe93 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 18 Jul 2026 21:35:16 +0200 Subject: [PATCH] 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 --- docs/ISSUES.md | 38 +- docs/architecture/acdream-architecture.md | 38 + docs/architecture/code-structure.md | 42 +- .../retail-divergence-register.md | 2 +- docs/architecture/worldbuilder-inventory.md | 14 + docs/plans/2026-04-11-roadmap.md | 6 +- docs/plans/2026-05-12-milestones.md | 14 +- ...il-texture-resource-lifetime-pseudocode.md | 208 ++ .../Physics/ProjectileController.cs | 31 +- .../Physics/RemotePhysicsUpdater.cs | 14 +- .../Rendering/BoundedUnownedResourceCache.cs | 121 + src/AcDream.App/Rendering/ClipFrame.cs | 578 +++- .../Rendering/ClipFrameAssembler.cs | 281 +- src/AcDream.App/Rendering/ClipPlaneSet.cs | 141 +- .../Rendering/CompositeTextureArrayCache.cs | 933 ++++++ .../Rendering/DynamicBufferCapacity.cs | 30 + .../EquippedChildRenderController.cs | 5 +- .../Rendering/FixedEntityTextureOwnerLease.cs | 31 + .../Rendering/FramePacingController.cs | 155 + .../Rendering/FramePacingPolicy.cs | 31 + src/AcDream.App/Rendering/GameWindow.cs | 893 ++++-- .../Rendering/GpuFrameFlightController.cs | 486 ++++ .../GpuRetiredTerrainSlotAllocator.cs | 89 + .../Rendering/OrderedResourceTeardown.cs | 36 + .../Rendering/OwnerScopedResourceRegistry.cs | 61 + .../Rendering/PaperdollViewportRenderer.cs | 25 +- .../ParticleEmitterRetirementTracker.cs | 139 + src/AcDream.App/Rendering/ParticleRenderer.cs | 1075 +++++-- .../Rendering/ParticleSubmissionOrdering.cs | 143 +- .../Rendering/PortalDepthMaskRenderer.cs | 128 +- .../Rendering/PortalMeshReferenceOwner.cs | 154 + src/AcDream.App/Rendering/PortalProjection.cs | 482 +++- .../Rendering/PortalTunnelPresentation.cs | 56 +- src/AcDream.App/Rendering/PortalView.cs | 378 ++- .../Rendering/PortalVisibilityBuilder.cs | 438 ++- src/AcDream.App/Rendering/RenderBootstrap.cs | 57 +- .../Rendering/ResourceShutdownTransaction.cs | 104 + src/AcDream.App/Rendering/RetailAlphaQueue.cs | 51 +- .../Rendering/RetailCursorManager.cs | 5 +- .../Rendering/RetailCursorResolver.cs | 7 +- .../Rendering/RetailPViewRenderer.cs | 397 ++- .../Rendering/SceneLightingUboBinding.cs | 90 +- .../Selection/RetailSelectionGeometryCache.cs | 5 +- src/AcDream.App/Rendering/Shader.cs | 30 +- .../Rendering/Shaders/mesh_modern.vert | 2 +- src/AcDream.App/Rendering/Sky/SkyRenderer.cs | 5 +- .../StandaloneBindlessTextureCache.cs | 242 ++ src/AcDream.App/Rendering/TerrainAtlas.cs | 7 +- .../Rendering/TerrainModernRenderer.cs | 588 +++- src/AcDream.App/Rendering/TextRenderer.cs | 158 +- src/AcDream.App/Rendering/TextureCache.cs | 676 +++-- .../Rendering/Vfx/EntityEffectController.cs | 101 +- .../Rendering/Vfx/EntityEffectPoseRegistry.cs | 81 +- src/AcDream.App/Rendering/ViewconeCuller.cs | 124 +- .../Rendering/Wb/AcSurfaceMetadataTable.cs | 9 + .../Rendering/Wb/ContiguousRangeAllocator.cs | 36 + .../Rendering/Wb/EntitySpawnAdapter.cs | 557 +++- .../Wb/EnvCellMeshPreparationScheduler.cs | 11 +- .../Rendering/Wb/EnvCellRenderer.cs | 997 +++++-- src/AcDream.App/Rendering/Wb/GLHelpers.cs | 66 +- .../Rendering/Wb/GlobalMeshBuffer.cs | 913 +++++- .../Rendering/Wb/GpuRetiredRangeAllocator.cs | 103 + src/AcDream.App/Rendering/Wb/GroupKey.cs | 1 - .../Rendering/Wb/IEntityTextureLifetime.cs | 12 + .../Rendering/Wb/ITextureCachePerInstance.cs | 22 - .../Rendering/Wb/IWbMeshAdapter.cs | 30 + .../Rendering/Wb/LandblockSpawnAdapter.cs | 233 +- .../Rendering/Wb/ManagedGLTexture.cs | 44 +- .../Rendering/Wb/ManagedGLTextureArray.cs | 569 ++-- .../Rendering/Wb/MeshUploadCaches.cs | 268 +- .../Rendering/Wb/MeshUploadFrameBudget.cs | 186 ++ .../Rendering/Wb/ObjectMeshManager.cs | 2502 +++++++++++++---- .../Rendering/Wb/OpenGLGraphicsDevice.cs | 93 +- .../Rendering/Wb/ParticleEmitterRenderer.cs | 4 + .../Wb/RetryableResourceReleaseLedger.cs | 135 + .../Rendering/Wb/TextureAtlasManager.cs | 194 +- .../Rendering/Wb/TextureAtlasSlotAllocator.cs | 55 + .../Rendering/Wb/TrackedGlResource.cs | 242 ++ .../Rendering/Wb/WbDrawDispatcher.cs | 902 +++++- src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs | 349 ++- .../Rendering/Wb/WbTextureResolutionPolicy.cs | 29 + .../RuntimeDatCollectionFactory.cs | 101 + src/AcDream.App/RuntimeOptions.cs | 5 + src/AcDream.App/Spells/MagicRuntime.cs | 3 +- .../Streaming/GpuLandblockRetirement.cs | 20 + src/AcDream.App/Streaming/GpuWorldState.cs | 865 ++++-- .../LandblockRetirementCoordinator.cs | 328 +++ .../Streaming/LandblockStreamer.cs | 99 +- .../Streaming/StreamingController.cs | 330 ++- .../Streaming/StreamingMutationException.cs | 20 + src/AcDream.App/Studio/FixtureProvider.cs | 3 +- src/AcDream.App/Studio/LayoutSource.cs | 5 +- src/AcDream.App/Studio/MockupDesktop.cs | 15 +- src/AcDream.App/Studio/StudioWindow.cs | 30 +- src/AcDream.App/UI/IconComposer.cs | 9 +- .../UI/Layout/CharacterSheetProvider.cs | 3 +- .../UI/Layout/ChatWindowController.cs | 58 +- .../UI/Layout/ComponentBookTemplateFactory.cs | 3 +- .../UI/Layout/DatStringResolver.cs | 5 +- .../UI/Layout/EffectRowTemplateFactory.cs | 3 +- .../UI/Layout/ItemListCellTemplate.cs | 7 +- src/AcDream.App/UI/Layout/LayoutImporter.cs | 15 +- .../UI/Layout/PaperdollClickMap.cs | 5 +- .../UI/Layout/PaperdollSlotBackgrounds.cs | 3 +- .../UI/Layout/RadarSnapshotProvider.cs | 29 +- .../UI/Layout/SpellbookRowStyle.cs | 3 +- src/AcDream.App/UI/RetailDataIdResolver.cs | 5 +- src/AcDream.App/UI/RetailUiRuntime.cs | 93 +- src/AcDream.App/UI/UiDatFont.cs | 3 +- src/AcDream.App/UI/UiElement.cs | 69 +- src/AcDream.App/UI/UiHost.cs | 64 +- src/AcDream.App/UI/UiRoot.cs | 7 +- src/AcDream.App/UI/UiScrollablePanel.cs | 2 +- .../CompositeLiveEntityResourceLifecycle.cs | 163 ++ .../World/LiveEntityIncarnationCleanup.cs | 55 + src/AcDream.App/World/LiveEntityRuntime.cs | 572 +++- .../World/LiveEntityRuntimeViews.cs | 86 +- src/AcDream.App/World/LiveEntityTeardown.cs | 45 + src/AcDream.Content/AcDream.Content.csproj | 4 + src/AcDream.Content/BoundedDatObjectCache.cs | 161 ++ src/AcDream.Content/DatCollectionAdapter.cs | 60 +- src/AcDream.Content/DecodedTextureCache.cs | 145 + src/AcDream.Content/IDatReaderWriter.cs | 12 +- src/AcDream.Content/MeshExtractor.cs | 148 +- src/AcDream.Content/ObjectMeshData.cs | 31 + .../Vfx/RetailAnimationLoader.cs | 4 +- .../Vfx/RetailPhysicsScriptLoader.cs | 4 +- src/AcDream.Core/Audio/DatSoundCache.cs | 5 +- src/AcDream.Core/Chat/ChatLog.cs | 11 + src/AcDream.Core/Content/IDatObjectSource.cs | 21 + src/AcDream.Core/Meshing/CellMesh.cs | 3 +- .../Meshing/GfxObjDegradeResolver.cs | 5 +- src/AcDream.Core/Meshing/GfxObjMesh.cs | 3 +- src/AcDream.Core/Meshing/MotionResolver.cs | 7 +- src/AcDream.Core/Plugins/WorldEvents.cs | 151 +- src/AcDream.Core/Plugins/WorldGameState.cs | 40 +- src/AcDream.Core/Vfx/EmitterDescLoader.cs | 7 +- .../Vfx/IEntityEffectPoseSource.cs | 16 + src/AcDream.Core/Vfx/ParticleHookSink.cs | 269 +- src/AcDream.Core/Vfx/ParticleSystem.cs | 433 ++- src/AcDream.Core/Vfx/PhysicsScriptRunner.cs | 9 +- src/AcDream.Core/World/LandblockLoader.cs | 28 +- .../World/LandblockStaticEntityIdAllocator.cs | 36 + src/AcDream.Core/World/MeshRef.cs | 5 +- src/AcDream.Core/World/SceneryGenerator.cs | 5 +- src/AcDream.Core/World/SkyDescLoader.cs | 3 +- src/AcDream.Core/World/WorldView.cs | 4 +- .../Panels/Chat/ChatVM.cs | 10 +- .../Panels/Settings/DisplaySettings.cs | 9 +- .../AcDream.App.Tests.csproj | 1 + .../BoundedTestDatCollection.cs | 76 + .../Physics/ProjectileControllerTests.cs | 43 +- .../Physics/RemotePhysicsUpdaterTests.cs | 117 + .../BoundedUnownedResourceCacheTests.cs | 100 + .../Rendering/BuildingGroupScratchTests.cs | 252 ++ .../Rendering/CellViewDedupTests.cs | 40 + .../Rendering/ClipFrameAssemblerTests.cs | 93 + .../Rendering/ClipFrameUploadTests.cs | 130 + .../Rendering/ClipPlaneSetTests.cs | 21 + .../CompositeTextureArrayCachePolicyTests.cs | 609 ++++ .../Rendering/DynamicBufferCapacityTests.cs | 23 + .../FixedEntityTextureOwnerLeaseTests.cs | 46 + .../Rendering/FramePacingControllerTests.cs | 116 + .../Rendering/FramePacingPolicyTests.cs | 63 + .../GpuFrameFlightControllerTests.cs | 212 ++ .../GpuResourceRetirementTransactionTests.cs | 372 +++ .../GpuRetiredTerrainSlotAllocatorTests.cs | 158 ++ .../Rendering/OrderedResourceTeardownTests.cs | 52 + .../OwnerScopedResourceRegistryTests.cs | 44 + .../ParticleEmitterRetirementTrackerTests.cs | 115 + .../PortalMeshReferenceOwnerTests.cs | 200 ++ .../Rendering/PortalProjectionTests.cs | 234 ++ .../Rendering/PortalVisibilityBuilderTests.cs | 226 ++ .../RendererResourceDisposalLedgerTests.cs | 64 + .../ResourceShutdownTransactionTests.cs | 81 + .../Rendering/RetailAlphaQueueTests.cs | 18 +- .../RetailParticleGeometryClassifierTests.cs | 110 + .../StandaloneBindlessTextureCacheTests.cs | 338 +++ .../Vfx/EntityEffectControllerTests.cs | 79 + .../Vfx/EntityEffectPoseRegistryTests.cs | 25 + .../Rendering/ViewconeCullerReuseTests.cs | 60 + .../Wb/ContiguousRangeAllocatorTests.cs | 31 + .../Wb/EntitySpawnAdapterLifetimeTests.cs | 692 ++++- .../Rendering/Wb/EnvCellRendererTests.cs | 43 + .../Wb/GpuRetiredRangeAllocatorTests.cs | 137 + .../Wb/LandblockSpawnAdapterReadinessTests.cs | 307 ++ .../Rendering/Wb/MeshUploadCachesTests.cs | 256 +- .../Wb/ObjectMeshUploadBudgetTests.cs | 292 ++ .../Wb/RetryableResourceReleaseLedgerTests.cs | 177 ++ .../Rendering/Wb/TextureAtlasCapacityTests.cs | 76 + .../Wb/TextureAtlasSlotAllocatorTests.cs | 162 ++ .../WbDrawDispatcherCompositeWarmupTests.cs | 112 + .../Wb/WbTextureResolutionPolicyTests.cs | 25 + .../RuntimeDatAccessArchitectureTests.cs | 91 + .../RuntimeDatCollectionFactoryTests.cs | 40 + .../AcDream.App.Tests/RuntimeOptionsTests.cs | 5 + .../GpuWorldStateAnimatedIndexTests.cs | 77 + .../Streaming/GpuWorldStateVisibilityTests.cs | 391 +++ .../LandblockRetirementCoordinatorTests.cs | 202 ++ .../StreamingControllerReadinessTests.cs | 8 +- .../UI/Layout/ChatWindowControllerTests.cs | 30 + .../UI/Layout/RadarSnapshotProviderTests.cs | 49 + .../UI/UiCompositeShutdownTests.cs | 118 + .../UI/UiElementChildOrderTests.cs | 67 + ...mpositeLiveEntityResourceLifecycleTests.cs | 109 + .../World/LiveEntityRuntimeTests.cs | 475 +++- .../World/LiveEntityTeardownPlanTests.cs | 57 + .../BoundedDatObjectCacheTests.cs | 125 + .../DecodedTextureCacheTests.cs | 152 + .../Vfx/RetailDatLoaderTests.cs | 3 +- .../HumanoidMotionTableRootMotionTests.cs | 4 +- .../Plugins/WorldEventsTests.cs | 117 + .../Plugins/WorldGameStateTests.cs | 50 + ...NetworkFountainRoomLightInspectionTests.cs | 7 +- .../Wb/EntityClassificationCacheTests.cs | 1 - .../Rendering/Wb/EntitySpawnAdapterTests.cs | 132 +- .../Wb/WbDrawDispatcherBucketingTests.cs | 1 - .../Streaming/LandblockStreamerTests.cs | 68 + .../StreamingControllerPriorityApplyTests.cs | 52 + .../Streaming/StreamingControllerTests.cs | 341 ++- .../Vfx/ParticleHookSinkTests.cs | 158 +- .../Vfx/ParticleSystemTests.cs | 210 ++ .../World/LandblockLoaderTests.cs | 29 +- .../LandblockStaticEntityIdAllocatorTests.cs | 46 + .../Panels/Settings/DisplaySettingsTests.cs | 10 +- 225 files changed, 29107 insertions(+), 3914 deletions(-) create mode 100644 docs/research/2026-07-18-retail-texture-resource-lifetime-pseudocode.md create mode 100644 src/AcDream.App/Rendering/BoundedUnownedResourceCache.cs create mode 100644 src/AcDream.App/Rendering/CompositeTextureArrayCache.cs create mode 100644 src/AcDream.App/Rendering/DynamicBufferCapacity.cs create mode 100644 src/AcDream.App/Rendering/FixedEntityTextureOwnerLease.cs create mode 100644 src/AcDream.App/Rendering/FramePacingController.cs create mode 100644 src/AcDream.App/Rendering/FramePacingPolicy.cs create mode 100644 src/AcDream.App/Rendering/GpuFrameFlightController.cs create mode 100644 src/AcDream.App/Rendering/GpuRetiredTerrainSlotAllocator.cs create mode 100644 src/AcDream.App/Rendering/OrderedResourceTeardown.cs create mode 100644 src/AcDream.App/Rendering/OwnerScopedResourceRegistry.cs create mode 100644 src/AcDream.App/Rendering/ParticleEmitterRetirementTracker.cs create mode 100644 src/AcDream.App/Rendering/PortalMeshReferenceOwner.cs create mode 100644 src/AcDream.App/Rendering/ResourceShutdownTransaction.cs create mode 100644 src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs create mode 100644 src/AcDream.App/Rendering/Wb/GpuRetiredRangeAllocator.cs create mode 100644 src/AcDream.App/Rendering/Wb/IEntityTextureLifetime.cs delete mode 100644 src/AcDream.App/Rendering/Wb/ITextureCachePerInstance.cs create mode 100644 src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs create mode 100644 src/AcDream.App/Rendering/Wb/RetryableResourceReleaseLedger.cs create mode 100644 src/AcDream.App/Rendering/Wb/TextureAtlasSlotAllocator.cs create mode 100644 src/AcDream.App/Rendering/Wb/TrackedGlResource.cs create mode 100644 src/AcDream.App/Rendering/Wb/WbTextureResolutionPolicy.cs create mode 100644 src/AcDream.App/RuntimeDatCollectionFactory.cs create mode 100644 src/AcDream.App/Streaming/GpuLandblockRetirement.cs create mode 100644 src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs create mode 100644 src/AcDream.App/Streaming/StreamingMutationException.cs create mode 100644 src/AcDream.App/World/CompositeLiveEntityResourceLifecycle.cs create mode 100644 src/AcDream.App/World/LiveEntityIncarnationCleanup.cs create mode 100644 src/AcDream.Content/BoundedDatObjectCache.cs create mode 100644 src/AcDream.Content/DecodedTextureCache.cs create mode 100644 src/AcDream.Core/Content/IDatObjectSource.cs create mode 100644 src/AcDream.Core/World/LandblockStaticEntityIdAllocator.cs create mode 100644 tests/AcDream.App.Tests/BoundedTestDatCollection.cs create mode 100644 tests/AcDream.App.Tests/Rendering/BoundedUnownedResourceCacheTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/BuildingGroupScratchTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ClipFrameUploadTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/CompositeTextureArrayCachePolicyTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/DynamicBufferCapacityTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/FixedEntityTextureOwnerLeaseTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/FramePacingControllerTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/FramePacingPolicyTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/GpuFrameFlightControllerTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/GpuResourceRetirementTransactionTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/GpuRetiredTerrainSlotAllocatorTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/OrderedResourceTeardownTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/OwnerScopedResourceRegistryTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ParticleEmitterRetirementTrackerTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/PortalMeshReferenceOwnerTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/RendererResourceDisposalLedgerTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ResourceShutdownTransactionTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/StandaloneBindlessTextureCacheTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ViewconeCullerReuseTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/GpuRetiredRangeAllocatorTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/ObjectMeshUploadBudgetTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/RetryableResourceReleaseLedgerTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/TextureAtlasCapacityTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/TextureAtlasSlotAllocatorTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherCompositeWarmupTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/WbTextureResolutionPolicyTests.cs create mode 100644 tests/AcDream.App.Tests/RuntimeDatAccessArchitectureTests.cs create mode 100644 tests/AcDream.App.Tests/RuntimeDatCollectionFactoryTests.cs create mode 100644 tests/AcDream.App.Tests/Streaming/GpuWorldStateAnimatedIndexTests.cs create mode 100644 tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs create mode 100644 tests/AcDream.App.Tests/UI/UiCompositeShutdownTests.cs create mode 100644 tests/AcDream.App.Tests/UI/UiElementChildOrderTests.cs create mode 100644 tests/AcDream.App.Tests/World/CompositeLiveEntityResourceLifecycleTests.cs create mode 100644 tests/AcDream.App.Tests/World/LiveEntityTeardownPlanTests.cs create mode 100644 tests/AcDream.Content.Tests/BoundedDatObjectCacheTests.cs create mode 100644 tests/AcDream.Content.Tests/DecodedTextureCacheTests.cs create mode 100644 tests/AcDream.Core.Tests/Plugins/WorldGameStateTests.cs create mode 100644 tests/AcDream.Core.Tests/World/LandblockStaticEntityIdAllocatorTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 8e8093ed..33019212 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,7 +46,7 @@ Copy this block when adding a new issue: ## #225 — Scene particles overpaint translucent world objects -**Status:** IN-PROGRESS — implementation complete; connected visual gate pending +**Status:** IN-PROGRESS — implementation/reviews and connected stress gate pass; final visual gate pending **Severity:** MEDIUM **Filed:** 2026-07-18 **Component:** rendering / world translucency / particles @@ -79,7 +79,7 @@ order inside one instanced draw, with only DAT blend-mode changes splitting a run. The identical location recovered to about 153 FPS / 6.6 ms in the connected Release client. -A second, genuinely cumulative portal regression remained: ACE retains +A second, genuinely cumulative portal regression was first isolated: ACE retains `KnownObjects` across normal teleports, while acdream retained every old `LiveEntityRecord` indefinitely. Each destination therefore left animation, effect, and render owners active; after enough trips the render thread blocked @@ -93,6 +93,30 @@ duplicating vertices per material and growing forever. A connected five-region round trip returned live/animation ownership to baseline, recreated C95B on revisit, and held its normal 60–80 FPS. +That shorter route did not close the process-lifetime problem. A subsequent +multi-recall run still climbed to about 3.0 GiB working set / 3.5 GiB private +memory and reproduced the 5–12 FPS collapse, with effect emitters, composite +textures, decoded DAT objects, texture atlases, and physical GL stores remaining +resident after their owners left. The final integration therefore makes the +whole chain owner-scoped and bounded: exact-incarnation appearance replacement, +retryable live/landblock/UI/portal teardown, emitter retirement indexes, +bounded DAT/decoded/standalone/composite caches, reclaimable mesh/atlas storage, +incremental arena migration, and three-frame GPU-fenced physical reuse. It also +reuses per-frame scratch storage without clearing a legitimate large working set. +No draw distance, texture resolution, particle range, or visual effect was +reduced. + +The final connected route (Caul → Sawato → Rynthid → Aerlinthe → Sawato → +Holtburg → Caul, with 25–30 second destination dwells and 60 seconds after the +return) passed without an exception, WER report, or AMD display-driver reset. +Peak working set fell from 2,954.5 MiB to 1,493.4 MiB and peak private memory +from 3,502.3 MiB to 1,969.5 MiB versus the failing build. Returned Caul settled +at 1,030.6 MiB working set / 1,638.2 MiB private / 831.6 MiB local GPU; the final +local-display dwell held 125–153 FPS (141.8 average). Emitter/binding ownership +balanced at 1,715/1,715 and composite physical residency remained below its +128 MiB ceiling. The older 32 FPS comparison run was RDP-refresh-capped and is +used only for memory comparison. The lifestone/particle visual gate remains. + **Files:** `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`; `src/AcDream.App/Rendering/ParticleRenderer.cs`; @@ -100,11 +124,17 @@ revisit, and held its normal 60–80 FPS. `src/AcDream.App/Rendering/Shaders/particle.frag`; `src/AcDream.App/Rendering/RetailPViewRenderer.cs`; `src/AcDream.App/World/LiveEntityLivenessController.cs`; -`src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs`. +`src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs`; +`src/AcDream.App/Rendering/GpuFrameFlightController.cs`; +`src/AcDream.App/Rendering/CompositeTextureArrayCache.cs`; +`src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs`; +`src/AcDream.Content/BoundedDatObjectCache.cs`; +`src/AcDream.Content/DecodedTextureCache.cs`. **Research:** `docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md`; -`docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md`. +`docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md`; +`docs/research/2026-07-18-retail-texture-resource-lifetime-pseudocode.md`. **Acceptance:** At a translucent lifestone, smoke or flame behind the crystal is attenuated by it while an effect in front remains bright. The lifestone's diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 4b3f2667..c132f59a 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -488,6 +488,44 @@ identity plus animation, motion, physics, collision, selection, and dead-reckoning owners. Only a real delete/despawn tears those owners down. This matches retail's `CPhysicsObj::DoObjDescChangesFromDefault` behavior. +### Runtime resource ownership and bounded residency + +Logical lifetime, spatial residency, and physical GPU lifetime are separate +contracts. A live entity or landblock owns stable logical references; moving it +between buckets does not reacquire resources. Appearance replacement acquires +the complete new mesh/texture set before publication, then releases the old set. +Despawn and landblock demotion withdraw every public render reference before +their physical resources become reclaimable. All of these transactions are +retryable and generation-scoped, so a failed release cannot silently strand a +half-retired owner or affect a reused server GUID. + +Runtime content residency is deliberately bounded rather than proportional to +every region visited: + +- `RuntimeDatCollectionFactory` keeps DAT indexes on demand but uses + `FileCachingStrategy.Never`; `DatCollection` remains the sole raw reader. +- Each typed DAT facade has a 256-entry / 64 MiB estimated LRU. Unknown object + graphs are conservatively charged at least 128 KiB. Canonical decoded texture + pixels use a separate 128-entry / 64 MiB cache. +- Standalone bindless textures retain at most 256 unowned entries / 32 MiB and + retire at most one per frame. Owner-scoped composite textures use a 64 MiB + unowned budget and 128 MiB physical budget, admitting at most 16 uploads or + 8 MiB per frame. +- `ObjectMeshManager` may retain at most 32 empty texture atlases / 64 MiB. + `GlobalMeshBuffer` owns reclaimable vertex/index ranges capped at 384 MiB and + 128 MiB respectively, with an 896 MiB physical ceiling that includes an + in-progress migration and its retired predecessor. + +OpenGL deletion and range/slot reuse are not synonymous with logical release. +`GpuFrameFlightController` fences three frames in flight. Mesh-buffer stores, +texture handles, atlas layers, terrain slots, and landblock render records enter +retirement only after they are no longer publishable, and their physical ids are +recycled only after the corresponding fence signals. Shutdown follows the same +dependency order and remains retryable: UI/controllers and render registrations +withdraw first, then owner leases and caches, then GL backing stores. This keeps +drivers from reading freed memory without adding a portal-specific purge or a +visual-distance reduction. + --- ## Per-Frame Update Order (current runtime) diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 2125e767..660994af 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -4,7 +4,7 @@ "Code Structure Rules" section in `CLAUDE.md`. **Purpose:** Describe the desired structural state of the App layer, explain the rules we've adopted, and lay out the safe extraction -sequence from today's reality (one 10,304-line `GameWindow.cs`) to the +sequence from today's reality (one 15,288-line `GameWindow.cs`) to the target (thin `GameWindow`, small focused collaborators). **Companion to:** [`acdream-architecture.md`](acdream-architecture.md) (the layered architecture) and @@ -20,7 +20,7 @@ layer is wire-compatible, the UI has a stable contract, plugins load. The structural debt is concentrated in **one file**: ``` -src/AcDream.App/Rendering/GameWindow.cs 10,304 lines +src/AcDream.App/Rendering/GameWindow.cs 15,288 lines ``` `GameWindow` is the single object that: @@ -75,28 +75,13 @@ delegate to a collaborator for the substance. a GL or windowing namespace, we've lost the ability to test it without a graphics context, and the layer split becomes fiction. -**How to apply:** The only currently-allowed seams from Core into the -WB / GL world are: - -- `WorldBuilder.Shared` — stateless helpers (`TerrainUtils`, - `TerrainEntry`, `RegionInfo`). -- `Chorizite.OpenGLSDLBackend.Lib` — stateless helpers - (`SceneryHelpers`, `TextureHelpers`). - -Both are leaf namespaces with no GL state. If you need a new seam (e.g. -WB's `ObjectMeshManager` needs to be visible from Core), the change -**must** come with an inventory-doc update justifying it and ideally a -slim interface in Core that the App layer implements. - -**Current reality:** `src/AcDream.Core/AcDream.Core.csproj` references -`Chorizite.OpenGLSDLBackend` (not just `OpenGLSDLBackend.Lib`). This is -historical. Two Core files import from it: -- `World/SceneryGenerator.cs` — `Chorizite.OpenGLSDLBackend.Lib` -- `Textures/SurfaceDecoder.cs` — `Chorizite.OpenGLSDLBackend.Lib` - -Both use the stateless `Lib` namespace only. The full project reference -is wider than it needs to be; tightening it to just `WorldBuilder.Shared` -+ a stateless-helpers shim is a candidate future cut, but not urgent. +**How to apply:** Phase O removed both external WorldBuilder/backend project +references. The only currently allowed seams are the GL-free helpers owned in +our tree under `src/AcDream.Core/Rendering/Wb/`: `TerrainUtils`, +`TerrainEntry`, `RegionInfo`, `SceneryHelpers`, and `TextureHelpers`. +`ObjectMeshManager` and every GL resource owner remain in App. If Core needs a +new capability, define a narrow Core interface and implement it in App; adding +a new project reference requires an inventory-doc update explaining why. ### Rule 3: UI panels target `AcDream.UI.Abstractions` only @@ -159,11 +144,12 @@ Today: - `tests/AcDream.Core.Tests/` ← `src/AcDream.Core/` - `tests/AcDream.Core.Net.Tests/` ← `src/AcDream.Core.Net/` - `tests/AcDream.UI.Abstractions.Tests/` ← `src/AcDream.UI.Abstractions/` +- `tests/AcDream.App.Tests/` ← `src/AcDream.App/` -`AcDream.App` does **not** yet have a test project. The RuntimeOptions -extraction is the trigger to create `tests/AcDream.App.Tests/`. Future -App-layer tests (LiveSessionController, SelectionInteractionController, -etc.) go there. +`tests/AcDream.App.Tests/` now exists and owns App-layer controller, streaming, +render-resource lifetime, retained-UI, and `RuntimeOptions` tests. New App tests +belong there; do not place GL-free Core behavior in that project merely because +App currently wires it. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 127cf64e..92a341b0 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -83,7 +83,7 @@ accepted-divergence entries (#96, #49, #50). | AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw` → `DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 | | AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 | | AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/GameWindow.cs:7671` | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 | -| AD-22 | Async streamed mesh loading with point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's per-frame meshMissing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:211` | Documented convergence argument: the self-heal makes absence transient, converging the async pipeline to retail's never-absent guarantee | A missing mesh referenced OUTSIDE the dispatcher's walk (a future consumer not touching meshMissing) stays permanently invisible — the #119/#128 broken-stairs class; best case, late pop-in | retail synchronous content load (note at WbMeshAdapter.cs:211) | +| AD-22 | Async streamed mesh loading with bounded CPU replay residency, per-frame upload budgets, and point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's mesh-missing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs` | Immutable preparation descriptors and the bounded CPU cache can re-stage an evicted mesh; dispatcher self-heal makes absence transient while upload budgets prevent a portal arrival from monopolizing a frame | A future consumer that neither retains an owner nor reaches the self-heal/replay path can remain invisible; under heavy admission pressure a valid mesh can pop in later than retail's synchronous path | retail synchronous content load; `docs/architecture/worldbuilder-inventory.md` portal-readiness and bounded-residency seams | | AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal | | AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) | | AD-25 | **REMOTE-DR sweep only** (the player half retired 2026-07-07 by the #182 verbatim rebuild): the remote dead-reckoning post-resolve still reflects velocity with the airborne-before-AND-after suppression; retail bounces unless grounded→grounded-and-not-sledding. The PLAYER path now runs the ported `handle_all_collisions` (`PhysicsObjUpdate`) with retail's `should_reflect` rule — the micro-bounce spiral it guarded is gone (contact is committed BEFORE the reflect and the small-velocity-zero is ungated) | `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | The remote DR sweep hasn't been rebuilt yet (it has no fsf/SetPositionInternal chain); the old airborne-only suppression keeps remote landings from micro-bouncing on the remote landing-snap gate | Remote landing-reflection behavior (slope-landing momentum) won't reproduce; retire when the remote-DR sweep gets the same UpdateObjectInternal rebuild as the player | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 | diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index feb3cb8f..80652b41 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -186,6 +186,20 @@ rendering and landblock mesh pins while retaining the terrain slot. Core's matching physics demotion preserves the terrain surface but removes indoor cells, portals, buildings, and static shadow registrations. +**Bounded residency and GPU retirement seam (2026-07-18).** Runtime DAT access +keeps raw file payload caching disabled and layers bounded typed-object and +decoded-pixel LRUs above the single `DatCollection`. `ObjectMeshManager`, the +standalone bindless texture cache, and the owner-scoped composite texture-array +cache all distinguish an active owner from an evictable unowned entry. Appearance +changes and landblock demotion acquire-before-publish and withdraw-before-release; +rebucketing never creates a second owner. `GlobalMeshBuffer` uses reclaimable, +coalescing vertex/index ranges and migrates incrementally within explicit physical +ceilings. Texture layers, terrain slots, mesh ranges, and old backing stores are +returned only after `GpuFrameFlightController` observes the frame fence that can +no longer reference them. This lifetime machinery is acdream-owned integration +around the extracted WB mesh pipeline; it does not add a second DAT decoder or a +reduced-distance rendering path. + **Workflow:** Before re-implementing any AC-specific rendering or dat-handling algorithm, **check this inventory first**. If we already extracted it (🟢 sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index cc7c3861..1e25b72a 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -561,7 +561,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **Missile/portal VFX campaign Step 8 implemented and independently reviewed 2026-07-14.** `LiveEntityRuntime` now distinguishes raw server PhysicsState from retail's side-effect-derived final state, beginning from constructor state `0x00400C08` and applying `set_state` in Lighting → NoDraw → Hidden order. `LiveEntityPresentationController` keeps Hidden objects logically alive while suppressing root mesh, collision, picking, radar, status, and new target acquisition; typed `PS_Hidden`/`PS_UnHide` plays remain DAT-driven, direct equipped children inherit NoDraw, particles/lights survive, and repeated/stale state cannot replay a transition. A normal visible CreateObject never fabricates UnHide. Effect-owner preparation is separate from pending F754/F755 replay so construction-state effects always run first. Remote fresh-teleport/cell-less placement now executes the exact ordered `teleport_hook` action bundle and a full-cell hard placement before contact/airborne routing, preventing an airborne correction from restoring the old location. Projectile Hidden state pauses the retained body without consuming its active identity or accumulating a clock backlog. TS-43 is retired; AP-69 remains for exact ObjCell-PVS/preview-retention parity after the 2026-07-18 liveness port. - **Missile/portal VFX campaign Step 9 automated hardening implemented and independently reviewed 2026-07-14; final two-client visual gate pending.** A deterministic 96-owner App fixture drives canonical missiles and effect owners through projectile updates, repeated DAT-effect scheduling, loaded↔pending↔loaded landblock churn, light/particle withdrawal and recovery, accepted deletes, same-GUID generation reuse, never-created F754 queues, and session reset. It asserts balanced logical render registration and zero residual records/bodies/projectiles, spatial buckets/rescues, shadows (including suspended registrations), effect profiles/packets, PhysicsScript FIFOs/anchors/delayed calls, particle bindings/logical IDs/render-pass owners, poses, light-controller/sink/manager owners, and stale record component references. A companion twelve-cycle gate drives exact recall motion `0x10000153` through AnimationSequencer/CallPES, Hidden, deferred remote placement, hydration, and UnHide; a second 96-owner `EntitySpawnAdapter` gate balances actual mesh-adapter reference counts without GL. The pass fixed undrained persistent rescue retention, delayed stale visibility edges, GUID-scoped teardown, create resurrection after a nested delete/reset, non-atomic resource registration, double teardown from a re-entrant session-clear callback, observer failure stranding later visibility edges, and stale outer projection transactions overwriting callback-created replacements. Teardown now removes exact projection references and generation/local-ID owners, uses per-GUID/session lifetime epochs plus per-record projection tokens, drains visibility fan-out before aggregating failures, and finishes or supersedes canonical commits before surfacing observer errors. Core remains GL/backend-free, panels remain on UI abstractions, and projectiles still draw through ordinary `WbDrawDispatcher` live-entity submission rather than a global projectile pass. AP-69 and TS-49 remain; AP-83/AP-91 now explicitly describe only a future mover that enables PerfectClip. -- **Portal lifetime/performance correction 2026-07-18; user visual gate pending.** `LiveEntityLivenessController` ports retail's 25-second leave-visibility destruction queue over canonical live records, cancels expiry for spatially resident or owned objects, and uses holtburger's conservative 384-unit envelope where ACE supplies no client-visible PVS leave event. Expiry reuses the exact generation-safe F747 teardown. The modern `GlobalMeshBuffer` now uploads vertices once per mesh and owns coalescing vertex/index ranges released by zero-reference LRU eviction. A connected five-region route returned animation ownership to baseline, reclaimed/reused GPU ranges, recreated the starting region on revisit, and prevented the prior persistent 3–12 FPS collapse. AP-69 is narrowed rather than retired because exact ObjCell PVS and explicit preview lifetimes remain. +- **Portal/resource lifetime correction 2026-07-18; connected stress gate passed, final visual gate pending.** `LiveEntityLivenessController` ports retail's 25-second leave-visibility destruction queue over canonical live records, cancels expiry for spatially resident or owned objects, and uses holtburger's conservative 384-unit envelope where ACE supplies no client-visible PVS leave event. The broader integration balances generation-scoped appearance/live/landblock/effect/UI/portal owners, bounds DAT/decoded/standalone/composite/atlas/mesh residency, performs incremental mesh-arena migration, and defers GL deletion or slot reuse through three frames of GPU fences. Render/UI scratch storage is reused with hysteretic trimming. No visual range or quality setting was reduced. The final seven-destination connected route cut peak working/private memory from 2,954.5/3,502.3 MiB to 1,493.4/1,969.5 MiB, returned Caul to 1,030.6/1,638.2 MiB, held 125–153 FPS locally through the final dwell, and produced no WER/driver reset. AP-69 is narrowed rather than retired because exact ObjCell PVS and explicit preview lifetimes remain. **Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation. @@ -1311,6 +1311,10 @@ port in any phase — no separate listing here. > number and accepts the rewrite risk. Rust rejected. Parked (resume from the table > below): MP1b pak/bake (needs the 865 GB EnvCell-dedup slice; loading-speed lever), > MP-Alloc hard sites, MP1c, MP2. Full rationale: `project_mp_track_findings.md`. +> The 2026-07-18 portal/resource-lifetime repair does not resume MP's ECS, pak, +> or throughput redesign. It is corrective ownership/reclamation work for a +> reproduced crash-class regression in the mandatory renderer, using the current +> architecture and preserving pixels/ranges. **Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the umbrella design — read it first). **Goal:** smoothness first (no frame over diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 45688c8e..627447f3 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -5,9 +5,12 @@ **Currently working toward:** **M3 — "Cast a spell." M2 — "Kill a drudge" LANDED 2026-07-15.** The complete connected melee/missile, death/loot, inventory, and item-giving demo is user-gated. The shared projectile/effect -foundation is hardened through Step 9 and its single-client visual gate passes. -Next is the connected F.5 component-book/favorite-bar visual gate, followed by -cast/enchantment presentation and the two-client portal/observer VFX gate. +foundation is hardened through Step 9. Spellbook/component-book filtering, +favorite spell bar, connected casting/enchantment effects, and portal-space +presentation have passed their single-client visual gates. The corrective bounded +render/resource-lifetime integration passed its seven-destination connected +stress gate without a crash or cumulative collapse. The remaining M3 visual +requirement is the final two-client portal-out/materialize observer gate. Carried: #145-residual, #116 slide-response, R6/TS-42, and Track MP0. @@ -285,6 +288,11 @@ as ad-hoc rework. Rule 2's freeze still binds all NON-MP work. MP runs in dedicated side-track sessions; the M1.5 critical path wins every conflict, and MP4 is hard-queued behind #137. +The 2026-07-18 repeated-portal lifetime repair is a freeze-allowed corrective +change, not a resumption of Track MP. It keeps the current renderer and visual +ranges while balancing owners, bounding residency, and fence-delaying physical +GPU reuse; MP's pak/ECS/throughput redesign remains parked. + **Dungeon support (Phase G.3, issue #133) — SHIPPED.** The original premise here (terrain-less dungeon landblocks unsupported anywhere in the streaming/load/render/physics pipeline) was refuted at design time diff --git a/docs/research/2026-07-18-retail-texture-resource-lifetime-pseudocode.md b/docs/research/2026-07-18-retail-texture-resource-lifetime-pseudocode.md new file mode 100644 index 00000000..34f0d558 --- /dev/null +++ b/docs/research/2026-07-18-retail-texture-resource-lifetime-pseudocode.md @@ -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. diff --git a/src/AcDream.App/Physics/ProjectileController.cs b/src/AcDream.App/Physics/ProjectileController.cs index 4d75dbfa..cf1d2c2b 100644 --- a/src/AcDream.App/Physics/ProjectileController.cs +++ b/src/AcDream.App/Physics/ProjectileController.cs @@ -50,6 +50,7 @@ internal sealed class ProjectileController private readonly ShadowObjectRegistry _shadows; private readonly Func _setupResolver; private readonly Action _publishRootPose; + private readonly List _spatialProjectileSnapshot = new(); private double _lastFiniteGameTime; internal ProjectileController( @@ -64,6 +65,7 @@ internal sealed class ProjectileController _shadows = physicsEngine.ShadowObjects; _setupResolver = setupResolver ?? (_ => null); _publishRootPose = publishRootPose ?? (_ => { }); + _liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged; } internal Action? DiagnosticSink { get; set; } @@ -483,9 +485,11 @@ internal sealed class ProjectileController return false; } - internal bool LeaveWorld(uint serverGuid) + internal bool LeaveWorld(LiveEntityRecord record) { - if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime) + ArgumentNullException.ThrowIfNull(record); + if (record.ProjectileRuntime is not Runtime runtime + || runtime.Generation != record.Generation || record.WorldEntity is not { } entity) return false; SuspendOutsideWorld(runtime, entity.Id); @@ -505,10 +509,12 @@ internal sealed class ProjectileController } _lastFiniteGameTime = currentTime; - foreach (LiveEntityRecord record in _liveEntities.Records.ToArray()) + _liveEntities.CopySpatialProjectileRecordsTo(_spatialProjectileSnapshot); + foreach (LiveEntityRecord record in _spatialProjectileSnapshot) { if (record.ProjectileRuntime is not Runtime runtime || runtime.Generation != record.Generation + || !_liveEntities.IsCurrentSpatialProjectile(record, runtime) || record.WorldEntity is not { } entity) continue; @@ -668,6 +674,25 @@ internal sealed class ProjectileController } } + private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible) + { + if (visible + || record.ProjectileRuntime is not Runtime runtime + || record.WorldEntity is not { } entity + || !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) + || !ReferenceEquals(current, record) + || !ReferenceEquals(current.ProjectileRuntime, runtime)) + { + return; + } + + // Pending/cell-less records retain their logical projectile runtime, + // but retail exit_world removes them from active physics and collision + // immediately. They are absent from the frame workset after this edge, + // so suspension belongs on the authoritative residency transition. + SuspendOutsideWorld(runtime, entity.Id); + } + private static bool HasVisibleCell(LiveEntityRecord record) => record.IsSpatiallyProjected && record.IsSpatiallyVisible diff --git a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs index 6ac2a424..0850fd6e 100644 --- a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs +++ b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs @@ -43,6 +43,7 @@ internal sealed class RemotePhysicsUpdater private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine; private readonly System.Func _getSetupCylinder; private readonly System.Action _applyServerControlledVelocityCycle; + private readonly List _spatialRemoteSnapshot = new(); internal RemotePhysicsUpdater( AcDream.Core.Physics.PhysicsEngine physicsEngine, @@ -72,17 +73,26 @@ internal sealed class RemotePhysicsUpdater ArgumentNullException.ThrowIfNull(liveEntities); ArgumentNullException.ThrowIfNull(publishRootPose); - foreach (AcDream.App.World.LiveEntityRecord record in liveEntities.Records.ToArray()) + liveEntities.CopySpatialRemoteMotionRecordsTo(_spatialRemoteSnapshot); + foreach (AcDream.App.World.LiveEntityRecord record in _spatialRemoteSnapshot) { if (record.ServerGuid == localPlayerServerGuid || (record.FinalPhysicsState & AcDream.Core.Physics.PhysicsStateFlags.Hidden) == 0 || !liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid) || record.RemoteMotionRuntime is not RemoteMotion remote + || !liveEntities.IsCurrentSpatialRemoteMotion(record, remote) || record.WorldEntity is not { } entity) continue; TickHidden(remote, entity, dt); - publishRootPose(entity); + // PositionManager callbacks can rebucket, delete, or replace this + // GUID. Publish only while the captured record/runtime pair still + // owns a loaded spatial projection. + if (liveEntities.IsCurrentSpatialRemoteMotion(record, remote) + && ReferenceEquals(record.WorldEntity, entity)) + { + publishRootPose(entity); + } } } diff --git a/src/AcDream.App/Rendering/BoundedUnownedResourceCache.cs b/src/AcDream.App/Rendering/BoundedUnownedResourceCache.cs new file mode 100644 index 00000000..50572d7e --- /dev/null +++ b/src/AcDream.App/Rendering/BoundedUnownedResourceCache.cs @@ -0,0 +1,121 @@ +namespace AcDream.App.Rendering; + +/// +/// Least-recently-used set for resources which currently have no logical +/// owner but remain resident for inexpensive reuse. The byte budget is a +/// cache policy, not a lifetime fence: callers remove one key here and then +/// retire its physical GPU storage through . +/// Render-thread only. +/// +internal sealed class BoundedUnownedResourceCache where TKey : notnull +{ + private readonly long _budgetBytes; + private readonly int _maximumCount; + private readonly LinkedList _lru = new(); + private readonly Dictionary _entries = new(); + private long _residentBytes; + + private readonly record struct Entry(long Bytes, LinkedListNode Node); + + public BoundedUnownedResourceCache(long budgetBytes, int maximumCount = int.MaxValue) + { + ArgumentOutOfRangeException.ThrowIfNegative(budgetBytes); + ArgumentOutOfRangeException.ThrowIfLessThan(maximumCount, 1); + _budgetBytes = budgetBytes; + _maximumCount = maximumCount; + } + + public int Count => _entries.Count; + public long ResidentBytes => _residentBytes; + public long BudgetBytes => _budgetBytes; + public int MaximumCount => _maximumCount; + public bool Contains(TKey key) => _entries.ContainsKey(key); + + public void MarkUnowned(TKey key, long bytes) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytes); + + if (_entries.TryGetValue(key, out Entry existing)) + { + if (existing.Bytes != bytes) + throw new InvalidOperationException( + $"Cached resource {key} changed size from {existing.Bytes} to {bytes} bytes."); + + _lru.Remove(existing.Node); + _lru.AddLast(existing.Node); + return; + } + + LinkedListNode node = _lru.AddLast(key); + _entries.Add(key, new Entry(bytes, node)); + _residentBytes = checked(_residentBytes + bytes); + } + + public bool MarkOwned(TKey key) + { + if (!_entries.Remove(key, out Entry entry)) + return false; + + _lru.Remove(entry.Node); + _residentBytes -= entry.Bytes; + return true; + } + + /// + /// Removes the oldest unowned key only while the cache is above budget. + /// Calling once per frame gives the GPU driver explicit destruction + /// backpressure instead of turning a portal transition into one large + /// resource-release burst. + /// + public bool TryTakeOldestOverBudget(out TKey key) + { + if ((_residentBytes <= _budgetBytes && _entries.Count <= _maximumCount) + || _lru.First is null) + { + key = default!; + return false; + } + + LinkedListNode node = _lru.First; + key = node.Value; + Entry entry = _entries[key]; + _lru.RemoveFirst(); + _entries.Remove(key); + _residentBytes -= entry.Bytes; + return true; + } + + public bool TryTakeOldest(out TKey key) + { + if (_lru.First is null) + { + key = default!; + return false; + } + + LinkedListNode node = _lru.First; + key = node.Value; + Entry entry = _entries[key]; + _lru.RemoveFirst(); + _entries.Remove(key); + _residentBytes -= entry.Bytes; + return true; + } + + public bool TryTake(TKey key) + { + if (!_entries.Remove(key, out Entry entry)) + return false; + + _lru.Remove(entry.Node); + _residentBytes -= entry.Bytes; + return true; + } + + public void Clear() + { + _lru.Clear(); + _entries.Clear(); + _residentBytes = 0; + } +} diff --git a/src/AcDream.App/Rendering/ClipFrame.cs b/src/AcDream.App/Rendering/ClipFrame.cs index 75414fe8..76174b22 100644 --- a/src/AcDream.App/Rendering/ClipFrame.cs +++ b/src/AcDream.App/Rendering/ClipFrame.cs @@ -22,12 +22,13 @@ // terrain OutsideView planes, then points each renderer's per-instance slot // buffer at the right slots. // -// Pure CPU byte-packing + a thin GL upload. NO GL types appear except in -// UploadShared. The byte layout is asserted by ClipFrameLayoutTests so a silent +// Pure CPU byte-packing + a thin GL upload. The byte layout is asserted by +// ClipFrameLayoutTests so a silent // std430/std140 drift can't reach the GPU. using System; using System.Collections.Generic; using System.Numerics; +using AcDream.App.Rendering.Wb; using Silk.NET.OpenGL; namespace AcDream.App.Rendering; @@ -78,16 +79,54 @@ public sealed class ClipFrame : IDisposable // Packed std140 bytes for the terrain UBO (always TerrainUboBytes long). private readonly byte[] _terrainBytes = new byte[TerrainUboBytes]; - // ---- GL-side state (lazily created on first UploadShared) ---------------- + // ---- GL-side state (lazily created on first upload) ---------------------- private uint _regionSsbo; private uint _terrainUbo; - private bool _glInitialized; + private RetryableResourceReleaseLedger? _disposeResources; + private bool _disposing; private bool _disposed; - // GL reference captured on the first UploadShared so Dispose can delete the two - // buffers. ClipFrame is long-lived in U.3 (GameWindow holds one via ??= NoClip() - // and reuses it every frame), so we DO own buffer teardown — see Dispose. + internal const int FrameSlotCount = GpuFrameFlightController.DefaultMaximumFramesInFlight; + + private sealed class RegionBuffer + { + public uint Name; + public int CapacityBytes; + public ClipBufferCapacityPolicy CapacityPolicy; + } + + private sealed class TerrainBufferArena + { + public uint Name; + public int CapacityBytes; + public ClipBufferCapacityPolicy CapacityPolicy; + } + + // A full clip-region table is immutable once ClipFrameAssembler has packed + // the frame. Keep exactly one SSBO per GPU-fenced frame slot. Terrain clip + // state changes between outside-view slices, so each frame slot owns one + // UBO arena and each draw binds a distinct aligned range within that arena. + // This bounds native object retention at six buffers total instead of one + // region/terrain pair per outside-view slice. + private readonly ClipFrameResourceRing _regionBuffers = + new(FrameSlotCount); + private readonly ClipFrameResourceRing _terrainBuffers = + new(FrameSlotCount); + private readonly ClipFrameUploadState _uploadState = new(); + private int _dynamicFrameSlot; + private bool _dynamicFrameStarted; + private int _terrainRecordStrideBytes; + private TerrainClipBufferBinding _terrainBinding; + + internal int DynamicBufferSetCount => + Math.Max(_regionBuffers.Count, _terrainBuffers.Count); + internal int RegionBufferCount => _regionBuffers.Count; + internal int TerrainBufferCount => _terrainBuffers.Count; + internal int TerrainRecordStrideBytes => _terrainRecordStrideBytes; + + // GL reference captured on the first upload so Dispose can delete each + // frame-slot buffer. ClipFrame is long-lived and owns their teardown. private GL? _gl; private ClipFrame(byte[] regionBytes, int slotCount) @@ -119,9 +158,7 @@ public sealed class ClipFrame : IDisposable /// (no-clip, count 0) and a terrain count of 0 — WITHOUT allocating a new /// frame or new GL buffers. The single long-lived _clipFrame in /// GameWindow is reset + re-packed every frame by , - /// then re-uploaded via (which reuses the same SSBO / - /// UBO ids). This keeps the per-frame cost at one BufferData per buffer instead - /// of leaking a fresh pair of GL buffers each frame. + /// then uploaded through one SSBO and one terrain arena per fenced frame slot. /// public void Reset() { @@ -139,16 +176,36 @@ public sealed class ClipFrame : IDisposable } /// The shared mesh-clip SSBO id, or 0 before the first - /// . Renderers may bind this directly if they don't - /// receive it via a parameter; already binds it to + /// . Renderers may bind this directly if they don't + /// receive it via a parameter; already binds it to /// . public uint RegionSsbo => _regionSsbo; /// The terrain-clip UBO id, or 0 before the first - /// . Handed to - /// so it can re-bind binding=2 (UBO namespace) before its draw. + /// . The buffer alone does not identify the + /// active range; consumers should use . public uint TerrainUbo => _terrainUbo; + /// The currently installed terrain-clip range. The buffer name is + /// stable for the current GPU-fenced frame slot; the offset advances once + /// for every outside-view draw. + public TerrainClipBufferBinding TerrainBinding => _terrainBinding; + + /// + /// Selects one GPU-fenced frame slot. Its region SSBO and terrain arena are + /// safe to reuse because the frame-flight controller already waited for the + /// slot's preceding submission. + /// + public void BeginFrame(int frameSlot) + { + if ((uint)frameSlot >= FrameSlotCount) + throw new ArgumentOutOfRangeException(nameof(frameSlot)); + _dynamicFrameSlot = frameSlot; + _dynamicFrameStarted = true; + _terrainBinding = default; + _uploadState.BeginFrame(); + } + /// /// Append one clip region (becomes the next slot index) from a /// . Only the convex-plane case is supported in @@ -215,55 +272,265 @@ public sealed class ClipFrame : IDisposable } /// - /// Upload the shared mesh-clip SSBO (binding=2) and the terrain-clip UBO - /// (binding=2, UBO namespace) and bind both to their binding points. Idempotent - /// to call once per frame. Creates the GL buffers lazily on first call. + /// Compatibility entry point for one-region/one-terrain callers. Complex + /// PView frames should reserve their complete terrain sequence, upload the + /// regions once, then call per slice. /// public unsafe void UploadShared(GL gl) { - ArgumentNullException.ThrowIfNull(gl); - ObjectDisposedException.ThrowIf(_disposed, this); + ReserveTerrainUploads(gl, 1); + UploadRegions(gl); + UploadTerrainClip(gl); + } - if (!_glInitialized) + /// + /// Allocates the current frame slot's terrain arena before any draw uses it. + /// The arena has one alignment-safe range per subsequent + /// call, so later slices never overwrite + /// uniform data referenced by earlier GPU commands. + /// + public void ReserveTerrainUploads(GL gl, int uploadCount) + { + ValidateGlContext(gl); + ArgumentOutOfRangeException.ThrowIfLessThan(uploadCount, 1); + _uploadState.ValidateTerrainReservation(uploadCount); + + if (_terrainRecordStrideBytes == 0) { - _gl = gl; // captured for Dispose (single context for the frame's lifetime) - _regionSsbo = gl.GenBuffer(); - _terrainUbo = gl.GenBuffer(); - _glInitialized = true; + GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain alignment query (precondition)"); + gl.GetInteger(GetPName.UniformBufferOffsetAlignment, out int alignment); + GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain alignment query"); + _terrainRecordStrideBytes = ClipFrameArenaLayout.RecordStride(alignment); + } + + TerrainBufferArena arena = GetOrCreateTerrainArena(gl); + int requiredBytes = ClipFrameArenaLayout.RequiredBytes( + uploadCount, + _terrainRecordStrideBytes); + int targetCapacity = arena.CapacityPolicy.SelectCapacity( + arena.CapacityBytes, + requiredBytes); + if (targetCapacity != arena.CapacityBytes) + { + ClipBufferCapacityTransaction.Resize( + ref arena.CapacityBytes, + targetCapacity, + (previousBytes, newBytes) => + TrackedGlResource.AllocateBufferStorage( + gl, + GLEnum.UniformBuffer, + arena.Name, + previousBytes, + newBytes, + GLEnum.DynamicDraw, + "ClipFrame terrain UBO arena resize")); + } + + _terrainUbo = arena.Name; + _uploadState.CommitTerrainReservation(uploadCount); + } + + /// Uploads and binds the immutable clip-region table once for the + /// assembled frame. + public unsafe void UploadRegions(GL gl) + { + ValidateGlContext(gl); + _uploadState.ValidateRegionsNotUploaded(); + + RegionBuffer region = GetOrCreateRegionBuffer(gl); + int regionByteCount = checked(_slotCount * CellClipStrideBytes); + int targetCapacity = region.CapacityPolicy.SelectCapacity( + region.CapacityBytes, + regionByteCount); + if (targetCapacity != region.CapacityBytes) + { + ClipBufferCapacityTransaction.Resize( + ref region.CapacityBytes, + targetCapacity, + (previousBytes, newBytes) => + TrackedGlResource.AllocateBufferStorage( + gl, + GLEnum.ShaderStorageBuffer, + region.Name, + previousBytes, + newBytes, + GLEnum.DynamicDraw, + "ClipFrame region SSBO resize")); } - int regionByteCount = _slotCount * CellClipStrideBytes; - gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, _regionSsbo); fixed (byte* p = _regionBytes) { - gl.BufferData(BufferTargetARB.ShaderStorageBuffer, - (nuint)regionByteCount, p, BufferUsageARB.DynamicDraw); + TrackedGlResource.UpdateBufferSubData( + gl, + GLEnum.ShaderStorageBuffer, + region.Name, + 0, + regionByteCount, + p, + "ClipFrame region SSBO update"); } - gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, MeshClipSsboBinding, _regionSsbo); + gl.BindBufferBase( + BufferTargetARB.ShaderStorageBuffer, + MeshClipSsboBinding, + region.Name); + GLHelpers.ThrowOnResourceError(gl, "ClipFrame region SSBO binding"); + + _regionSsbo = region.Name; + _uploadState.MarkRegionsUploaded(); + } + + /// Uploads the current terrain clip bytes into the next unique + /// aligned arena record and installs that range at UBO binding 2. + public unsafe TerrainClipBufferBinding UploadTerrainClip(GL gl) + { + ValidateGlContext(gl); + int recordIndex = _uploadState.NextTerrainRecord(); + TerrainBufferArena arena = _terrainBuffers.GetRequired(_dynamicFrameSlot); + int offsetBytes = ClipFrameArenaLayout.RecordOffset( + recordIndex, + _terrainRecordStrideBytes); - gl.BindBuffer(BufferTargetARB.UniformBuffer, _terrainUbo); fixed (byte* p = _terrainBytes) { - gl.BufferData(BufferTargetARB.UniformBuffer, - (nuint)TerrainUboBytes, p, BufferUsageARB.DynamicDraw); + TrackedGlResource.UpdateBufferSubData( + gl, + GLEnum.UniformBuffer, + arena.Name, + offsetBytes, + TerrainUboBytes, + p, + "ClipFrame terrain UBO range update"); } - gl.BindBufferBase(BufferTargetARB.UniformBuffer, TerrainClipUboBinding, _terrainUbo); + + _terrainBinding = new TerrainClipBufferBinding( + arena.Name, + offsetBytes, + TerrainUboBytes); + _terrainBinding.Bind(gl); + _terrainUbo = arena.Name; + return _terrainBinding; + } + + public void BindTerrainClip(GL gl) + { + ValidateGlContext(gl); + if (!_terrainBinding.IsValid) + throw new InvalidOperationException("No terrain clip range has been uploaded for this frame."); + _terrainBinding.Bind(gl); + } + + private RegionBuffer GetOrCreateRegionBuffer(GL gl) + { + if (_regionBuffers.TryGet(_dynamicFrameSlot, out RegionBuffer? existing)) + return existing!; + + uint name = TrackedGlResource.CreateBuffer(gl, "ClipFrame region SSBO creation"); + var created = new RegionBuffer { Name = name }; + try + { + _regionBuffers.Set(_dynamicFrameSlot, created); + return created; + } + catch + { + TrackedGlResource.DeleteBuffer(gl, name, 0, "ClipFrame region SSBO rollback"); + throw; + } + } + + private TerrainBufferArena GetOrCreateTerrainArena(GL gl) + { + if (_terrainBuffers.TryGet(_dynamicFrameSlot, out TerrainBufferArena? existing)) + return existing!; + + uint name = TrackedGlResource.CreateBuffer(gl, "ClipFrame terrain UBO creation"); + var created = new TerrainBufferArena { Name = name }; + try + { + _terrainBuffers.Set(_dynamicFrameSlot, created); + return created; + } + catch + { + TrackedGlResource.DeleteBuffer(gl, name, 0, "ClipFrame terrain UBO rollback"); + throw; + } + } + + private void ValidateGlContext(GL gl) + { + ArgumentNullException.ThrowIfNull(gl); + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_dynamicFrameStarted) + throw new InvalidOperationException("BeginFrame must be called before uploading clip data."); + if (_gl is null) + _gl = gl; + else if (!ReferenceEquals(_gl, gl)) + throw new InvalidOperationException("ClipFrame cannot span OpenGL contexts."); } public void Dispose() { - if (_disposed) return; - _disposed = true; - // ClipFrame is long-lived in U.3 (GameWindow holds one via ??= NoClip() and - // reuses it every frame), so we own the two GL buffers and delete them here. - // _glInitialized guards the case where UploadShared never ran (no buffers to - // delete, and _gl was never captured). - if (_glInitialized && _gl is not null) + if (_disposed || _disposing) return; + _disposing = true; + try { - _gl.DeleteBuffer(_regionSsbo); - _gl.DeleteBuffer(_terrainUbo); + if (_disposeResources is null) + { + var releases = new List<(string Name, Action Release)>(); + if (_gl is not null) + { + int regionIndex = 0; + foreach (RegionBuffer set in _regionBuffers.Values) + { + RetryableGpuResourceRelease release = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + set.Name, + set.CapacityBytes, + "ClipFrame region SSBO disposal"); + releases.Add(($"region-ssbo-{regionIndex++}", release.Run)); + } + + int terrainIndex = 0; + foreach (TerrainBufferArena arena in _terrainBuffers.Values) + { + RetryableGpuResourceRelease release = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + arena.Name, + arena.CapacityBytes, + "ClipFrame terrain UBO disposal"); + releases.Add(($"terrain-ubo-{terrainIndex++}", release.Run)); + } + } + + _disposeResources = new RetryableResourceReleaseLedger(releases); + } + + ResourceReleaseAttempt attempt = _disposeResources.Advance(); + if (!_disposeResources.IsComplete) + { + throw attempt.ToException( + "One or more ClipFrame GPU buffers could not be released."); + } + _regionSsbo = 0; _terrainUbo = 0; + _terrainBinding = default; + _dynamicFrameStarted = false; + _disposeResources = null; + _disposed = true; + + if (attempt.HasFailures) + { + throw attempt.ToException( + "ClipFrame GPU buffers released with exceptional committed outcomes."); + } + } + finally + { + _disposing = false; } } @@ -310,3 +577,230 @@ public sealed class ClipFrame : IDisposable /// Test seam: the packed std140 terrain UBO bytes. internal ReadOnlySpan TerrainBytesForTest => _terrainBytes; } + +/// A single std140 terrain-clip record within a frame-slot UBO arena. +public readonly record struct TerrainClipBufferBinding( + uint Buffer, + int OffsetBytes, + int SizeBytes) +{ + public bool IsValid => Buffer != 0 && OffsetBytes >= 0 && SizeBytes > 0; + + public void Bind(GL gl) + { + ArgumentNullException.ThrowIfNull(gl); + if (!IsValid) + throw new InvalidOperationException("Cannot bind an empty terrain clip range."); + + gl.BindBufferRange( + BufferTargetARB.UniformBuffer, + ClipFrame.TerrainClipUboBinding, + Buffer, + (nint)OffsetBytes, + (nuint)SizeBytes); + GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain UBO range binding"); + } +} + +internal static class ClipFrameArenaLayout +{ + public static int RecordStride(int uniformBufferOffsetAlignment) + { + ArgumentOutOfRangeException.ThrowIfLessThan(uniformBufferOffsetAlignment, 1); + long stride = ((long)ClipFrame.TerrainUboBytes + uniformBufferOffsetAlignment - 1L) + / uniformBufferOffsetAlignment + * uniformBufferOffsetAlignment; + if (stride > int.MaxValue) + throw new OverflowException("Terrain clip UBO record stride exceeds Int32.MaxValue."); + return (int)stride; + } + + public static int RecordOffset(int recordIndex, int recordStrideBytes) + { + ArgumentOutOfRangeException.ThrowIfNegative(recordIndex); + ArgumentOutOfRangeException.ThrowIfLessThan(recordStrideBytes, ClipFrame.TerrainUboBytes); + return checked(recordIndex * recordStrideBytes); + } + + public static int RequiredBytes(int recordCount, int recordStrideBytes) + { + ArgumentOutOfRangeException.ThrowIfLessThan(recordCount, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(recordStrideBytes, ClipFrame.TerrainUboBytes); + return checked(recordCount * recordStrideBytes); + } +} + +internal static class ClipBufferCapacityTransaction +{ + /// Publishes capacity only after BufferData succeeds, and before + /// any later upload/bind operation can throw. + public static void Resize( + ref int publishedCapacityBytes, + int targetCapacityBytes, + Action allocate) + { + ArgumentOutOfRangeException.ThrowIfNegative(publishedCapacityBytes); + ArgumentOutOfRangeException.ThrowIfLessThan(targetCapacityBytes, 1); + ArgumentNullException.ThrowIfNull(allocate); + + int previousCapacityBytes = publishedCapacityBytes; + allocate(previousCapacityBytes, targetCapacityBytes); + publishedCapacityBytes = targetCapacityBytes; + } +} + +/// +/// Growth is immediate; shrinking requires repeated severe under-use. This +/// avoids reallocating during ordinary portal-count jitter while ensuring a +/// one-off pathological flood does not permanently pin its peak GPU storage. +/// +internal struct ClipBufferCapacityPolicy +{ + internal const int ShrinkAfterUnderusedFrames = 3; + internal const int ShrinkUtilizationDivisor = 4; + + private int _underusedFrames; + + public int SelectCapacity(int currentBytes, int requiredBytes) + { + ArgumentOutOfRangeException.ThrowIfNegative(currentBytes); + ArgumentOutOfRangeException.ThrowIfLessThan(requiredBytes, 1); + + if (requiredBytes > currentBytes) + { + _underusedFrames = 0; + return DynamicBufferCapacity.Grow(currentBytes, requiredBytes); + } + + if ((long)requiredBytes * ShrinkUtilizationDivisor <= currentBytes) + { + _underusedFrames++; + if (_underusedFrames >= ShrinkAfterUnderusedFrames) + { + _underusedFrames = 0; + return DynamicBufferCapacity.Grow(0, requiredBytes); + } + } + else + { + _underusedFrames = 0; + } + + return currentBytes; + } +} + +/// Fixed-cardinality resource ownership indexed by the same frame +/// slots protected by . +internal sealed class ClipFrameResourceRing(int slotCount) where T : class +{ + private readonly T?[] _slots = new T?[slotCount > 0 + ? slotCount + : throw new ArgumentOutOfRangeException(nameof(slotCount))]; + + public int Count { get; private set; } + + public bool TryGet(int slot, out T? resource) + { + ValidateSlot(slot); + resource = _slots[slot]; + return resource is not null; + } + + public T GetRequired(int slot) + { + ValidateSlot(slot); + return _slots[slot] + ?? throw new InvalidOperationException($"Frame slot {slot} has no resource."); + } + + public void Set(int slot, T resource) + { + ValidateSlot(slot); + ArgumentNullException.ThrowIfNull(resource); + if (_slots[slot] is not null) + throw new InvalidOperationException($"Frame slot {slot} already owns a resource."); + _slots[slot] = resource; + Count++; + } + + public IEnumerable Values + { + get + { + for (int i = 0; i < _slots.Length; i++) + if (_slots[i] is T value) + yield return value; + } + } + + private void ValidateSlot(int slot) + { + if ((uint)slot >= (uint)_slots.Length) + throw new ArgumentOutOfRangeException(nameof(slot)); + } +} + +/// Pure sequencing state for one ClipFrame render submission. +internal sealed class ClipFrameUploadState +{ + private bool _frameStarted; + private bool _regionsUploaded; + private int _terrainReserved; + private int _terrainCursor; + + public int TerrainReserved => _terrainReserved; + public int TerrainUploaded => _terrainCursor; + public bool RegionsUploaded => _regionsUploaded; + + public void BeginFrame() + { + _frameStarted = true; + _regionsUploaded = false; + _terrainReserved = 0; + _terrainCursor = 0; + } + + public void ValidateRegionsNotUploaded() + { + EnsureFrameStarted(); + if (_regionsUploaded) + throw new InvalidOperationException("Clip regions may be uploaded only once per frame."); + } + + public void MarkRegionsUploaded() + { + ValidateRegionsNotUploaded(); + _regionsUploaded = true; + } + + public void ValidateTerrainReservation(int uploadCount) + { + EnsureFrameStarted(); + ArgumentOutOfRangeException.ThrowIfLessThan(uploadCount, 1); + if (_terrainReserved != 0) + throw new InvalidOperationException("Terrain uploads have already been reserved for this frame."); + } + + public void CommitTerrainReservation(int uploadCount) + { + ValidateTerrainReservation(uploadCount); + _terrainReserved = uploadCount; + } + + public int NextTerrainRecord() + { + EnsureFrameStarted(); + if (_terrainReserved == 0) + throw new InvalidOperationException("ReserveTerrainUploads must run before terrain clip uploads."); + if (_terrainCursor >= _terrainReserved) + throw new InvalidOperationException("Terrain clip uploads exceeded the reserved arena record count."); + return _terrainCursor++; + } + + private void EnsureFrameStarted() + { + if (!_frameStarted) + throw new InvalidOperationException("BeginFrame must be called before uploading clip data."); + } +} diff --git a/src/AcDream.App/Rendering/ClipFrameAssembler.cs b/src/AcDream.App/Rendering/ClipFrameAssembler.cs index 3545bee7..38b72df4 100644 --- a/src/AcDream.App/Rendering/ClipFrameAssembler.cs +++ b/src/AcDream.App/Rendering/ClipFrameAssembler.cs @@ -45,47 +45,220 @@ public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] /// public sealed class ClipFrameAssembly { - public required ClipFrame Frame { get; init; } + public ClipFrame Frame { get; private set; } = null!; /// First drawable slice slot per visible cell. Compatibility map /// for renderer APIs that can accept only one slot at a time. - public required Dictionary CellIdToSlot { get; init; } + public Dictionary CellIdToSlot { get; } = new(); /// Slot-only cell slices, retained for older renderer APIs. - public required Dictionary CellIdToViewSlots { get; init; } + public Dictionary CellIdToViewSlots { get; } = new(); /// Full retail portal_view slices per visible cell. - public required Dictionary CellIdToViewSlices { get; init; } + public Dictionary CellIdToViewSlices { get; } = new(); /// Full retail outside_view slices. - public required ClipViewSlice[] OutsideViewSlices { get; init; } + public ClipViewSlice[] OutsideViewSlices { get; private set; } = System.Array.Empty(); - public required int OutdoorSlot { get; init; } - public required bool OutdoorVisible { get; init; } - public required TerrainClipMode TerrainMode { get; init; } - public required Vector4 TerrainScissorNdcAabb { get; init; } - public required bool HasOutsideView { get; init; } - public required Vector4 OutsideViewNdcAabb { get; init; } + public int OutdoorSlot { get; internal set; } + public bool OutdoorVisible { get; internal set; } + public TerrainClipMode TerrainMode { get; internal set; } + public Vector4 TerrainScissorNdcAabb { get; internal set; } + public bool HasOutsideView { get; internal set; } + public Vector4 OutsideViewNdcAabb { get; internal set; } // Probe data. - public required int OutsidePlaneCount { get; init; } - public required Dictionary PerCellPlaneCounts { get; init; } - public required int ScissorFallbacks { get; init; } + public int OutsidePlaneCount { get; internal set; } + public Dictionary PerCellPlaneCounts { get; } = new(); + public int ScissorFallbacks { get; internal set; } + + // The assembly is frame-scoped. An owner that passes it back to Assemble may reuse the + // dictionaries, per-cell arrays, and construction list after the prior frame is consumed. + // Exact-length pools keep the public array API honest: Length is always the live slice count. + private readonly Dictionary> _sliceArraysByLength = new(); + private readonly Dictionary> _slotArraysByLength = new(); + internal List SliceScratch { get; } = new(); + internal int SliceArrayAllocationCount { get; private set; } + internal int SlotArrayAllocationCount { get; private set; } + internal const int MaxRetainedSliceItems = 4096; + internal const int MaxRetainedSlotItems = 8192; + internal const int MaxRetainedArraysPerPool = 128; + internal int RetainedSliceItems { get; private set; } + internal int RetainedSlotItems { get; private set; } + internal int RetainedSliceArrays { get; private set; } + internal int RetainedSlotArrays { get; private set; } + + internal void Reset(ClipFrame frame) + { + Frame = frame; + foreach (ClipViewSlice[] slices in CellIdToViewSlices.Values) + ReturnSlices(slices); + foreach (int[] slots in CellIdToViewSlots.Values) + ReturnSlots(slots); + if (OutsideViewSlices.Length != 0) + ReturnSlices(OutsideViewSlices); + + CellIdToSlot.Clear(); + CellIdToViewSlots.Clear(); + CellIdToViewSlices.Clear(); + PerCellPlaneCounts.Clear(); + OutsideViewSlices = System.Array.Empty(); + SliceScratch.Clear(); + } + + internal ClipViewSlice[] CopySlices(List source) + { + if (source.Count == 0) + return System.Array.Empty(); + ClipViewSlice[] result = RentSlices(source.Count); + source.CopyTo(result, 0); + return result; + } + + internal int[] CopySlots(ClipViewSlice[] slices) + { + if (slices.Length == 0) + return System.Array.Empty(); + int[] result = RentSlots(slices.Length); + for (int i = 0; i < slices.Length; i++) + result[i] = slices[i].Slot; + return result; + } + + internal void SetOutsideViewSlices(ClipViewSlice[] slices) => OutsideViewSlices = slices; + + private ClipViewSlice[] RentSlices(int length) + { + if (_sliceArraysByLength.TryGetValue(length, out Stack? pool) + && pool.Count != 0) + { + ClipViewSlice[] result = pool.Pop(); + RetainedSliceItems -= result.Length; + RetainedSliceArrays--; + if (pool.Count == 0) + _sliceArraysByLength.Remove(length); + return result; + } + SliceArrayAllocationCount++; + return new ClipViewSlice[length]; + } + + private int[] RentSlots(int length) + { + if (_slotArraysByLength.TryGetValue(length, out Stack? pool) + && pool.Count != 0) + { + int[] result = pool.Pop(); + RetainedSlotItems -= result.Length; + RetainedSlotArrays--; + if (pool.Count == 0) + _slotArraysByLength.Remove(length); + return result; + } + SlotArrayAllocationCount++; + return new int[length]; + } + + private void ReturnSlices(ClipViewSlice[] array) + { + // ClipViewSlice contains a Vector4[] reference. Clear before either retaining or dropping + // the array so a bounded cache cannot accidentally extend plane-payload lifetimes. + System.Array.Clear(array); + if (array.Length > MaxRetainedSliceItems) + return; + while (RetainedSliceItems + array.Length > MaxRetainedSliceItems + || RetainedSliceArrays >= MaxRetainedArraysPerPool) + { + if (!EvictOneSliceArray()) + break; + } + if (!_sliceArraysByLength.TryGetValue(array.Length, out Stack? pool)) + { + pool = new Stack(); + _sliceArraysByLength.Add(array.Length, pool); + } + pool.Push(array); + RetainedSliceItems += array.Length; + RetainedSliceArrays++; + } + + private void ReturnSlots(int[] array) + { + if (array.Length > MaxRetainedSlotItems) + return; + while (RetainedSlotItems + array.Length > MaxRetainedSlotItems + || RetainedSlotArrays >= MaxRetainedArraysPerPool) + { + if (!EvictOneSlotArray()) + break; + } + if (!_slotArraysByLength.TryGetValue(array.Length, out Stack? pool)) + { + pool = new Stack(); + _slotArraysByLength.Add(array.Length, pool); + } + pool.Push(array); + RetainedSlotItems += array.Length; + RetainedSlotArrays++; + } + + private bool EvictOneSliceArray() + { + int selectedLength = -1; + foreach ((int length, Stack pool) in _sliceArraysByLength) + { + if (pool.Count != 0 && length > selectedLength) + selectedLength = length; + } + if (selectedLength < 0) + return false; + Stack selected = _sliceArraysByLength[selectedLength]; + ClipViewSlice[] evicted = selected.Pop(); + RetainedSliceItems -= evicted.Length; + RetainedSliceArrays--; + if (selected.Count == 0) + _sliceArraysByLength.Remove(selectedLength); + return true; + } + + private bool EvictOneSlotArray() + { + int selectedLength = -1; + foreach ((int length, Stack pool) in _slotArraysByLength) + { + if (pool.Count != 0 && length > selectedLength) + selectedLength = length; + } + if (selectedLength < 0) + return false; + Stack selected = _slotArraysByLength[selectedLength]; + int[] evicted = selected.Pop(); + RetainedSlotItems -= evicted.Length; + RetainedSlotArrays--; + if (selected.Count == 0) + _slotArraysByLength.Remove(selectedLength); + return true; + } } public static class ClipFrameAssembler { - public static ClipFrameAssembly Assemble(ClipFrame frame, PortalVisibilityFrame pvFrame) + public static ClipFrameAssembly Assemble( + ClipFrame frame, + PortalVisibilityFrame pvFrame, + ClipFrameAssembly? reuseAssembly = null) { System.ArgumentNullException.ThrowIfNull(frame); System.ArgumentNullException.ThrowIfNull(pvFrame); frame.Reset(); + ClipFrameAssembly assembly = reuseAssembly ?? new ClipFrameAssembly(); + assembly.Reset(frame); - var cellIdToSlot = new Dictionary(); - var cellIdToViewSlots = new Dictionary(); - var cellIdToViewSlices = new Dictionary(); - var perCellPlaneCounts = new Dictionary(); + Dictionary cellIdToSlot = assembly.CellIdToSlot; + Dictionary cellIdToViewSlots = assembly.CellIdToViewSlots; + Dictionary cellIdToViewSlices = assembly.CellIdToViewSlices; + Dictionary perCellPlaneCounts = assembly.PerCellPlaneCounts; int scissorFallbacks = 0; foreach (uint cellId in pvFrame.OrderedVisibleCells) @@ -93,12 +266,13 @@ public static class ClipFrameAssembler if (!pvFrame.CellViews.TryGetValue(cellId, out var view)) continue; - var slices = new List(view.Polygons.Count); + List slices = assembly.SliceScratch; + slices.Clear(); int maxPlaneCount = 0; foreach (var poly in view.Polygons) { - var cps = ClipPlaneSet.From(ViewOf(poly)); + var cps = ClipPlaneSet.From(poly); if (cps.IsNothingVisible) continue; @@ -106,7 +280,7 @@ public static class ClipFrameAssembler Vector4[] planes; if (cps.Count > 0) { - planes = ToPlaneSpan(cps); + planes = cps.PlaneArray; slot = frame.AppendSlot(planes); if (cps.Count > maxPlaneCount) maxPlaneCount = cps.Count; @@ -124,20 +298,21 @@ public static class ClipFrameAssembler if (slices.Count == 0) continue; - var sliceArray = slices.ToArray(); + ClipViewSlice[] sliceArray = assembly.CopySlices(slices); cellIdToViewSlices[cellId] = sliceArray; - cellIdToViewSlots[cellId] = ToSlots(sliceArray); + cellIdToViewSlots[cellId] = assembly.CopySlots(sliceArray); cellIdToSlot[cellId] = sliceArray[0].Slot; perCellPlaneCounts[cellId] = maxPlaneCount; } - var outsideSlicesList = new List(pvFrame.OutsideView.Polygons.Count); + List outsideSlicesList = assembly.SliceScratch; + outsideSlicesList.Clear(); int outsideMaxPlaneCount = 0; bool outsideHasScissorFallback = false; foreach (var poly in pvFrame.OutsideView.Polygons) { - var cps = ClipPlaneSet.From(ViewOf(poly)); + var cps = ClipPlaneSet.From(poly); if (cps.IsNothingVisible) continue; @@ -145,7 +320,7 @@ public static class ClipFrameAssembler Vector4[] planes; if (cps.Count > 0) { - planes = ToPlaneSpan(cps); + planes = cps.PlaneArray; slot = frame.AppendSlot(planes); if (cps.Count > outsideMaxPlaneCount) outsideMaxPlaneCount = cps.Count; @@ -161,7 +336,7 @@ public static class ClipFrameAssembler outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), planes)); } - var outsideViewSlices = outsideSlicesList.ToArray(); + ClipViewSlice[] outsideViewSlices = assembly.CopySlices(outsideSlicesList); bool outdoorVisible = outsideViewSlices.Length > 0; int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0; TerrainClipMode terrainMode = !outdoorVisible @@ -176,49 +351,19 @@ public static class ClipFrameAssembler ? outsideViewNdcAabb : Vector4.Zero; - return new ClipFrameAssembly - { - Frame = frame, - CellIdToSlot = cellIdToSlot, - CellIdToViewSlots = cellIdToViewSlots, - CellIdToViewSlices = cellIdToViewSlices, - OutsideViewSlices = outsideViewSlices, - OutdoorSlot = outdoorSlot, - OutdoorVisible = outdoorVisible, - TerrainMode = terrainMode, - TerrainScissorNdcAabb = terrainScissor, - HasOutsideView = outdoorVisible, - OutsideViewNdcAabb = outsideViewNdcAabb, - OutsidePlaneCount = terrainMode == TerrainClipMode.Planes ? outsideMaxPlaneCount : 0, - PerCellPlaneCounts = perCellPlaneCounts, - ScissorFallbacks = scissorFallbacks, - }; - } - - private static CellView ViewOf(ViewPolygon poly) - { - var view = new CellView(); - view.Add(poly); - return view; + assembly.SetOutsideViewSlices(outsideViewSlices); + assembly.OutdoorSlot = outdoorSlot; + assembly.OutdoorVisible = outdoorVisible; + assembly.TerrainMode = terrainMode; + assembly.TerrainScissorNdcAabb = terrainScissor; + assembly.HasOutsideView = outdoorVisible; + assembly.OutsideViewNdcAabb = outsideViewNdcAabb; + assembly.OutsidePlaneCount = terrainMode == TerrainClipMode.Planes ? outsideMaxPlaneCount : 0; + assembly.ScissorFallbacks = scissorFallbacks; + return assembly; } private static Vector4 AabbOf(ViewPolygon poly) => new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY); - private static int[] ToSlots(ClipViewSlice[] slices) - { - var slots = new int[slices.Length]; - for (int i = 0; i < slices.Length; i++) - slots[i] = slices[i].Slot; - return slots; - } - - private static Vector4[] ToPlaneSpan(ClipPlaneSet set) - { - int n = set.Count; - var planes = new Vector4[n]; - for (int i = 0; i < n; i++) - planes[i] = set.Planes[i]; - return planes; - } } diff --git a/src/AcDream.App/Rendering/ClipPlaneSet.cs b/src/AcDream.App/Rendering/ClipPlaneSet.cs index 47d4e2f6..dc4f0bd8 100644 --- a/src/AcDream.App/Rendering/ClipPlaneSet.cs +++ b/src/AcDream.App/Rendering/ClipPlaneSet.cs @@ -38,6 +38,7 @@ // pass-all" slot 0 is constructed by the consumer directly, not via From().) // When Count > 0, Planes carries the convex gate and the scissor fields are unused. using System; +using System.Buffers; using System.Collections.Generic; using System.Numerics; @@ -84,6 +85,10 @@ public readonly struct ClipPlaneSet /// A clip-space point clip is inside iff Vector4.Dot(plane, clip) >= 0 for every plane. public IReadOnlyList Planes => _planes ?? (IReadOnlyList)Array.Empty(); + // The set is immutable after construction. ClipFrameAssembler transfers this exact array into + // its frame-scoped slice instead of cloning every plane payload a second time. + internal Vector4[] PlaneArray => _planes ?? Array.Empty(); + /// True ⇒ the convex-plane budget was exceeded; gate on /// instead (draw the box). Always false when > 0 or when the region is empty. public bool UseScissorFallback { get; } @@ -119,34 +124,68 @@ public readonly struct ClipPlaneSet if (region.Polygons.Count > 1) return Scissor(region.MinX, region.MinY, region.MaxX, region.MaxY); - // Exactly one polygon: normalize winding to CCW, merge collinear edges, then decide. - Vector2[] verts = NormalizeAndMerge(region.Polygons[0].Vertices); + return From(region.Polygons[0]); + } - // Fewer than 3 distinct edges survive ⇒ a sliver/line with no area. There is no - // meaningful AABB to over-include (a zero-area region), so treat it as nothing visible. - if (verts.Length < 3) + /// + /// Reduce one convex view polygon without wrapping it in a temporary . + /// The clip-frame assembler already iterates individual retail view_poly slices, so + /// constructing a CellView there needlessly ran the flood's canonical-key/dedup machinery for + /// every slice on every frame. This overload is behavior-identical to the one-polygon branch + /// of and avoids that unrelated allocation path. + /// + public static ClipPlaneSet From(in ViewPolygon polygon) + { + if (polygon.IsEmpty) return Empty; - // A single convex polygon with too many edges to fit the hardware budget ⇒ scissor - // on ITS own AABB (still a superset of the polygon → over-include, safe). - if (verts.Length > MaxPlanes) - return Scissor(verts); + // Exactly one polygon: normalize winding to CCW and merge collinear edges in reusable + // scratch. The surviving vertices are consumed immediately to build the actual plane + // payload; there is no reason to allocate a second exact-sized polygon array per slice. + Vector2[] input = polygon.Vertices; + Vector2[]? rented = null; + Span verts = input.Length <= 32 + ? stackalloc Vector2[input.Length] + : (rented = ArrayPool.Shared.Rent(input.Length)).AsSpan(0, input.Length); - // 3..8 edges: emit one inward half-space plane per edge (CCW formula). - var planes = new Vector4[verts.Length]; - for (int i = 0; i < verts.Length; i++) + try { - Vector2 p = verts[i]; - Vector2 q = verts[(i + 1) % verts.Length]; - Vector2 dir = q - p; - // Inward normal for CCW winding: perp(dir) = (-dir.y, dir.x) points to the polygon's - // interior (the "left" side of the directed edge p→q). - Vector2 n = Vector2.Normalize(new Vector2(-dir.Y, dir.X)); - // Plane: n·x + d >= 0 inside, with d = -(n·p). In clip space with NDC x = clip.x/clip.w: - // dist = n.x*clip.x + n.y*clip.y + 0*clip.z + (-(n·p))*clip.w (>= 0 ⇒ keep) - planes[i] = new Vector4(n.X, n.Y, 0f, -Vector2.Dot(n, p)); + int count = NormalizeAndMerge(input, verts); + + // Fewer than 3 distinct edges survive ⇒ a sliver/line with no area. There is no + // meaningful AABB to over-include (a zero-area region), so treat it as nothing visible. + if (count < 3) + return Empty; + + ReadOnlySpan normalized = verts[..count]; + + // A single convex polygon with too many edges to fit the hardware budget ⇒ scissor + // on ITS own AABB (still a superset of the polygon → over-include, safe). + if (count > MaxPlanes) + return Scissor(normalized); + + // 3..8 edges: emit one inward half-space plane per edge (CCW formula). This array is + // the retained GPU-routing payload and therefore the one necessary allocation. + var planes = new Vector4[count]; + for (int i = 0; i < count; i++) + { + Vector2 p = normalized[i]; + Vector2 q = normalized[(i + 1) % count]; + Vector2 dir = q - p; + // Inward normal for CCW winding: perp(dir) = (-dir.y, dir.x) points to the polygon's + // interior (the "left" side of the directed edge p→q). + Vector2 n = Vector2.Normalize(new Vector2(-dir.Y, dir.X)); + // Plane: n·x + d >= 0 inside, with d = -(n·p). In clip space with NDC x = clip.x/clip.w: + // dist = n.x*clip.x + n.y*clip.y + 0*clip.z + (-(n·p))*clip.w (>= 0 ⇒ keep) + planes[i] = new Vector4(n.X, n.Y, 0f, -Vector2.Dot(n, p)); + } + return new ClipPlaneSet(planes, useScissorFallback: false, isNothingVisible: false, scissorNdcAabb: DegenerateAabb); + } + finally + { + if (rented is not null) + ArrayPool.Shared.Return(rented); } - return new ClipPlaneSet(planes, useScissorFallback: false, isNothingVisible: false, scissorNdcAabb: DegenerateAabb); } private static ClipPlaneSet Scissor(float minX, float minY, float maxX, float maxY) => @@ -171,41 +210,41 @@ public readonly struct ClipPlaneSet /// already EnsureCcw's its output, but From() is a public entry point that must be robust to /// either winding (e.g. a hand-built CellView), so we normalize here too. /// - private static Vector2[] NormalizeAndMerge(Vector2[] input) + private static int NormalizeAndMerge(ReadOnlySpan input, Span points) { - if (input is null || input.Length < 3) - return Array.Empty(); + if (input.Length < 3) + return 0; // 1) Drop exact/near-duplicate consecutive points first so edge directions are well-defined. - var pts = new List(input.Length); - foreach (var v in input) + int count = 0; + foreach (Vector2 vertex in input) { - if (pts.Count == 0 || (v - pts[^1]).LengthSquared() > DegenerateEdgeLen * DegenerateEdgeLen) - pts.Add(v); + if (count == 0 || (vertex - points[count - 1]).LengthSquared() > DegenerateEdgeLen * DegenerateEdgeLen) + points[count++] = vertex; } // Wrap-around duplicate (last == first). - if (pts.Count >= 2 && (pts[^1] - pts[0]).LengthSquared() <= DegenerateEdgeLen * DegenerateEdgeLen) - pts.RemoveAt(pts.Count - 1); - if (pts.Count < 3) - return Array.Empty(); + if (count >= 2 && (points[count - 1] - points[0]).LengthSquared() <= DegenerateEdgeLen * DegenerateEdgeLen) + count--; + if (count < 3) + return 0; // 2) Force CCW winding (positive signed area). perp(dir)=(-y,x) is the inward normal only // for CCW; if the caller handed us CW, reverse so the plane signs come out inside-positive. - if (SignedArea2(pts) < 0f) - pts.Reverse(); + if (SignedArea2(points[..count]) < 0f) + points[..count].Reverse(); // 3) Merge collinear edges: drop vertex i when edge (i-1→i) and edge (i→i+1) point the same // way (turn angle < ~0.5°). Iterate until stable — removing one vertex can expose a new // collinear triple. |cross(a,b)| of unit dirs = |sin θ|; dot>0 rules out a 180° reversal. bool changed = true; - while (changed && pts.Count >= 3) + while (changed && count >= 3) { changed = false; - for (int i = 0; i < pts.Count; i++) + for (int i = 0; i < count; i++) { - Vector2 prev = pts[(i - 1 + pts.Count) % pts.Count]; - Vector2 cur = pts[i]; - Vector2 next = pts[(i + 1) % pts.Count]; + Vector2 prev = points[(i - 1 + count) % count]; + Vector2 cur = points[i]; + Vector2 next = points[(i + 1) % count]; Vector2 d0 = cur - prev; Vector2 d1 = next - cur; @@ -213,7 +252,8 @@ public readonly struct ClipPlaneSet float l1 = d1.Length(); if (l0 < DegenerateEdgeLen || l1 < DegenerateEdgeLen) { - pts.RemoveAt(i); + points[(i + 1)..count].CopyTo(points[i..]); + count--; changed = true; break; } @@ -224,34 +264,35 @@ public readonly struct ClipPlaneSet float dot = d0.X * d1.X + d0.Y * d1.Y; // cos θ if (dot > 0f && MathF.Abs(cross) < CollinearSinEps) { - pts.RemoveAt(i); // cur lies on the straight line prev→next + points[(i + 1)..count].CopyTo(points[i..]); + count--; // cur lies on the straight line prev→next changed = true; break; } } } - if (pts.Count < 3) - return Array.Empty(); + if (count < 3) + return 0; // Final degeneracy gate: a polygon with negligible area is a line/point even if it still // has >= 3 distinct vertices (e.g. an edge-on portal, or a near-collinear triple the 0.5° // merge didn't quite collapse). Emitting its planes would yield an empty half-space // intersection that silently gates out everything; report it honestly as nothing-visible. - if (MathF.Abs(SignedArea2(pts)) * 0.5f < MinPolygonArea) - return Array.Empty(); + if (MathF.Abs(SignedArea2(points[..count])) * 0.5f < MinPolygonArea) + return 0; - return pts.ToArray(); + return count; } // Twice the signed area (the "shoelace" sum). > 0 ⇒ CCW, < 0 ⇒ CW. - private static float SignedArea2(List poly) + private static float SignedArea2(ReadOnlySpan poly) { float a = 0f; - for (int i = 0; i < poly.Count; i++) + for (int i = 0; i < poly.Length; i++) { Vector2 p = poly[i]; - Vector2 q = poly[(i + 1) % poly.Count]; + Vector2 q = poly[(i + 1) % poly.Length]; a += p.X * q.Y - q.X * p.Y; } return a; diff --git a/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs b/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs new file mode 100644 index 00000000..47fd21ce --- /dev/null +++ b/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs @@ -0,0 +1,933 @@ +using AcDream.Core.Textures; +using AcDream.Core.World; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// Location of one decoded entity-material composite in a resident bindless +/// texture array. The modern mesh shader consumes this exact pair. +/// +public readonly record struct BindlessTextureLocation(ulong Handle, uint Layer); + +internal enum CompositeTextureKind : byte +{ + OriginalTextureOverride, + PaletteComposite, +} + +/// +/// Structural palette identity. The precomputed hash accelerates dictionary +/// bucketing, but equality still compares every server-supplied range so a +/// hash collision can never share the wrong material pixels. +/// +internal readonly struct PaletteCompositeIdentity : IEquatable +{ + private readonly IReadOnlyList? _ranges; + + public PaletteCompositeIdentity(PaletteOverride palette, ulong hash) + { + ArgumentNullException.ThrowIfNull(palette); + BasePaletteId = palette.BasePaletteId; + Hash = hash; + _ranges = palette.SubPalettes; + } + + public uint BasePaletteId { get; } + public ulong Hash { get; } + public int RangeCount => _ranges?.Count ?? 0; + + public bool Equals(PaletteCompositeIdentity other) + { + if (Hash != other.Hash + || BasePaletteId != other.BasePaletteId + || RangeCount != other.RangeCount) + { + return false; + } + + for (int i = 0; i < RangeCount; i++) + if (_ranges![i] != other._ranges![i]) + return false; + return true; + } + + public override bool Equals(object? obj) => + obj is PaletteCompositeIdentity other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(BasePaletteId, Hash, RangeCount); + + public static bool operator ==(PaletteCompositeIdentity left, PaletteCompositeIdentity right) => + left.Equals(right); + + public static bool operator !=(PaletteCompositeIdentity left, PaletteCompositeIdentity right) => + !left.Equals(right); +} + +internal readonly record struct CompositeTextureKey( + CompositeTextureKind Kind, + uint SurfaceId, + uint OrigTextureOverride, + PaletteCompositeIdentity Palette); + +internal sealed class CompositeTextureArrayResource +{ + public required uint Name { get; init; } + public required ulong Handle { get; init; } + public required int Width { get; init; } + public required int Height { get; init; } + public required int Capacity { get; init; } + public required long Bytes { get; init; } +} + +internal interface ICompositeTextureArrayBackend +{ + int MaximumArrayLayers { get; } + CompositeTextureArrayResource Create(int width, int height, int capacity); + void Upload(CompositeTextureArrayResource resource, int layer, byte[] rgba); + void MakeNonResident(CompositeTextureArrayResource resource); + void Delete(CompositeTextureArrayResource resource); +} + +/// +/// Narrow GL backend for composite arrays. Unlike ManagedGLTextureArray it +/// deliberately has one mip level, no PBO, and one resident handle: those are +/// the semantics of the standalone composite textures this pool replaces. +/// +internal sealed unsafe class GlCompositeTextureArrayBackend : ICompositeTextureArrayBackend +{ + private readonly GL _gl; + private readonly Wb.BindlessSupport _bindless; + + public GlCompositeTextureArrayBackend(GL gl, Wb.BindlessSupport bindless) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _bindless = bindless ?? throw new ArgumentNullException(nameof(bindless)); + _gl.GetInteger(GetPName.MaxArrayTextureLayers, out int maximumLayers); + MaximumArrayLayers = Math.Max(1, maximumLayers); + } + + public int MaximumArrayLayers { get; } + + public CompositeTextureArrayResource Create(int width, int height, int capacity) + { + uint name = _gl.GenTexture(); + if (name == 0) + throw new InvalidOperationException("OpenGL did not create a composite texture array."); + + bool resident = false; + ulong handle = 0; + long bytes = 0; + bool tracked = false; + try + { + // Composite creation/upload runs in the render thread's pre-draw + // preparation phase. Normalize that phase to texture unit zero + // instead of synchronously reading driver binding state. + _gl.ActiveTexture(TextureUnit.Texture0); + Wb.RenderStateCache.CurrentAtlas = 0; + _gl.BindTexture(TextureTarget.Texture2DArray, name); + _gl.TexStorage3D( + TextureTarget.Texture2DArray, + levels: 1, + SizedInternalFormat.Rgba8, + checked((uint)width), + checked((uint)height), + checked((uint)capacity)); + _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureBaseLevel, 0); + _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMaxLevel, 0); + _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); + _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); + _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); + _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); + handle = _bindless.GetResidentHandle(name); + resident = true; + Wb.GLHelpers.ThrowOnResourceError( + _gl, + $"creating composite texture array {width}x{height}x{capacity}"); + bytes = checked((long)width * height * 4L * capacity); + Wb.GpuMemoryTracker.TrackResourceAllocation(Wb.GpuResourceType.Texture); + Wb.GpuMemoryTracker.TrackAllocation(bytes, Wb.GpuResourceType.Texture); + tracked = true; + return new CompositeTextureArrayResource + { + Name = name, + Handle = handle, + Width = width, + Height = height, + Capacity = capacity, + Bytes = bytes, + }; + } + catch (Exception creationFailure) + { + List? cleanupFailures = null; + void Attempt(Action cleanup) + { + try { cleanup(); } + catch (Exception ex) { (cleanupFailures ??= []).Add(ex); } + } + + bool residencyReleased = !resident; + if (resident) + { + Attempt(() => + { + _bindless.MakeNonResident(handle); + Wb.GLHelpers.ThrowOnResourceError(_gl, "rolling back composite texture residency"); + residencyReleased = true; + }); + } + if (residencyReleased) + { + Attempt(() => + { + _gl.DeleteTexture(name); + Wb.GLHelpers.ThrowOnResourceError(_gl, "rolling back composite texture array"); + if (tracked) + { + Wb.GpuMemoryTracker.TrackDeallocation(bytes, Wb.GpuResourceType.Texture); + Wb.GpuMemoryTracker.TrackResourceDeallocation(Wb.GpuResourceType.Texture); + } + }); + } + + if (cleanupFailures is not null) + { + cleanupFailures.Insert(0, creationFailure); + throw new AggregateException( + "Composite texture-array construction and rollback both failed.", + cleanupFailures); + } + throw; + } + finally + { + _gl.BindTexture(TextureTarget.Texture2DArray, 0); + _gl.ActiveTexture(TextureUnit.Texture0); + } + } + + public void Upload(CompositeTextureArrayResource resource, int layer, byte[] rgba) + { + _gl.ActiveTexture(TextureUnit.Texture0); + Wb.RenderStateCache.CurrentAtlas = 0; + try + { + _gl.BindBuffer(BufferTargetARB.PixelUnpackBuffer, 0); + _gl.BindTexture(TextureTarget.Texture2DArray, resource.Name); + fixed (byte* pixels = rgba) + { + _gl.TexSubImage3D( + TextureTarget.Texture2DArray, + level: 0, + xoffset: 0, + yoffset: 0, + zoffset: layer, + checked((uint)resource.Width), + checked((uint)resource.Height), + depth: 1, + PixelFormat.Rgba, + PixelType.UnsignedByte, + pixels); + } + Wb.GLHelpers.ThrowOnResourceError( + _gl, + $"uploading composite texture layer {layer} ({resource.Width}x{resource.Height})"); + } + finally + { + _gl.BindTexture(TextureTarget.Texture2DArray, 0); + _gl.BindBuffer(BufferTargetARB.PixelUnpackBuffer, 0); + _gl.ActiveTexture(TextureUnit.Texture0); + } + } + + public void MakeNonResident(CompositeTextureArrayResource resource) + { + Wb.GLHelpers.ThrowOnResourceError( + _gl, + $"releasing composite texture handle {resource.Handle} (precondition)"); + _bindless.MakeNonResident(resource.Handle); + Wb.GLHelpers.ThrowOnResourceError(_gl, $"releasing composite texture handle {resource.Handle}"); + } + + public void Delete(CompositeTextureArrayResource resource) + { + Wb.GLHelpers.ThrowOnResourceError( + _gl, + $"deleting composite texture array {resource.Name} (precondition)"); + _gl.DeleteTexture(resource.Name); + Wb.GLHelpers.ThrowOnResourceError(_gl, $"deleting composite texture array {resource.Name}"); + Wb.GpuMemoryTracker.TrackDeallocation(resource.Bytes, Wb.GpuResourceType.Texture); + Wb.GpuMemoryTracker.TrackResourceDeallocation(Wb.GpuResourceType.Texture); + } +} + +/// +/// Pools per-entity material composites into dimension-compatible texture +/// arrays. Retail releases the owning CSurface reference immediately. This +/// modern adaptation preserves that logical boundary while retaining recent +/// same-pixel layers under a bounded LRU; layer reuse waits for a GPU fence. +/// +internal sealed class CompositeTextureArrayCache : IDisposable +{ + internal const long DefaultUnownedBudgetBytes = 64L * 1024 * 1024; + internal const long DefaultPhysicalBudgetBytes = 128L * 1024 * 1024; + internal const long TargetArrayBytes = 4L * 1024 * 1024; + internal const int MaximumLayersPerArray = 64; + internal const int DefaultMaximumUploadsPerFrame = 16; + internal const long DefaultMaximumUploadBytesPerFrame = 8L * 1024 * 1024; + internal const int MaximumLogicalEvictionsPerFrame = 16; + internal const int MaximumAtlasCreationsPerFrame = 1; + + private readonly ICompositeTextureArrayBackend _backend; + private readonly GpuRetirementLedger _retirementLedger; + private readonly OwnerScopedResourceRegistry _owners = new(); + private readonly BoundedUnownedResourceCache _unowned; + private readonly long _physicalBudgetBytes; + private readonly int _maximumArrayLayers; + private readonly int _maximumUploadsPerFrame; + private readonly long _maximumUploadBytesPerFrame; + private readonly Dictionary _entries = new(); + private readonly Dictionary<(int Width, int Height), List> _atlasesBySize = new(); + private readonly List _atlases = new(); + private long _allocatedBytes; + private long _useSequence; + private int _frameUploadCount; + private long _frameUploadBytes; + private int _frameAtlasCreationCount; + private bool _uploadBudgetBlocked; + private int _pendingAtlasWidth; + private int _pendingAtlasHeight; + private long _pendingAtlasAllocationBytes; + private readonly List _evictionScratch = new(MaximumLogicalEvictionsPerFrame); + private bool _disposeRequested; + private bool _disposed; + + private sealed class Entry + { + public required Atlas Atlas { get; init; } + public required int Layer { get; init; } + public required long Bytes { get; init; } + } + + private sealed class Atlas + { + public required CompositeTextureArrayResource Resource { get; init; } + public required Wb.TextureAtlasSlotAllocator Slots { get; init; } + public int EntryCount { get; set; } + public int PendingRetirements { get; set; } + public long LastUseSequence { get; set; } + public bool ReleaseRequested { get; set; } + public AtlasReleaseStage ReleaseStage { get; set; } + + public int AvailableLayers => Slots.AvailableCount; + public bool IsGpuSafeEmpty => EntryCount == 0 && PendingRetirements == 0; + public bool IsReusable => !ReleaseRequested && ReleaseStage == AtlasReleaseStage.Resident; + public bool Deleted => ReleaseStage >= AtlasReleaseStage.Deleted; + } + + private enum AtlasReleaseStage : byte + { + Resident, + NonResident, + Deleted, + Accounted, + } + + public CompositeTextureArrayCache( + GL gl, + Wb.BindlessSupport bindless, + IGpuResourceRetirementQueue retirementQueue, + long unownedBudgetBytes = DefaultUnownedBudgetBytes, + long physicalBudgetBytes = DefaultPhysicalBudgetBytes, + int maximumUploadsPerFrame = DefaultMaximumUploadsPerFrame, + long maximumUploadBytesPerFrame = DefaultMaximumUploadBytesPerFrame) + : this( + new GlCompositeTextureArrayBackend(gl, bindless), + retirementQueue, + unownedBudgetBytes, + physicalBudgetBytes, + maximumUploadsPerFrame, + maximumUploadBytesPerFrame) + { + } + + internal CompositeTextureArrayCache( + ICompositeTextureArrayBackend backend, + IGpuResourceRetirementQueue retirementQueue, + long unownedBudgetBytes = DefaultUnownedBudgetBytes, + long physicalBudgetBytes = DefaultPhysicalBudgetBytes, + int maximumUploadsPerFrame = DefaultMaximumUploadsPerFrame, + long maximumUploadBytesPerFrame = DefaultMaximumUploadBytesPerFrame) + { + _backend = backend ?? throw new ArgumentNullException(nameof(backend)); + ArgumentNullException.ThrowIfNull(retirementQueue); + _retirementLedger = new GpuRetirementLedger(retirementQueue); + ArgumentOutOfRangeException.ThrowIfNegative(physicalBudgetBytes); + ArgumentOutOfRangeException.ThrowIfLessThan(maximumUploadsPerFrame, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(maximumUploadBytesPerFrame, 1); + _unowned = new BoundedUnownedResourceCache(unownedBudgetBytes); + _physicalBudgetBytes = physicalBudgetBytes; + _maximumUploadsPerFrame = maximumUploadsPerFrame; + _maximumUploadBytesPerFrame = maximumUploadBytesPerFrame; + _maximumArrayLayers = Math.Max( + 1, + Math.Min(backend.MaximumArrayLayers, MaximumLayersPerArray)); + } + + internal int ActiveResourceCount => _owners.ResourceCount; + internal int OwnerCount => _owners.OwnerCount; + internal int CachedEntryCount => _entries.Count; + internal int UnownedEntryCount => _unowned.Count; + internal long UnownedBytes => _unowned.ResidentBytes; + internal int AtlasCount => _atlases.Count; + internal long AllocatedBytes => _allocatedBytes; + internal int FrameUploadCount => _frameUploadCount; + internal long FrameUploadBytes => _frameUploadBytes; + internal bool CanStartUpload => + !_uploadBudgetBlocked + && _frameUploadCount < _maximumUploadsPerFrame + && (_frameUploadCount == 0 || _frameUploadBytes < _maximumUploadBytesPerFrame); + + internal bool CanUpload(long bytes) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytes); + if (_frameUploadCount >= _maximumUploadsPerFrame) + return false; + + // Always allow one item so a texture larger than the normal frame + // budget cannot permanently stall portal readiness. Every later item + // must fit completely inside the advertised byte budget. + return _frameUploadCount == 0 + || bytes <= _maximumUploadBytesPerFrame - _frameUploadBytes; + } + + internal bool CanPrepareUpload(int width, int height) + { + ArgumentOutOfRangeException.ThrowIfLessThan(width, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(height, 1); + + long layerBytes = checked((long)width * height * 4L); + if (!CanUpload(layerBytes)) + { + _uploadBudgetBlocked = true; + return false; + } + + if (_atlasesBySize.TryGetValue((width, height), out List? compatible)) + { + for (int i = 0; i < compatible.Count; i++) + if (compatible[i].IsReusable && compatible[i].AvailableLayers != 0) + return true; + } + + int capacity = CalculateLayerCapacity(width, height, _maximumArrayLayers); + long requestedBytes = checked(layerBytes * capacity); + if (_frameAtlasCreationCount < MaximumAtlasCreationsPerFrame + && CanAllocateAtlas(requestedBytes)) + return true; + + SetPendingAllocation(width, height, requestedBytes); + _uploadBudgetBlocked = true; + return false; + } + + public void BeginFrame() + { + ThrowIfUnavailable(); + _retirementLedger.RetryPendingPublications(); + _frameUploadCount = 0; + _frameUploadBytes = 0; + _frameAtlasCreationCount = 0; + _uploadBudgetBlocked = false; + } + + public bool TryAcquire( + uint ownerLocalId, + CompositeTextureKey key, + out BindlessTextureLocation location) + { + ThrowIfUnavailable(); + if (!_entries.TryGetValue(key, out Entry? entry)) + { + location = default; + return false; + } + + _owners.Acquire(ownerLocalId, key); + _unowned.MarkOwned(key); + entry.Atlas.LastUseSequence = ++_useSequence; + location = new BindlessTextureLocation( + entry.Atlas.Resource.Handle, + checked((uint)entry.Layer)); + return true; + } + + public bool TryAddAndAcquire( + uint ownerLocalId, + CompositeTextureKey key, + DecodedTexture decoded, + out BindlessTextureLocation location) + { + ThrowIfUnavailable(); + if (TryAcquire(ownerLocalId, key, out BindlessTextureLocation existing)) + { + location = existing; + return true; + } + + ValidateDecodedTexture(decoded); + long bytes = checked((long)decoded.Width * decoded.Height * 4L); + if (!CanUpload(bytes)) + { + // Dimensions are only known after DAT decode. Once one candidate + // does not fit, stop all later decodes this frame rather than + // repeatedly allocating RGBA buffers that cannot be uploaded. + _uploadBudgetBlocked = true; + location = default; + return false; + } + + if (!TryFindOrCreateAtlas(decoded.Width, decoded.Height, out Atlas atlas)) + { + // As with a byte-budget rejection, stop subsequent DAT decodes in + // this frame. Tick advances compatible reclamation before the next + // frame retries the same logical composite. + _uploadBudgetBlocked = true; + location = default; + return false; + } + + int layer = atlas.Slots.Rent(); + try + { + _backend.Upload(atlas.Resource, layer, decoded.Rgba8); + } + catch (Exception uploadFailure) + { + atlas.Slots.Return(layer); + if (atlas.IsGpuSafeEmpty) + { + try { DeleteAtlas(atlas); } + catch (Exception releaseFailure) + { + throw new AggregateException( + "Composite upload and empty-atlas rollback both failed.", + uploadFailure, + releaseFailure); + } + } + throw; + } + + var entry = new Entry { Atlas = atlas, Layer = layer, Bytes = bytes }; + _entries.Add(key, entry); + atlas.EntryCount++; + atlas.LastUseSequence = ++_useSequence; + _owners.Acquire(ownerLocalId, key); + _frameUploadCount++; + _frameUploadBytes = checked(_frameUploadBytes + bytes); + location = new BindlessTextureLocation(atlas.Resource.Handle, checked((uint)layer)); + return true; + } + + public void ReleaseOwner(uint ownerLocalId) + { + ThrowIfUnavailable(); + IReadOnlyList unowned = _owners.ReleaseOwner(ownerLocalId); + for (int i = 0; i < unowned.Count; i++) + { + CompositeTextureKey key = unowned[i]; + if (_entries.TryGetValue(key, out Entry? entry)) + _unowned.MarkUnowned(key, entry.Bytes); + } + } + + /// + /// Logical layer eviction is a bounded CPU-only batch; its fenced callback + /// merely returns integer slots. At most one already-empty GL array becomes + /// non-resident and is deleted per frame, so a portal unload cannot become + /// one large driver destruction batch. + /// + public void Tick() + { + ThrowIfUnavailable(); + _retirementLedger.RetryPendingPublications(); + + bool allocationPressure = HasPendingAllocationPressure(); + bool physicalOverBudget = _allocatedBytes > _physicalBudgetBytes; + bool needsPhysicalRelief = allocationPressure || physicalOverBudget; + + // Finish an already-started release before selecting another array. + // This keeps driver-visible destruction bounded to one array per tick + // even when a prior non-resident/delete stage had to be retried. + bool servicedPendingRelease = CompleteOnePendingAtlasRelease(); + + // A fence may have made an array safe since the prior frame. Free one + // first; this is the only driver-visible destruction operation here. + if (needsPhysicalRelief && !servicedPendingRelease) + DeleteOneGpuSafeEmptyAtlas( + _pendingAtlasAllocationBytes == 0 ? null : (_pendingAtlasWidth, _pendingAtlasHeight)); + + allocationPressure = HasPendingAllocationPressure(); + physicalOverBudget = _allocatedBytes > _physicalBudgetBytes; + needsPhysicalRelief = allocationPressure || physicalOverBudget; + + int evicted = 0; + if (needsPhysicalRelief && _pendingAtlasAllocationBytes != 0) + { + evicted += EvictCompatibleUnowned( + _pendingAtlasWidth, + _pendingAtlasHeight, + MaximumLogicalEvictionsPerFrame); + } + + while (evicted < MaximumLogicalEvictionsPerFrame) + { + bool take = needsPhysicalRelief + ? _unowned.TryTakeOldest(out CompositeTextureKey key) + : _unowned.TryTakeOldestOverBudget(out key); + if (!take) + break; + EvictEntry(key); + evicted++; + } + + // Allocation pressure is a one-frame demand signal. The requesting + // entity will set it again later this frame if it is still relevant; + // stale portal destinations must not keep evicting unrelated storage. + _pendingAtlasWidth = 0; + _pendingAtlasHeight = 0; + _pendingAtlasAllocationBytes = 0; + } + + internal void VisitEntries(Action visitor) + { + ArgumentNullException.ThrowIfNull(visitor); + foreach ((CompositeTextureKey key, Entry entry) in _entries) + visitor(key.SurfaceId, entry.Atlas.Resource.Width, entry.Atlas.Resource.Height); + } + + internal static int CalculateLayerCapacity(int width, int height, int driverMaximumLayers) + { + ArgumentOutOfRangeException.ThrowIfLessThan(width, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(height, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(driverMaximumLayers, 1); + + long layerBytes = checked((long)width * height * 4L); + long targetLayers = Math.Max(1L, TargetArrayBytes / layerBytes); + return checked((int)Math.Min( + targetLayers, + Math.Min(driverMaximumLayers, MaximumLayersPerArray))); + } + + private bool TryFindOrCreateAtlas(int width, int height, out Atlas atlas) + { + var size = (width, height); + if (_atlasesBySize.TryGetValue(size, out List? compatible)) + { + for (int i = 0; i < compatible.Count; i++) + { + Atlas candidate = compatible[i]; + if (candidate.IsReusable && candidate.AvailableLayers != 0) + { + ClearPendingAllocation(width, height); + atlas = candidate; + return true; + } + } + } + + int capacity = CalculateLayerCapacity(width, height, _maximumArrayLayers); + long requestedBytes = checked((long)width * height * 4L * capacity); + if (_frameAtlasCreationCount >= MaximumAtlasCreationsPerFrame + || !CanAllocateAtlas(requestedBytes)) + { + SetPendingAllocation(width, height, requestedBytes); + atlas = null!; + return false; + } + + CompositeTextureArrayResource resource = _backend.Create(width, height, capacity); + atlas = new Atlas + { + Resource = resource, + Slots = new Wb.TextureAtlasSlotAllocator(capacity), + }; + if (compatible is null) + { + compatible = new List(); + _atlasesBySize.Add(size, compatible); + } + compatible.Add(atlas); + _atlases.Add(atlas); + _allocatedBytes = checked(_allocatedBytes + resource.Bytes); + _frameAtlasCreationCount++; + ClearPendingAllocation(width, height); + return true; + } + + private bool CanAllocateAtlas(long requestedBytes) + { + if (FitsWithinPhysicalBudget(requestedBytes)) + return true; + + // The budget bounds reusable/cache storage, not required live scene + // content. Wait while stale or retiring storage can make room; if the + // entire resident set is live, permit one new atlas this frame so an + // unusually large destination cannot deadlock portal readiness. + return !HasReclaimableStorage(); + } + + private bool FitsWithinPhysicalBudget(long requestedBytes) + { + if (requestedBytes > _physicalBudgetBytes) + return _allocatedBytes == 0; + return _allocatedBytes <= _physicalBudgetBytes - requestedBytes; + } + + private bool HasPendingAllocationPressure() => + _pendingAtlasAllocationBytes != 0 + && !FitsWithinPhysicalBudget(_pendingAtlasAllocationBytes); + + private bool HasReclaimableStorage() + { + if (_unowned.Count != 0) + return true; + for (int i = 0; i < _atlases.Count; i++) + { + Atlas candidate = _atlases[i]; + if (!candidate.Deleted + && (candidate.ReleaseRequested + || candidate.PendingRetirements != 0 + || candidate.IsGpuSafeEmpty)) + { + return true; + } + } + return false; + } + + private void SetPendingAllocation(int width, int height, long bytes) + { + _pendingAtlasWidth = width; + _pendingAtlasHeight = height; + _pendingAtlasAllocationBytes = bytes; + } + + private void ClearPendingAllocation(int width, int height) + { + if (_pendingAtlasWidth != width || _pendingAtlasHeight != height) + return; + _pendingAtlasWidth = 0; + _pendingAtlasHeight = 0; + _pendingAtlasAllocationBytes = 0; + } + + private int EvictCompatibleUnowned(int width, int height, int maximum) + { + _evictionScratch.Clear(); + foreach ((CompositeTextureKey key, Entry entry) in _entries) + { + if (_evictionScratch.Count == maximum) + break; + if (entry.Atlas.Resource.Width == width + && entry.Atlas.Resource.Height == height + && _unowned.Contains(key)) + { + _evictionScratch.Add(key); + } + } + + int evicted = 0; + for (int i = 0; i < _evictionScratch.Count; i++) + { + CompositeTextureKey key = _evictionScratch[i]; + if (!_unowned.TryTake(key)) + continue; + EvictEntry(key); + evicted++; + } + return evicted; + } + + private void EvictEntry(CompositeTextureKey key) + { + if (!_entries.Remove(key, out Entry? entry)) + return; + + Atlas atlas = entry.Atlas; + atlas.EntryCount--; + atlas.PendingRetirements++; + int layer = entry.Layer; + _retirementLedger.Retire(new RetryableGpuResourceRelease( + () => atlas.Slots.Return(layer), + () => atlas.PendingRetirements--)); + } + + private bool CompleteOnePendingAtlasRelease() + { + for (int i = 0; i < _atlases.Count; i++) + { + Atlas atlas = _atlases[i]; + if (!atlas.ReleaseRequested) + continue; + DeleteAtlas(atlas); + return true; + } + return false; + } + + private void DeleteOneGpuSafeEmptyAtlas((int Width, int Height)? preserveSize = null) + { + Atlas? oldest = null; + for (int i = 0; i < _atlases.Count; i++) + { + Atlas candidate = _atlases[i]; + if (preserveSize is { } preserve + && candidate.Resource.Width == preserve.Width + && candidate.Resource.Height == preserve.Height) + { + continue; + } + if (candidate.IsReusable + && candidate.IsGpuSafeEmpty + && (oldest is null || candidate.LastUseSequence < oldest.LastUseSequence)) + { + oldest = candidate; + } + } + + if (oldest is not null) + DeleteAtlas(oldest); + } + + private void DeleteAtlas(Atlas atlas, bool requireGpuSafeEmpty = true) + { + if (atlas.ReleaseStage == AtlasReleaseStage.Accounted) + return; + if (requireGpuSafeEmpty && !atlas.IsGpuSafeEmpty) + throw new InvalidOperationException("Cannot delete a composite array while a layer is live or retiring."); + + atlas.ReleaseRequested = true; + if (atlas.ReleaseStage == AtlasReleaseStage.Resident) + { + try + { + _backend.MakeNonResident(atlas.Resource); + atlas.ReleaseStage = AtlasReleaseStage.NonResident; + RemoveFromReusableAtlasIndex(atlas); + } + catch (GpuResourceMutationException error) when (error.MutationCommitted) + { + atlas.ReleaseStage = AtlasReleaseStage.NonResident; + RemoveFromReusableAtlasIndex(atlas); + throw; + } + } + if (atlas.ReleaseStage == AtlasReleaseStage.NonResident) + { + try + { + _backend.Delete(atlas.Resource); + atlas.ReleaseStage = AtlasReleaseStage.Deleted; + } + catch (GpuResourceMutationException error) when (error.MutationCommitted) + { + atlas.ReleaseStage = AtlasReleaseStage.Deleted; + throw; + } + } + if (atlas.ReleaseStage == AtlasReleaseStage.Deleted) + { + _allocatedBytes = checked(_allocatedBytes - atlas.Resource.Bytes); + _atlases.Remove(atlas); + atlas.ReleaseStage = AtlasReleaseStage.Accounted; + } + } + + private void RevokeAtlasResidencyForDispose(Atlas atlas) + { + atlas.ReleaseRequested = true; + if (atlas.ReleaseStage != AtlasReleaseStage.Resident) + return; + try + { + _backend.MakeNonResident(atlas.Resource); + atlas.ReleaseStage = AtlasReleaseStage.NonResident; + RemoveFromReusableAtlasIndex(atlas); + } + catch (GpuResourceMutationException error) when (error.MutationCommitted) + { + atlas.ReleaseStage = AtlasReleaseStage.NonResident; + RemoveFromReusableAtlasIndex(atlas); + throw; + } + } + + private void RemoveFromReusableAtlasIndex(Atlas atlas) + { + var size = (atlas.Resource.Width, atlas.Resource.Height); + if (!_atlasesBySize.TryGetValue(size, out List? compatible)) + return; + compatible.Remove(atlas); + if (compatible.Count == 0) + _atlasesBySize.Remove(size); + } + + private static void ValidateDecodedTexture(DecodedTexture decoded) + { + ArgumentOutOfRangeException.ThrowIfLessThan(decoded.Width, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(decoded.Height, 1); + long expected = checked((long)decoded.Width * decoded.Height * 4L); + if (decoded.Rgba8.LongLength != expected) + throw new ArgumentException( + $"Decoded RGBA texture has {decoded.Rgba8.LongLength} bytes; expected {expected}.", + nameof(decoded)); + } + + public void Dispose() + { + if (_disposed) + return; + _disposeRequested = true; + _retirementLedger.RetryPendingPublications(); + + // GameWindow drains frame-flight fences before TextureCache teardown. + // Release every handle first, then delete any backing array. A failed + // stage leaves its exact atlas/stage reachable for a later Dispose. + List? failures = null; + for (int i = 0; i < _atlases.Count; i++) + { + Atlas atlas = _atlases[i]; + try { RevokeAtlasResidencyForDispose(atlas); } + catch (Exception ex) { (failures ??= []).Add(ex); } + } + if (failures is not null) + throw new AggregateException("One or more composite-array residency releases failed.", failures); + + // DeleteAtlas removes completed entries, so walk a stable snapshot. + Atlas[] atlases = _atlases.ToArray(); + for (int i = 0; i < atlases.Length; i++) + { + try { DeleteAtlas(atlases[i], requireGpuSafeEmpty: false); } + catch (Exception ex) { (failures ??= []).Add(ex); } + } + if (failures is not null) + throw new AggregateException("One or more composite-array deletions failed.", failures); + + _entries.Clear(); + _owners.Clear(); + _unowned.Clear(); + _atlasesBySize.Clear(); + _atlases.Clear(); + _allocatedBytes = 0; + _pendingAtlasAllocationBytes = 0; + _disposed = true; + } + + private void ThrowIfUnavailable() => + ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this); +} diff --git a/src/AcDream.App/Rendering/DynamicBufferCapacity.cs b/src/AcDream.App/Rendering/DynamicBufferCapacity.cs new file mode 100644 index 00000000..ea0d035b --- /dev/null +++ b/src/AcDream.App/Rendering/DynamicBufferCapacity.cs @@ -0,0 +1,30 @@ +namespace AcDream.App.Rendering; + +/// +/// Capacity policy for render-thread-owned streaming GPU buffers. Dynamic +/// buffers keep one backing allocation and update its active prefix; they do +/// not orphan a new driver allocation on every draw call. +/// +internal static class DynamicBufferCapacity +{ + internal const int DefaultAlignment = 4096; + + public static int Grow(int currentBytes, int requiredBytes, int alignment = DefaultAlignment) + { + if (currentBytes < 0) + throw new ArgumentOutOfRangeException(nameof(currentBytes)); + if (requiredBytes < 0) + throw new ArgumentOutOfRangeException(nameof(requiredBytes)); + if (alignment <= 0) + throw new ArgumentOutOfRangeException(nameof(alignment)); + if (requiredBytes <= currentBytes) + return currentBytes; + + long doubled = currentBytes == 0 ? alignment : (long)currentBytes * 2L; + long target = Math.Max(requiredBytes, doubled); + long aligned = ((target + alignment - 1L) / alignment) * alignment; + if (aligned > int.MaxValue) + throw new OverflowException("Dynamic GPU buffer capacity exceeds Int32.MaxValue."); + return (int)aligned; + } +} diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index 093966d4..1e63cae8 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -8,6 +8,7 @@ using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; using DatReaderWriter; +using AcDream.Content; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; @@ -23,7 +24,7 @@ namespace AcDream.App.Rendering; /// public sealed class EquippedChildRenderController : IDisposable { - private readonly DatCollection _dats; + private readonly IDatReaderWriter _dats; private readonly object _datLock; private readonly ClientObjectTable _objects; private readonly LiveEntityRuntime _liveEntities; @@ -53,7 +54,7 @@ public sealed class EquippedChildRenderController : IDisposable } public EquippedChildRenderController( - DatCollection dats, + IDatReaderWriter dats, object datLock, ClientObjectTable objects, LiveEntityRuntime liveEntities, diff --git a/src/AcDream.App/Rendering/FixedEntityTextureOwnerLease.cs b/src/AcDream.App/Rendering/FixedEntityTextureOwnerLease.cs new file mode 100644 index 00000000..89e3457a --- /dev/null +++ b/src/AcDream.App/Rendering/FixedEntityTextureOwnerLease.cs @@ -0,0 +1,31 @@ +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Rendering; + +/// +/// Balances one synthetic render owner's texture lifetime across replacement. +/// The next draw reacquires only the replacement entity's current materials. +/// +internal sealed class FixedEntityTextureOwnerLease : IDisposable +{ + private readonly IEntityTextureLifetime _lifetime; + private readonly uint _ownerLocalId; + private bool _active; + + public FixedEntityTextureOwnerLease(IEntityTextureLifetime lifetime, uint ownerLocalId) + { + _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); + if (ownerLocalId == 0) + throw new ArgumentOutOfRangeException(nameof(ownerLocalId)); + _ownerLocalId = ownerLocalId; + } + + public void Replace(bool hasReplacement) + { + if (_active) + _lifetime.ReleaseOwner(_ownerLocalId); + _active = hasReplacement; + } + + public void Dispose() => Replace(hasReplacement: false); +} diff --git a/src/AcDream.App/Rendering/FramePacingController.cs b/src/AcDream.App/Rendering/FramePacingController.cs new file mode 100644 index 00000000..4f5e04ba --- /dev/null +++ b/src/AcDream.App/Rendering/FramePacingController.cs @@ -0,0 +1,155 @@ +using System.Diagnostics; + +namespace AcDream.App.Rendering; + +/// +/// Deadline-based software frame pacer used only when the user disables +/// VSync. VSync-on presentation waits in the driver's buffer swap instead. +/// +/// +/// Silk.NET's FramesPerSecond gate still runs its outer loop as a busy +/// poll. This owner performs a real thread wait, bounding both render work and +/// CPU use. A missed deadline is rebased from the current time so one slow +/// frame cannot trigger a burst of catch-up frames. +/// +internal sealed class FramePacingController +{ + private readonly IFramePacingClock _clock; + private readonly IFramePacingWaiter _waiter; + + private FramePacingPolicy _policy; + private long _periodTicks; + private long _nextDeadline; + private bool _hasDeadline; + + public FramePacingController() + : this(StopwatchFramePacingClock.Instance, ThreadFramePacingWaiter.Instance) + { + } + + internal FramePacingController( + IFramePacingClock clock, + IFramePacingWaiter waiter) + { + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _waiter = waiter ?? throw new ArgumentNullException(nameof(waiter)); + if (_clock.Frequency <= 0) + throw new ArgumentOutOfRangeException(nameof(clock), "Clock frequency must be positive."); + } + + internal FramePacingPolicy Policy => _policy; + + public void Apply(FramePacingPolicy policy) + { + if (_policy == policy) + return; + + _policy = policy; + if (policy.UseVSync + || policy.SoftwareLimitHz is not { } limitHz + || !double.IsFinite(limitHz) + || limitHz <= 0d) + { + _periodTicks = 0; + _nextDeadline = 0; + _hasDeadline = false; + return; + } + + _periodTicks = Math.Max( + 1L, + checked((long)Math.Round(_clock.Frequency / limitHz))); + _nextDeadline = AddSaturating(_clock.GetTimestamp(), _periodTicks); + _hasDeadline = true; + } + + /// + /// Wait until the current frame's presentation deadline. GameWindow wires + /// this after its render callback and before Silk.NET's automatic swap. + /// + public void CompleteFrame() + { + if (!_hasDeadline || _periodTicks <= 0) + return; + + long deadline = _nextDeadline; + long now = _clock.GetTimestamp(); + + if (now < deadline) + { + do + { + long remaining = deadline - now; + _waiter.Wait(remaining, _clock.Frequency); + now = _clock.GetTimestamp(); + } + while (now < deadline); + + long followingDeadline = AddSaturating(deadline, _periodTicks); + _nextDeadline = followingDeadline > now + ? followingDeadline + : AddSaturating(now, _periodTicks); + return; + } + + // The frame reached or missed its deadline without waiting. Rebase + // from now instead of advancing through old deadlines: there is never + // an immediate catch-up frame after a stall or long portal load. + _nextDeadline = AddSaturating(now, _periodTicks); + } + + private static long AddSaturating(long value, long increment) + => value > long.MaxValue - increment + ? long.MaxValue + : value + increment; +} + +internal interface IFramePacingClock +{ + long Frequency { get; } + + long GetTimestamp(); +} + +internal interface IFramePacingWaiter +{ + void Wait(long durationTicks, long clockFrequency); +} + +internal sealed class StopwatchFramePacingClock : IFramePacingClock +{ + public static StopwatchFramePacingClock Instance { get; } = new(); + + private StopwatchFramePacingClock() + { + } + + public long Frequency => Stopwatch.Frequency; + + public long GetTimestamp() => Stopwatch.GetTimestamp(); +} + +internal sealed class ThreadFramePacingWaiter : IFramePacingWaiter +{ + public static ThreadFramePacingWaiter Instance { get; } = new(); + + private ThreadFramePacingWaiter() + { + } + + public void Wait(long durationTicks, long clockFrequency) + { + if (durationTicks <= 0 || clockFrequency <= 0) + return; + + double milliseconds = durationTicks * 1000d / clockFrequency; + + // A kernel sleep is intentionally preferred over a final spin. It can + // overshoot a presentation deadline slightly, but it keeps normal CPU + // use low—the central reason this fallback exists. + int sleepMilliseconds = Math.Max( + 1, + (int)Math.Ceiling(Math.Min(milliseconds, int.MaxValue))); + Thread.Sleep(sleepMilliseconds); + } +} diff --git a/src/AcDream.App/Rendering/FramePacingPolicy.cs b/src/AcDream.App/Rendering/FramePacingPolicy.cs new file mode 100644 index 00000000..c8587f63 --- /dev/null +++ b/src/AcDream.App/Rendering/FramePacingPolicy.cs @@ -0,0 +1,31 @@ +namespace AcDream.App.Rendering; + +/// +/// Resolves the user's presentation preference into a bounded render policy. +/// A normal client is never uncapped: disabling VSync selects a software +/// ceiling at the active monitor's refresh rate. Only the explicit startup +/// diagnostic may disable both mechanisms. +/// +internal readonly record struct FramePacingPolicy( + bool UseVSync, + double? SoftwareLimitHz) +{ + internal const double FallbackRefreshHz = 60d; + + public static FramePacingPolicy Resolve( + bool requestedVSync, + bool uncappedRendering, + int? monitorRefreshHz) + { + if (uncappedRendering) + return new FramePacingPolicy(UseVSync: false, SoftwareLimitHz: null); + + if (requestedVSync) + return new FramePacingPolicy(UseVSync: true, SoftwareLimitHz: null); + + double refreshHz = monitorRefreshHz is > 0 + ? monitorRefreshHz.Value + : FallbackRefreshHz; + return new FramePacingPolicy(UseVSync: false, SoftwareLimitHz: refreshHz); + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ab1d9862..b9d9e40a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1,7 +1,7 @@ using AcDream.Core.Plugins; using AcDream.App.World; +using AcDream.Content; using DatReaderWriter; -using DatReaderWriter.Options; using Silk.NET.Input; using Silk.NET.Maths; using Silk.NET.OpenGL; @@ -31,7 +31,7 @@ public sealed class GameWindow : IDisposable private Shader? _terrainModernShader; private CameraController? _cameraController; private IMouse? _capturedMouse; - private DatCollection? _dats; + private IDatReaderWriter? _dats; private float _lastMouseX; private float _lastMouseY; private Shader? _meshShader; @@ -133,10 +133,17 @@ public sealed class GameWindow : IDisposable // per OnRender + three stage scopes. All logic lives in // AcDream.App.Diagnostics.FrameProfiler (structure rule 1). private readonly AcDream.App.Diagnostics.FrameProfiler _frameProfiler = new(); + private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; + private ResourceShutdownTransaction? _shutdown; + private readonly FramePacingController _framePacing = new(); + private bool _requestedVSync = + AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default.VSync; + private int? _activeMonitorRefreshHz; // Phase A.1: streaming fields replacing the one-shot _entities list. private AcDream.App.Streaming.LandblockStreamer? _streamer; private AcDream.App.Streaming.GpuWorldState _worldState = new(); + private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements; private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer; private AcDream.App.Streaming.StreamingController? _streamingController; private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback) @@ -247,6 +254,7 @@ public sealed class GameWindow : IDisposable private readonly List _outdoorNodeBuildingCells = new(); private readonly HashSet _outdoorSceneParticleEntityIds = new(); private readonly HashSet _visibleSceneParticleEntityIds = new(); + private readonly HashSet _noSceneParticleEntityIds = new(); private string? _lastRenderSignature; private int _renderSignatureFrame; private int _renderSignatureStableFrames; @@ -1237,7 +1245,12 @@ public sealed class GameWindow : IDisposable hidden)); _liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( AdvanceLiveObjectRuntime, - () => _liveSessionController?.Tick(), + () => + { + using AcDream.App.Streaming.GpuWorldState.MutationBatch spatialBatch = + _worldState.BeginMutationBatch(); + _liveSessionController?.Tick(); + }, _localPlayerFrame.RunPostNetworkCommandPhase, ReconcileLiveObjectSpatialPresentation); } @@ -1253,6 +1266,12 @@ public sealed class GameWindow : IDisposable var startupDisplay = startupStore.LoadDisplay(); var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality); var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase); + _requestedVSync = startupDisplay.VSync; + FramePacingPolicy startupPacing = FramePacingPolicy.Resolve( + startupDisplay.VSync, + _options.UncappedRendering, + monitorRefreshHz: null); + _framePacing.Apply(startupPacing); var options = WindowOptions.Default with { Size = new Vector2D(1280, 720), @@ -1262,7 +1281,7 @@ public sealed class GameWindow : IDisposable ContextProfile.Core, ContextFlags.ForwardCompatible, new APIVersion(4, 3)), - VSync = false, // off during development so the perf overlay shows true framerate + VSync = startupPacing.UseVSync, // A.5 T22.5: MSAA from quality preset (0 = disabled, 2/4/8 = multisample). // Silk.NET passes this to SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES). // Cannot be changed at runtime; Quality changes mid-session that would @@ -1279,8 +1298,13 @@ public sealed class GameWindow : IDisposable _window.Load += OnLoad; _window.Update += OnUpdate; _window.Render += OnRender; + // Registered after OnRender so software pacing waits after all frame + // work and before Silk.NET performs its automatic buffer swap. + _window.Render += OnFrameRendered; _window.Closing += OnClosing; _window.FocusChanged += OnFocusChanged; + _window.Move += OnWindowMoved; + _window.StateChanged += OnWindowStateChanged; // L.0 Display tab: keep the GL viewport + camera aspect in sync // with the window framebuffer. Without this handler, resizing // the window (or applying a Display-tab Resolution change at @@ -1298,6 +1322,7 @@ public sealed class GameWindow : IDisposable _physicsEngine.DataCache = _physicsDataCache; _gl = GL.GetApi(_window!); + _gpuFrameFlights = new AcDream.App.Rendering.GpuFrameFlightController(_gl); _input = _window!.CreateInput(); // Phase K.1b — every keyboard/mouse handler routes through the @@ -1534,7 +1559,7 @@ public sealed class GameWindow : IDisposable _cameraController = new CameraController(orbit, fly); _cameraController.ModeChanged += OnCameraModeChanged; - _dats = new DatCollection(_datDir, DatAccessType.Read); + _dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir); _magicCatalog = AcDream.App.Spells.MagicCatalog.Load(_dats); SpellBook.InstallMetadata(_magicCatalog.SpellTable); Console.WriteLine($"spells: loaded {SpellTable.Count} entries from portal.dat"); @@ -2000,7 +2025,12 @@ public sealed class GameWindow : IDisposable // (4x) and Medium (8x) actually take effect. terrainAtlas.SetAnisotropic(_resolvedQuality.AnisotropicLevel); - _terrain = new TerrainModernRenderer(_gl, _bindlessSupport, _terrainModernShader!, terrainAtlas); + _terrain = new TerrainModernRenderer( + _gl, + _bindlessSupport, + _terrainModernShader!, + terrainAtlas, + _gpuFrameFlights!); int centerX = (int)((centerLandblockId >> 24) & 0xFFu); int centerY = (int)((centerLandblockId >> 16) & 0xFFu); @@ -2039,7 +2069,7 @@ public sealed class GameWindow : IDisposable Path.Combine(shadersDir, "mesh_modern.frag")); Console.WriteLine("[N.5] mesh_modern shader loaded"); - _textureCache = new TextureCache(_gl, _dats, _bindlessSupport); + _textureCache = new TextureCache(_gl, _dats, _bindlessSupport, _gpuFrameFlights!); // Two persistent GL sampler objects (Repeat + ClampToEdge) so // the sky pass can pick wrap mode per submesh without mutating // shared per-texture wrap state. See SamplerCache + the @@ -2223,7 +2253,8 @@ public sealed class GameWindow : IDisposable selectedGuid: () => _selection.SelectedObjectId, coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar, uiLocked: () => _persistedGameplay.LockUI, - playerEntities: () => _entitiesByServerGuid); + playerEntities: () => _entitiesByServerGuid, + copySpatialCandidates: _worldState.CopyLiveEntitiesNearLandblock); var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); _retailChatVm = retailChatVm; AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null @@ -2394,7 +2425,11 @@ public sealed class GameWindow : IDisposable // is shared with the rest of the client. { var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; - _wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(_gl, _dats, wbLogger); + _wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter( + _gl, + _dats, + wbLogger, + _gpuFrameFlights!); Console.WriteLine("[N.4+N.5] WB foundation + modern path active — routing all content through ObjectMeshManager."); } @@ -2510,28 +2545,27 @@ public sealed class GameWindow : IDisposable wbSpawnAdapter, onLandblockUnloaded: _classificationCache.InvalidateLandblock, entityScriptActivator: entityScriptActivator); + _landblockRetirements = + new AcDream.App.Streaming.LandblockRetirementCoordinator( + _worldState, + AdvanceLandblockPresentationRetirement); _liveEntities = new AcDream.App.World.LiveEntityRuntime( _worldState, - new AcDream.App.World.DelegateLiveEntityResourceLifecycle( - entity => - { - wbEntitySpawnAdapter.OnCreate(entity); - entityScriptActivator.OnCreate(entity); - }, - entity => - { - try - { - entityScriptActivator.OnRemove(entity); - } - finally - { - wbEntitySpawnAdapter.OnRemove(entity.ServerGuid); - } - }), + new AcDream.App.World.CompositeLiveEntityResourceLifecycle( + new( + entity => wbEntitySpawnAdapter.OnCreate(entity), + entity => wbEntitySpawnAdapter.OnRemove(entity)), + new( + entityScriptActivator.OnCreate, + entityScriptActivator.OnRemove)), TearDownLiveEntityRuntimeComponents); _liveEntities.ProjectionVisibilityChanged += (record, visible) => + { + if (record.WorldEntity is { } entity) + wbEntitySpawnAdapter.SetPresentationResident(entity, visible); + }; + _liveEntities.ProjectionVisibilityChanged += (record, visible) => { if (record.WorldEntity is { } entity) _particleSink!.SetEntityPresentationVisible(entity.Id, visible); @@ -2645,7 +2679,7 @@ public sealed class GameWindow : IDisposable && _sceneLightingUbo is not null) { _paperdollViewportRenderer = new AcDream.App.Rendering.PaperdollViewportRenderer( - _gl, _wbDrawDispatcher, _sceneLightingUbo); + _gl, _wbDrawDispatcher, _sceneLightingUbo, _textureCache!); paperdollViewport.Renderer = _paperdollViewportRenderer; } @@ -2672,6 +2706,10 @@ public sealed class GameWindow : IDisposable _wbDrawDispatcher, _sceneLightingUbo!, _wbMeshAdapter!); + // Publish the presentation before touching the mesh-reference + // backend. Partial acquisition remains reachable by staged + // shutdown instead of becoming constructor-local leaked state. + _portalTunnel.PrepareResources(); } // Phase G.1 sky renderer — its own shader (sky.vert / sky.frag) @@ -2746,33 +2784,12 @@ public sealed class GameWindow : IDisposable nearRadius: _nearRadius, farRadius: _farRadius, clearPendingLoads: _streamer.ClearPendingLoads, - removeTerrain: id => - { - // Phase G.2: release any LightSources attached to entities - // in this landblock before their records disappear from - // _worldState — otherwise the LightManager accumulates - // stale entries for every walk across a landblock boundary. - if (_lightingSink is not null && - _worldState.TryGetLandblock(id, out var lb)) - { - foreach (var ent in lb!.Entities) - { - _lightingSink.UnregisterOwner(ent.Id); - _translucencyFades.ClearEntity(ent.Id); // #188 - } - } - _terrain?.RemoveLandblock(id); - _physicsEngine.RemoveLandblock(id); - _cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu); - _buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28) - _envCellRenderer?.RemoveLandblock(id); // Phase A8 - }, - demoteNearLayer: DemoteLoadedLandblockToTerrain, // #138: restore retained server objects when a landblock reloads // (dungeon-exit expand or Far→Near promote). ACE won't re-send the // objects it thinks we still know, so we re-project them ourselves. onLandblockLoaded: RehydrateServerEntitiesForLandblock, - ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin); + ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin, + retirementCoordinator: _landblockRetirements); // A.5 T22.5: apply max-completions from resolved quality. _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; @@ -3820,9 +3837,8 @@ public sealed class GameWindow : IDisposable // SurfaceTexture. So we have to resolve each Surface → OrigTextureId, // match that against the part's oldSurfaceTextureId set, and build // a new dict keyed by Surface id → replacement OrigTextureId. The - // renderer then calls TextureCache.GetOrUploadWithOrigTextureOverride - // to get a texture decoded with the replacement SurfaceTexture - // substituted inside the Surface's decode chain. + // renderer then resolves an owner-scoped texture with the replacement + // SurfaceTexture substituted inside the Surface's decode chain. var textureChanges = spawn.TextureChanges ?? Array.Empty(); if (dumpClothing) { @@ -4041,35 +4057,50 @@ public sealed class GameWindow : IDisposable if (appearanceUpdate is { } visualUpdate) { AcDream.Core.World.WorldEntity existing = visualUpdate.Entity; - existing.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides); - existing.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable); - if (liveBounds.TryGet(out var appearanceMin, out var appearanceMax)) - existing.SetLocalBounds(appearanceMin, appearanceMax); - if (visualUpdate.Animation is { } animation) - RebindAnimatedEntityForAppearance( - animation, - existing, - setup, - scale, - animatedPartTemplate, - indexedPartAvailable); - _classificationCache.InvalidateEntity(existing.Id); - if (_liveEntities!.TryGetRecord(spawn.Guid, out LiveEntityRecord record) - && record.ProjectionKind is LiveEntityProjectionKind.Attached) - { - // The attachment controller composes child-local parts through - // the parent's current animated pose. Re-run that composition - // after replacing the child's visual description. - _equippedChildRenderer?.OnSpawn(spawn); - } - else - { - // SmartBox::UpdateVisualDesc mutates the existing PartArray. - // Publish those exact replacement part frames before another - // packet in this update can execute a part-attached hook. - _effectPoses.PublishMeshRefs(existing); - _equippedChildRenderer?.OnPosePublished(spawn.Guid); - } + _wbEntitySpawnAdapter!.OnAppearanceChanged( + existing, + meshRefs, + entityPartOverrides, + () => + { + // Retail CSurface::SetTextureAndPalette (0x00535FB0) + // releases the prior combined ImgTex before installing the + // replacement. Mesh references for the replacement have + // already been acquired before this publication callback. + _textureCache!.ReleaseOwner(existing.Id); + existing.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides); + }, + () => + { + existing.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable); + if (liveBounds.TryGet(out var appearanceMin, out var appearanceMax)) + existing.SetLocalBounds(appearanceMin, appearanceMax); + if (visualUpdate.Animation is { } animation) + RebindAnimatedEntityForAppearance( + animation, + existing, + setup, + scale, + animatedPartTemplate, + indexedPartAvailable); + _classificationCache.InvalidateEntity(existing.Id); + if (_liveEntities!.TryGetRecord(spawn.Guid, out LiveEntityRecord record) + && record.ProjectionKind is LiveEntityProjectionKind.Attached) + { + // The attachment controller composes child-local parts + // through the parent's current animated pose. Re-run + // that composition after replacing the child visual. + _equippedChildRenderer?.OnSpawn(spawn); + } + else + { + // SmartBox::UpdateVisualDesc mutates the existing + // PartArray. Publish those exact replacement part frames + // before another packet can execute an attached hook. + _effectPoses.PublishMeshRefs(existing); + _equippedChildRenderer?.OnPosePublished(spawn.Guid); + } + }); return; } @@ -4135,6 +4166,7 @@ public sealed class GameWindow : IDisposable Position: entity.Position, Rotation: entity.Rotation); _worldGameState.Add(snapshot); + _worldEvents.UpsertCurrent(snapshot); if (_liveEntities!.TryMarkWorldSpawnPublished(spawn.Guid)) _worldEvents.FireEntitySpawned(snapshot); @@ -4514,7 +4546,7 @@ public sealed class GameWindow : IDisposable { var dats = _dats; if (dats is null) return 0u; - uint masterDid = (uint)dats.Portal.Header.MasterMapId; + uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId; if (masterDid == 0) return 0u; if (!dats.Portal.TryGet(masterDid, out var master)) return 0u; // DBCache::GetDIDFromEnum (decomp 20380) resolves master[arg4] → submap, then submap[arg3]. @@ -5210,22 +5242,38 @@ public sealed class GameWindow : IDisposable } private void TearDownLiveEntityRuntimeComponents(LiveEntityRecord record) + { + record.RuntimeComponentTeardownPlan ??= CreateLiveEntityRuntimeTeardownPlan(record); + record.RuntimeComponentTeardownPlan.Advance(); + } + + private AcDream.App.World.LiveEntityTeardownPlan CreateLiveEntityRuntimeTeardownPlan( + LiveEntityRecord record) { uint serverGuid = record.ServerGuid; + var incarnation = new AcDream.App.World.LiveEntityIncarnationCleanup( + record, + guid => _liveEntities?.TryGetRecord(guid, out LiveEntityRecord current) == true + ? current + : null); var cleanups = new List { () => _liveEntityPresentation?.Forget(record), () => _entityEffects?.OnLiveEntityUnregistered(record), () => _remoteTeleportController?.Forget(record), + () => incarnation.RunIfNoReplacement(() => + { + if (_pendingPostArrivalAction is { Guid: var pendingGuid } + && pendingGuid == serverGuid) + { + _pendingPostArrivalAction = null; + } + }), }; - if (_pendingPostArrivalAction is { Guid: var pendingGuid } - && pendingGuid == serverGuid) - _pendingPostArrivalAction = null; if (record.WorldEntity is not { } existingEntity) { - AcDream.App.World.LiveEntityTeardown.Run(cleanups); - return; + return new AcDream.App.World.LiveEntityTeardownPlan(cleanups); } // R2-Q5 + R3-W2: retail runs BOTH layers' exit-world drains @@ -5236,7 +5284,8 @@ public sealed class GameWindow : IDisposable // relay: interp ONLY → CMotionInterp::HandleExitWorld 0x00527f30). if (_animatedEntities.TryGetValue(existingEntity.Id, out var aeGone)) cleanups.Add(() => aeGone.Sequencer?.Manager.HandleExitWorld()); - if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmGone)) + RemoteMotion? rmGone = record.RemoteMotionRuntime as RemoteMotion; + if (rmGone is not null) cleanups.Add(rmGone.Movement.HandleExitWorld); // R5-V2: retail CPhysicsObj::exit_world tells every voyeur of this // entity that it left the world (TargetManager::NotifyVoyeurOfEvent @@ -5244,8 +5293,16 @@ public sealed class GameWindow : IDisposable // moveto/stick. This is the ONLY clean-up for a watcher whose target // already sent an Ok (once status != Undefined the 10 s staleness // timeout never fires), so it must run before the host is pruned. - if (_physicsHosts.TryGetValue(serverGuid, out var hostGone) - && hostGone is EntityPhysicsHost ephGone) + EntityPhysicsHost? ephGone = rmGone?.Host; + if (ephGone is null) + { + incarnation.RunIfNoReplacement(() => + { + if (_physicsHosts.TryGetValue(serverGuid, out var hostGone)) + ephGone = hostGone as EntityPhysicsHost; + }); + } + if (ephGone is not null) { // R5-V3 (#171): retail exit_world (0x00514e60) order — the // departing entity first tears down its OWN stick @@ -5259,16 +5316,21 @@ public sealed class GameWindow : IDisposable cleanups.Add(ephGone.PositionManager.UnStick); cleanups.Add(ephGone.ClearTarget); cleanups.Add(ephGone.NotifyExitWorld); + EntityPhysicsHost capturedHost = ephGone; + cleanups.Add(() => incarnation.RemoveCaptured(_physicsHosts, capturedHost)); } - cleanups.Add(() => _physicsHosts.Remove(serverGuid)); cleanups.Add(() => _animatedEntities.Remove(existingEntity.Id)); cleanups.Add(() => _classificationCache.InvalidateEntity(existingEntity.Id)); // Dead-reckon state is keyed by SERVER guid (not local id) so we // clear using the same guid the next spawn/update would use. - cleanups.Add(() => _remoteDeadReckon.Remove(serverGuid)); - cleanups.Add(() => _remoteLastMove.Remove(serverGuid)); - cleanups.Add(() => + // LiveEntityRuntime removes the canonical remote-motion index by + // captured reference after this App teardown plan converges. Never use + // the GUID-only dictionary view here: it may already expose a newer + // incarnation. + cleanups.Add(() => incarnation.RunIfNoReplacement( + () => _remoteLastMove.Remove(serverGuid))); + cleanups.Add(() => incarnation.RunIfNoReplacement(() => { if (_selection.SelectedObjectId == serverGuid) { @@ -5276,7 +5338,7 @@ public sealed class GameWindow : IDisposable AcDream.Core.Selection.SelectionChangeSource.System, AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved); } - }); + })); cleanups.Add(() => _translucencyFades.ClearEntity(existingEntity.Id)); // #188 cleanups.Add(() => LeaveWorldLiveEntityRuntimeComponents(record)); @@ -5284,7 +5346,7 @@ public sealed class GameWindow : IDisposable // pickup/parent path stops after Suspend so re-entry can restore it. cleanups.Add(() => _physicsEngine.ShadowObjects.Deregister(existingEntity.Id)); cleanups.Add(() => _liveEntityLights?.Forget(existingEntity.Id)); - AcDream.App.World.LiveEntityTeardown.Run(cleanups); + return new AcDream.App.World.LiveEntityTeardownPlan(cleanups); } /// @@ -5300,12 +5362,20 @@ public sealed class GameWindow : IDisposable return; bool retainedProjectileShadow = - _projectileController?.LeaveWorld(record.ServerGuid) == true; + _projectileController?.LeaveWorld(record) == true; _worldGameState.RemoveById(entity.Id); + _worldEvents.ForgetEntity(entity.Id); if (!retainedProjectileShadow) _physicsEngine.ShadowObjects.Deregister(entity.Id); if (record.ServerGuid == _playerServerGuid) - _lastLocalPlayerShadow = null; + { + var incarnation = new AcDream.App.World.LiveEntityIncarnationCleanup( + record, + guid => _liveEntities?.TryGetRecord(guid, out LiveEntityRecord current) == true + ? current + : null); + incarnation.RunIfNoReplacement(() => _lastLocalPlayerShadow = null); + } // Pose-attached effects keep logical ownership while cell-less. A // missing pose suppresses stale anchors; re-entry republishes the same @@ -6302,6 +6372,7 @@ public sealed class GameWindow : IDisposable } if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return; + _entityEffects?.MarkLiveOwnerPoseDirty(update.Guid); // Phase A.1 / #135: track the PLAYER's last server-known landblock so the // streaming controller can follow the player in the fly-camera / pre-player-mode @@ -7044,6 +7115,7 @@ public sealed class GameWindow : IDisposable _pendingTeleportCell = p.LandblockId; _teleportHoldSeconds = 0f; _teleportForced = false; + _wbDrawDispatcher?.InvalidateCompositeWarmupReadiness(); if (_streamingController is not null) { _streamingController.PriorityLandblockId = @@ -7121,6 +7193,7 @@ public sealed class GameWindow : IDisposable bool renderReady = _streamingController?.IsRenderNeighborhoodResident( destCell, renderRadius) == true; + renderReady &= _wbDrawDispatcher?.CompositeTexturesReady == true; return indoor ? renderReady && _physicsEngine.IsSpawnCellReady(destCell) : renderReady @@ -7595,12 +7668,6 @@ public sealed class GameWindow : IDisposable // `merged` so entity GfxObj/Setup ids are known. var physicsDats = BuildPhysicsDatBundle(landblockId, merged); var completedEnvCells = envCellBuild.Build(); - if (_wbMeshAdapter?.MeshManager is { } meshManager) - { - AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule( - completedEnvCells, - meshManager); - } return new AcDream.App.Streaming.LandblockBuild( new AcDream.Core.World.LoadedLandblock( baseLoaded.LandblockId, @@ -8193,29 +8260,56 @@ public sealed class GameWindow : IDisposable return result; } - /// - /// Retire the Near-only presentation and collision layer while preserving - /// the terrain slot/surface used by Far streaming. StreamingController calls - /// this before GpuWorldState drops static entities so owner-based resources - /// can be released exactly once. - /// - private void DemoteLoadedLandblockToTerrain(uint landblockId) + private void AdvanceLandblockPresentationRetirement( + AcDream.App.Streaming.LandblockRetirementTicket ticket) { - if (_worldState.TryGetLandblock(landblockId, out var landblock)) - { - foreach (var entity in landblock!.Entities) + bool staticOnly = + ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.NearLayer; + + ticket.RunForEachEntity( + AcDream.App.Streaming.LandblockRetirementStage.EntityLighting, + entity => !staticOnly || entity.ServerGuid == 0, + entity => _lightingSink?.UnregisterOwner(entity.Id)); + ticket.RunForEachEntity( + AcDream.App.Streaming.LandblockRetirementStage.EntityTranslucency, + entity => !staticOnly || entity.ServerGuid == 0, + entity => _translucencyFades.ClearEntity(entity.Id)); + ticket.RunForEachEntity( + AcDream.App.Streaming.LandblockRetirementStage.PluginProjection, + static entity => entity.ServerGuid == 0, + entity => { - if (entity.ServerGuid != 0) - continue; - _lightingSink?.UnregisterOwner(entity.Id); - _translucencyFades.ClearEntity(entity.Id); - } + _worldGameState.RemoveById(entity.Id); + _worldEvents.ForgetEntity(entity.Id); + }); + + if (ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.Full) + { + ticket.RunOnce( + AcDream.App.Streaming.LandblockRetirementStage.Terrain, + () => _terrain?.RemoveLandblock(ticket.LandblockId)); } - _physicsEngine.DemoteLandblockToTerrain(landblockId); - _cellVisibility.RemoveLandblock((landblockId >> 16) & 0xFFFFu); - _buildingRegistries.Remove(landblockId & 0xFFFF0000u); - _envCellRenderer?.RemoveLandblock(landblockId); + ticket.RunOnce( + AcDream.App.Streaming.LandblockRetirementStage.Physics, + () => + { + if (ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.Full) + _physicsEngine.RemoveLandblock(ticket.LandblockId); + else + _physicsEngine.DemoteLandblockToTerrain(ticket.LandblockId); + }); + ticket.RunOnce( + AcDream.App.Streaming.LandblockRetirementStage.CellVisibility, + () => _cellVisibility.RemoveLandblock( + (ticket.LandblockId >> 16) & 0xFFFFu)); + ticket.RunOnce( + AcDream.App.Streaming.LandblockRetirementStage.BuildingRegistry, + () => _buildingRegistries.Remove( + ticket.LandblockId & 0xFFFF0000u)); + ticket.RunOnce( + AcDream.App.Streaming.LandblockRetirementStage.EnvironmentCells, + () => _envCellRenderer?.RemoveLandblock(ticket.LandblockId)); } /// @@ -8648,7 +8742,7 @@ public sealed class GameWindow : IDisposable bool _isScenery = _isOutdoorMesh; // ISSUES #83 / Phase A1 (2026-05-21): landblock stabs // (LandBlockInfo.Objects + Buildings) use entity.Id with the - // 0xC0XXYY00+n layout per LandblockLoader.cs:55. Their BSP + // 0xCXXYYIII layout per LandblockStaticEntityIdAllocator. Their BSP // collision covers the whole structure; the mesh-AABB-fallback // path below is for canopy-only-BSP procedural scenery // (0x8XXYYIII) and produces a redundant 1.5m-clamped @@ -8656,7 +8750,8 @@ public sealed class GameWindow : IDisposable // "thin air" collision inside cottages. Gate the fallback to // exclude stabs. Spec: // docs/superpowers/specs/2026-05-21-cylinder-fallback-dedup-design.md. - bool _isLandblockStab = (entity.Id & 0xFF000000u) == 0xC0000000u; + bool _isLandblockStab = + AcDream.Core.World.LandblockStaticEntityIdAllocator.IsInNamespace(entity.Id); if (_isScenery) scTried++; // #185 FIX (2026-07-08): register the entity's BSP parts as ONE // multi-part shadow object keyed on the entity's UNIQUE 32-bit id, @@ -8890,6 +8985,12 @@ public sealed class GameWindow : IDisposable // visibility into the streaming world state. foreach (var entity in lb.Entities) { + // Live objects publish through LiveEntityRuntime exactly once per + // accepted incarnation. This landblock callback owns only DAT + // statics; a retained live projection must not emit a second spawn + // when its spatial bucket reloads. + if (entity.ServerGuid != 0) + continue; var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( Id: entity.Id, SourceId: entity.SourceGfxObjOrSetupId, @@ -8906,6 +9007,22 @@ public sealed class GameWindow : IDisposable { using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update); + // A resource callback can request deletion while the presentation + // owner is inside a synchronous resume/suspend transition. The first + // unregister leaves a canonical teardown tombstone; drain it only now, + // after that callback stack has unwound on the same update thread. + try + { + _liveEntities?.RetryPendingTeardowns(); + } + catch (AggregateException error) + { + // The tombstone retains every unfinished owner step. Report this + // attempt, but keep the frame advancing so the next update can + // retry after transient renderer/plugin teardown failures. + Console.Error.WriteLine($"[live-entity-teardown] {error}"); + } + double frameSeconds = AcDream.App.World.RetailLiveFrameCoordinator.NormalizeDeltaSeconds(dt); float frameDelta = (float)frameSeconds; @@ -9533,6 +9650,20 @@ public sealed class GameWindow : IDisposable private void OnRender(double deltaSeconds) { + _gpuFrameFlights!.BeginFrame(); + Exception? renderFailure = null; + try + { + _textureCache?.BeginCompositeTextureFrame(); + _textureCache?.TickCompositeTextureCache(); + _wbDrawDispatcher?.BeginFrame(_gpuFrameFlights.CurrentSlot); + _envCellRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot); + _portalDepthMask?.BeginFrame(_gpuFrameFlights.CurrentSlot); + _textRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot); + _uiHost?.TextRenderer.BeginFrame(_gpuFrameFlights.CurrentSlot); + _clipFrame?.BeginFrame(_gpuFrameFlights.CurrentSlot); + _terrain?.BeginFrame(_gpuFrameFlights.CurrentSlot); + _sceneLightingUbo?.BeginFrame(_gpuFrameFlights.CurrentSlot); _frameProfiler.FrameBoundary(_gl!); // gmSmartBoxUI::UseTime @ 0x004D6E30 swaps visibility between the @@ -9611,7 +9742,25 @@ public sealed class GameWindow : IDisposable using (var _uplStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Upload)) { _wbMeshAdapter?.Tick(); - _particleRenderer?.BeginFrame(); + if (_teleportTransit.IsActive && _pendingTeleportCell != 0) + { + int compositeRadius = (_pendingTeleportCell & 0xFFFFu) >= 0x0100u + ? 0 + : TeleportNearRingRadius; + bool destinationMeshesPublished = + _streamingController?.IsRenderNeighborhoodResident( + _pendingTeleportCell, + compositeRadius) == true; + if (destinationMeshesPublished) + { + _wbDrawDispatcher?.PrepareCompositeTextures( + _worldState.Entities, + _worldState.FlatViewGeneration, + _pendingTeleportCell, + compositeRadius); + } + } + _particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot); } if (_frameDiag) FrameDiagPush(_entityUploadSamples, ref _entityUploadSampleCursor, @@ -9701,8 +9850,7 @@ public sealed class GameWindow : IDisposable _cameraController.Fly.FovY = fovYRad; if (_cameraController.Chase is not null) _cameraController.Chase.FovY = fovYRad; - if (_window is not null && _window.VSync != d.VSync) - _window.VSync = d.VSync; + ApplyFramePacingPreference(d.VSync); } // Phase E.2 audio: update listener pose so 3D sounds pan/attenuate @@ -9965,11 +10113,9 @@ public sealed class GameWindow : IDisposable // no pvFrame, the frame stays no-clip, every instance is slot 0 and terrain // draws normally — bit-identical to U.3 (outdoor→building peering is U.5). // - // The single _clipFrame instance is RESET + repacked in place each frame - // (ClipFrameAssembler.Assemble → ClipFrame.Reset) so its SSBO/UBO ids are - // reused — no per-frame GL buffer churn. UploadShared binds binding=2 - // (mesh SSBO) + binding=2 (terrain UBO); each renderer re-binds its - // binding=2 defensively from the ids we hand it. + // The single _clipFrame instance is RESET + repacked in place each frame. + // One SSBO and one range-addressed terrain UBO arena are reused per + // GPU-fenced frame slot; each renderer re-binds binding=2 defensively. _clipFrame ??= ClipFrame.NoClip(); // Phase 3 (render unification, additive): build the synthetic outdoor cell node when @@ -10078,10 +10224,18 @@ public sealed class GameWindow : IDisposable _interiorPartition = null; } - _clipFrame.UploadShared(_gl); - _wbDrawDispatcher?.SetClipRegionSsbo(_clipFrame.RegionSsbo); - _envCellRenderer?.SetClipRegionSsbo(_clipFrame.RegionSsbo); - _terrain?.SetClipUbo(_clipFrame.TerrainUbo); + if (clipRoot is null) + { + // Outdoor frames have one no-clip terrain state. Indoor frames + // defer both allocations to RetailPViewRenderer, which knows the + // exact slice count and packs every terrain state into one arena. + _clipFrame.ReserveTerrainUploads(_gl, 1); + _clipFrame.UploadRegions(_gl); + TerrainClipBufferBinding terrainBinding = _clipFrame.UploadTerrainClip(_gl); + _wbDrawDispatcher?.SetClipRegionSsbo(_clipFrame.RegionSsbo); + _envCellRenderer?.SetClipRegionSsbo(_clipFrame.RegionSsbo); + _terrain?.SetClipUbo(terrainBinding); + } bool drawSkyThisFrame = false; @@ -10095,8 +10249,7 @@ public sealed class GameWindow : IDisposable sigSkyDrawn = drawSkyThisFrame; if (drawSkyThisFrame) { - _gl.BindBufferBase(BufferTargetARB.UniformBuffer, - ClipFrame.TerrainClipUboBinding, _clipFrame.TerrainUbo); + _clipFrame.BindTerrainClip(_gl); for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) _gl.Enable(EnableCap.ClipDistance0 + _cp); _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, @@ -10166,7 +10319,7 @@ public sealed class GameWindow : IDisposable RenderCenterLbY = renderCenterLbY, RenderRadius = _nearRadius, LandblockEntries = _worldState.LandblockEntries, - SetTerrainClipUbo = uboId => _terrain?.SetClipUbo(uboId), + SetTerrainClipUbo = binding => _terrain?.SetClipUbo(binding), DrawLandscapeSlice = sliceCtx => DrawRetailPViewLandscapeSlice( sliceCtx, @@ -10228,11 +10381,12 @@ public sealed class GameWindow : IDisposable if (_particleSystem is null || _particleRenderer is null) return; DisableClipDistances(); - _particleRenderer.Draw( + _particleRenderer.DrawForOwners( camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, - emitter => emitter.AttachedObjectId == 0); + _noSceneParticleEntityIds, + includeUnattached: true); }, FlushLandscapeAlpha = _retailAlphaQueue.Flush, DrawCellParticles = sliceCtx => @@ -10345,13 +10499,13 @@ public sealed class GameWindow : IDisposable if (clipAssembly is not null) { sigSceneParticles = sigSceneParticles == "none" ? "filtered" : sigSceneParticles + "+filtered"; - _particleRenderer.Draw( + _particleRenderer.DrawForOwners( camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, - emitter => emitter.AttachedObjectId == 0 - || (!_outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId) - && _visibleSceneParticleEntityIds.Contains(emitter.AttachedObjectId))); + _visibleSceneParticleEntityIds, + includeUnattached: true, + excludedAttachedOwnerIds: _outdoorSceneParticleEntityIds); } else { @@ -10379,12 +10533,12 @@ public sealed class GameWindow : IDisposable // emitters keep their own passes (no double-draw: their owners // are never in the outdoor-static id set). sigSceneParticles = sigSceneParticles == "none" ? "unattached" : sigSceneParticles + "+unattached"; - _particleRenderer.Draw( + _particleRenderer.DrawForOwners( camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, - emitter => emitter.AttachedObjectId == 0 - || _outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)); + _outdoorSceneParticleEntityIds, + includeUnattached: true); } // Bug A fix (post-#26 worktree, 2026-04-26): weather sky @@ -10393,8 +10547,7 @@ public sealed class GameWindow : IDisposable if (clipRoot is null && drawSkyThisFrame) { sigSkyDrawn = true; - _gl.BindBufferBase(BufferTargetARB.UniformBuffer, - ClipFrame.TerrainClipUboBinding, _clipFrame.TerrainUbo); + _clipFrame.BindTerrainClip(_gl); for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) _gl.Enable(EnableCap.ClipDistance0 + _cp); _skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction, @@ -10718,11 +10871,95 @@ public sealed class GameWindow : IDisposable $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " + $"lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount}/anim {animatedCount} | " + $"PY{titleCal.Year} {titleCal.Month} {titleCal.Day} {titleCal.Hour} (df={df:F4})"; + if (_options.UiProbeEnabled) + { + (int particleSets, long particleBytes) = + _particleRenderer?.DynamicBufferDiagnostics ?? default; + var meshManager = _wbMeshAdapter?.MeshManager; + var meshDiag = meshManager?.Diagnostics ?? default; + var globalMesh = meshManager?.GlobalBuffer; + var cpuMeshCache = _wbMeshAdapter?.CpuMeshCacheDiagnostics ?? default; + GCMemoryInfo gcMemory = GC.GetGCMemoryInfo(); + Console.WriteLine( + $"[gpu-stream] emit={_particleSystem?.ActiveEmitterCount ?? 0} " + + $"particles={_particleSystem?.ActiveParticleCount ?? 0} " + + $"bindings={_particleSink?.ActiveBindingCount ?? 0} " + + $"wbSets={_wbDrawDispatcher?.DynamicBufferSetCount ?? 0} " + + $"cellSets={_envCellRenderer?.DynamicBufferSetCount ?? 0} " + + $"particleSets={particleSets}/{particleBytes}B " + + $"uiBytes={_uiHost?.TextRenderer.DynamicBufferCapacityBytes ?? 0} " + + $"portalBytes={_portalDepthMask?.DynamicBufferCapacityBytes ?? 0} " + + $"clipSets={_clipFrame?.DynamicBufferSetCount ?? 0} " + + $"terrainBuffers={_terrain?.DynamicIndirectBufferCount ?? 0} " + + $"lightBuffers={_sceneLightingUbo?.DynamicBufferCount ?? 0} " + + $"mesh={meshDiag.RenderData}/{meshDiag.AtlasArrays}/{meshDiag.UnusedLru} " + + $"meshEst={meshDiag.EstimatedBytes} " + + $"meshUpload={globalMesh?.UploadCount ?? 0}/{globalMesh?.UploadedBytes ?? 0} " + + $"uploadFrame={_wbMeshAdapter?.LastUploadCount ?? 0}/" + + $"{_wbMeshAdapter?.LastUploadBytes ?? 0}/" + + $"{_wbMeshAdapter?.LastArrayAllocationBytes ?? 0}/" + + $"{_wbMeshAdapter?.LastPlannedMipmapBytes ?? 0}/" + + $"{_wbMeshAdapter?.LastNewArrayCount ?? 0} " + + $"bufferFrame={_wbMeshAdapter?.LastBufferUploadBytes ?? 0}/" + + $"{_wbMeshAdapter?.LastBufferAllocationBytes ?? 0}/" + + $"{_wbMeshAdapter?.LastBufferCopyBytes ?? 0}/" + + $"{_wbMeshAdapter?.LastNewBufferCount ?? 0} " + + $"staging={_wbMeshAdapter?.StagedUploadBacklog ?? 0}/" + + $"{_wbMeshAdapter?.StagedUploadBytes ?? 0}/" + + $"{_wbMeshAdapter?.StagingAtHighWater ?? false}/" + + $"{_wbMeshAdapter?.LastStaleDiscardCount ?? 0} " + + $"cpuMesh={cpuMeshCache.Count}/{cpuMeshCache.Bytes} " + + $"mipmapFrame={_wbMeshAdapter?.LastMipmapArrayCount ?? 0}/" + + $"{_wbMeshAdapter?.LastMipmapBytes ?? 0} " + + $"meshCap={globalMesh?.CapacityBytes ?? 0} " + + $"meshPhys={globalMesh?.PhysicalCapacityBytes ?? 0}/" + + $"{globalMesh?.IsMigrationInProgress ?? false} " + + $"gpuTrack={AcDream.App.Rendering.Wb.GpuMemoryTracker.AllocatedBytes}/" + + $"{AcDream.App.Rendering.Wb.GpuMemoryTracker.BufferCount}/" + + $"{AcDream.App.Rendering.Wb.GpuMemoryTracker.TextureCount} " + + $"managed={GC.GetTotalMemory(false)}/{gcMemory.TotalCommittedBytes} " + + $"ownedTextures={_textureCache?.OwnedBindlessTextureCount ?? 0}/" + + $"{_textureCache?.TextureOwnerCount ?? 0} " + + $"particleTextures={_textureCache?.ActiveParticleTextureCount ?? 0}/" + + $"{_textureCache?.ParticleTextureOwnerCount ?? 0}/" + + $"{_textureCache?.CachedParticleTextureCount ?? 0}/" + + $"{_textureCache?.CachedUnownedParticleTextureCount ?? 0}/" + + $"{_textureCache?.CachedUnownedParticleTextureBytes ?? 0} " + + $"compositeCache={_textureCache?.CachedCompositeTextureCount ?? 0}/" + + $"{_textureCache?.CachedUnownedCompositeCount ?? 0}/" + + $"{_textureCache?.CachedUnownedCompositeBytes ?? 0} " + + $"compositeAtlas={_textureCache?.CompositeAtlasCount ?? 0}/" + + $"{_textureCache?.CompositeAtlasBytes ?? 0} " + + $"compositeUpload={_textureCache?.CompositeFrameUploadCount ?? 0}/" + + $"{_textureCache?.CompositeFrameUploadBytes ?? 0}/" + + $"{_wbDrawDispatcher?.LastCompositeWarmupPendingCount ?? 0}"); + } _lastFps = fps; _lastFrameMs = avgFrameTime; _perfAccum = 0; _perfFrameCount = 0; } + + } + catch (Exception ex) + { + renderFailure = ex; + throw; + } + finally + { + try + { + _gpuFrameFlights.EndFrame(); + } + catch (Exception closeFailure) when (renderFailure is not null) + { + throw new AggregateException( + "Rendering failed and the in-flight GPU frame could not be closed.", + renderFailure, + closeFailure); + } + } } private void RunRemoteTeleportHook(uint serverGuid, uint localEntityId) @@ -11577,8 +11814,7 @@ public sealed class GameWindow : IDisposable if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled) EmitClipRouteScissorProbe(scissor, slice.NdcAabb); - _gl!.BindBufferBase(BufferTargetARB.UniformBuffer, - ClipFrame.TerrainClipUboBinding, _clipFrame!.TerrainUbo); + _clipFrame!.BindTerrainClip(_gl!); EnableClipDistances(); if (renderSky) @@ -11656,8 +11892,7 @@ public sealed class GameWindow : IDisposable var slice = lateCtx.Slice; bool scissor = BeginDoorwayScissor(true, slice.NdcAabb); - _gl!.BindBufferBase(BufferTargetARB.UniformBuffer, - ClipFrame.TerrainClipUboBinding, _clipFrame!.TerrainUbo); + _clipFrame!.BindTerrainClip(_gl!); // Outside-stage dynamics' meshes — viewcone pre-filtered by the // renderer, never hard-clipped (T3). @@ -11730,12 +11965,11 @@ public sealed class GameWindow : IDisposable && _particleSystem is not null && _particleRenderer is not null) { - _particleRenderer.Draw( + _particleRenderer.DrawForOwners( camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, - emitter => emitter.AttachedObjectId != 0 - && _outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)); + _outdoorSceneParticleEntityIds); } // T3 (BR-5): weather gates on the PLAYER being outside, not the viewer @@ -11853,12 +12087,11 @@ public sealed class GameWindow : IDisposable // id-predicate below IS the cone gate; the punch/seal depth discipline // composites the pixels. DisableClipDistances(); - _particleRenderer.Draw( + _particleRenderer.DrawForOwners( camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, - emitter => emitter.AttachedObjectId != 0 - && _visibleSceneParticleEntityIds.Contains(emitter.AttachedObjectId)); + _visibleSceneParticleEntityIds); DisableClipDistances(); } @@ -11889,12 +12122,11 @@ public sealed class GameWindow : IDisposable return; DisableClipDistances(); - _particleRenderer.Draw( + _particleRenderer.DrawForOwners( camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, - emitter => emitter.AttachedObjectId != 0 - && _dynamicsSceneParticleEntityIds.Contains(emitter.AttachedObjectId)); + _dynamicsSceneParticleEntityIds); DisableClipDistances(); } @@ -12447,13 +12679,13 @@ public sealed class GameWindow : IDisposable // name and re-loads via SettingsVM.LoadCharacterContext. _persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey); - // Apply Display to the Silk.NET window. VSync goes via the - // window property; resolution + fullscreen go through - // ApplyDisplayWindowState which is shared with the on-Save path. + // Apply Display to the Silk.NET window. VSync and its bounded + // refresh-rate fallback go through the shared pacing owner; + // resolution + fullscreen use the on-Save path below. if (_window is not null) { - if (_window.VSync != _persistedDisplay.VSync) - _window.VSync = _persistedDisplay.VSync; + RefreshActiveMonitorFramePacing(); + ApplyFramePacingPreference(_persistedDisplay.VSync); ApplyDisplayWindowState(_persistedDisplay); } @@ -12478,9 +12710,9 @@ public sealed class GameWindow : IDisposable /// /// What changes immediately: /// - /// Streaming radii: disposes the old - /// + - /// and constructs new ones with the new radii. + /// Streaming radii: transactionally reconciles the existing + /// against its published world; + /// the worker and controller retain their identity. /// Anisotropic filtering: calls /// TerrainAtlas.SetAnisotropic. /// Alpha-to-coverage gate: sets @@ -12522,63 +12754,18 @@ public sealed class GameWindow : IDisposable // Anisotropic — immediate GL TexParameter call on the terrain atlas. _terrain?.Atlas?.SetAnisotropic(newResolved.AnisotropicLevel); - // Streaming radii — requires tearing down + rebuilding the controller - // (radii are constructor-time on StreamingController, not live-mutable). - // The ~1-2s hitch while the worker drains is acceptable for a settings change. - if (_streamer is not null && _streamingController is not null) + // Reconcile streaming radii against the current published world. This + // preserves resident blocks that remain in range and never rebuilds + // the worker merely to change a completion budget. + if (_streamingController is not null) { _nearRadius = newResolved.NearRadius; _farRadius = newResolved.FarRadius; - - // StreamingController is stateless (no Dispose needed); dispose - // only the LandblockStreamer worker thread. - _streamer.Dispose(); - - _streamer = new AcDream.App.Streaming.LandblockStreamer( - loadLandblock: (id, kind) => BuildLandblockForStreaming(id, kind), - buildMeshOrNull: (id, lb) => - { - if (lb is null || _heightTable is null || _blendCtx is null || _surfaceCache is null) - return null; - uint lbX = (id >> 24) & 0xFFu; - uint lbY = (id >> 16) & 0xFFu; - return AcDream.Core.Terrain.LandblockMesh.Build( - lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache); - }); - _streamer.Start(); - - _streamingController = new AcDream.App.Streaming.StreamingController( - enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(id, kind, generation), - enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation), - drainCompletions: _streamer.DrainCompletions, - applyTerrain: ApplyLoadedTerrain, - state: _worldState, - nearRadius: _nearRadius, - farRadius: _farRadius, - clearPendingLoads: _streamer.ClearPendingLoads, - removeTerrain: id => - { - if (_lightingSink is not null && - _worldState.TryGetLandblock(id, out var lb)) - { - foreach (var ent in lb!.Entities) - { - _lightingSink.UnregisterOwner(ent.Id); - _translucencyFades.ClearEntity(ent.Id); // #188 - } - } - _terrain?.RemoveLandblock(id); - _physicsEngine.RemoveLandblock(id); - _cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu); - _buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28) - _envCellRenderer?.RemoveLandblock(id); // Phase A8 - }, - demoteNearLayer: DemoteLoadedLandblockToTerrain, - ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin); + _streamingController.ReconfigureRadii(_nearRadius, _farRadius); _streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame; Console.WriteLine( - $"[QUALITY] Streaming restarted: nearRadius={_nearRadius}, " + + $"[QUALITY] Streaming reconciled: nearRadius={_nearRadius}, " + $"farRadius={_farRadius}, maxCompletions={newResolved.MaxCompletionsPerFrame}"); } } @@ -12685,6 +12872,35 @@ public sealed class GameWindow : IDisposable _window.WindowState = desiredState; } + private void ApplyFramePacingPreference(bool requestedVSync) + { + _requestedVSync = requestedVSync; + FramePacingPolicy policy = FramePacingPolicy.Resolve( + requestedVSync, + _options.UncappedRendering, + _activeMonitorRefreshHz); + _framePacing.Apply(policy); + + if (_window is not null && _window.VSync != policy.UseVSync) + _window.VSync = policy.UseVSync; + } + + private void RefreshActiveMonitorFramePacing() + { + int? refreshHz = _window?.Monitor?.VideoMode.RefreshRate; + _activeMonitorRefreshHz = refreshHz is > 0 ? refreshHz : null; + ApplyFramePacingPreference(_requestedVSync); + } + + private void OnFrameRendered(double _) + => _framePacing.CompleteFrame(); + + private void OnWindowMoved(Silk.NET.Maths.Vector2D _) + => RefreshActiveMonitorFramePacing(); + + private void OnWindowStateChanged(Silk.NET.Windowing.WindowState _) + => RefreshActiveMonitorFramePacing(); + private static bool TryParseResolution(string spec, out int width, out int height) { width = height = 0; @@ -14807,78 +15023,179 @@ public sealed class GameWindow : IDisposable private void OnClosing() { - EndMouseLookAndRestoreCursor(); - - // Retained controllers subscribe to session-owned object/selection/combat sources. - // Tear their window-manager ownership down first, while every source and GL-backed - // UI resource is still alive. UiHost disposes controllers before its renderer. - _retailUiRuntime?.Dispose(); - _retailUiRuntime = null; - _combatTargetController?.Dispose(); - _combatTargetController = null; - _combatAttackController?.Dispose(); - _combatAttackController = null; - _uiHost = null; - _itemInteractionController?.Dispose(); - _itemInteractionController = null; - _externalContainerLifecycle?.Dispose(); - _externalContainerLifecycle = null; - _magicRuntime = null; - _magicCatalog = null; - - // Phase A.1: join the streamer worker thread before tearing down GL - // state. The worker may still be processing a load job that references - // _dats; Dispose cancels the token and waits up to 2s for the thread. - _streamer?.Dispose(); - // Phase I.7: unsubscribe combat → chat translator before the - // session it depends on goes away. - _combatChatTranslator?.Dispose(); - _equippedChildRenderer?.Dispose(); - _liveSessionController?.Dispose(); - _liveSession = null; + _shutdown ??= CreateShutdownTransaction(); try { - _liveEntities?.Clear(); + _shutdown.CompleteOrThrow(); } - finally + catch (Exception error) { - _liveEntityLights?.Dispose(); - _liveEntityLights = null; - _liveEntityPresentation?.Dispose(); - _remoteTeleportController?.Dispose(); - _remoteTeleportController = null; - _entityEffects?.ClearNetworkState(); - _animationHookFrames?.Clear(); - _effectPoses.Clear(); + // The session/network stage runs first, so ACE is disconnected + // gracefully even if a persistent driver failure prevents full GL + // convergence. Later dependencies remain intact and the native + // context/process teardown is the final safety net. + Console.Error.WriteLine($"[shutdown] {error}"); } - _audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context - _portalTunnel?.Dispose(); - _portalTunnel = null; - _wbDrawDispatcher?.Dispose(); - _envCellRenderer?.Dispose(); // Phase A8 - _portalDepthMask?.Dispose(); // T1 - _clipFrame?.Dispose(); // Phase U.3 - _skyRenderer?.Dispose(); // depends on sampler cache; dispose first - _particleRenderer?.Dispose(); // releases particle mesh refs before WB - _samplerCache?.Dispose(); - _textureCache?.Dispose(); - _wbMeshAdapter?.Dispose(); // Phase N.4+N.5 WB foundation (mandatory modern path) - _meshShader?.Dispose(); - _terrain?.Dispose(); - _terrainModernShader?.Dispose(); - _sceneLightingUbo?.Dispose(); - _paperdollViewportRenderer?.Dispose(); // Slice 2: the doll's off-screen FBO + textures - _debugLines?.Dispose(); - _textRenderer?.Dispose(); - _debugFont?.Dispose(); - _frameProfiler.Dispose(); // MP0: releases the GpuFrameTimer query ring - _dats?.Dispose(); - Combat.CombatModeChanged -= SetInputCombatScope; - _input?.Dispose(); - _gl?.Dispose(); } + private ResourceShutdownTransaction CreateShutdownTransaction() => new( + new ResourceShutdownStage("session owners", + [ + new("mouse capture", EndMouseLookAndRestoreCursor), + new("retail UI", () => + { + _retailUiRuntime?.Dispose(); + _retailUiRuntime = null; + _uiHost = null; + }), + new("combat target", () => + { + _combatTargetController?.Dispose(); + _combatTargetController = null; + }), + new("combat attack", () => + { + _combatAttackController?.Dispose(); + _combatAttackController = null; + }), + new("item interaction", () => + { + _itemInteractionController?.Dispose(); + _itemInteractionController = null; + }), + new("external containers", () => + { + _externalContainerLifecycle?.Dispose(); + _externalContainerLifecycle = null; + }), + new("magic runtime", () => + { + _magicRuntime = null; + _magicCatalog = null; + }), + new("streamer", () => _streamer?.Dispose()), + new("combat chat", () => _combatChatTranslator?.Dispose()), + new("equipped children", () => _equippedChildRenderer?.Dispose()), + new("live session", () => + { + _liveSessionController?.Dispose(); + _liveSessionController = null; + _liveSession = null; + }), + ]), + // Retained tombstones are retried while every callback owner is alive. + new ResourceShutdownStage("live entities", + [ + new("live entity runtime", () => _liveEntities?.Clear()), + ]), + new ResourceShutdownStage("live entity dependents", + [ + new("live lights", () => + { + _liveEntityLights?.Dispose(); + _liveEntityLights = null; + }), + new("live presentation", () => _liveEntityPresentation?.Dispose()), + new("remote teleport", () => + { + _remoteTeleportController?.Dispose(); + _remoteTeleportController = null; + }), + new("effect network state", () => + { + _entityEffects?.ClearNetworkState(); + _animationHookFrames?.Clear(); + _effectPoses.Clear(); + }), + new("audio", () => _audioEngine?.Dispose()), + ]), + new ResourceShutdownStage("submitted GPU work", + [ + new("frame flight drain", () => _gpuFrameFlights?.WaitForSubmittedWork()), + ]), + new ResourceShutdownStage("render frontends", + [ + new("portal tunnel", () => + { + _portalTunnel?.Dispose(); + _portalTunnel = null; + }), + new("paperdoll viewport", () => + { + _paperdollViewportRenderer?.Dispose(); + _paperdollViewportRenderer = null; + }), + new("mesh draw dispatcher", () => _wbDrawDispatcher?.Dispose()), + new("environment cells", () => _envCellRenderer?.Dispose()), + new("portal depth mask", () => _portalDepthMask?.Dispose()), + new("clip frame", () => _clipFrame?.Dispose()), + new("sky", () => _skyRenderer?.Dispose()), + new("particles", () => _particleRenderer?.Dispose()), + ]), + new ResourceShutdownStage("shared texture owners", + [ + new("sampler cache", () => _samplerCache?.Dispose()), + new("texture cache", () => _textureCache?.Dispose()), + ]), + new ResourceShutdownStage("mesh adapter", + [ + new("WB mesh adapter", () => + { + _wbMeshAdapter?.Dispose(); + _wbMeshAdapter = null; + }), + ]), + new ResourceShutdownStage("remaining render owners", + [ + new("mesh shader", () => _meshShader?.Dispose()), + new("terrain", () => + { + _terrain?.Dispose(); + _terrain = null; + }), + new("terrain shader", () => _terrainModernShader?.Dispose()), + new("scene lighting", () => _sceneLightingUbo?.Dispose()), + new("debug lines", () => _debugLines?.Dispose()), + new("text renderer", () => _textRenderer?.Dispose()), + new("debug font", () => _debugFont?.Dispose()), + new("frame profiler", _frameProfiler.Dispose), + ]), + new ResourceShutdownStage("frame flight owner", + [ + new("frame flights", () => + { + _gpuFrameFlights?.Dispose(); + _gpuFrameFlights = null; + }), + ]), + new ResourceShutdownStage("content mappings", + [ + new("DAT collection", () => + { + _dats?.Dispose(); + _dats = null; + }), + ]), + new ResourceShutdownStage("input", + [ + new("combat input subscription", () + => Combat.CombatModeChanged -= SetInputCombatScope), + new("input context", () => + { + _input?.Dispose(); + _input = null; + }), + ]), + new ResourceShutdownStage("OpenGL context", + [ + new("OpenGL", () => + { + _gl?.Dispose(); + _gl = null; + }), + ])); + private void OnFocusChanged(bool focused) { if (!focused) diff --git a/src/AcDream.App/Rendering/GpuFrameFlightController.cs b/src/AcDream.App/Rendering/GpuFrameFlightController.cs new file mode 100644 index 00000000..3090eb21 --- /dev/null +++ b/src/AcDream.App/Rendering/GpuFrameFlightController.cs @@ -0,0 +1,486 @@ +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// Bounds how far the CPU may submit OpenGL work ahead of the GPU. +/// Dynamic buffers are intentionally reused from frame to frame; without a +/// frames-in-flight bound, an uncapped render loop can make the driver retain +/// an unbounded chain of renamed backing stores while older draws are pending. +/// +internal interface IGpuResourceRetirementQueue +{ + void Retire(Action release); +} + +/// +/// A physical GPU-resource release split into independently committed stages. +/// A retirement callback may be retried after a driver or accounting failure; +/// stages which already returned successfully are never executed twice. +/// +internal sealed class RetryableGpuResourceRelease +{ + private readonly Action[] _stages; + private int _nextStage; + private bool _running; + + public RetryableGpuResourceRelease(params Action[] stages) + { + ArgumentNullException.ThrowIfNull(stages); + if (stages.Length == 0) + throw new ArgumentException("At least one release stage is required.", nameof(stages)); + if (Array.Exists(stages, static stage => stage is null)) + throw new ArgumentException("Release stages cannot contain null.", nameof(stages)); + _stages = stages; + } + + public int CompletedStageCount => _nextStage; + public bool IsComplete => _nextStage == _stages.Length; + + public void Run() + { + // A release stage is allowed to call code which drains retirement + // work. Treat that nested drain as observing the active transaction, + // not as permission to execute the same physical mutation twice. + if (_running) + return; + + _running = true; + try + { + while (_nextStage < _stages.Length) + { + _stages[_nextStage](); + _nextStage++; + } + } + finally + { + _running = false; + } + } +} + +/// +/// Reports whether a throwable GPU operation changed physical ownership +/// before surfacing its error. Stateful resource owners use this for backends +/// or observers which can explicitly prove that ownership changed before an +/// exception. OpenGL error validation remains in the same retryable stage as +/// its command because a GL error means that command did not commit. +/// +internal sealed class GpuResourceMutationException : InvalidOperationException +{ + public GpuResourceMutationException( + string message, + bool mutationCommitted, + Exception innerException) + : base(message, innerException) => MutationCommitted = mutationCommitted; + + public bool MutationCommitted { get; } +} + +/// +/// Retains release ownership until a callback has either run immediately or +/// has been accepted by the frame-flight queue. Queue insertion failure can +/// therefore be retried without losing the only references to old GL names. +/// +internal sealed class GpuRetirementLedger +{ + private readonly IGpuResourceRetirementQueue _queue; + private readonly List _awaitingPublication = []; + private readonly HashSet _publishing = + new(ReferenceEqualityComparer.Instance); + + public GpuRetirementLedger(IGpuResourceRetirementQueue queue) => + _queue = queue ?? throw new ArgumentNullException(nameof(queue)); + + public int AwaitingPublicationCount => _awaitingPublication.Count; + + public void Retire(RetryableGpuResourceRelease release) + { + ArgumentNullException.ThrowIfNull(release); + _awaitingPublication.Add(release); + PublishAt(_awaitingPublication.Count - 1); + } + + public void RetireMany(IEnumerable releases) + { + ArgumentNullException.ThrowIfNull(releases); + RetryableGpuResourceRelease[] batch = releases.ToArray(); + if (batch.Length == 0) + return; + if (Array.Exists(batch, static release => release is null)) + throw new ArgumentException("Retirement batches cannot contain null.", nameof(releases)); + + // Establish ownership for the complete physical set before the first + // queue call. If publication N fails, later resources remain reachable + // and the next maintenance pass can publish every independent member. + _awaitingPublication.AddRange(batch); + List? failures = null; + for (int i = 0; i < batch.Length; i++) + { + try { Publish(batch[i]); } + catch (Exception error) { (failures ??= []).Add(error); } + } + if (failures is not null) + throw new AggregateException( + "One or more GPU retirement callbacks could not be published.", + failures); + } + + public void RetryPendingPublications() + { + RetryableGpuResourceRelease[] pending = _awaitingPublication.ToArray(); + List? failures = null; + for (int i = 0; i < pending.Length; i++) + { + RetryableGpuResourceRelease release = pending[i]; + if (!_awaitingPublication.Contains(release, ReferenceEqualityComparer.Instance) + || _publishing.Contains(release)) + { + continue; + } + + try + { + Publish(release); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } + + if (failures is not null) + throw new AggregateException( + "One or more GPU retirement callbacks could not be published.", + failures); + } + + public void RetryPendingPublication(RetryableGpuResourceRelease release) + { + ArgumentNullException.ThrowIfNull(release); + if (!_awaitingPublication.Contains(release, ReferenceEqualityComparer.Instance) + || _publishing.Contains(release)) + { + return; + } + + Publish(release); + } + + private void PublishAt(int index) + { + RetryableGpuResourceRelease release = _awaitingPublication[index]; + Publish(release); + } + + private void Publish(RetryableGpuResourceRelease release) + { + if (!_publishing.Add(release)) + return; + try + { + _queue.Retire(release.Run); + _awaitingPublication.Remove(release); + } + catch + { + // An immediate queue can throw from the callback itself. If every + // stage committed before a later wrapper failed, no retry remains. + if (release.IsComplete) + _awaitingPublication.Remove(release); + throw; + } + finally + { + _publishing.Remove(release); + } + } +} + +internal sealed class ImmediateGpuResourceRetirementQueue : IGpuResourceRetirementQueue +{ + public static ImmediateGpuResourceRetirementQueue Instance { get; } = new(); + + private ImmediateGpuResourceRetirementQueue() + { + } + + public void Retire(Action release) + { + ArgumentNullException.ThrowIfNull(release); + release(); + } +} + +internal sealed class GpuFrameFlightController : IGpuResourceRetirementQueue, IDisposable +{ + internal const int DefaultMaximumFramesInFlight = 3; + private const ulong WaitSliceNanoseconds = 1_000_000; + + private readonly IGpuFenceApi _fenceApi; + private readonly nint[] _fences; + private readonly long[] _fenceSerials; + private readonly SortedDictionary> _retirements = new(); + private int _slot; + private long _lastSubmittedSerial; + private bool _frameOpen; + private bool _disposed; + + public int CurrentSlot => _slot; + public int SlotCount => _fences.Length; + internal int PendingRetirementCount => _retirements.Sum(entry => entry.Value.Count); + + public GpuFrameFlightController(GL gl, int maximumFramesInFlight = DefaultMaximumFramesInFlight) + : this(new SilkGpuFenceApi(gl), maximumFramesInFlight) + { + } + + internal GpuFrameFlightController( + IGpuFenceApi fenceApi, + int maximumFramesInFlight = DefaultMaximumFramesInFlight) + { + ArgumentNullException.ThrowIfNull(fenceApi); + ArgumentOutOfRangeException.ThrowIfLessThan(maximumFramesInFlight, 1); + + _fenceApi = fenceApi; + _fences = new nint[maximumFramesInFlight]; + _fenceSerials = new long[maximumFramesInFlight]; + } + + /// + /// Waits for the frame currently occupying the next ring slot. The first + /// wait flushes submitted commands so the fence can make progress; later + /// one-millisecond slices avoid an unbounded native blocking call. + /// + public void BeginFrame() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_frameOpen) + throw new InvalidOperationException("EndFrame must close the current frame before BeginFrame is called again."); + + nint fence = _fences[_slot]; + if (fence != 0) + RetireFence(_slot); + + _frameOpen = true; + } + + private void RetireFence(int slot) + { + nint fence = _fences[slot]; + if (fence == 0) + return; + + bool flushCommands = true; + while (true) + { + GpuFenceWaitResult result = _fenceApi.Wait( + fence, + flushCommands, + WaitSliceNanoseconds); + flushCommands = false; + + if (result == GpuFenceWaitResult.Timeout) + continue; + if (result == GpuFenceWaitResult.Failed) + throw new InvalidOperationException("OpenGL failed while waiting for an in-flight frame fence."); + + break; + } + + _fenceApi.Delete(fence); + _fences[slot] = 0; + long completedSerial = _fenceSerials[slot]; + _fenceSerials[slot] = 0; + RunRetirementsThrough(completedSerial); + } + + /// Marks every GL command submitted by the current frame. + public void EndFrame() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_frameOpen) + throw new InvalidOperationException("BeginFrame must be called before EndFrame."); + if (_fences[_slot] != 0) + throw new InvalidOperationException("BeginFrame must retire the current frame slot before EndFrame."); + + nint fence = _fenceApi.Insert(); + if (fence == 0) + throw new InvalidOperationException("OpenGL did not create an in-flight frame fence."); + + _fences[_slot] = fence; + _fenceSerials[_slot] = ++_lastSubmittedSerial; + _frameOpen = false; + _slot = (_slot + 1) % _fences.Length; + } + + /// + /// Defers destruction until the fence covering every draw that could still + /// reference the resource has signaled. Calls made during a render frame + /// include that frame; update-thread calls cover the last submitted frame. + /// + public void Retire(Action release) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(release); + + long targetSerial = _frameOpen + ? _lastSubmittedSerial + 1 + : _lastSubmittedSerial; + if (targetSerial == 0) + { + release(); + return; + } + + if (!_retirements.TryGetValue(targetSerial, out List? releases)) + { + releases = []; + _retirements.Add(targetSerial, releases); + } + + releases.Add(release); + } + + /// + /// Waits for all submitted GL work and runs every resource retirement it + /// protects. Used before orderly renderer teardown while the context lives. + /// + public void WaitForSubmittedWork() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_frameOpen) + throw new InvalidOperationException("Cannot drain submitted work while a render frame is open."); + + List? failures = null; + for (int i = 0; i < _fences.Length; i++) + { + int slot = (_slot + i) % _fences.Length; + try + { + RetireFence(slot); + } + catch (AggregateException ex) + { + (failures ??= []).AddRange(ex.InnerExceptions); + } + } + + try + { + RunRetirementsThrough(_lastSubmittedSerial); + } + catch (AggregateException ex) + { + (failures ??= []).AddRange(ex.InnerExceptions); + } + + if (failures is not null) + throw new AggregateException("One or more GPU resource retirements failed.", failures); + } + + private void RunRetirementsThrough(long completedSerial) + { + List? failures = null; + List<(long Serial, Action Release)>? retry = null; + while (_retirements.Count != 0) + { + KeyValuePair> first; + using (IEnumerator>> enumerator = _retirements.GetEnumerator()) + { + if (!enumerator.MoveNext() || enumerator.Current.Key > completedSerial) + break; + first = enumerator.Current; + } + + if (!_retirements.Remove(first.Key)) + break; + + List releases = first.Value; + for (int i = 0; i < releases.Count; i++) + { + try + { + releases[i](); + } + catch (Exception ex) + { + (failures ??= []).Add(ex); + (retry ??= []).Add((first.Key, releases[i])); + } + } + } + + if (retry is not null) + { + for (int i = 0; i < retry.Count; i++) + { + (long serial, Action release) = retry[i]; + if (!_retirements.TryGetValue(serial, out List? releases)) + { + releases = []; + _retirements.Add(serial, releases); + } + releases.Add(release); + } + } + + if (failures is not null) + throw new AggregateException("One or more GPU resource retirements failed.", failures); + } + + public void Dispose() + { + if (_disposed) + return; + + if (_frameOpen) + EndFrame(); + WaitForSubmittedWork(); + + Array.Clear(_fences); + Array.Clear(_fenceSerials); + _disposed = true; + } +} + +internal enum GpuFenceWaitResult +{ + Signaled, + Timeout, + Failed, +} + +internal interface IGpuFenceApi +{ + nint Insert(); + GpuFenceWaitResult Wait(nint fence, bool flushCommands, ulong timeoutNanoseconds); + void Delete(nint fence); +} + +internal sealed class SilkGpuFenceApi(GL gl) : IGpuFenceApi +{ + private readonly GL _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + + public nint Insert() => + _gl.FenceSync(SyncCondition.SyncGpuCommandsComplete, SyncBehaviorFlags.None); + + public GpuFenceWaitResult Wait(nint fence, bool flushCommands, ulong timeoutNanoseconds) + { + SyncObjectMask flags = flushCommands + ? SyncObjectMask.Bit + : 0; + return _gl.ClientWaitSync(fence, flags, timeoutNanoseconds) switch + { + GLEnum.AlreadySignaled or GLEnum.ConditionSatisfied => GpuFenceWaitResult.Signaled, + GLEnum.TimeoutExpired => GpuFenceWaitResult.Timeout, + GLEnum.WaitFailed => GpuFenceWaitResult.Failed, + GLEnum value => throw new InvalidOperationException( + $"OpenGL returned unexpected fence wait status {value} (0x{(uint)value:X})."), + }; + } + + public void Delete(nint fence) => _gl.DeleteSync(fence); +} diff --git a/src/AcDream.App/Rendering/GpuRetiredTerrainSlotAllocator.cs b/src/AcDream.App/Rendering/GpuRetiredTerrainSlotAllocator.cs new file mode 100644 index 00000000..d1f63505 --- /dev/null +++ b/src/AcDream.App/Rendering/GpuRetiredTerrainSlotAllocator.cs @@ -0,0 +1,89 @@ +using AcDream.Core.Terrain; + +namespace AcDream.App.Rendering; + +/// +/// Adds GPU-frame retirement to the terrain renderer's CPU slot allocator. +/// Removing a landblock stops future draws immediately, but its VBO/EBO slot +/// is not returned for overwrite until older submitted draws have completed. +/// +internal sealed class GpuRetiredTerrainSlotAllocator +{ + private readonly TerrainSlotAllocator _allocator; + private readonly GpuRetirementLedger _retirementLedger; + private readonly Dictionary _pendingReleases = []; + + public GpuRetiredTerrainSlotAllocator( + int initialCapacity, + IGpuResourceRetirementQueue retirement) + { + _allocator = new TerrainSlotAllocator(initialCapacity); + _retirementLedger = new GpuRetirementLedger( + retirement ?? throw new ArgumentNullException(nameof(retirement))); + } + + public int Capacity => _allocator.Capacity; + public int LoadedCount => _allocator.LoadedCount; + internal int PendingReleaseCount => _pendingReleases.Count; + + public int Allocate(out bool needsGrow) => _allocator.Allocate(out needsGrow); + + public void GrowTo(int newCapacity) => _allocator.GrowTo(newCapacity); + + /// + /// Returns a slot whose contents were never published to a GPU draw. No + /// retirement fence is required because no submitted command can refer to + /// it. + /// + public void ReleaseUnsubmitted(int slot) + { + if (_pendingReleases.ContainsKey(slot)) + { + throw new InvalidOperationException( + "A GPU-submitted terrain slot cannot be released as unsubmitted."); + } + + _allocator.Free(slot); + } + + public void FreeAfterGpuUse(int slot) + { + if (_pendingReleases.TryGetValue(slot, out RetryableGpuResourceRelease? pending)) + { + try + { + _retirementLedger.RetryPendingPublication(pending); + } + catch when (pending.IsComplete) + { + // Completion is stronger than publication acknowledgement. + } + return; + } + + var release = new RetryableGpuResourceRelease( + () => _allocator.Free(slot), + () => + { + if (!_pendingReleases.Remove(slot)) + { + throw new InvalidOperationException( + "Terrain-slot retirement lost its ownership record."); + } + }); + _pendingReleases.Add(slot, release); + + try + { + _retirementLedger.Retire(release); + } + catch when (release.IsComplete) + { + // An immediate retirement queue can complete before surfacing a + // wrapper failure. The slot is already safely reusable. + } + } + + public void RetryPendingPublications() => + _retirementLedger.RetryPendingPublications(); +} diff --git a/src/AcDream.App/Rendering/OrderedResourceTeardown.cs b/src/AcDream.App/Rendering/OrderedResourceTeardown.cs new file mode 100644 index 00000000..2d2a62fa --- /dev/null +++ b/src/AcDream.App/Rendering/OrderedResourceTeardown.cs @@ -0,0 +1,36 @@ +namespace AcDream.App.Rendering; + +/// +/// Runs dependency-ordered resource teardown one stage at a time. A failed +/// stage remains current, so a later retries exactly +/// that owner before any dependent resource can be destroyed. +/// +internal sealed class OrderedResourceTeardown(params Action[] stages) +{ + private readonly Action[] _stages = stages ?? throw new ArgumentNullException(nameof(stages)); + private int _nextStage; + private bool _advancing; + + public bool IsComplete => _nextStage == _stages.Length; + internal int NextStage => _nextStage; + + public void Advance() + { + if (_advancing || IsComplete) + return; + + _advancing = true; + try + { + while (_nextStage < _stages.Length) + { + _stages[_nextStage](); + _nextStage++; + } + } + finally + { + _advancing = false; + } + } +} diff --git a/src/AcDream.App/Rendering/OwnerScopedResourceRegistry.cs b/src/AcDream.App/Rendering/OwnerScopedResourceRegistry.cs new file mode 100644 index 00000000..68fe4802 --- /dev/null +++ b/src/AcDream.App/Rendering/OwnerScopedResourceRegistry.cs @@ -0,0 +1,61 @@ +namespace AcDream.App.Rendering; + +/// +/// Tracks shared resources by logical owner. Repeated use of the same key by +/// one owner is idempotent; a key is returned for destruction only after its +/// final owner leaves. Render-thread only. +/// +internal sealed class OwnerScopedResourceRegistry where TKey : notnull +{ + private readonly Dictionary> _keysByOwner = new(); + private readonly Dictionary _ownerCountByKey = new(); + + public int OwnerCount => _keysByOwner.Count; + public int ResourceCount => _ownerCountByKey.Count; + + public bool Acquire(uint ownerId, TKey key) + { + if (ownerId == 0) + throw new ArgumentOutOfRangeException(nameof(ownerId)); + + if (!_keysByOwner.TryGetValue(ownerId, out HashSet? keys)) + { + keys = new HashSet(); + _keysByOwner.Add(ownerId, keys); + } + + if (!keys.Add(key)) + return false; + + _ownerCountByKey[key] = _ownerCountByKey.GetValueOrDefault(key) + 1; + return true; + } + + public IReadOnlyList ReleaseOwner(uint ownerId) + { + if (!_keysByOwner.Remove(ownerId, out HashSet? keys)) + return Array.Empty(); + + List? unowned = null; + foreach (TKey key in keys) + { + int remaining = _ownerCountByKey[key] - 1; + if (remaining > 0) + { + _ownerCountByKey[key] = remaining; + continue; + } + + _ownerCountByKey.Remove(key); + (unowned ??= new List()).Add(key); + } + + return unowned is null ? Array.Empty() : unowned; + } + + public void Clear() + { + _keysByOwner.Clear(); + _ownerCountByKey.Clear(); + } +} diff --git a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs index 0a3f83bd..d432eba7 100644 --- a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs +++ b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs @@ -32,6 +32,7 @@ public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDis private readonly GL _gl; private readonly WbDrawDispatcher _dispatcher; private readonly SceneLightingUboBinding _lightUbo; + private readonly FixedEntityTextureOwnerLease _textureOwnerLease; private readonly DollCamera _camera = new(); // Off-screen target (lazily (re)created on size change). @@ -55,15 +56,28 @@ public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDis // AND what lets the idle animation (later slice, TickAnimations rebuilds MeshRefs) show. private static readonly HashSet _dollAnimatedIds = new() { DollEntityBuilder.DollRenderId }; - public PaperdollViewportRenderer(GL gl, WbDrawDispatcher dispatcher, SceneLightingUboBinding lightUbo) + public PaperdollViewportRenderer( + GL gl, + WbDrawDispatcher dispatcher, + SceneLightingUboBinding lightUbo, + IEntityTextureLifetime textureLifetime) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo)); + _textureOwnerLease = new FixedEntityTextureOwnerLease( + textureLifetime, + DollEntityBuilder.DollRenderId); } /// Set (or clear) the doll entity. GameWindow builds/re-dresses it from the live player. - public void SetDoll(WorldEntity? doll) => _doll = doll; + public void SetDoll(WorldEntity? doll) + { + if (ReferenceEquals(_doll, doll)) + return; + _textureOwnerLease.Replace(doll is not null); + _doll = doll; + } /// public uint Render(int width, int height) @@ -189,5 +203,10 @@ public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDis _fbW = _fbH = 0; } - public void Dispose() => DeleteFramebuffer(); + public void Dispose() + { + _doll = null; + _textureOwnerLease.Dispose(); + DeleteFramebuffer(); + } } diff --git a/src/AcDream.App/Rendering/ParticleEmitterRetirementTracker.cs b/src/AcDream.App/Rendering/ParticleEmitterRetirementTracker.cs new file mode 100644 index 00000000..01a88410 --- /dev/null +++ b/src/AcDream.App/Rendering/ParticleEmitterRetirementTracker.cs @@ -0,0 +1,139 @@ +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Rendering; + +/// +/// Durable, per-emitter teardown ledger. Emitter death removes simulation +/// state first, so renderer-owned resources must retain their own retryable +/// obligations instead of relying on the death event being raised again. +/// +internal sealed class ParticleEmitterRetirementTracker +{ + private sealed class RetirementState + { + public bool MeshReleased; + public bool GfxInfoRemoved; + public bool TextureOwnerReleased; + public bool FailureReported; + } + + private readonly Action _releaseMesh; + private readonly Action _removeGfxInfo; + private readonly Action _releaseTextureOwner; + private readonly Action? _reportFailure; + private readonly Dictionary _pending = []; + private readonly HashSet _advancingHandles = []; + + public ParticleEmitterRetirementTracker( + Action releaseMesh, + Action removeGfxInfo, + Action releaseTextureOwner, + Action? reportFailure = null) + { + _releaseMesh = releaseMesh ?? throw new ArgumentNullException(nameof(releaseMesh)); + _removeGfxInfo = removeGfxInfo ?? throw new ArgumentNullException(nameof(removeGfxInfo)); + _releaseTextureOwner = releaseTextureOwner ?? throw new ArgumentNullException(nameof(releaseTextureOwner)); + _reportFailure = reportFailure; + } + + internal int PendingCount => _pending.Count; + + public void BeginRetirement(int emitterHandle) + { + if (_advancingHandles.Contains(emitterHandle)) + return; + if (!_pending.TryGetValue(emitterHandle, out RetirementState? state)) + { + state = new RetirementState(); + _pending.Add(emitterHandle, state); + } + Advance(emitterHandle, state); + } + + public void RetryPending() + { + if (_pending.Count == 0) + return; + + int[] handles = [.. _pending.Keys]; + for (int i = 0; i < handles.Length; i++) + { + if (_pending.TryGetValue(handles[i], out RetirementState? state)) + Advance(handles[i], state); + } + } + + public void CompleteOrThrow() + { + RetryPending(); + if (_pending.Count != 0) + throw new InvalidOperationException( + $"{_pending.Count} particle-emitter teardown obligation(s) remain incomplete."); + } + + private void Advance(int emitterHandle, RetirementState state) + { + if (!_advancingHandles.Add(emitterHandle)) + return; + + List? failures = null; + try + { + if (!state.MeshReleased) + { + try + { + _releaseMesh(emitterHandle); + state.MeshReleased = true; + } + catch (Exception error) + { + if (error is MeshReferenceMutationException { MutationCommitted: true }) + state.MeshReleased = true; + (failures ??= []).Add(error); + } + } + + if (!state.GfxInfoRemoved) + { + try + { + _removeGfxInfo(emitterHandle); + state.GfxInfoRemoved = true; + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } + + if (!state.TextureOwnerReleased) + { + try + { + _releaseTextureOwner(emitterHandle); + state.TextureOwnerReleased = true; + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } + + if (state.MeshReleased && state.GfxInfoRemoved && state.TextureOwnerReleased) + _pending.Remove(emitterHandle); + + if (failures is not null && !state.FailureReported) + { + state.FailureReported = true; + _reportFailure?.Invoke(new AggregateException( + $"Particle emitter {emitterHandle} teardown will be retried.", + failures)); + } + } + finally + { + _advancingHandles.Remove(emitterHandle); + } + } +} diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index 92969999..c6b8fc19 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -10,6 +10,7 @@ using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using Silk.NET.OpenGL; +using RuntimeParticleEmitter = AcDream.Core.Vfx.ParticleEmitter; namespace AcDream.App.Rendering; @@ -97,25 +98,65 @@ public sealed unsafe class ParticleRenderer : IDisposable private readonly Shader _shader; private readonly Shader? _meshShader; private readonly TextureCache? _textures; - private readonly DatCollection? _dats; + private readonly IDatReaderWriter? _dats; private readonly WbMeshAdapter? _meshAdapter; private readonly ParticleSystem _particles; private readonly RetailAlphaQueue? _alphaQueue; private readonly AlphaDrawSource _alphaSource; private readonly Dictionary _particleGfxInfoByGfxObj = new(); + private readonly Dictionary _particleGfxInfoByEmitter = new(); private readonly Dictionary _geometryKindByGfxObj = new(); private readonly Dictionary _meshBlendBySurface = new(); private readonly ParticleMeshReferenceTracker? _meshReferences; + private readonly ParticleEmitterRetirementTracker _emitterRetirements; + private RetryableResourceReleaseLedger? _disposeResources; + private bool _disposing; + private bool _disposed; private readonly HashSet _meshLoadRequestedThisFrame = new(); private readonly int _meshTextureHandleLoc = -1; private readonly int _meshTextureLayerLoc = -1; - private readonly uint _quadVao; + private uint _quadVao; private readonly uint _quadVbo; private readonly uint _quadEbo; - private readonly uint _instanceVbo; - private readonly uint _meshVao; - private readonly uint _meshInstanceVbo; + private uint _instanceVbo; + private uint _meshVao; + private uint _meshInstanceVbo; + private int _instanceVboCapacityBytes; + private int _meshInstanceVboCapacityBytes; + + private sealed class DynamicBufferSet + { + public uint BillboardVao; + public uint BillboardInstanceVbo; + public int BillboardCapacityBytes; + public uint MeshVao; + public uint MeshInstanceVbo; + public int MeshCapacityBytes; + } + + private readonly List[] _dynamicBufferSetsByFrame = + [[], [], []]; + private int _dynamicFrameSlot; + private int _dynamicBufferSetCursor; + private bool _dynamicFrameStarted; + private DynamicBufferSet? _activeDynamicBufferSet; + + internal (int SetCount, long CapacityBytes) DynamicBufferDiagnostics + { + get + { + int count = 0; + long bytes = 0; + foreach (List frameSets in _dynamicBufferSetsByFrame) + { + count += frameSets.Count; + foreach (DynamicBufferSet set in frameSets) + bytes += set.BillboardCapacityBytes + set.MeshCapacityBytes; + } + return (count, bytes); + } + } private BillboardGpuInstance[] _instanceScratch = new BillboardGpuInstance[256]; private float[] _meshInstanceScratch = new float[256 * 20]; @@ -135,15 +176,22 @@ public sealed unsafe class ParticleRenderer : IDisposable private readonly List _meshDrawListScratch = new(64); private readonly List _meshRunScratch = new(64); private readonly List _submissionScratch = new(128); + private readonly List _scopedEmitterScratch = new(64); private readonly List _deferredAlpha = new(128); + private DeferredParticleDraw[] _preparedAlpha = new DeferredParticleDraw[256]; + private uint[] _preparedInstanceOffsets = new uint[256]; + private int _preparedAlphaCount; private sealed class AlphaDrawSource(ParticleRenderer owner) : IRetailAlphaDrawSource { - public void DrawAlphaBatch(ReadOnlySpan tokens) - => owner.DrawDeferredAlphaBatch(tokens); + public void PrepareAlphaDraws(ReadOnlySpan tokens) + => owner.PrepareDeferredAlphaDraws(tokens); + + public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount) + => owner.DrawPreparedAlphaBatch(firstPreparedDraw, drawCount); public void ResetAlphaSubmissions() - => owner._deferredAlpha.Clear(); + => owner.ResetDeferredAlpha(); } internal ParticleRenderer( @@ -151,7 +199,7 @@ public sealed unsafe class ParticleRenderer : IDisposable string shadersDir, ParticleSystem particles, TextureCache? textures = null, - DatCollection? dats = null, + IDatReaderWriter? dats = null, WbMeshAdapter? meshAdapter = null, RetailAlphaQueue? alphaQueue = null) { @@ -168,6 +216,11 @@ public sealed unsafe class ParticleRenderer : IDisposable gfxObjId => _meshAdapter.IncrementRefCount(gfxObjId), gfxObjId => _meshAdapter.DecrementRefCount(gfxObjId)); } + _emitterRetirements = new ParticleEmitterRetirementTracker( + handle => _meshReferences?.Release(handle), + handle => _particleGfxInfoByEmitter.Remove(handle), + handle => _textures?.ReleaseParticleTextureOwner(handle), + error => Console.Error.WriteLine($"[particles] {error}")); _shader = new Shader(_gl, System.IO.Path.Combine(shadersDir, "particle.vert"), System.IO.Path.Combine(shadersDir, "particle.frag")); @@ -189,78 +242,81 @@ public sealed unsafe class ParticleRenderer : IDisposable }; uint[] quadIdx = { 0, 1, 2, 0, 2, 3 }; - _quadVao = _gl.GenVertexArray(); - _gl.BindVertexArray(_quadVao); - - _quadVbo = _gl.GenBuffer(); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _quadVbo); - fixed (void* p = quadVerts) + uint quadVbo = 0; + uint quadEbo = 0; + uint uploadVao = 0; + bool vboAllocated = false; + bool eboAllocated = false; + try { - _gl.BufferData( - BufferTargetARB.ArrayBuffer, - (nuint)(quadVerts.Length * sizeof(float)), - p, - BufferUsageARB.StaticDraw); + quadVbo = TrackedGlResource.CreateBuffer(_gl, "creating particle quad VBO"); + fixed (void* p = quadVerts) + { + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.ArrayBuffer, + quadVbo, + 0, + quadVerts.Length * sizeof(float), + GLEnum.StaticDraw, + p, + "uploading particle quad VBO"); + } + vboAllocated = true; + + quadEbo = TrackedGlResource.CreateBuffer(_gl, "creating particle quad EBO"); + uploadVao = TrackedGlResource.CreateVertexArray(_gl, "creating particle upload VAO"); + _gl.BindVertexArray(uploadVao); + fixed (void* p = quadIdx) + { + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.ElementArrayBuffer, + quadEbo, + 0, + quadIdx.Length * sizeof(uint), + GLEnum.StaticDraw, + p, + "uploading particle quad EBO"); + } + eboAllocated = true; + _gl.BindVertexArray(0); + TrackedGlResource.DeleteVertexArray(_gl, uploadVao, "deleting particle upload VAO"); + uploadVao = 0; + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + _quadVbo = quadVbo; + _quadEbo = quadEbo; } - - _gl.EnableVertexAttribArray(0); - _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), (void*)0); - _gl.EnableVertexAttribArray(1); - _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), (void*)(2 * sizeof(float))); - - _quadEbo = _gl.GenBuffer(); - _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _quadEbo); - fixed (void* p = quadIdx) + catch (Exception creationFailure) { - _gl.BufferData( - BufferTargetARB.ElementArrayBuffer, - (nuint)(quadIdx.Length * sizeof(uint)), - p, - BufferUsageARB.StaticDraw); + List? cleanupFailures = null; + void Attempt(Action action) + { + try { action(); } + catch (Exception ex) { (cleanupFailures ??= []).Add(ex); } + } + if (uploadVao != 0) + Attempt(() => TrackedGlResource.DeleteVertexArray(_gl, uploadVao, "rolling back particle upload VAO")); + if (quadEbo != 0) + Attempt(() => TrackedGlResource.DeleteBuffer( + _gl, + quadEbo, + eboAllocated ? quadIdx.Length * sizeof(uint) : 0, + "rolling back particle quad EBO")); + if (quadVbo != 0) + Attempt(() => TrackedGlResource.DeleteBuffer( + _gl, + quadVbo, + vboAllocated ? quadVerts.Length * sizeof(float) : 0, + "rolling back particle quad VBO")); + if (cleanupFailures is not null) + { + cleanupFailures.Insert(0, creationFailure); + throw new AggregateException("Particle static-buffer creation and rollback failed.", cleanupFailures); + } + throw; } - _instanceVbo = _gl.GenBuffer(); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo); - uint instanceStride = (uint)sizeof(BillboardGpuInstance); - _gl.BufferData( - BufferTargetARB.ArrayBuffer, - (nuint)(256 * instanceStride), - (void*)0, - BufferUsageARB.DynamicDraw); - - _gl.EnableVertexAttribArray(2); - _gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)0); - _gl.VertexAttribDivisor(2, 1); - _gl.EnableVertexAttribArray(3); - _gl.VertexAttribPointer(3, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(4 * sizeof(float))); - _gl.VertexAttribDivisor(3, 1); - _gl.EnableVertexAttribArray(4); - _gl.VertexAttribPointer(4, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(8 * sizeof(float))); - _gl.VertexAttribDivisor(4, 1); - _gl.EnableVertexAttribArray(5); - _gl.VertexAttribPointer(5, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(12 * sizeof(float))); - _gl.VertexAttribDivisor(5, 1); - _gl.EnableVertexAttribArray(6); - _gl.VertexAttribIPointer( - 6, - 2, - VertexAttribIType.UnsignedInt, - instanceStride, - (void*)(16 * sizeof(float))); - _gl.VertexAttribDivisor(6, 1); - - _gl.BindVertexArray(0); - - _meshVao = _gl.GenVertexArray(); - _meshInstanceVbo = _gl.GenBuffer(); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _meshInstanceVbo); - _gl.BufferData( - BufferTargetARB.ArrayBuffer, - (nuint)(256 * 20 * sizeof(float)), - (void*)0, - BufferUsageARB.DynamicDraw); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); - _particles.EmitterDied += OnEmitterDied; } @@ -269,8 +325,19 @@ public sealed unsafe class ParticleRenderer : IDisposable /// request per missing GfxObj even though portal slicing may invoke Draw /// many times during the frame. /// - public void BeginFrame() - => _meshLoadRequestedThisFrame.Clear(); + public void BeginFrame(int frameSlot) + { + if ((uint)frameSlot >= (uint)_dynamicBufferSetsByFrame.Length) + throw new ArgumentOutOfRangeException(nameof(frameSlot)); + + _dynamicFrameSlot = frameSlot; + _dynamicBufferSetCursor = 0; + _dynamicFrameStarted = true; + _activeDynamicBufferSet = null; + _meshLoadRequestedThisFrame.Clear(); + _emitterRetirements.RetryPending(); + _textures?.TickParticleTextureCache(); + } public void Draw( ICamera camera, @@ -284,7 +351,48 @@ public sealed unsafe class ParticleRenderer : IDisposable Matrix4x4.Invert(camera.View, out var invView); Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13)); Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23)); - BuildDrawLists(cameraWorldPos, renderPass, cameraRight, cameraUp, emitterFilter); + BuildDrawLists( + cameraWorldPos, + renderPass, + cameraRight, + cameraUp, + emitterFilter, + scopedEmitters: null); + FinishDraw(camera, renderPass); + } + + public void DrawForOwners( + ICamera camera, + Vector3 cameraWorldPos, + ParticleRenderPass renderPass, + IReadOnlySet attachedOwnerIds, + bool includeUnattached = false, + IReadOnlySet? excludedAttachedOwnerIds = null) + { + if (camera is null) + return; + + _particles.CopyRenderableEmittersForOwners( + renderPass, + attachedOwnerIds, + includeUnattached, + _scopedEmitterScratch, + excludedAttachedOwnerIds); + Matrix4x4.Invert(camera.View, out Matrix4x4 invView); + Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13)); + Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23)); + BuildDrawLists( + cameraWorldPos, + renderPass, + cameraRight, + cameraUp, + emitterFilter: null, + _scopedEmitterScratch); + FinishDraw(camera, renderPass); + } + + private void FinishDraw(ICamera camera, ParticleRenderPass renderPass) + { if (_submissionScratch.Count == 0) return; @@ -326,12 +434,12 @@ public sealed unsafe class ParticleRenderer : IDisposable { ParticleSubmissionOrdering.Sort(_submissionScratch); GlobalMeshBuffer? global = _meshAdapter?.MeshManager?.GlobalBuffer; + Matrix4x4 viewProjection = camera.View * camera.Projection; _gl.Enable(EnableCap.DepthTest); _gl.Enable(EnableCap.Blend); _gl.DepthMask(false); - ParticleSubmissionKind? activeKind = null; for (int i = 0; i < _submissionScratch.Count;) { ParticleSubmission submission = _submissionScratch[i]; @@ -339,12 +447,6 @@ public sealed unsafe class ParticleRenderer : IDisposable { ParticleDraw draw = _drawListScratch[submission.DrawIndex]; BatchKey key = draw.Key; - if (activeKind != ParticleSubmissionKind.Billboard) - { - PrepareBillboardPipeline(camera.View * camera.Projection); - activeKind = ParticleSubmissionKind.Billboard; - } - _runScratch.Clear(); do { @@ -360,7 +462,7 @@ public sealed unsafe class ParticleRenderer : IDisposable _gl.BlendFunc( BlendingFactor.SrcAlpha, key.Additive ? BlendingFactor.One : BlendingFactor.OneMinusSrcAlpha); - DrawInstances(_runScratch); + DrawInstances(_runScratch, viewProjection); continue; } @@ -373,12 +475,6 @@ public sealed unsafe class ParticleRenderer : IDisposable MeshParticleDraw meshDraw = _meshDrawListScratch[submission.DrawIndex]; MeshBatchKey meshKey = meshDraw.Key; ObjectRenderBatch batch = meshDraw.Batch; - if (activeKind != ParticleSubmissionKind.Mesh) - { - PrepareMeshPipeline(camera.View * camera.Projection, global); - activeKind = ParticleSubmissionKind.Mesh; - } - _meshRunScratch.Clear(); do { @@ -412,6 +508,7 @@ public sealed unsafe class ParticleRenderer : IDisposable _gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex); UploadMeshInstances(_meshRunScratch); + PrepareMeshPipeline(viewProjection, global); _gl.DrawElementsInstancedBaseVertex( PrimitiveType.Triangles, (uint)batch.IndexCount, @@ -427,11 +524,79 @@ public sealed unsafe class ParticleRenderer : IDisposable _gl.Disable(EnableCap.CullFace); } - private void DrawDeferredAlphaBatch(ReadOnlySpan tokens) + private void PrepareDeferredAlphaDraws(ReadOnlySpan tokens) { if (tokens.Length == 0) return; + ActivateNextDynamicBufferSet(); + + int count = tokens.Length; + if (_preparedAlpha.Length < count) + Array.Resize(ref _preparedAlpha, count + 256); + if (_preparedInstanceOffsets.Length < count) + Array.Resize(ref _preparedInstanceOffsets, count + 256); + if (_instanceScratch.Length < count) + Array.Resize(ref _instanceScratch, count + 256); + int neededMeshFloats = count * 20; + if (_meshInstanceScratch.Length < neededMeshFloats) + Array.Resize(ref _meshInstanceScratch, neededMeshFloats + 256 * 20); + + int billboardCount = 0; + int meshCount = 0; + for (int i = 0; i < count; i++) + { + DeferredParticleDraw deferred = _deferredAlpha[tokens[i]]; + _preparedAlpha[i] = deferred; + if (deferred.Kind == ParticleSubmissionKind.Billboard) + { + _preparedInstanceOffsets[i] = (uint)billboardCount; + WriteBillboardGpuInstance( + ref _instanceScratch[billboardCount++], + deferred.Billboard.Instance); + } + else + { + _preparedInstanceOffsets[i] = (uint)meshCount; + WriteMeshGpuInstance( + _meshInstanceScratch, + meshCount++ * 20, + deferred.Mesh.Instance); + } + } + + if (billboardCount > 0) + { + fixed (void* bp = _instanceScratch) + UploadDynamicArrayBuffer( + _instanceVbo, + ref _instanceVboCapacityBytes, + bp, + billboardCount * sizeof(BillboardGpuInstance)); + } + + if (meshCount > 0) + { + fixed (void* mp = _meshInstanceScratch) + UploadDynamicArrayBuffer( + _meshInstanceVbo, + ref _meshInstanceVboCapacityBytes, + mp, + meshCount * 20 * sizeof(float)); + } + + PersistActiveDynamicBufferCapacities(); + _preparedAlphaCount = count; + } + + private void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount) + { + if (drawCount <= 0) + return; + if (firstPreparedDraw < 0 + || firstPreparedDraw > _preparedAlphaCount - drawCount) + throw new ArgumentOutOfRangeException(nameof(firstPreparedDraw)); + GlobalMeshBuffer? global = _meshAdapter?.MeshManager?.GlobalBuffer; _gl.Enable(EnableCap.DepthTest); _gl.Enable(EnableCap.Blend); @@ -439,10 +604,11 @@ public sealed unsafe class ParticleRenderer : IDisposable ParticleSubmissionKind? activeKind = null; Matrix4x4 activeViewProjection = default; - int i = 0; - while (i < tokens.Length) + int i = firstPreparedDraw; + int preparedEnd = firstPreparedDraw + drawCount; + while (i < preparedEnd) { - DeferredParticleDraw deferred = _deferredAlpha[tokens[i]]; + DeferredParticleDraw deferred = _preparedAlpha[i]; if (deferred.Kind == ParticleSubmissionKind.Billboard) { ParticleDraw draw = deferred.Billboard; @@ -455,14 +621,14 @@ public sealed unsafe class ParticleRenderer : IDisposable activeViewProjection = deferred.ViewProjection; } - _runScratch.Clear(); + uint baseInstance = _preparedInstanceOffsets[i]; + int runStart = i; do { - _runScratch.Add(deferred.Billboard.Instance); i++; - if (i >= tokens.Length) + if (i >= preparedEnd) break; - deferred = _deferredAlpha[tokens[i]]; + deferred = _preparedAlpha[i]; } while (deferred.Kind == ParticleSubmissionKind.Billboard && deferred.Billboard.Key == key @@ -471,7 +637,14 @@ public sealed unsafe class ParticleRenderer : IDisposable _gl.BlendFunc( BlendingFactor.SrcAlpha, key.Additive ? BlendingFactor.One : BlendingFactor.OneMinusSrcAlpha); - DrawInstances(_runScratch); + _gl.BindVertexArray(_quadVao); + _gl.DrawElementsInstancedBaseInstance( + PrimitiveType.Triangles, + 6, + DrawElementsType.UnsignedInt, + (void*)0, + (uint)(i - runStart), + baseInstance); continue; } @@ -492,14 +665,14 @@ public sealed unsafe class ParticleRenderer : IDisposable activeViewProjection = deferred.ViewProjection; } - _meshRunScratch.Clear(); + uint meshBaseInstance = _preparedInstanceOffsets[i]; + int meshRunStart = i; do { - _meshRunScratch.Add(deferred.Mesh.Instance); i++; - if (i >= tokens.Length) + if (i >= preparedEnd) break; - deferred = _deferredAlpha[tokens[i]]; + deferred = _preparedAlpha[i]; } while (deferred.Kind == ParticleSubmissionKind.Mesh && deferred.Mesh.Key == meshKey @@ -525,14 +698,15 @@ public sealed unsafe class ParticleRenderer : IDisposable (uint)(batch.BindlessTextureHandle >> 32)); _gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex); - UploadMeshInstances(_meshRunScratch); - _gl.DrawElementsInstancedBaseVertex( + _gl.BindVertexArray(_meshVao); + _gl.DrawElementsInstancedBaseVertexBaseInstance( PrimitiveType.Triangles, (uint)batch.IndexCount, DrawElementsType.UnsignedShort, (void*)(batch.FirstIndex * sizeof(ushort)), - (uint)_meshRunScratch.Count, - (int)batch.BaseVertex); + (uint)(i - meshRunStart), + (int)batch.BaseVertex, + meshBaseInstance); } _gl.BindVertexArray(0); @@ -541,6 +715,12 @@ public sealed unsafe class ParticleRenderer : IDisposable _gl.Disable(EnableCap.CullFace); } + private void ResetDeferredAlpha() + { + _deferredAlpha.Clear(); + _preparedAlphaCount = 0; + } + private void PrepareBillboardPipeline(Matrix4x4 viewProjection) { _shader.Use(); @@ -594,80 +774,107 @@ public sealed unsafe class ParticleRenderer : IDisposable ParticleRenderPass renderPass, Vector3 cameraRight, Vector3 cameraUp, - Func? emitterFilter) + Func? emitterFilter, + IReadOnlyList? scopedEmitters) { var draws = _drawListScratch; draws.Clear(); _meshDrawListScratch.Clear(); _submissionScratch.Clear(); int sequence = 0; - foreach (var em in _particles.EnumerateEmitters()) + if (scopedEmitters is not null) { - if (!em.PresentationVisible || !em.ViewEligible) - continue; - if (em.RenderPass != renderPass) - continue; - if (emitterFilter is not null && !emitterFilter(em)) - continue; - - for (int idx = 0; idx < em.Particles.Length; idx++) + for (int i = 0; i < scopedEmitters.Count; i++) { - ref var p = ref em.Particles[idx]; - if (!p.Alive) - continue; + AppendEmitterDraws( + scopedEmitters[i], + cameraWorldPos, + cameraRight, + cameraUp, + ref sequence); + } + return; + } + + foreach (RuntimeParticleEmitter emitter in _particles.EnumerateRenderableEmitters(renderPass)) + { + if (emitterFilter is null || emitterFilter(emitter)) + AppendEmitterDraws(emitter, cameraWorldPos, cameraRight, cameraUp, ref sequence); + } + } + + private void AppendEmitterDraws( + RuntimeParticleEmitter em, + Vector3 cameraWorldPos, + Vector3 cameraRight, + Vector3 cameraUp, + ref int sequence) + { + List draws = _drawListScratch; + ParticleGfxInfo gfxInfo = default; + bool gfxInfoResolved = false; + + for (int idx = 0; idx < em.Particles.Length; idx++) + { + ref Particle p = ref em.Particles[idx]; + if (!p.Alive) + continue; // `p.Position` is already in world coordinates: AttachLocal // emitters get their AnchorPos refreshed each frame by the // owning subsystem (sky-PES driver, animation tick, etc.) which // mirrors retail's live-parent-frame read at // ParticleEmitter::UpdateParticles 0x0051d2d4 for is_parent_local=1. - Vector3 pos = p.Position; - uint gfxObjId = em.Desc.HwGfxObjId != 0 ? em.Desc.HwGfxObjId : em.Desc.GfxObjId; - if (gfxObjId != 0 - && ResolveGeometryKind(gfxObjId) == RetailParticleGeometryKind.FullMesh - && TryAppendMeshDraws(em, p, gfxObjId, cameraWorldPos, ref sequence)) - { - continue; - } - - var gfxInfo = ResolveParticleGfxInfo(em.Desc); - bool additive = gfxInfo.HasMaterial - ? gfxInfo.Additive - : (em.Desc.Flags & EmitterFlags.Additive) != 0; - var key = new BatchKey(additive); - Vector3 axisX; - Vector3 axisY; - if (gfxInfo.IsBillboard) - { - pos += Vector3.UnitZ * (gfxInfo.CenterOffset.Z * p.Size); - axisX = cameraRight * (gfxInfo.Size.X * p.Size); - axisY = cameraUp * (gfxInfo.Size.Y * p.Size); - } - else - { - Quaternion orientation = ParticleOrientation(em, p); - pos += Vector3.Transform(gfxInfo.CenterOffset * p.Size, orientation); - axisX = Vector3.Transform(gfxInfo.AxisX, orientation) * (gfxInfo.Size.X * p.Size); - axisY = Vector3.Transform(gfxInfo.AxisY, orientation) * (gfxInfo.Size.Y * p.Size); - } - - float distSq = Vector3.DistanceSquared(pos, cameraWorldPos); - - int drawIndex = draws.Count; - draws.Add(new ParticleDraw( - key, - new ParticleInstance( - pos, - axisX, - axisY, - p.ColorArgb, - gfxInfo.TextureHandle, - distSq))); - _submissionScratch.Add(new ParticleSubmission( - ParticleSubmissionKind.Billboard, - drawIndex, - distSq, - sequence++)); + Vector3 pos = p.Position; + uint gfxObjId = em.Desc.HwGfxObjId != 0 ? em.Desc.HwGfxObjId : em.Desc.GfxObjId; + if (gfxObjId != 0 + && ResolveGeometryKind(gfxObjId) == RetailParticleGeometryKind.FullMesh + && TryAppendMeshDraws(em, p, gfxObjId, cameraWorldPos, ref sequence)) + { + continue; } + + if (!gfxInfoResolved) + { + gfxInfo = ResolveParticleGfxInfo(em); + gfxInfoResolved = true; + } + bool additive = gfxInfo.HasMaterial + ? gfxInfo.Additive + : (em.Desc.Flags & EmitterFlags.Additive) != 0; + var key = new BatchKey(additive); + Vector3 axisX; + Vector3 axisY; + if (gfxInfo.IsBillboard) + { + pos += Vector3.UnitZ * (gfxInfo.CenterOffset.Z * p.Size); + axisX = cameraRight * (gfxInfo.Size.X * p.Size); + axisY = cameraUp * (gfxInfo.Size.Y * p.Size); + } + else + { + Quaternion orientation = ParticleOrientation(em, p); + pos += Vector3.Transform(gfxInfo.CenterOffset * p.Size, orientation); + axisX = Vector3.Transform(gfxInfo.AxisX, orientation) * (gfxInfo.Size.X * p.Size); + axisY = Vector3.Transform(gfxInfo.AxisY, orientation) * (gfxInfo.Size.Y * p.Size); + } + + float distSq = Vector3.DistanceSquared(pos, cameraWorldPos); + + int drawIndex = draws.Count; + draws.Add(new ParticleDraw( + key, + new ParticleInstance( + pos, + axisX, + axisY, + p.ColorArgb, + gfxInfo.TextureHandle, + distSq))); + _submissionScratch.Add(new ParticleSubmission( + ParticleSubmissionKind.Billboard, + drawIndex, + distSq, + sequence++)); } } @@ -722,88 +929,240 @@ public sealed unsafe class ParticleRenderer : IDisposable return true; } - private void DrawInstances(List instances) + private void DrawInstances(List instances, Matrix4x4 viewProjection) { if (instances.Count == 0) return; + ActivateNextDynamicBufferSet(); + if (_instanceScratch.Length < instances.Count) _instanceScratch = new BillboardGpuInstance[instances.Count + 256]; for (int i = 0; i < instances.Count; i++) - { - var p = instances[i]; - _instanceScratch[i] = new BillboardGpuInstance - { - Center = new Vector4(p.Position, 0f), - AxisX = new Vector4(p.AxisX, 0f), - AxisY = new Vector4(p.AxisY, 0f), - Color = new Vector4( - ((p.ColorArgb >> 16) & 0xFF) / 255f, - ((p.ColorArgb >> 8) & 0xFF) / 255f, - (p.ColorArgb & 0xFF) / 255f, - ((p.ColorArgb >> 24) & 0xFF) / 255f), - TextureHandleLow = (uint)(p.TextureHandle & 0xFFFFFFFFu), - TextureHandleHigh = (uint)(p.TextureHandle >> 32), - }; - } + WriteBillboardGpuInstance(ref _instanceScratch[i], instances[i]); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo); fixed (void* bp = _instanceScratch) - { - _gl.BufferData( - BufferTargetARB.ArrayBuffer, - (nuint)(instances.Count * sizeof(BillboardGpuInstance)), + UploadDynamicArrayBuffer( + _instanceVbo, + ref _instanceVboCapacityBytes, bp, - BufferUsageARB.DynamicDraw); - } + instances.Count * sizeof(BillboardGpuInstance)); - _gl.BindVertexArray(_quadVao); + PersistActiveDynamicBufferCapacities(); + PrepareBillboardPipeline(viewProjection); _gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, DrawElementsType.UnsignedInt, (void*)0, (uint)instances.Count); } private void UploadMeshInstances(List instances) { + ActivateNextDynamicBufferSet(); int needed = instances.Count * 20; if (_meshInstanceScratch.Length < needed) _meshInstanceScratch = new float[needed + 256 * 20]; for (int i = 0; i < instances.Count; i++) + WriteMeshGpuInstance(_meshInstanceScratch, i * 20, instances[i]); + + fixed (void* bp = _meshInstanceScratch) + UploadDynamicArrayBuffer( + _meshInstanceVbo, + ref _meshInstanceVboCapacityBytes, + bp, + instances.Count * 20 * sizeof(float)); + PersistActiveDynamicBufferCapacities(); + } + + private static void WriteBillboardGpuInstance( + ref BillboardGpuInstance destination, + ParticleInstance particle) + { + destination = new BillboardGpuInstance { - MeshParticleInstance instance = instances[i]; - Matrix4x4 model = instance.Model; - int o = i * 20; - _meshInstanceScratch[o + 0] = model.M11; - _meshInstanceScratch[o + 1] = model.M12; - _meshInstanceScratch[o + 2] = model.M13; - _meshInstanceScratch[o + 3] = model.M14; - _meshInstanceScratch[o + 4] = model.M21; - _meshInstanceScratch[o + 5] = model.M22; - _meshInstanceScratch[o + 6] = model.M23; - _meshInstanceScratch[o + 7] = model.M24; - _meshInstanceScratch[o + 8] = model.M31; - _meshInstanceScratch[o + 9] = model.M32; - _meshInstanceScratch[o + 10] = model.M33; - _meshInstanceScratch[o + 11] = model.M34; - _meshInstanceScratch[o + 12] = model.M41; - _meshInstanceScratch[o + 13] = model.M42; - _meshInstanceScratch[o + 14] = model.M43; - _meshInstanceScratch[o + 15] = model.M44; - _meshInstanceScratch[o + 16] = ((instance.ColorArgb >> 16) & 0xFF) / 255f; - _meshInstanceScratch[o + 17] = ((instance.ColorArgb >> 8) & 0xFF) / 255f; - _meshInstanceScratch[o + 18] = (instance.ColorArgb & 0xFF) / 255f; - _meshInstanceScratch[o + 19] = ((instance.ColorArgb >> 24) & 0xFF) / 255f; + Center = new Vector4(particle.Position, 0f), + AxisX = new Vector4(particle.AxisX, 0f), + AxisY = new Vector4(particle.AxisY, 0f), + Color = new Vector4( + ((particle.ColorArgb >> 16) & 0xFF) / 255f, + ((particle.ColorArgb >> 8) & 0xFF) / 255f, + (particle.ColorArgb & 0xFF) / 255f, + ((particle.ColorArgb >> 24) & 0xFF) / 255f), + TextureHandleLow = (uint)(particle.TextureHandle & 0xFFFFFFFFu), + TextureHandleHigh = (uint)(particle.TextureHandle >> 32), + }; + } + + private static void WriteMeshGpuInstance( + float[] destination, + int offset, + MeshParticleInstance instance) + { + Matrix4x4 model = instance.Model; + destination[offset + 0] = model.M11; + destination[offset + 1] = model.M12; + destination[offset + 2] = model.M13; + destination[offset + 3] = model.M14; + destination[offset + 4] = model.M21; + destination[offset + 5] = model.M22; + destination[offset + 6] = model.M23; + destination[offset + 7] = model.M24; + destination[offset + 8] = model.M31; + destination[offset + 9] = model.M32; + destination[offset + 10] = model.M33; + destination[offset + 11] = model.M34; + destination[offset + 12] = model.M41; + destination[offset + 13] = model.M42; + destination[offset + 14] = model.M43; + destination[offset + 15] = model.M44; + destination[offset + 16] = ((instance.ColorArgb >> 16) & 0xFF) / 255f; + destination[offset + 17] = ((instance.ColorArgb >> 8) & 0xFF) / 255f; + destination[offset + 18] = (instance.ColorArgb & 0xFF) / 255f; + destination[offset + 19] = ((instance.ColorArgb >> 24) & 0xFF) / 255f; + } + + private void ActivateNextDynamicBufferSet() + { + if (!_dynamicFrameStarted) + throw new InvalidOperationException("BeginFrame must be called before drawing particles."); + + List slotSets = _dynamicBufferSetsByFrame[_dynamicFrameSlot]; + if (_dynamicBufferSetCursor == slotSets.Count) + slotSets.Add(CreateDynamicBufferSet()); + + DynamicBufferSet set = slotSets[_dynamicBufferSetCursor++]; + _activeDynamicBufferSet = set; + _quadVao = set.BillboardVao; + _instanceVbo = set.BillboardInstanceVbo; + _instanceVboCapacityBytes = set.BillboardCapacityBytes; + _meshVao = set.MeshVao; + _meshInstanceVbo = set.MeshInstanceVbo; + _meshInstanceVboCapacityBytes = set.MeshCapacityBytes; + } + + private DynamicBufferSet CreateDynamicBufferSet() + { + var set = new DynamicBufferSet(); + try + { + set.BillboardVao = TrackedGlResource.CreateVertexArray(_gl, "creating particle billboard VAO"); + set.BillboardInstanceVbo = TrackedGlResource.CreateBuffer(_gl, "creating particle billboard instance VBO"); + set.MeshVao = TrackedGlResource.CreateVertexArray(_gl, "creating particle mesh VAO"); + set.MeshInstanceVbo = TrackedGlResource.CreateBuffer(_gl, "creating particle mesh instance VBO"); + + _gl.BindVertexArray(set.BillboardVao); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _quadVbo); + _gl.EnableVertexAttribArray(0); + _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), (void*)0); + _gl.EnableVertexAttribArray(1); + _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), (void*)(2 * sizeof(float))); + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _quadEbo); + + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.BillboardInstanceVbo); + uint instanceStride = (uint)sizeof(BillboardGpuInstance); + _gl.EnableVertexAttribArray(2); + _gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)0); + _gl.VertexAttribDivisor(2, 1); + _gl.EnableVertexAttribArray(3); + _gl.VertexAttribPointer(3, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(4 * sizeof(float))); + _gl.VertexAttribDivisor(3, 1); + _gl.EnableVertexAttribArray(4); + _gl.VertexAttribPointer(4, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(8 * sizeof(float))); + _gl.VertexAttribDivisor(4, 1); + _gl.EnableVertexAttribArray(5); + _gl.VertexAttribPointer(5, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(12 * sizeof(float))); + _gl.VertexAttribDivisor(5, 1); + _gl.EnableVertexAttribArray(6); + _gl.VertexAttribIPointer( + 6, + 2, + VertexAttribIType.UnsignedInt, + instanceStride, + (void*)(16 * sizeof(float))); + _gl.VertexAttribDivisor(6, 1); + + _gl.BindVertexArray(0); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + GLHelpers.ThrowOnResourceError(_gl, "configuring particle dynamic VAOs"); + return set; + } + catch (Exception creationFailure) + { + try { DeleteDynamicBufferSet(set); } + catch (Exception cleanupFailure) + { + throw new AggregateException( + "Particle dynamic-buffer creation and rollback failed.", + creationFailure, + cleanupFailure); + } + throw; + } + } + + private void DeleteDynamicBufferSet(DynamicBufferSet set) + { + List? failures = null; + void Attempt(Action action) + { + try { action(); } + catch (Exception ex) { (failures ??= []).Add(ex); } } - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _meshInstanceVbo); - fixed (void* bp = _meshInstanceScratch) + Attempt(() => TrackedGlResource.DeleteBuffer( + _gl, + set.BillboardInstanceVbo, + set.BillboardCapacityBytes, + "deleting particle billboard instance VBO")); + Attempt(() => TrackedGlResource.DeleteBuffer( + _gl, + set.MeshInstanceVbo, + set.MeshCapacityBytes, + "deleting particle mesh instance VBO")); + Attempt(() => TrackedGlResource.DeleteVertexArray( + _gl, + set.BillboardVao, + "deleting particle billboard VAO")); + Attempt(() => TrackedGlResource.DeleteVertexArray( + _gl, + set.MeshVao, + "deleting particle mesh VAO")); + if (failures is not null) + throw new AggregateException("One or more particle dynamic resources failed to delete.", failures); + } + + private void PersistActiveDynamicBufferCapacities() + { + DynamicBufferSet set = _activeDynamicBufferSet + ?? throw new InvalidOperationException("No dynamic particle buffer set is active."); + set.BillboardCapacityBytes = _instanceVboCapacityBytes; + set.MeshCapacityBytes = _meshInstanceVboCapacityBytes; + } + + private void UploadDynamicArrayBuffer( + uint buffer, + ref int capacityBytes, + void* data, + int byteCount) + { + if (byteCount <= 0) + throw new ArgumentOutOfRangeException(nameof(byteCount)); + + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, buffer); + if (capacityBytes < byteCount) { - _gl.BufferData( - BufferTargetARB.ArrayBuffer, - (nuint)(instances.Count * 20 * sizeof(float)), - bp, - BufferUsageARB.DynamicDraw); + int grownCapacity = DynamicBufferCapacity.Grow(capacityBytes, byteCount); + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.ArrayBuffer, + buffer, + capacityBytes, + grownCapacity, + GLEnum.DynamicDraw, + $"growing particle dynamic buffer {buffer} to {grownCapacity} bytes"); + capacityBytes = grownCapacity; } + + _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)byteCount, data); } private void ApplyMeshCullMode(CullMode mode) @@ -874,20 +1233,31 @@ public sealed unsafe class ParticleRenderer : IDisposable } private void OnEmitterDied(int handle) - => _meshReferences?.Release(handle); + { + _emitterRetirements.BeginRetirement(handle); + } - private ParticleGfxInfo ResolveParticleGfxInfo(EmitterDesc desc) + private ParticleGfxInfo ResolveParticleGfxInfo(RuntimeParticleEmitter emitter) { if (_textures is null) return ParticleGfxInfo.Default; + if (_particleGfxInfoByEmitter.TryGetValue(emitter.Handle, out ParticleGfxInfo resolved)) + return resolved; + + EmitterDesc desc = emitter.Desc; if (desc.TextureSurfaceId != 0) - return ParticleGfxInfo.Billboard( - _textures.GetOrUploadBindless(desc.TextureSurfaceId), + { + resolved = ParticleGfxInfo.Billboard( + _textures.AcquireParticleTexture(emitter.Handle, desc.TextureSurfaceId), Vector2.One, Vector3.Zero, additive: (desc.Flags & EmitterFlags.Additive) != 0, - hasMaterial: false); + hasMaterial: false, + surfaceId: desc.TextureSurfaceId); + _particleGfxInfoByEmitter.Add(emitter.Handle, resolved); + return resolved; + } uint gfxObjId = desc.HwGfxObjId != 0 ? desc.HwGfxObjId : desc.GfxObjId; if (gfxObjId == 0 || _dats is null) @@ -899,7 +1269,16 @@ public sealed unsafe class ParticleRenderer : IDisposable _particleGfxInfoByGfxObj[gfxObjId] = info; } - return info.TextureHandle != 0 ? info : ParticleGfxInfo.Default; + resolved = info.SurfaceId == 0 + ? ParticleGfxInfo.Default + : info with + { + TextureHandle = _textures.AcquireParticleTexture( + emitter.Handle, + info.SurfaceId), + }; + _particleGfxInfoByEmitter.Add(emitter.Handle, resolved); + return resolved; } private ParticleGfxInfo ReadParticleGfxInfo(uint gfxObjId) @@ -911,16 +1290,18 @@ public sealed unsafe class ParticleRenderer : IDisposable return ParticleGfxInfo.Default; uint surfaceId = gfx.Surfaces.Count > 0 ? gfx.Surfaces[0].DataId : 0u; - ulong texture = surfaceId != 0 && _textures is not null - ? _textures.GetOrUploadBindless(surfaceId) - : 0u; bool additive = false; if (surfaceId != 0) { var surface = _dats?.Get(surfaceId); additive = surface is not null && surface.Type.HasFlag(SurfaceType.Additive); } - return AuthoredParticleGfxInfo(gfx, texture, additive, surfaceId != 0); + return AuthoredParticleGfxInfo( + gfx, + texture: 0, + additive, + hasMaterial: surfaceId != 0, + surfaceId: surfaceId); } catch { @@ -928,10 +1309,21 @@ public sealed unsafe class ParticleRenderer : IDisposable } } - private ParticleGfxInfo AuthoredParticleGfxInfo(GfxObj gfx, ulong texture, bool additive, bool hasMaterial) + private ParticleGfxInfo AuthoredParticleGfxInfo( + GfxObj gfx, + ulong texture, + bool additive, + bool hasMaterial, + uint surfaceId) { if (gfx.VertexArray.Vertices.Count == 0) - return ParticleGfxInfo.Billboard(texture, Vector2.One, Vector3.Zero, additive, hasMaterial); + return ParticleGfxInfo.Billboard( + texture, + Vector2.One, + Vector3.Zero, + additive, + hasMaterial, + surfaceId); var min = new Vector3(float.PositiveInfinity); var max = new Vector3(float.NegativeInfinity); @@ -947,7 +1339,13 @@ public sealed unsafe class ParticleRenderer : IDisposable { float sx = FallbackParticleExtent(size.X) * 0.9f; float sy = FallbackParticleExtent(size.Z) * 0.9f; - return ParticleGfxInfo.Billboard(texture, new Vector2(sx, sy), center, additive, hasMaterial); + return ParticleGfxInfo.Billboard( + texture, + new Vector2(sx, sy), + center, + additive, + hasMaterial, + surfaceId); } Vector3 axisX; @@ -1001,7 +1399,16 @@ public sealed unsafe class ParticleRenderer : IDisposable planeSize.X = FallbackParticleExtent(planeSize.X); planeSize.Y = FallbackParticleExtent(planeSize.Y); - return new ParticleGfxInfo(texture, planeSize, axisX, axisY, center, false, additive, hasMaterial); + return new ParticleGfxInfo( + texture, + planeSize, + axisX, + axisY, + center, + false, + additive, + hasMaterial, + surfaceId); } private bool IsPointSprite(GfxObj gfx) @@ -1044,17 +1451,162 @@ public sealed unsafe class ParticleRenderer : IDisposable public void Dispose() { - _particles.EmitterDied -= OnEmitterDied; - _meshReferences?.Dispose(); + if (_disposed || _disposing) return; + _disposing = true; + try + { + if (_disposeResources is null) + { + var releases = new List<(string Name, Action Release)>(); + BuildDisposeReleases(releases); + _disposeResources = new RetryableResourceReleaseLedger(releases); + } - _gl.DeleteBuffer(_quadVbo); - _gl.DeleteBuffer(_quadEbo); - _gl.DeleteBuffer(_instanceVbo); - _gl.DeleteBuffer(_meshInstanceVbo); - _gl.DeleteVertexArray(_quadVao); - _gl.DeleteVertexArray(_meshVao); - _shader.Dispose(); - _meshShader?.Dispose(); + ResourceReleaseAttempt attempt = _disposeResources.Advance(); + if (!_disposeResources.IsComplete) + { + throw attempt.ToException( + "One or more particle renderer resources could not be released."); + } + + CompleteDispose(); + _disposeResources = null; + _disposed = true; + + if (attempt.HasFailures) + { + throw attempt.ToException( + "Particle renderer resources released with exceptional committed outcomes."); + } + } + finally + { + _disposing = false; + } + } + + private void BuildDisposeReleases(List<(string Name, Action Release)> releases) + { + releases.Add(("emitter-death-subscription", () => + _particles.EmitterDied -= OnEmitterDied)); + releases.Add(("emitter-resources", RetireEveryResolvedEmitter)); + if (_meshReferences is not null) + releases.Add(("mesh-references", _meshReferences.Dispose)); + + AddTrackedBufferRelease( + releases, + _quadVbo, + 16L * sizeof(float), + "quad-vbo", + "deleting particle quad VBO"); + AddTrackedBufferRelease( + releases, + _quadEbo, + 6L * sizeof(uint), + "quad-ebo", + "deleting particle quad EBO"); + + for (int frame = 0; frame < _dynamicBufferSetsByFrame.Length; frame++) + { + List frameSets = _dynamicBufferSetsByFrame[frame]; + for (int index = 0; index < frameSets.Count; index++) + AddDynamicBufferSetReleases(releases, frameSets[index], frame, index); + } + + releases.Add(("billboard-shader", _shader.Dispose)); + if (_meshShader is not null) + releases.Add(("mesh-shader", _meshShader.Dispose)); + } + + private void RetireEveryResolvedEmitter() + { + int[] handles = [.. _particleGfxInfoByEmitter.Keys]; + for (int i = 0; i < handles.Length; i++) + _emitterRetirements.BeginRetirement(handles[i]); + _emitterRetirements.CompleteOrThrow(); + } + + private void AddDynamicBufferSetReleases( + List<(string Name, Action Release)> releases, + DynamicBufferSet set, + int frame, + int index) + { + AddTrackedBufferRelease( + releases, + set.BillboardInstanceVbo, + set.BillboardCapacityBytes, + $"dynamic-{frame}-{index}-billboard-vbo", + "deleting particle billboard instance VBO"); + AddTrackedBufferRelease( + releases, + set.MeshInstanceVbo, + set.MeshCapacityBytes, + $"dynamic-{frame}-{index}-mesh-vbo", + "deleting particle mesh instance VBO"); + AddTrackedVertexArrayRelease( + releases, + set.BillboardVao, + $"dynamic-{frame}-{index}-billboard-vao", + "deleting particle billboard VAO"); + AddTrackedVertexArrayRelease( + releases, + set.MeshVao, + $"dynamic-{frame}-{index}-mesh-vao", + "deleting particle mesh VAO"); + } + + private void AddTrackedBufferRelease( + List<(string Name, Action Release)> releases, + uint buffer, + long capacityBytes, + string name, + string context) + { + if (buffer == 0) + return; + RetryableGpuResourceRelease release = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + buffer, + capacityBytes, + context); + releases.Add((name, release.Run)); + } + + private void AddTrackedVertexArrayRelease( + List<(string Name, Action Release)> releases, + uint vertexArray, + string name, + string context) + { + if (vertexArray == 0) + return; + RetryableGpuResourceRelease release = + TrackedGlResource.CreateRetryableVertexArrayDeletion( + _gl, + vertexArray, + context); + releases.Add((name, release.Run)); + } + + private void CompleteDispose() + { + foreach (List frameSets in _dynamicBufferSetsByFrame) + frameSets.Clear(); + _activeDynamicBufferSet = null; + _dynamicFrameStarted = false; + _quadVao = 0; + _instanceVbo = 0; + _meshVao = 0; + _meshInstanceVbo = 0; + _instanceVboCapacityBytes = 0; + _meshInstanceVboCapacityBytes = 0; + _particleGfxInfoByEmitter.Clear(); + _particleGfxInfoByGfxObj.Clear(); + _geometryKindByGfxObj.Clear(); + _meshBlendBySurface.Clear(); + _deferredAlpha.Clear(); } private readonly record struct ParticleGfxInfo( @@ -1065,17 +1617,34 @@ public sealed unsafe class ParticleRenderer : IDisposable Vector3 CenterOffset, bool IsBillboard, bool Additive, - bool HasMaterial) + bool HasMaterial, + uint SurfaceId) { public static ParticleGfxInfo Default { get; } = - Billboard(0ul, Vector2.One, Vector3.Zero, additive: false, hasMaterial: false); + Billboard( + 0ul, + Vector2.One, + Vector3.Zero, + additive: false, + hasMaterial: false, + surfaceId: 0); public static ParticleGfxInfo Billboard( ulong textureHandle, Vector2 size, Vector3 centerOffset, bool additive, - bool hasMaterial) => - new(textureHandle, size, Vector3.UnitX, Vector3.UnitY, centerOffset, true, additive, hasMaterial); + bool hasMaterial, + uint surfaceId) => + new( + textureHandle, + size, + Vector3.UnitX, + Vector3.UnitY, + centerOffset, + true, + additive, + hasMaterial, + surfaceId); } } diff --git a/src/AcDream.App/Rendering/ParticleSubmissionOrdering.cs b/src/AcDream.App/Rendering/ParticleSubmissionOrdering.cs index 06b8c584..889555d9 100644 --- a/src/AcDream.App/Rendering/ParticleSubmissionOrdering.cs +++ b/src/AcDream.App/Rendering/ParticleSubmissionOrdering.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using AcDream.App.Rendering.Wb; namespace AcDream.App.Rendering; @@ -42,9 +43,18 @@ internal static class ParticleSubmissionOrdering /// internal sealed class ParticleMeshReferenceTracker : IDisposable { + private sealed class ReferenceState + { + public required uint GfxObjId { get; init; } + public bool Desired { get; set; } + public bool Held { get; set; } + public bool Reconciling { get; set; } + } + private readonly Action _increment; private readonly Action _decrement; - private readonly Dictionary _gfxByEmitter = new(); + private readonly Dictionary _referencesByEmitter = new(); + private bool _disposeRequested; private bool _disposed; public ParticleMeshReferenceTracker(Action increment, Action decrement) @@ -55,28 +65,137 @@ internal sealed class ParticleMeshReferenceTracker : IDisposable public void Register(int emitterHandle, uint gfxObjId) { - ObjectDisposedException.ThrowIf(_disposed, this); - if (_gfxByEmitter.ContainsKey(emitterHandle)) - return; + ObjectDisposedException.ThrowIf(_disposeRequested, this); + if (!_referencesByEmitter.TryGetValue(emitterHandle, out ReferenceState? state)) + { + state = new ReferenceState { GfxObjId = gfxObjId }; + _referencesByEmitter.Add(emitterHandle, state); + } + else if (state.GfxObjId != gfxObjId) + { + throw new InvalidOperationException( + $"Particle emitter {emitterHandle} is already associated with " + + $"GfxObj 0x{state.GfxObjId:X8}, not 0x{gfxObjId:X8}."); + } - _gfxByEmitter.Add(emitterHandle, gfxObjId); - _increment(gfxObjId); + state.Desired = true; + Reconcile(emitterHandle, state); } public void Release(int emitterHandle) { - if (_disposed || !_gfxByEmitter.Remove(emitterHandle, out uint gfxObjId)) + if (_disposed || !_referencesByEmitter.TryGetValue(emitterHandle, out ReferenceState? state)) return; - _decrement(gfxObjId); + + state.Desired = false; + Reconcile(emitterHandle, state); } public void Dispose() { if (_disposed) return; - _disposed = true; - foreach (uint gfxObjId in _gfxByEmitter.Values) - _decrement(gfxObjId); - _gfxByEmitter.Clear(); + + _disposeRequested = true; + List? failures = null; + int[] emitterHandles = [.. _referencesByEmitter.Keys]; + for (int i = 0; i < emitterHandles.Length; i++) + { + int emitterHandle = emitterHandles[i]; + if (!_referencesByEmitter.TryGetValue(emitterHandle, out ReferenceState? state)) + continue; + + state.Desired = false; + try + { + Reconcile(emitterHandle, state); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } + + if (_referencesByEmitter.Count == 0) + _disposed = true; + + if (failures is not null) + throw new AggregateException( + "One or more particle mesh references failed to release.", + failures); + } + + private void Reconcile(int emitterHandle, ReferenceState state) + { + if (state.Reconciling) + return; + + state.Reconciling = true; + Exception? failure = null; + try + { + while (state.Desired != state.Held) + { + if (state.Desired) + { + try + { + _increment(state.GfxObjId); + state.Held = true; + } + catch (MeshReferenceMutationException error) + { + if (error.MutationCommitted) + { + state.Held = true; + failure ??= error; + continue; + } + failure = error; + break; + } + catch (Exception error) + { + failure = error; + break; + } + } + else + { + try + { + _decrement(state.GfxObjId); + state.Held = false; + } + catch (MeshReferenceMutationException error) + { + if (error.MutationCommitted) + { + state.Held = false; + failure ??= error; + continue; + } + failure = error; + break; + } + catch (Exception error) + { + failure = error; + break; + } + } + } + } + finally + { + state.Reconciling = false; + if (!state.Desired && !state.Held) + _referencesByEmitter.Remove(emitterHandle); + if (_disposeRequested && _referencesByEmitter.Count == 0) + _disposed = true; + } + + if (failure is not null) + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw(); } } diff --git a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs index 4f0a6436..d9c20c7f 100644 --- a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs +++ b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs @@ -1,5 +1,7 @@ using System; +using System.Linq; using System.Numerics; +using AcDream.App.Rendering.Wb; using Silk.NET.OpenGL; namespace AcDream.App.Rendering; @@ -80,8 +82,6 @@ void main() { } // depth-only: color writes are masked off by the caller state private readonly GL _gl; private readonly uint _program; - private readonly uint _vao; - private readonly uint _vbo; private readonly int _locViewProjection; private readonly int _locPlaneCount; private readonly int _locPlanes; @@ -92,6 +92,20 @@ void main() { } // depth-only: color writes are masked off by the caller state private const int MaxFanVerts = 32; private readonly float[] _scratch = new float[MaxFanVerts * 3]; + private sealed class FrameBufferSet + { + public uint Vao; + public uint Vbo; + public int CapacityBytes; + public int UsedVertices; + } + + private readonly FrameBufferSet[] _frameBuffers = new FrameBufferSet[3]; + private FrameBufferSet? _activeFrameBuffer; + + internal long DynamicBufferCapacityBytes => + _frameBuffers.Sum(set => (long)set.CapacityBytes); + public PortalDepthMaskRenderer(GL gl) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); @@ -115,19 +129,57 @@ void main() { } // depth-only: color writes are masked off by the caller state _locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias"); _locDepthBiasEyeCapN = _gl.GetUniformLocation(_program, "uDepthBiasEyeCapN"); - _vao = _gl.GenVertexArray(); - _vbo = _gl.GenBuffer(); - _gl.BindVertexArray(_vao); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); - unsafe + for (int i = 0; i < _frameBuffers.Length; i++) + _frameBuffers[i] = CreateFrameBufferSet(); + } + + /// + /// Selects the GPU-fenced frame slot and resets its append cursor. Portal + /// fans written during one frame occupy distinct ranges, so later portal + /// slices never overwrite vertices still referenced by earlier draws. + /// + public void BeginFrame(int frameSlot) + { + if ((uint)frameSlot >= (uint)_frameBuffers.Length) + throw new ArgumentOutOfRangeException(nameof(frameSlot)); + _activeFrameBuffer = _frameBuffers[frameSlot]; + _activeFrameBuffer.UsedVertices = 0; + } + + private FrameBufferSet CreateFrameBufferSet() + { + uint vao = 0; + uint vbo = 0; + try { - _gl.BufferData(BufferTargetARB.ArrayBuffer, - (nuint)(MaxFanVerts * 3 * sizeof(float)), null, BufferUsageARB.DynamicDraw); - _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 3 * sizeof(float), (void*)0); + vao = TrackedGlResource.CreateVertexArray( + _gl, + "PortalDepthMask frame VAO creation"); + vbo = TrackedGlResource.CreateBuffer( + _gl, + "PortalDepthMask frame VBO creation"); + var set = new FrameBufferSet { Vao = vao, Vbo = vbo }; + _gl.BindVertexArray(set.Vao); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.Vbo); + unsafe + { + _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 3 * sizeof(float), (void*)0); + } + _gl.EnableVertexAttribArray(0); + _gl.BindVertexArray(0); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + return set; + } + catch + { + if (vbo != 0) + TrackedGlResource.DeleteBuffer( + _gl, vbo, 0, "PortalDepthMask frame VBO rollback"); + if (vao != 0) + TrackedGlResource.DeleteVertexArray( + _gl, vao, "PortalDepthMask frame VAO rollback"); + throw; } - _gl.EnableVertexAttribArray(0); - _gl.BindVertexArray(0); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); } private uint Compile(ShaderType type, string src) @@ -210,8 +262,12 @@ void main() { } // depth-only: color writes are masked off by the caller state { if (worldVerts.Length < 3) return; + FrameBufferSet frameBuffer = _activeFrameBuffer + ?? throw new InvalidOperationException("BeginFrame must be called before drawing portal depth masks."); int n = Math.Min(worldVerts.Length, MaxFanVerts); int planeCount = Math.Min(planes.Length, 8); + int firstVertex = frameBuffer.UsedVertices; + int requiredBytes = checked((firstVertex + n) * 3 * sizeof(float)); for (int i = 0; i < n; i++) { @@ -249,10 +305,30 @@ void main() { } // depth-only: color writes are masked off by the caller state _gl.Uniform4(_locPlanes, (uint)planeCount, pp); } - _gl.BindVertexArray(_vao); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); + _gl.BindVertexArray(frameBuffer.Vao); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, frameBuffer.Vbo); + if (frameBuffer.CapacityBytes < requiredBytes) + { + int newCapacity = DynamicBufferCapacity.Grow( + frameBuffer.CapacityBytes, + requiredBytes); + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.ArrayBuffer, + frameBuffer.Vbo, + frameBuffer.CapacityBytes, + newCapacity, + GLEnum.DynamicDraw, + "PortalDepthMask frame VBO growth"); + frameBuffer.CapacityBytes = newCapacity; + } fixed (float* v = _scratch) - _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)(n * 3 * sizeof(float)), v); + _gl.BufferSubData( + BufferTargetARB.ArrayBuffer, + (nint)(firstVertex * 3 * sizeof(float)), + (nuint)(n * 3 * sizeof(float)), + v); + frameBuffer.UsedVertices += n; if (!forceFarZ) { @@ -261,7 +337,7 @@ void main() { } // depth-only: color writes are masked off by the caller state _gl.DepthMask(true); _gl.Uniform1(_locForceFarZ, 0); _gl.Uniform1(_locDepthBias, 0f); - _gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n); + _gl.DrawArrays(PrimitiveType.TriangleFan, firstVertex, (uint)n); } else { @@ -276,7 +352,7 @@ void main() { } // depth-only: color writes are masked off by the caller state _gl.Uniform1(_locDepthBias, PunchMarkDepthBias); _gl.Uniform1(_locDepthBiasEyeCapN, PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters); - _gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n); + _gl.DrawArrays(PrimitiveType.TriangleFan, firstVertex, (uint)n); // ── PUNCH pass B: far-Z write on marked pixels only; // zero the stencil as we go (self-cleaning) ── @@ -286,7 +362,7 @@ void main() { } // depth-only: color writes are masked off by the caller state _gl.DepthMask(true); _gl.Uniform1(_locForceFarZ, 1); _gl.Uniform1(_locDepthBias, 0f); - _gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n); + _gl.DrawArrays(PrimitiveType.TriangleFan, firstVertex, (uint)n); _gl.Disable(EnableCap.StencilTest); } @@ -310,7 +386,17 @@ void main() { } // depth-only: color writes are masked off by the caller state public void Dispose() { _gl.DeleteProgram(_program); - _gl.DeleteVertexArray(_vao); - _gl.DeleteBuffer(_vbo); + foreach (FrameBufferSet set in _frameBuffers) + { + TrackedGlResource.DeleteVertexArray( + _gl, + set.Vao, + "PortalDepthMask frame VAO disposal"); + TrackedGlResource.DeleteBuffer( + _gl, + set.Vbo, + set.CapacityBytes, + "PortalDepthMask frame VBO disposal"); + } } } diff --git a/src/AcDream.App/Rendering/PortalMeshReferenceOwner.cs b/src/AcDream.App/Rendering/PortalMeshReferenceOwner.cs new file mode 100644 index 00000000..7110769b --- /dev/null +++ b/src/AcDream.App/Rendering/PortalMeshReferenceOwner.cs @@ -0,0 +1,154 @@ +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Rendering; + +/// +/// Owns the synthetic portal creature's mesh references. Acquisition happens +/// only after the presentation has been published to its runtime owner, so a +/// partial backend failure can never strand an unreachable constructor-local +/// reference. Per-reference physical state makes both acquisition and teardown +/// resumable without replaying successful mutations. +/// +internal sealed class PortalMeshReferenceOwner : IDisposable +{ + private sealed class ReferenceState(ulong gfxObjId) + { + public ulong GfxObjId { get; } = gfxObjId; + public bool Held { get; set; } + } + + private readonly IWbMeshAdapter _meshAdapter; + private readonly ReferenceState[] _references; + private bool _desired; + private bool _disposeRequested; + private bool _disposed; + private bool _reconciling; + private bool _reconcileAgain; + + public PortalMeshReferenceOwner( + IWbMeshAdapter meshAdapter, + IEnumerable gfxObjIds) + { + _meshAdapter = meshAdapter ?? throw new ArgumentNullException(nameof(meshAdapter)); + ArgumentNullException.ThrowIfNull(gfxObjIds); + _references = gfxObjIds + .Where(static id => id != 0u) + .Distinct() + .Select(static id => new ReferenceState(id)) + .ToArray(); + } + + public bool IsDisposed => _disposed; + + public void Acquire() + { + ObjectDisposedException.ThrowIf(_disposeRequested, this); + _desired = true; + try + { + Reconcile(); + ObjectDisposedException.ThrowIf(_disposeRequested, this); + } + catch (Exception acquisitionFailure) + { + // Acquisition is transactional even though the owner is already + // reachable. Roll back every committed sibling; a failed rollback + // remains represented by Held=true so Dispose can resume it. + _desired = false; + try + { + Reconcile(); + } + catch (Exception rollbackFailure) + { + throw new AggregateException( + "Portal mesh acquisition failed and its rollback did not fully converge.", + acquisitionFailure, + rollbackFailure); + } + + System.Runtime.ExceptionServices.ExceptionDispatchInfo + .Capture(acquisitionFailure) + .Throw(); + throw new InvalidOperationException("Unreachable exception dispatch path."); + } + } + + public void Dispose() + { + if (_disposed) + return; + + _disposeRequested = true; + _desired = false; + if (_reconciling) + { + _reconcileAgain = true; + return; + } + + Reconcile(); + } + + private void Reconcile() + { + if (_reconciling) + { + _reconcileAgain = true; + return; + } + + _reconciling = true; + List? failures = null; + try + { + do + { + _reconcileAgain = false; + for (int i = 0; i < _references.Length; i++) + { + ReferenceState reference = _references[i]; + bool targetHeld = _desired; + if (reference.Held == targetHeld) + continue; + + try + { + if (targetHeld) + _meshAdapter.IncrementRefCount(reference.GfxObjId); + else + _meshAdapter.DecrementRefCount(reference.GfxObjId); + reference.Held = targetHeld; + } + catch (Exception error) + { + if (error is MeshReferenceMutationException + { + MutationCommitted: true, + }) + { + reference.Held = targetHeld; + } + + string operation = targetHeld ? "acquisition" : "release"; + (failures ??= []).Add(new InvalidOperationException( + $"Portal mesh 0x{reference.GfxObjId:X10} reference {operation} failed.", + error)); + } + } + } + while (_reconcileAgain); + } + finally + { + _reconciling = false; + if (_disposeRequested && _references.All(static reference => !reference.Held)) + _disposed = true; + } + + if (failures is not null) + throw new AggregateException( + "One or more portal mesh references failed to reconcile.", + failures); + } +} diff --git a/src/AcDream.App/Rendering/PortalProjection.cs b/src/AcDream.App/Rendering/PortalProjection.cs index 587624a3..d6092ded 100644 --- a/src/AcDream.App/Rendering/PortalProjection.cs +++ b/src/AcDream.App/Rendering/PortalProjection.cs @@ -17,6 +17,7 @@ // (w > MinW) keeps a portal you're standing in (it covers the screen) so the cell behind stays // visible. Retail PView::GetClip / ConstructView(CBldPortal) (decomp:432344 / 433832) near-clip the // portal poly likewise before projecting. +using System.Buffers; using System.Collections.Generic; using System.Numerics; @@ -24,21 +25,97 @@ namespace AcDream.App.Rendering; public static class PortalProjection { + internal ref struct ClipPolygonLease + { + private readonly ArrayPool? _pool; + private Vector4[]? _first; + private Vector4[]? _second; + private Vector4[]? _result; + private readonly int _count; + private bool _disposed; + + internal ClipPolygonLease( + ArrayPool? pool, + Vector4[]? first, + Vector4[]? second, + Vector4[]? result, + int count) + { + _pool = pool; + _first = first; + _second = second; + _result = result; + _count = count; + _disposed = false; + } + + public int Count + { + get + { + ThrowIfDisposed(); + return _count; + } + } + + public ReadOnlySpan Span + { + get + { + ThrowIfDisposed(); + return _result is null + ? ReadOnlySpan.Empty + : _result.AsSpan(0, _count); + } + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + ArrayPool? pool = _pool; + if (_first is not null) + pool!.Return(_first); + if (_second is not null) + pool!.Return(_second); + _first = null; + _second = null; + _result = null; + } + + private readonly void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(ClipPolygonLease)); + } + } + /// Project a cell-local polygon to NDC, preserving the projected winding of /// the input (NOT normalized to CCW). The caller (PortalVisibilityBuilder) is responsible /// for feeding camera-facing portal polygons (via the portal-side test) so the result is /// CCW for the CCW-only . Returns fewer than 3 verts when /// the polygon is entirely behind the camera / degenerate. public static Vector2[] ProjectToNdc(IReadOnlyList localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj) + => ProjectToNdc(localPoly, cellToWorld, viewProj, ArrayPool.Shared); + + internal static Vector2[] ProjectToNdc( + IReadOnlyList localPoly, + Matrix4x4 cellToWorld, + Matrix4x4 viewProj, + ArrayPool vectorPool) { if (localPoly == null || localPoly.Count < 3) return System.Array.Empty(); + ArgumentNullException.ThrowIfNull(vectorPool); Matrix4x4 m = cellToWorld * viewProj; - // To clip space (keep w). - var clip = new List(localPoly.Count); - foreach (var lp in localPoly) - clip.Add(Vector4.Transform(new Vector4(lp, 1f), m)); + // A convex polygon can gain at most one vertex at each clipping plane. Keep the two + // Sutherland-Hodgman work buffers in ArrayPool instead of allocating six Lists per portal. + int capacity = checked(localPoly.Count + 5); + Vector4[] first = vectorPool.Rent(capacity); + Vector4[]? second = null; // Homogeneous frustum clip in CLIP SPACE, before the perspective divide. First the // in-front-of-eye half-space (w > MinW) — near-INDEPENDENT, so a portal the camera is @@ -52,22 +129,47 @@ public static class PortalProjection // all survivors have w > 0, making the side-plane functionals (w ± x, w ± y) well defined. // Near/far are intentionally NOT clipped (near-independence). Retail PView::GetClip // (decomp:0x005a4320) projects + frustum-clips the portal poly likewise (research doc A §3.5). - clip = ClipPlane(clip, v => v.W - MinW); // in front of eye (near-independent) - if (clip.Count < 3) return System.Array.Empty(); - clip = ClipPlane(clip, v => v.W + v.X); // left: x/w >= -1 <=> w + x >= 0 - clip = ClipPlane(clip, v => v.W - v.X); // right: x/w <= 1 <=> w - x >= 0 - clip = ClipPlane(clip, v => v.W + v.Y); // bottom: y/w >= -1 <=> w + y >= 0 - clip = ClipPlane(clip, v => v.W - v.Y); // top: y/w <= 1 <=> w - y >= 0 - if (clip.Count < 3) return System.Array.Empty(); - - // Perspective divide → NDC xy. - var ndc = new Vector2[clip.Count]; - for (int i = 0; i < clip.Count; i++) + try { - float w = clip[i].W; - ndc[i] = new Vector2(clip[i].X / w, clip[i].Y / w); + second = vectorPool.Rent(capacity); + int currentCount = localPoly.Count; + for (int i = 0; i < currentCount; i++) + first[i] = Vector4.Transform(new Vector4(localPoly[i], 1f), m); + + Vector4[] current = first; + Vector4[] output = second; + ReadOnlySpan planes = + [ + HomogeneousPlane.EyeMinW, + HomogeneousPlane.Left, + HomogeneousPlane.Right, + HomogeneousPlane.Bottom, + HomogeneousPlane.Top, + ]; + foreach (HomogeneousPlane plane in planes) + { + currentCount = ClipHomogeneousPlane( + current.AsSpan(0, currentCount), output, plane); + if (currentCount < 3) + return System.Array.Empty(); + (current, output) = (output, current); + } + + // Perspective divide → NDC xy. This is the only result allocation. + var ndc = new Vector2[currentCount]; + for (int i = 0; i < currentCount; i++) + { + float w = current[i].W; + ndc[i] = new Vector2(current[i].X / w, current[i].Y / w); + } + return ndc; + } + finally + { + vectorPool.Return(first); + if (second is not null) + vectorPool.Return(second); } - return ndc; } /// Faithful homogeneous projection (retail PrimD3DRender::xformStart + the W=0 clip of @@ -89,24 +191,83 @@ public static class PortalProjection /// when some vertex has w < 0; <3 survivors → reject (empty). public static Vector4[] ProjectToClip(IReadOnlyList localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj) { - if (localPoly == null || localPoly.Count < 3) return System.Array.Empty(); + using ClipPolygonLease lease = ProjectToClipLease(localPoly, cellToWorld, viewProj); + return lease.Count < 3 ? System.Array.Empty() : lease.Span.ToArray(); + } + + internal static ClipPolygonLease ProjectToClipLease( + IReadOnlyList localPoly, + Matrix4x4 cellToWorld, + Matrix4x4 viewProj) + => ProjectToClipLease( + localPoly, + cellToWorld, + viewProj, + ArrayPool.Shared); + + internal static ClipPolygonLease ProjectToClipLease( + IReadOnlyList localPoly, + Matrix4x4 cellToWorld, + Matrix4x4 viewProj, + ArrayPool vectorPool) + { + ArgumentNullException.ThrowIfNull(vectorPool); + if (localPoly == null || localPoly.Count < 3) + return new ClipPolygonLease(null, null, null, null, 0); Matrix4x4 m = cellToWorld * viewProj; - var clip = new List(localPoly.Count); - bool anyBehind = false; - foreach (var lp in localPoly) + Vector4[] transformed = vectorPool.Rent(localPoly.Count); + Vector4[]? clipped = null; + bool success = false; + try { - var v = Vector4.Transform(new Vector4(lp, 1f), m); - if (v.W < 0f) anyBehind = true; - clip.Add(v); - } + clipped = vectorPool.Rent(checked(localPoly.Count + 1)); + bool anyBehind = false; + for (int i = 0; i < localPoly.Count; i++) + { + Vector4 vertex = Vector4.Transform(new Vector4(localPoly[i], 1f), m); + if (vertex.W < 0f) anyBehind = true; + transformed[i] = vertex; + } - // polyClipFinish part 1 (0x006b6d5d): the W pass runs only when some vertex sits behind - // the eye plane (w < 0); an all-in-front polygon passes through untouched (and an - // all-behind one clips to empty inside the pass). - if (anyBehind) - clip = ClipPlane(clip, v => v.W); - return clip.Count >= 3 ? clip.ToArray() : System.Array.Empty(); + // polyClipFinish part 1 (0x006b6d5d): the W pass runs only when some vertex sits behind + // the eye plane (w < 0); an all-in-front polygon passes through untouched (and an + // all-behind one clips to empty inside the pass). + ReadOnlySpan result = transformed.AsSpan(0, localPoly.Count); + if (anyBehind) + { + int count = ClipHomogeneousPlane(result, clipped, HomogeneousPlane.EyeZero); + if (count < 3) + { + success = true; + return new ClipPolygonLease( + vectorPool, + transformed, + clipped, + null, + 0); + } + result = clipped.AsSpan(0, count); + } + + Vector4[] resultArray = anyBehind ? clipped : transformed; + success = true; + return new ClipPolygonLease( + vectorPool, + transformed, + clipped, + resultArray, + result.Length); + } + finally + { + if (!success) + { + vectorPool.Return(transformed); + if (clipped is not null) + vectorPool.Return(clipped); + } + } } /// Clip a homogeneous (clip-space) portal polygon against an NDC view region @@ -120,55 +281,128 @@ public static class PortalProjection if (subjectClip == null || regionCcwNdc == null || subjectClip.Count < 3 || regionCcwNdc.Count < 3) return System.Array.Empty(); + if (subjectClip is Vector4[] array) + return ClipToRegion(array.AsSpan(), regionCcwNdc); + + Vector4[] rented = ArrayPool.Shared.Rent(subjectClip.Count); + try + { + for (int i = 0; i < subjectClip.Count; i++) + rented[i] = subjectClip[i]; + return ClipToRegion(rented.AsSpan(0, subjectClip.Count), regionCcwNdc); + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + internal static Vector2[] ClipToRegion( + ReadOnlySpan subjectClip, + IReadOnlyList regionCcwNdc) + => ClipToRegionCore(subjectClip, regionCcwNdc, vertexStore: null); + + internal static Vector2[] ClipToRegion( + ReadOnlySpan subjectClip, + IReadOnlyList regionCcwNdc, + PortalPolygonVertexStore vertexStore) + => ClipToRegionCore( + subjectClip, + regionCcwNdc, + vertexStore, + ArrayPool.Shared, + ArrayPool.Shared); + + internal static Vector2[] ClipToRegion( + ReadOnlySpan subjectClip, + IReadOnlyList regionCcwNdc, + PortalPolygonVertexStore vertexStore, + ArrayPool vector4Pool) + => ClipToRegionCore( + subjectClip, + regionCcwNdc, + vertexStore, + vector4Pool, + ArrayPool.Shared); + + private static Vector2[] ClipToRegionCore( + ReadOnlySpan subjectClip, + IReadOnlyList regionCcwNdc, + PortalPolygonVertexStore? vertexStore, + ArrayPool? vector4Pool = null, + ArrayPool? vector2Pool = null) + { + if (subjectClip.Length < 3 || regionCcwNdc == null || regionCcwNdc.Count < 3) + return System.Array.Empty(); + vector4Pool ??= ArrayPool.Shared; + vector2Pool ??= ArrayPool.Shared; + // Homogeneous Sutherland-Hodgman: clip the (w > 0) subject against each CCW edge of the NDC // region. f(P) below is the NDC inside test cross(edge, P_ndc - a) multiplied through P.W, // which is > 0 after the eye-plane clip — so the sign is the NDC sign yet no near-eye vertex // is ever divided (retail polyClipFinish, decomp 702749). - var poly = new List(subjectClip); - int n = regionCcwNdc.Count; - for (int e = 0; e < n; e++) - { - if (poly.Count < 3) return System.Array.Empty(); - poly = ClipHomogeneousEdge(poly, regionCcwNdc[e], regionCcwNdc[(e + 1) % n]); - } - if (poly.Count < 3) return System.Array.Empty(); + int regionCount = regionCcwNdc.Count; + int capacity = checked(subjectClip.Length + regionCount); + Vector4[] first = vector4Pool.Rent(capacity); + Vector4[]? second = null; + Vector2[]? ndcScratch = null; - // Divide survivors → NDC. They are inside the region now, so |x| ≤ |w| and |y| ≤ |w|: the - // divide is bounded by construction (this is why the homogeneous clip avoids the early-divide - // blow-up). Normalize to CCW so the result is a valid clip region for the next portal hop. - // - // W=0 port (2026-06-11): with ProjectToClip clipping at exactly w >= 0, a w == 0 vertex - // (a direction) cannot survive the bounded region clip above — a nonzero direction fails at - // least one edge's inside test of any bounded convex region — EXCEPT the measure-zero case - // of a direction lying exactly on a region corner with d == 0 on the adjoining edges. That - // case divides to ±Inf/NaN; treat it as the degenerate knife-edge sliver it is and return - // empty (retail's effective result for the same input: a <1 px degenerate region). - var ndc = new Vector2[poly.Count]; - for (int i = 0; i < poly.Count; i++) + try { - float w = poly[i].W; - var v = new Vector2(poly[i].X / w, poly[i].Y / w); - if (!float.IsFinite(v.X) || !float.IsFinite(v.Y)) + second = vector4Pool.Rent(capacity); + int currentCount = subjectClip.Length; + subjectClip.CopyTo(first); + + Vector4[] current = first; + Vector4[] output = second; + for (int edge = 0; edge < regionCount; edge++) + { + if (currentCount < 3) + return System.Array.Empty(); + int outputCount = ClipHomogeneousEdge( + current.AsSpan(0, currentCount), + output, + regionCcwNdc[edge], + regionCcwNdc[(edge + 1) % regionCount]); + (current, output) = (output, current); + currentCount = outputCount; + } + if (currentCount < 3) return System.Array.Empty(); - ndc[i] = v; + + // Divide survivors → NDC. They are already inside the bounded region. A w=0 + // measure-zero corner remains the same empty knife-edge result as the prior path. + ndcScratch = vector2Pool.Rent(currentCount); + Span ndc = ndcScratch.AsSpan(0, currentCount); + for (int i = 0; i < currentCount; i++) + { + float w = current[i].W; + var vertex = new Vector2(current[i].X / w, current[i].Y / w); + if (!float.IsFinite(vertex.X) || !float.IsFinite(vertex.Y)) + return System.Array.Empty(); + ndc[i] = vertex; + } + + // T2 (BR-4): retail's post-divide ~1-pixel vertex merge. Compact in place; only a + // genuinely shortened result needs a second exactly-sized output array. + int mergedCount = MergeSubPixelVertices(ndc); + if (mergedCount < 3) + return System.Array.Empty(); + Vector2[] merged = vertexStore?.Rent(mergedCount) + ?? GC.AllocateUninitializedArray(mergedCount); + ndc[..mergedCount].CopyTo(merged); + + EnsureCcw(merged); + return merged; + } + finally + { + vector4Pool.Return(first); + if (second is not null) + vector4Pool.Return(second); + if (ndcScratch is not null) + vector2Pool.Return(ndcScratch); } - - // T2 (BR-4): retail's post-divide vertex merge — Render::copy_view - // (Ghidra 0x0054dfc0) collapses consecutive vertices closer than ~1 - // PIXEL (|dx|<=1 && |dy|<=1 screen units) after the perspective divide. - // This is the flood's physical fixpoint floor: re-clipping a view can - // only insert sub-pixel sliver vertices, which this merge removes, so - // accumulated views converge instead of drifting (the drift is what - // forced the MaxReprocessPerCell=16 cap). Unit approximation: the - // builder has no viewport, so 1 px is expressed in NDC at a reference - // 1080p (2/1080 ≈ 0.00185); at higher resolutions the merge is merely - // slightly coarser than retail's, which only strengthens convergence. - var merged = MergeSubPixelVertices(ndc); - if (merged.Length < 3) - return System.Array.Empty(); - - EnsureCcw(merged); - return merged; } // Retail copy_view's ~1-pixel vertex merge (see ClipToRegion). Collapses @@ -178,47 +412,52 @@ public static class PortalProjection // "<3 surviving verts → output count 0". private const float VertexMergeEpsilonNdc = 2f / 1080f; - private static Vector2[] MergeSubPixelVertices(Vector2[] poly) + private static int MergeSubPixelVertices(Span poly) { - if (poly.Length < 3) return poly; - var kept = new List(poly.Length); - foreach (var v in poly) + if (poly.Length < 3) return poly.Length; + int kept = 0; + for (int i = 0; i < poly.Length; i++) { - if (kept.Count > 0) + Vector2 vertex = poly[i]; + if (kept > 0) { - var prev = kept[^1]; - if (MathF.Abs(v.X - prev.X) <= VertexMergeEpsilonNdc - && MathF.Abs(v.Y - prev.Y) <= VertexMergeEpsilonNdc) + Vector2 previous = poly[kept - 1]; + if (MathF.Abs(vertex.X - previous.X) <= VertexMergeEpsilonNdc + && MathF.Abs(vertex.Y - previous.Y) <= VertexMergeEpsilonNdc) continue; } - kept.Add(v); + poly[kept++] = vertex; } // Wrap-around: last ≈ first. - while (kept.Count >= 2) + while (kept >= 2) { - var first = kept[0]; - var last = kept[^1]; + Vector2 first = poly[0]; + Vector2 last = poly[kept - 1]; if (MathF.Abs(first.X - last.X) <= VertexMergeEpsilonNdc && MathF.Abs(first.Y - last.Y) <= VertexMergeEpsilonNdc) - kept.RemoveAt(kept.Count - 1); + kept--; else break; } - return kept.Count == poly.Length ? poly : kept.ToArray(); + return kept; } // One Sutherland-Hodgman half-plane against the directed NDC edge a→b, keeping the CCW-inside // (left) part of a HOMOGENEOUS polygon. Inside test for vertex P (clip space): the NDC cross // product cross(b-a, P/P.W - a) scaled by P.W (> 0): ex·(P.Y - P.W·a.Y) - ey·(P.X - P.W·a.X) ≥ 0. // Crossings interpolate in homogeneous coords (perspective-correct), via the shared Lerp. - private static List ClipHomogeneousEdge(List poly, Vector2 a, Vector2 b) + private static int ClipHomogeneousEdge( + ReadOnlySpan polygon, + Span result, + Vector2 a, + Vector2 b) { - var result = new List(poly.Count + 1); + int outputCount = 0; float ex = b.X - a.X, ey = b.Y - a.Y; - for (int i = 0; i < poly.Count; i++) + for (int i = 0; i < polygon.Length; i++) { - Vector4 cur = poly[i]; - Vector4 prev = poly[(i + poly.Count - 1) % poly.Count]; + Vector4 cur = polygon[i]; + Vector4 prev = polygon[(i + polygon.Length - 1) % polygon.Length]; float dCur = ex * (cur.Y - cur.W * a.Y) - ey * (cur.X - cur.W * a.X); float dPrev = ex * (prev.Y - prev.W * a.Y) - ey * (prev.X - prev.W * a.X); bool curIn = dCur >= 0f; @@ -226,15 +465,15 @@ public static class PortalProjection if (curIn) { - if (!prevIn) result.Add(Lerp(prev, cur, dPrev, dCur)); - result.Add(cur); + if (!prevIn) result[outputCount++] = Lerp(prev, cur, dPrev, dCur); + result[outputCount++] = cur; } else if (prevIn) { - result.Add(Lerp(prev, cur, dPrev, dCur)); + result[outputCount++] = Lerp(prev, cur, dPrev, dCur); } } - return result; + return outputCount; } // Reverse vertex order in place if wound clockwise (signed area < 0). Mirrors the builder's @@ -257,33 +496,54 @@ public static class PortalProjection private const float MinW = 0.05f; // Sutherland-Hodgman against one half-space of the homogeneous view frustum, in CLIP SPACE. - // `dist` is the signed plane functional (>= 0 keeps the vertex); crossings are interpolated in - // homogeneous coords (perspective-correct). Callers apply the eye plane first so every survivor - // has w > 0, making the side-plane functionals (w ± x, w ± y) well defined. - private static List ClipPlane(List poly, System.Func dist) + // The enum avoids per-plane delegate/lambda traffic in this per-portal hot path. + private static int ClipHomogeneousPlane( + ReadOnlySpan polygon, + Span result, + HomogeneousPlane plane) { - if (poly.Count == 0) return poly; - var result = new List(poly.Count + 1); - for (int i = 0; i < poly.Count; i++) + int outputCount = 0; + for (int i = 0; i < polygon.Length; i++) { - Vector4 cur = poly[i]; - Vector4 prev = poly[(i + poly.Count - 1) % poly.Count]; - float dCur = dist(cur); - float dPrev = dist(prev); + Vector4 cur = polygon[i]; + Vector4 prev = polygon[(i + polygon.Length - 1) % polygon.Length]; + float dCur = PlaneDistance(cur, plane); + float dPrev = PlaneDistance(prev, plane); bool curIn = dCur >= 0f; bool prevIn = dPrev >= 0f; if (curIn) { - if (!prevIn) result.Add(Lerp(prev, cur, dPrev, dCur)); - result.Add(cur); + if (!prevIn) result[outputCount++] = Lerp(prev, cur, dPrev, dCur); + result[outputCount++] = cur; } else if (prevIn) { - result.Add(Lerp(prev, cur, dPrev, dCur)); + result[outputCount++] = Lerp(prev, cur, dPrev, dCur); } } - return result; + return outputCount; + } + + private static float PlaneDistance(in Vector4 vertex, HomogeneousPlane plane) => plane switch + { + HomogeneousPlane.EyeMinW => vertex.W - MinW, + HomogeneousPlane.EyeZero => vertex.W, + HomogeneousPlane.Left => vertex.W + vertex.X, + HomogeneousPlane.Right => vertex.W - vertex.X, + HomogeneousPlane.Bottom => vertex.W + vertex.Y, + HomogeneousPlane.Top => vertex.W - vertex.Y, + _ => throw new System.ArgumentOutOfRangeException(nameof(plane)), + }; + + private enum HomogeneousPlane : byte + { + EyeMinW, + EyeZero, + Left, + Right, + Bottom, + Top, } private static Vector4 Lerp(Vector4 p, Vector4 q, float dp, float dq) diff --git a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs index f0c0a40d..aeb4227b 100644 --- a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs +++ b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs @@ -8,6 +8,7 @@ using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using AcDream.Core.World; using DatReaderWriter; +using AcDream.Content; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; using Silk.NET.OpenGL; @@ -44,7 +45,6 @@ public sealed class PortalTunnelPresentation : IDisposable private readonly GL _gl; private readonly WbDrawDispatcher _dispatcher; private readonly SceneLightingUboBinding _lightUbo; - private readonly IWbMeshAdapter _meshAdapter; private readonly Setup _setup; private readonly uint _setupDid; private readonly uint _animationDid; @@ -54,7 +54,7 @@ public sealed class PortalTunnelPresentation : IDisposable private readonly PortalTunnelCamera _camera = new(); private readonly Random _random; private readonly Action? _displayNotice; - private readonly uint[] _registeredGfxObjects; + private readonly PortalMeshReferenceOwner _meshReferences; private bool _visible; private double _rotationElapsed; @@ -62,6 +62,8 @@ public sealed class PortalTunnelPresentation : IDisposable private float _rotationStartAngle; private float _rotationEndAngle; private float _rotationCurrentAngle; + private bool _disposeRequested; + private bool _disposing; private bool _disposed; private PortalTunnelPresentation( @@ -80,7 +82,6 @@ public sealed class PortalTunnelPresentation : IDisposable _gl = gl; _dispatcher = dispatcher; _lightUbo = lightUbo; - _meshAdapter = meshAdapter; _setup = setup; _setupDid = setupDid; _animationDid = animationDid; @@ -99,13 +100,9 @@ public sealed class PortalTunnelPresentation : IDisposable MeshRefs = SetupMesh.Flatten(setup), }; - _registeredGfxObjects = setup.Parts - .Select(part => (uint)part) - .Where(id => id != 0u) - .Distinct() - .ToArray(); - foreach (uint gfxObjId in _registeredGfxObjects) - _meshAdapter.IncrementRefCount(gfxObjId); + _meshReferences = new PortalMeshReferenceOwner( + meshAdapter, + setup.Parts.Select(part => (ulong)(uint)part)); } /// @@ -116,7 +113,7 @@ public sealed class PortalTunnelPresentation : IDisposable /// public static PortalTunnelPresentation CreateRequired( GL gl, - DatCollection dats, + IDatReaderWriter dats, IAnimationLoader animationLoader, IAnimationHookSink hookSink, WbDrawDispatcher dispatcher, @@ -180,6 +177,17 @@ public sealed class PortalTunnelPresentation : IDisposable public uint SetupDid => _setupDid; public uint AnimationDid => _animationDid; + /// + /// Publishes the synthetic scene's mesh ownership after the presentation + /// itself has been stored by GameWindow. A partial acquisition stays + /// reachable and is resumed or released by the same lifetime owner. + /// + internal void PrepareResources() + { + ThrowIfDisposed(); + _meshReferences.Acquire(); + } + /// /// Retail set_sequence_animation(anim, clear=1, low=1, fps=40) /// on the edge where portal space becomes visible. @@ -332,7 +340,8 @@ public sealed class PortalTunnelPresentation : IDisposable }); } - private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); + private void ThrowIfDisposed() => + ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this); /// /// Retail stages animation hooks while advancing the sequence, then drains @@ -372,12 +381,23 @@ public sealed class PortalTunnelPresentation : IDisposable public void Dispose() { - if (_disposed) + if (_disposed || _disposing) return; - Exit(); - _animationHooks.Clear(); - foreach (uint gfxObjId in _registeredGfxObjects) - _meshAdapter.DecrementRefCount(gfxObjId); - _disposed = true; + + _disposeRequested = true; + _disposing = true; + try + { + _visible = false; + _animationHooks.Clear(); + _sequence.ClearAnimations(); + _meshReferences.Dispose(); + if (_meshReferences.IsDisposed) + _disposed = true; + } + finally + { + _disposing = false; + } } } diff --git a/src/AcDream.App/Rendering/PortalView.cs b/src/AcDream.App/Rendering/PortalView.cs index 5cf5c268..9dd082cd 100644 --- a/src/AcDream.App/Rendering/PortalView.cs +++ b/src/AcDream.App/Rendering/PortalView.cs @@ -3,9 +3,9 @@ // Phase A8.F: GL-free 2D screen-space (NDC) clip-region data model. // Mirrors retail view_poly (acclient.h:32465) and view_type (acclient.h:32338): // a cell's clip region is a SET of convex polygons in normalized device coords. +using System.Buffers; using System.Collections.Generic; using System.Numerics; -using System.Text; namespace AcDream.App.Rendering; @@ -37,14 +37,96 @@ public readonly struct ViewPolygon public bool IsEmpty => Vertices is null || Vertices.Length < 3; } +/// +/// Frame-owned exact-length storage for projected portal polygons. A polygon's +/// vertex array remains immutable for the lifetime of its visibility frame, then +/// becomes reusable when that frame is reset. Exact lengths preserve the existing +/// contract and all clipping semantics. +/// +internal sealed class PortalPolygonVertexStore +{ + private const int MaxRetainedArrays = 4_096; + private const int MaxRetainedVertices = 65_536; + private const int MaxRetainedPolygonVertices = 256; + + private sealed class Bucket + { + public readonly List Buffers = new(4); + public int Used; + } + + private readonly Dictionary _buckets = new(); + private int _retainedArrays; + private int _retainedVertices; + + internal int AllocationCount { get; private set; } + internal int RetainedArrayCount => _retainedArrays; + + internal Vector2[] Rent(int vertexCount) + { + ArgumentOutOfRangeException.ThrowIfLessThan(vertexCount, 1); + + if (_buckets.TryGetValue(vertexCount, out Bucket? bucket) + && bucket.Used < bucket.Buffers.Count) + { + return bucket.Buffers[bucket.Used++]; + } + + Vector2[] result = GC.AllocateUninitializedArray(vertexCount); + AllocationCount++; + + bool retain = vertexCount <= MaxRetainedPolygonVertices + && _retainedArrays < MaxRetainedArrays + && _retainedVertices + vertexCount <= MaxRetainedVertices; + if (!retain) + return result; + + if (bucket is null) + { + bucket = new Bucket(); + _buckets.Add(vertexCount, bucket); + } + bucket.Buffers.Add(result); + bucket.Used++; + _retainedArrays++; + _retainedVertices += vertexCount; + return result; + } + + internal void ResetUsage() + { + foreach (Bucket bucket in _buckets.Values) + bucket.Used = 0; + } +} + /// A cell's accumulated clip region: a set of convex view polygons + the union bounding rect. public sealed class CellView { + // ViewPolygon exposes its vertex array for the renderer, so this seed must + // be owned by the CellView rather than shared globally. Pooling the + // CellView then reuses the four vertices without allowing one caller to + // corrupt every future full-screen seed. + private readonly ViewPolygon _fullScreenPolygon = new(new[] + { + new Vector2(-1f, -1f), + new Vector2(1f, -1f), + new Vector2(1f, 1f), + new Vector2(-1f, 1f), + }); + public readonly List Polygons = new(); // Canonical (snapped) keys of the polygons in , backing the drift-tolerant - // dedup in . One entry per stored polygon; HashSet membership IS the dedup. - private readonly HashSet _polygonKeys = new(); + // dedup in . Hash-bucket membership is the dedup; a stored key owns only its + // snapped integer coordinates while duplicate probes use stack or ArrayPool scratch. + // Hash -> head index in _polygonKeyStorage. Collision chains are integer + // links rather than one List allocation per accepted hash. Storage and + // coordinate arrays stay with this pooled CellView and are reused across + // frames; _activePolygonKeyCount marks the live prefix. + private readonly Dictionary _polygonKeyHeads = new(); + private readonly List _polygonKeyStorage = new(); + private int _activePolygonKeyCount; public float MinX { get; private set; } = float.MaxValue; public float MinY { get; private set; } = float.MaxValue; public float MaxX { get; private set; } = float.MinValue; @@ -52,18 +134,57 @@ public sealed class CellView public bool IsEmpty => Polygons.Count == 0; + internal bool IsRetainable + { + get + { + if (Polygons.Capacity > 256 + || _polygonKeyHeads.EnsureCapacity(0) > 512 + || _polygonKeyStorage.Count > 512) + { + return false; + } + + int retainedCoordinateInts = 0; + for (int i = 0; i < _polygonKeyStorage.Count; i++) + { + int length = _polygonKeyStorage[i].Coordinates.Length; + if (length > 256) + return false; + retainedCoordinateInts += length; + if (retainedCoordinateInts > 8192) + return false; + } + return true; + } + } + + internal void Reset() + { + Polygons.Clear(); + _polygonKeyHeads.Clear(); + _activePolygonKeyCount = 0; + MinX = float.MaxValue; + MinY = float.MaxValue; + MaxX = float.MinValue; + MaxY = float.MinValue; + } + /// A region covering the entire NDC viewport — the camera cell's seed region /// (mirrors retail PView::DrawInside copy_view(..., 4) at decomp:433814). public static CellView FullScreen() { var v = new CellView(); - v.Add(new ViewPolygon(new[] - { - new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f), - })); + v.Add(v._fullScreenPolygon); return v; } + internal void SetFullScreen() + { + Reset(); + Add(_fullScreenPolygon); + } + public bool Add(ViewPolygon p) { if (p.IsEmpty) return false; @@ -79,9 +200,9 @@ public sealed class CellView // bounded and the flood is GUARANTEED to converge. The stored polygon keeps full precision (only // the key is snapped), so downstream clip geometry is unchanged, and the grid (1e-3 NDC ~ sub- // pixel) is far finer than the gap between genuinely distinct openings, so real regions never merge. - string? key = CanonicalKey(p.Vertices); - if (key is null) return false; // degenerate after snap (< 3 distinct vertices) - if (!_polygonKeys.Add(key)) return false; // duplicate region (drift / rotation / count tolerant) + CanonicalKeyResult keyResult = TryAddCanonicalKey(p.Vertices); + if (keyResult == CanonicalKeyResult.Degenerate) return false; + if (keyResult == CanonicalKeyResult.Duplicate) return false; // #120 convergence (2026-06-11): reject a polygon CONTAINED in one already // stored. The reciprocal ping-pong (eye within PortalSideEpsilon of a @@ -178,8 +299,8 @@ public sealed class CellView // against the zero-area region). Rejecting these views dropped the whole chain behind an // exactly-in-plane portal for the frame — the parked-eye knife-edge band (tower deck, spiral // landings). The segment key space is finite like the area-key space, so dedup + the strict - // growth convergence invariant are unchanged. Returns null only when fewer than 2 distinct - // snapped points survive (a true sub-grid point — not a real region OR segment). + // growth convergence invariant are unchanged. Degenerate is returned only when fewer than 2 + // distinct snapped points survive (a true sub-grid point — not a real region OR segment). // // §4 corner/doorway fix (2026-06-10) — the collinear pass: the homogeneous region clipper // (PortalProjection.ClipToRegion, used by the forward AND — as of today — the reciprocal hop) @@ -190,89 +311,194 @@ public sealed class CellView // exact reason the reciprocal clip was previously parked on the unstable divide-first path). // Dropping collinear snapped points makes the key purely a function of the region's CORNERS, so // any re-emission of the same shape — drifted, rotated, vertex-count-inflated — deduplicates. - private static string? CanonicalKey(Vector2[]? verts) + private CanonicalKeyResult TryAddCanonicalKey(Vector2[]? verts) { - if (verts is null || verts.Length < 3) return null; + if (verts is null || verts.Length < 3) + return CanonicalKeyResult.Degenerate; - var pts = new List<(int X, int Y)>(verts.Length); - foreach (var v in verts) + SnappedPoint[]? rented = null; + Span points = verts.Length <= 32 + ? stackalloc SnappedPoint[verts.Length] + : (rented = ArrayPool.Shared.Rent(verts.Length)).AsSpan(0, verts.Length); + + try { - var q = ((int)System.MathF.Round(v.X / DedupGridNdc), (int)System.MathF.Round(v.Y / DedupGridNdc)); - if (pts.Count == 0 || pts[^1] != q) pts.Add(q); - } - if (pts.Count >= 2 && pts[^1] == pts[0]) pts.RemoveAt(pts.Count - 1); - - // Snapshot the distinct snapped points BEFORE collinear removal — the all-collinear - // fallback keys off the segment EXTREMES of the full point set (stable across - // re-emissions regardless of the removal loop's order). - List<(int X, int Y)>? preCollinear = pts.Count >= 2 ? new List<(int, int)>(pts) : null; - - // Remove collinear points: for consecutive (prev, cur, next) around the cycle, drop cur when - // cross(cur-prev, next-cur) == 0 — exact in integer grid coordinates (deltas ≤ ~4000, products - // ≤ ~1.6e7, no overflow). Loop to a fixpoint: removing one point can make its neighbour - // collinear. All-collinear inputs reduce below 3 → the segment-key fallback below. - bool removed = true; - while (removed && pts.Count >= 3) - { - removed = false; - for (int i = 0; i < pts.Count && pts.Count >= 3; i++) + int count = 0; + foreach (Vector2 vertex in verts) { - var prev = pts[(i + pts.Count - 1) % pts.Count]; - var cur = pts[i]; - var next = pts[(i + 1) % pts.Count]; - long cross = (long)(cur.X - prev.X) * (next.Y - cur.Y) - - (long)(cur.Y - prev.Y) * (next.X - cur.X); - if (cross == 0) + var point = new SnappedPoint( + (int)MathF.Round(vertex.X / DedupGridNdc), + (int)MathF.Round(vertex.Y / DedupGridNdc)); + if (count == 0 || points[count - 1] != point) + points[count++] = point; + } + if (count >= 2 && points[count - 1] == points[0]) + count--; + if (count < 2) + return CanonicalKeyResult.Degenerate; + + SnappedPoint lo = points[0]; + SnappedPoint hi = points[0]; + for (int i = 1; i < count; i++) + { + SnappedPoint point = points[i]; + if (point.X < lo.X || (point.X == lo.X && point.Y < lo.Y)) lo = point; + if (point.X > hi.X || (point.X == hi.X && point.Y > hi.Y)) hi = point; + } + + bool removed = true; + while (removed && count >= 3) + { + removed = false; + for (int i = 0; i < count && count >= 3; i++) { - pts.RemoveAt(i); + SnappedPoint previous = points[(i + count - 1) % count]; + SnappedPoint current = points[i]; + SnappedPoint next = points[(i + 1) % count]; + long cross = (long)(current.X - previous.X) * (next.Y - current.Y) + - (long)(current.Y - previous.Y) * (next.X - current.X); + if (cross != 0) + continue; + + points.Slice(i + 1, count - i - 1).CopyTo(points.Slice(i)); + count--; removed = true; i--; } } - } - if (pts.Count < 3) - { - // Zero-area (all-collinear) view — key as its snapped segment so retail's - // degenerate-view propagation works (see method doc). Extremes are the - // lexicographic min/max of the full snapped point set. - if (preCollinear is null) return null; - var lo = preCollinear[0]; - var hi = preCollinear[0]; - foreach (var q in preCollinear) + + if (count < 3) { - if (q.X < lo.X || (q.X == lo.X && q.Y < lo.Y)) lo = q; - if (q.X > hi.X || (q.X == hi.X && q.Y > hi.Y)) hi = q; + if (lo == hi) + return CanonicalKeyResult.Degenerate; + Span segment = stackalloc SnappedPoint[2] { lo, hi }; + return AddCanonicalKey(PolygonKeyKind.Line, segment, start: 0); } - if (lo == hi) return null; // a sub-grid point — not a region or a segment - return $"L:{lo.X},{lo.Y};{hi.X},{hi.Y};"; + + int best = 0; + for (int start = 1; start < count; start++) + if (RotationLess(points, start, best, count)) best = start; + + return AddCanonicalKey(PolygonKeyKind.Polygon, points[..count], best); } - - int n = pts.Count; - int best = 0; - for (int s = 1; s < n; s++) - if (RotationLess(pts, s, best, n)) best = s; - - var sb = new StringBuilder(n * 10); - for (int i = 0; i < n; i++) + finally { - var q = pts[(best + i) % n]; - sb.Append(q.X).Append(',').Append(q.Y).Append(';'); + if (rented is not null) + ArrayPool.Shared.Return(rented); } - return sb.ToString(); } - // True when the rotation of `pts` starting at index a is lexicographically less than the rotation - // starting at b (compare X then Y, vertex by vertex around the cycle). Gives a unique canonical - // start even when two vertices share the minimum snapped coordinate. - private static bool RotationLess(List<(int X, int Y)> pts, int a, int b, int n) + private CanonicalKeyResult AddCanonicalKey( + PolygonKeyKind kind, + ReadOnlySpan points, + int start) { - for (int i = 0; i < n; i++) + int hash = ComputeHash(kind, points, start); + if (_polygonKeyHeads.TryGetValue(hash, out int keyIndex)) { - var pa = pts[(a + i) % n]; - var pb = pts[(b + i) % n]; - if (pa.X != pb.X) return pa.X < pb.X; - if (pa.Y != pb.Y) return pa.Y < pb.Y; + while (keyIndex >= 0) + { + PolygonKey existing = _polygonKeyStorage[keyIndex]; + if (existing.Equals(kind, points, start)) + return CanonicalKeyResult.Duplicate; + keyIndex = existing.Next; + } + } + + int coordinateCount = points.Length * 2; + int storageIndex = FindOrCreateCoordinateStorage(coordinateCount); + int[] coordinates = _polygonKeyStorage[storageIndex].Coordinates; + for (int i = 0; i < points.Length; i++) + { + SnappedPoint point = points[(start + i) % points.Length]; + coordinates[i * 2] = point.X; + coordinates[i * 2 + 1] = point.Y; + } + int next = _polygonKeyHeads.GetValueOrDefault(hash, -1); + _polygonKeyStorage[storageIndex] = new PolygonKey(kind, coordinates, next); + _polygonKeyHeads[hash] = storageIndex; + _activePolygonKeyCount++; + return CanonicalKeyResult.Added; + } + + private int FindOrCreateCoordinateStorage(int coordinateCount) + { + int storageIndex = _activePolygonKeyCount; + for (int i = storageIndex; i < _polygonKeyStorage.Count; i++) + { + if (_polygonKeyStorage[i].Coordinates.Length != coordinateCount) + continue; + if (i != storageIndex) + (_polygonKeyStorage[storageIndex], _polygonKeyStorage[i]) = + (_polygonKeyStorage[i], _polygonKeyStorage[storageIndex]); + return storageIndex; + } + + _polygonKeyStorage.Add(new PolygonKey( + PolygonKeyKind.Polygon, + new int[coordinateCount], + -1)); + int addedIndex = _polygonKeyStorage.Count - 1; + if (addedIndex != storageIndex) + (_polygonKeyStorage[storageIndex], _polygonKeyStorage[addedIndex]) = + (_polygonKeyStorage[addedIndex], _polygonKeyStorage[storageIndex]); + return storageIndex; + } + + private static int ComputeHash( + PolygonKeyKind kind, + ReadOnlySpan points, + int start) + { + unchecked + { + uint hash = 2166136261u; + hash = (hash ^ (byte)kind) * 16777619u; + hash = (hash ^ (uint)points.Length) * 16777619u; + for (int i = 0; i < points.Length; i++) + { + SnappedPoint point = points[(start + i) % points.Length]; + hash = (hash ^ (uint)point.X) * 16777619u; + hash = (hash ^ (uint)point.Y) * 16777619u; + } + return (int)hash; + } + } + + private static bool RotationLess( + ReadOnlySpan points, + int a, + int b, + int count) + { + for (int i = 0; i < count; i++) + { + SnappedPoint left = points[(a + i) % count]; + SnappedPoint right = points[(b + i) % count]; + if (left.X != right.X) return left.X < right.X; + if (left.Y != right.Y) return left.Y < right.Y; } return false; } + + private readonly record struct SnappedPoint(int X, int Y); + + private readonly record struct PolygonKey(PolygonKeyKind Kind, int[] Coordinates, int Next) + { + public bool Equals(PolygonKeyKind kind, ReadOnlySpan points, int start) + { + if (Kind != kind || Coordinates.Length != points.Length * 2) + return false; + for (int i = 0; i < points.Length; i++) + { + SnappedPoint point = points[(start + i) % points.Length]; + if (Coordinates[i * 2] != point.X || Coordinates[i * 2 + 1] != point.Y) + return false; + } + return true; + } + } + + private enum PolygonKeyKind : byte { Polygon, Line } + private enum CanonicalKeyResult : byte { Degenerate, Duplicate, Added } } diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index c1dc2d85..2edaaf2a 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -14,9 +14,26 @@ namespace AcDream.App.Rendering; /// Per-frame output of the portal-frame BFS. public sealed class PortalVisibilityFrame { + private const int MaxRetainedCellViews = 512; + internal const int MaxRetainedBuildCollectionCapacity = 512; + internal const int CapacityTrimIdleFrames = 120; + private readonly Stack _cellViewPool = new(); + private readonly PortalPolygonVertexStore _polygonVertices = new(); + private int _cellViewsUnderusedFrames; + private int _crossBuildingViewsUnderusedFrames; + private int _queuedUnderusedFrames; + private int _drawListedUnderusedFrames; + private int _processedViewCountsUnderusedFrames; + private int _orderedVisibleCellsUnderusedFrames; + private int _todoUnderusedFrames; + + internal PortalPolygonVertexStore PolygonVertices => _polygonVertices; + internal int PolygonVertexAllocationCount => _polygonVertices.AllocationCount; + internal int RetainedPolygonVertexArrayCount => _polygonVertices.RetainedArrayCount; + /// Screen region (NDC) where outdoor terrain/scenery may draw — exit portals /// recursively clipped to their portal chain. The cellar-flap fix. - public CellView OutsideView { get; } = new(); + public CellView OutsideView { get; private set; } = new(); /// Per-cell accumulated clip region, keyed by full cell id (wire-in #2). public Dictionary CellViews { get; } = new(); @@ -31,6 +48,199 @@ public sealed class PortalVisibilityFrame /// Entry clip regions for other buildings reached through our portals, keyed by the /// neighbour cell id that left the camera building's cell set (wire-in #3 / Step 5). public Dictionary CrossBuildingViews { get; } = new(); + + // Build scratch belongs to the frame so a caller that reuses a frame also reuses the large + // hash tables and the 128-entry convergence trace. This is especially important outdoors, + // where retail runs one small ConstructView flood per nearby building every frame. + internal HashSet QueuedScratch { get; } = new(); + internal HashSet DrawListedScratch { get; } = new(); + internal Dictionary ProcessedViewCountsScratch { get; } = new(); + internal uint[] PropagationChainScratch { get; } = new uint[128]; + internal List<(LoadedCell Cell, float Distance)> TodoScratch { get; } = new(); + + // A build can recurse while an outer portal's clipped region is still live, so one mutable + // singleton is unsafe. Leases reuse Lists by recursion lifetime rather than by total portal-call + // count: ordinary sequential portals need one List, while nested AdjustCellView propagation gets + // one per active depth. Dispose returns scratch on every continue/exception path. + private readonly Stack> _clipRegionPool = new(); + private const int MaxRetainedClipRegionLists = 132; // recursion tripwire (128) + nested side work + private const int MaxRetainedClipRegionCapacity = 256; + + internal int ClipRegionScratchCount => _clipRegionPool.Count; + internal int RetainedCellViewCount => _cellViewPool.Count; + internal int CellViewMapCapacity => CellViews.EnsureCapacity(0); + internal int CrossBuildingViewMapCapacity => CrossBuildingViews.EnsureCapacity(0); + internal int QueuedCapacity => QueuedScratch.EnsureCapacity(0); + internal int DrawListedCapacity => DrawListedScratch.EnsureCapacity(0); + internal int ProcessedViewCountCapacity => ProcessedViewCountsScratch.EnsureCapacity(0); + + internal ClipRegionScratchLease RentClipRegionScratch() + { + List region = _clipRegionPool.Count != 0 + ? _clipRegionPool.Pop() + : new List(2); + region.Clear(); + return new ClipRegionScratchLease(this, region); + } + + private void ReturnClipRegionScratch(List region) + { + region.Clear(); + if (region.Capacity <= MaxRetainedClipRegionCapacity + && _clipRegionPool.Count < MaxRetainedClipRegionLists) + _clipRegionPool.Push(region); + } + + internal readonly ref struct ClipRegionScratchLease + { + private readonly PortalVisibilityFrame _owner; + public List Region { get; } + + internal ClipRegionScratchLease(PortalVisibilityFrame owner, List region) + { + _owner = owner; + Region = region; + } + + public void Dispose() => _owner.ReturnClipRegionScratch(Region); + } + + internal void ResetForBuild() + { + int cellViewCount = CellViews.Count; + int crossBuildingViewCount = CrossBuildingViews.Count; + int queuedCount = QueuedScratch.Count; + int drawListedCount = DrawListedScratch.Count; + int processedViewCount = ProcessedViewCountsScratch.Count; + int orderedVisibleCellCount = OrderedVisibleCells.Count; + int todoCount = TodoScratch.Count; + + if (OutsideView.IsRetainable) + OutsideView.Reset(); + else + OutsideView = new CellView(); + ReturnCellViews(CellViews); + ReturnCellViews(CrossBuildingViews); + CellViews.Clear(); + OrderedVisibleCells.Clear(); + CrossBuildingViews.Clear(); + QueuedScratch.Clear(); + DrawListedScratch.Clear(); + ProcessedViewCountsScratch.Clear(); + TodoScratch.Clear(); + _polygonVertices.ResetUsage(); + + // These collections live as long as RetailPViewRenderer. A fixed + // capacity cutoff is unsafe here: a legitimate large portal flood can + // exceed it every frame, causing us to discard and rebuild the same + // warmed tables forever. Retire high-water storage only after it has + // remained at least 50% underused for a sustained idle window. Once a + // collection is right-sized for its recurring workload it stays warm. + TrimIfCold(CellViews, cellViewCount, ref _cellViewsUnderusedFrames); + TrimIfCold( + CrossBuildingViews, + crossBuildingViewCount, + ref _crossBuildingViewsUnderusedFrames); + TrimIfCold(QueuedScratch, queuedCount, ref _queuedUnderusedFrames); + TrimIfCold(DrawListedScratch, drawListedCount, ref _drawListedUnderusedFrames); + TrimIfCold( + ProcessedViewCountsScratch, + processedViewCount, + ref _processedViewCountsUnderusedFrames); + TrimIfCold( + OrderedVisibleCells, + orderedVisibleCellCount, + ref _orderedVisibleCellsUnderusedFrames); + TrimIfCold(TodoScratch, todoCount, ref _todoUnderusedFrames); + } + + internal ViewPolygon CopyPolygon(ReadOnlySpan vertices) + { + if (vertices.Length < 3) + return default; + Vector2[] owned = _polygonVertices.Rent(vertices.Length); + vertices.CopyTo(owned); + return new ViewPolygon(owned); + } + + internal CellView RentCellView(bool fullScreen = false) + { + CellView result = _cellViewPool.Count != 0 + ? _cellViewPool.Pop() + : new CellView(); + if (fullScreen) + result.SetFullScreen(); + else + result.Reset(); + return result; + } + + private void ReturnCellViews(Dictionary views) + { + foreach (CellView view in views.Values) + { + view.Reset(); + if (view.IsRetainable && _cellViewPool.Count < MaxRetainedCellViews) + _cellViewPool.Push(view); + } + } + + private static void TrimIfCold( + Dictionary values, + int usedCount, + ref int underusedFrames) + where TKey : notnull + { + int capacity = values.EnsureCapacity(0); + if (!ShouldTrim(capacity, usedCount, ref underusedFrames)) + return; + + if (capacity > MaxRetainedBuildCollectionCapacity) + values.TrimExcess(); + } + + private static void TrimIfCold( + HashSet values, + int usedCount, + ref int underusedFrames) + { + int capacity = values.EnsureCapacity(0); + if (!ShouldTrim(capacity, usedCount, ref underusedFrames)) + return; + + if (capacity > MaxRetainedBuildCollectionCapacity) + values.TrimExcess(); + } + + private static void TrimIfCold( + List values, + int usedCount, + ref int underusedFrames) + { + int capacity = values.Capacity; + if (!ShouldTrim(capacity, usedCount, ref underusedFrames)) + return; + + if (capacity > MaxRetainedBuildCollectionCapacity) + values.Capacity = 0; + } + + private static bool ShouldTrim(int capacity, int usedCount, ref int underusedFrames) + { + if (capacity <= MaxRetainedBuildCollectionCapacity + || (long)usedCount * 2L > capacity) + { + underusedFrames = 0; + return false; + } + + underusedFrames++; + if (underusedFrames < CapacityTrimIdleFrames) + return false; + + underusedFrames = 0; + return true; + } } public static class PortalVisibilityBuilder @@ -121,16 +331,18 @@ public static class PortalVisibilityBuilder Func lookup, Matrix4x4 viewProj, Func? buildingMembership = null, - float drawLiftZ = 0f) + float drawLiftZ = 0f, + PortalVisibilityFrame? reuseFrame = null) { - var frame = new PortalVisibilityFrame(); + var frame = reuseFrame ?? new PortalVisibilityFrame(); + frame.ResetForBuild(); if (cameraCell == null) return frame; // Interior portals never cross landblocks (same invariant as CellVisibility.GetVisibleCells); // building-boundary crossings are handled separately via the buildingMembership escape hatch. uint lbMask = cameraCell.CellId & 0xFFFF0000u; - frame.CellViews[cameraCell.CellId] = CellView.FullScreen(); + frame.CellViews[cameraCell.CellId] = frame.RentCellView(fullScreen: true); // Render unification (outdoor-as-cell, 2026-06-07): when the root IS the synthetic outdoor // node, the landscape is visible FULL-SCREEN, so seed OutsideView with the full-screen NDC @@ -142,15 +354,15 @@ public static class PortalVisibilityBuilder // ids, so an id test would misfire. An interior root never sets this flag, so the indoor // exit-portal path (OtherCellId==0xFFFF below) still owns the doorway OutsideView region. if (cameraCell.IsOutdoorNode) - frame.OutsideView.Add(new ViewPolygon((Vector2[])FullScreenQuad.Clone())); + frame.OutsideView.Add(frame.CopyPolygon(FullScreenQuad)); // Distance-priority work list (retail PView::cell_todo_list). Cells pop closest-first; // each cell carries the camera→nearest-portal-vertex distance that put it on the list // (retail keys on InitCell's per-portal min-vertex distance, decomp 432988-433004). The // camera cell seeds at distance 0 (retail InsCellTodoList(this, arg2, 0f) at 433758) so it // always pops first. - var todo = new CellTodoList(); - todo.Insert(cameraCell, 0f); + List<(LoadedCell Cell, float Distance)> todo = frame.TodoScratch; + InsertTodo(todo, cameraCell, 0f); // Fixpoint termination replacing the old MaxReprocessPerCell hard cap. This mirrors the // retail portal_view slice offset 0x44 (last-incorporated view-poly watermark) vs 0x38 @@ -162,9 +374,10 @@ public static class PortalVisibilityBuilder // the instant a cell is popped). Enqueue-once across the cell set is the hard termination // guarantee for cyclic / hub / diamond graphs: at most N cells are ever processed. The // camera cell is pre-marked so a portal looping back to it can never re-enqueue it. - var queued = new HashSet { cameraCell.CellId }; - var drawListed = new HashSet(); - var processedViewCounts = new Dictionary(); + HashSet queued = frame.QueuedScratch; + queued.Add(cameraCell.CellId); + HashSet drawListed = frame.DrawListedScratch; + Dictionary processedViewCounts = frame.ProcessedViewCountsScratch; var trace = PortalBuildTrace.Start(cameraCell, cameraPos); // [portal-churn] apparatus (2026-06-08): when ProbePortalChurnEnabled, accumulate re-enqueue churn @@ -224,7 +437,7 @@ public static class PortalVisibilityBuilder // reproduce the T5 firing — production-only ingredients (full lookup // graph / real camera path) are suspected; this dump pins them on the // next natural occurrence. - var propagationChain = new uint[RecursionTripwire]; + uint[] propagationChain = frame.PropagationChainScratch; void ProcessCellPortals(LoadedCell cell, int depth) { @@ -251,7 +464,6 @@ public static class PortalVisibilityBuilder } trace?.Add($"proc cell=0x{cell.CellId:X8} processed={processedCount}->{endCount} depth={depth}"); - var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount); processedViewCounts[cell.CellId] = endCount; for (int i = 0; i < cell.Portals.Count; i++) @@ -301,11 +513,18 @@ public static class PortalVisibilityBuilder // homogeneous clip space, clip at the eye, then clip against the current // portal_view region before the divide. Do the same here; the old early // ProjectToNdc + 2D intersect path is too unstable for near/grazing doorways. - var clippedRegion = ClipPortalAgainstView( + using PortalVisibilityFrame.ClipRegionScratchLease clippedRegionLease = + frame.RentClipRegionScratch(); + List clippedRegion = clippedRegionLease.Region; + ClipPortalAgainstView( + frame, poly, cell.WorldTransform, viewProj, - activeViewPolygons, + currentView.Polygons, + processedCount, + endCount - processedCount, + clippedRegion, out int clipVerts); if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipN={clipVerts} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2})"); if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}"); @@ -339,16 +558,31 @@ public static class PortalVisibilityBuilder // lifted space or terrain stops a lift-height short of the // drawn lintel (#130 strip). Flood semantics keep the // unlifted clippedRegion path above. - var outsideRegion = drawLiftZ == 0f - ? clippedRegion - : ClipPortalAgainstView( + int outsideCount; + if (drawLiftZ == 0f) + { + AddRegion(frame.OutsideView, clippedRegion); + outsideCount = clippedRegion.Count; + } + else + { + using PortalVisibilityFrame.ClipRegionScratchLease outsideRegionLease = + frame.RentClipRegionScratch(); + List outsideRegion = outsideRegionLease.Region; + ClipPortalAgainstView( + frame, poly, cell.WorldTransform * Matrix4x4.CreateTranslation(0f, 0f, drawLiftZ), viewProj, - activeViewPolygons, + currentView.Polygons, + processedCount, + endCount - processedCount, + outsideRegion, out _); - AddRegion(frame.OutsideView, outsideRegion); - trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={outsideRegion.Count} clipVerts={clipVerts}"); + AddRegion(frame.OutsideView, outsideRegion); + outsideCount = outsideRegion.Count; + } + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={outsideCount} clipVerts={clipVerts}"); continue; } @@ -360,7 +594,7 @@ public static class PortalVisibilityBuilder // call inside ConstructView at decomp:433692.) if (buildingMembership != null && !buildingMembership(neighbourId)) { - var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId); + var xview = GetOrCreate(frame, frame.CrossBuildingViews, neighbourId); bool grewCross = AddRegion(xview, clippedRegion); trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} crossBldg polys={clippedRegion.Count} grew={grewCross}"); continue; @@ -391,7 +625,8 @@ public static class PortalVisibilityBuilder // neighbour's side; the old eye-in-opening restore was part of // the deleted rescue. int preReciprocalCount = clippedRegion.Count; - ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj); + ApplyReciprocalClip( + frame, clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj); if (churnProbe) churnReciprocal!.Append(System.FormattableString.Invariant( $" recip[0x{neighbourId:X8} {preReciprocalCount}->{clippedRegion.Count}]")); @@ -402,7 +637,7 @@ public static class PortalVisibilityBuilder } // Union the clipped region into the neighbour's accumulated view. - var nview = GetOrCreate(frame.CellViews, neighbourId); + var nview = GetOrCreate(frame, frame.CellViews, neighbourId); bool grew = AddRegion(nview, clippedRegion); bool inserted = false; bool inPlace = false; @@ -417,7 +652,7 @@ public static class PortalVisibilityBuilder if (queued.Add(neighbourId)) { dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); - todo.Insert(neighbour, dist); + InsertTodo(todo, neighbour, dist); inserted = true; } // Growth into an already-POPPED cell → retail AdjustCellView: @@ -437,7 +672,7 @@ public static class PortalVisibilityBuilder while (todo.Count > 0) { - var cell = todo.PopNearest(); + LoadedCell cell = PopNearest(todo); // Single pop per cell (enqueue-once) IS the cell's closest-first // draw position (retail appends to cell_draw_list once per pop, @@ -491,13 +726,15 @@ public static class PortalVisibilityBuilder Func lookup, Matrix4x4 viewProj, float maxSeedDistance = float.PositiveInfinity, - IReadOnlyList? seedRegion = null) + IReadOnlyList? seedRegion = null, + PortalVisibilityFrame? reuseFrame = null) { - var frame = new PortalVisibilityFrame(); - var todo = new CellTodoList(); - var queued = new HashSet(); - var drawListed = new HashSet(); - var processedViewCounts = new Dictionary(); + var frame = reuseFrame ?? new PortalVisibilityFrame(); + frame.ResetForBuild(); + List<(LoadedCell Cell, float Distance)> todo = frame.TodoScratch; + HashSet queued = frame.QueuedScratch; + HashSet drawListed = frame.DrawListedScratch; + Dictionary processedViewCounts = frame.ProcessedViewCountsScratch; foreach (var cell in candidateCells) { @@ -534,11 +771,19 @@ public static class PortalVisibilityBuilder if (seedDistance > maxSeedDistance) continue; - var clippedRegion = ClipPortalAgainstView( + using PortalVisibilityFrame.ClipRegionScratchLease clippedRegionLease = + frame.RentClipRegionScratch(); + List clippedRegion = clippedRegionLease.Region; + IReadOnlyList activeSeedRegion = seedRegion ?? FullScreenRegion; + ClipPortalAgainstView( + frame, poly, cell.WorldTransform, viewProj, - seedRegion ?? FullScreenRegion, + activeSeedRegion, + 0, + activeSeedRegion.Count, + clippedRegion, out _); // T2 (BR-4): empty clip = no seed, no exceptions (retail's @@ -547,11 +792,11 @@ public static class PortalVisibilityBuilder if (clippedRegion.Count == 0) continue; - var seedView = GetOrCreate(frame.CellViews, cell.CellId); + var seedView = GetOrCreate(frame, frame.CellViews, cell.CellId); bool grew = AddRegion(seedView, clippedRegion); if (grew && queued.Add(cell.CellId)) - todo.Insert(cell, seedDistance); + InsertTodo(todo, cell, seedDistance); } } @@ -560,7 +805,7 @@ public static class PortalVisibilityBuilder // re-enqueue + MaxReprocessPerCell cap and the eye-in-opening rescues // are deleted (empty clip culls, period). const int RecursionTripwire = 128; - var propagationChain = new uint[RecursionTripwire]; // #120 self-attribution — see Build() + uint[] propagationChain = frame.PropagationChainScratch; // #120 self-attribution — see Build() void ProcessCellPortals(LoadedCell cell, int depth) { @@ -580,7 +825,6 @@ public static class PortalVisibilityBuilder if (processedCount >= endCount) return; - var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount); processedViewCounts[cell.CellId] = endCount; uint lbMask = cell.CellId & 0xFFFF0000u; @@ -602,11 +846,18 @@ public static class PortalVisibilityBuilder && !CameraOnInteriorSide(cell, i, cameraPos)) continue; - var clippedRegion = ClipPortalAgainstView( + using PortalVisibilityFrame.ClipRegionScratchLease clippedRegionLease = + frame.RentClipRegionScratch(); + List clippedRegion = clippedRegionLease.Region; + ClipPortalAgainstView( + frame, poly, cell.WorldTransform, viewProj, - activeViewPolygons, + currentView.Polygons, + processedCount, + endCount - processedCount, + clippedRegion, out _); if (clippedRegion.Count == 0) @@ -617,11 +868,12 @@ public static class PortalVisibilityBuilder if (neighbour == null) continue; - ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj); + ApplyReciprocalClip( + frame, clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj); if (clippedRegion.Count == 0) continue; - var nview = GetOrCreate(frame.CellViews, neighbourId); + var nview = GetOrCreate(frame, frame.CellViews, neighbourId); bool grew = AddRegion(nview, clippedRegion); if (grew) @@ -629,7 +881,7 @@ public static class PortalVisibilityBuilder if (queued.Add(neighbourId)) { float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); - todo.Insert(neighbour, dist); + InsertTodo(todo, neighbour, dist); } else if (drawListed.Contains(neighbourId)) { @@ -641,7 +893,7 @@ public static class PortalVisibilityBuilder while (todo.Count > 0) { - var cell = todo.PopNearest(); + LoadedCell cell = PopNearest(todo); if (drawListed.Add(cell.CellId)) frame.OrderedVisibleCells.Add(cell.CellId); @@ -669,8 +921,16 @@ public static class PortalVisibilityBuilder Func lookup, Matrix4x4 viewProj, float maxSeedDistance = float.PositiveInfinity, - IReadOnlyList? seedRegion = null) - => BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance, seedRegion); + IReadOnlyList? seedRegion = null, + PortalVisibilityFrame? reuseFrame = null) + => BuildFromExterior( + buildingCells, + cameraPos, + lookup, + viewProj, + maxSeedDistance, + seedRegion, + reuseFrame); // The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute. private static readonly Vector2[] FullScreenQuad = @@ -679,30 +939,35 @@ public static class PortalVisibilityBuilder private static readonly ViewPolygon[] FullScreenRegion = { new ViewPolygon(FullScreenQuad) }; - private static List ClipPortalAgainstView( + private static void ClipPortalAgainstView( + PortalVisibilityFrame frame, Vector3[] localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj, IReadOnlyList viewPolygons, + int viewStart, + int viewCount, + List clippedRegion, out int clipVertexCount) { - var portalClip = PortalProjection.ProjectToClip(localPoly, cellToWorld, viewProj); - clipVertexCount = portalClip.Length; - var clippedRegion = new List(); - if (portalClip.Length < 3) - return clippedRegion; + using PortalProjection.ClipPolygonLease portalClip = + PortalProjection.ProjectToClipLease(localPoly, cellToWorld, viewProj); + clipVertexCount = portalClip.Count; + if (portalClip.Count < 3) + return; - foreach (var vp in viewPolygons) + int viewEnd = viewStart + viewCount; + for (int i = viewStart; i < viewEnd; i++) { + ViewPolygon vp = viewPolygons[i]; if (vp.IsEmpty) continue; - var clipped = PortalProjection.ClipToRegion(portalClip, vp.Vertices); + var clipped = PortalProjection.ClipToRegion( + portalClip.Span, vp.Vertices, frame.PolygonVertices); if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped)); } - - return clippedRegion; } private const int PortalTraceEmitLimit = 160; @@ -913,6 +1178,7 @@ public static class PortalVisibilityBuilder private const ushort PortalFlagExactMatch = 0x0001; private static void ApplyReciprocalClip( + PortalVisibilityFrame frame, List clippedRegion, ushort otherPortalId, ushort portalFlags, LoadedCell neighbour, Matrix4x4 viewProj) { @@ -944,23 +1210,37 @@ public static class PortalVisibilityBuilder // pins this deterministically; the glitch steps die with this change). The old path's other // rationale — per-round float drift defeating the exact-match CellView dedup — is obsolete: // CanonicalKey's 1e-3-grid snap dedup (2026-06-06) absorbs re-clip drift by construction. - var reciprocalClip = PortalProjection.ProjectToClip(reciprocalPoly, neighbour.WorldTransform, viewProj); - if (reciprocalClip.Length < 3) return; // reciprocal entirely behind the eye → no constraint (over-include) + using PortalProjection.ClipPolygonLease reciprocalClip = + PortalProjection.ProjectToClipLease( + reciprocalPoly, + neighbour.WorldTransform, + viewProj); + if (reciprocalClip.Count < 3) return; // reciprocal entirely behind the eye → no constraint (over-include) // Intersect the reciprocal opening into each near-side polygon; drop any that fall away. // ClipToRegion(subject=homogeneous reciprocal, region=near-side NDC polygon) = the same // region-edge homogeneous Sutherland-Hodgman the forward hop uses (polyClipFinish port). for (int k = clippedRegion.Count - 1; k >= 0; k--) { - var tightened = PortalProjection.ClipToRegion(reciprocalClip, clippedRegion[k].Vertices); + var tightened = PortalProjection.ClipToRegion( + reciprocalClip.Span, + clippedRegion[k].Vertices, + frame.PolygonVertices); if (tightened.Length >= 3) clippedRegion[k] = new ViewPolygon(tightened); else clippedRegion.RemoveAt(k); } } - private static CellView GetOrCreate(Dictionary map, uint key) + private static CellView GetOrCreate( + PortalVisibilityFrame frame, + Dictionary map, + uint key) { - if (!map.TryGetValue(key, out var v)) { v = new CellView(); map[key] = v; } + if (!map.TryGetValue(key, out var v)) + { + v = frame.RentCellView(); + map[key] = v; + } return v; } @@ -998,30 +1278,22 @@ public static class PortalVisibilityBuilder /// not-greater entry), so an equal-distance newcomer lands at the tail and pops FIRST — /// LIFO on ties, matching retail's break-on-first-not-greater + pop-from-tail. /// - private sealed class CellTodoList + private static void InsertTodo( + List<(LoadedCell Cell, float Distance)> items, + LoadedCell cell, + float distance) { - private readonly List<(LoadedCell Cell, float Distance)> _items = new(); + int index = items.Count; + while (index > 0 && items[index - 1].Distance < distance) + index--; + items.Insert(index, (cell, distance)); + } - public int Count => _items.Count; - - public void Insert(LoadedCell cell, float distance) - { - // Find the slot: scan from the tail (nearest) toward the head while existing entries are - // strictly nearer than `distance`, so the newcomer lands just ABOVE every entry that is - // farther-or-equal — i.e. nearest-at-tail order, LIFO on ties (an equal-distance - // newcomer inserts at the tail and pops first). - int idx = _items.Count; - while (idx > 0 && _items[idx - 1].Distance < distance) - idx--; - _items.Insert(idx, (cell, distance)); - } - - public LoadedCell PopNearest() - { - int last = _items.Count - 1; - var cell = _items[last].Cell; - _items.RemoveAt(last); - return cell; - } + private static LoadedCell PopNearest(List<(LoadedCell Cell, float Distance)> items) + { + int last = items.Count - 1; + LoadedCell cell = items[last].Cell; + items.RemoveAt(last); + return cell; } } diff --git a/src/AcDream.App/Rendering/RenderBootstrap.cs b/src/AcDream.App/Rendering/RenderBootstrap.cs index 5eeee68c..6c5c1d27 100644 --- a/src/AcDream.App/Rendering/RenderBootstrap.cs +++ b/src/AcDream.App/Rendering/RenderBootstrap.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using AcDream.Content; using DatReaderWriter; using Microsoft.Extensions.Logging.Abstractions; using Silk.NET.OpenGL; @@ -13,7 +14,7 @@ namespace AcDream.App.Rendering; /// public sealed record RenderStack( GL Gl, - DatReaderWriter.DatCollection Dats, + IDatReaderWriter Dats, string ShaderDir, Wb.BindlessSupport Bindless, TextureCache TextureCache, @@ -26,17 +27,47 @@ public sealed record RenderStack( AcDream.App.UI.UiDatFont? VitalsDatFont, AcDream.App.UI.UiDatFont? LargeDatFont) : System.IDisposable { + internal GpuFrameFlightController FrameFlights { get; init; } = null!; + private ResourceShutdownTransaction? _shutdown; + + internal void BeginFrame() + { + FrameFlights.BeginFrame(); + UiHost.TextRenderer.BeginFrame(FrameFlights.CurrentSlot); + } + + internal void EndFrame() => FrameFlights.EndFrame(); + /// Dispose the GL pieces this stack OWNS (everything created in /// ). + are caller-owned /// and NOT disposed here. Called once at studio teardown. public void Dispose() { - DrawDispatcher.Dispose(); - MeshAdapter.Dispose(); - TextureCache.Dispose(); - MeshShader.Dispose(); - LightingUbo.Dispose(); - UiHost.Dispose(); + _shutdown ??= new ResourceShutdownTransaction( + new ResourceShutdownStage("submitted GPU work", + [ + new("frame flight drain", FrameFlights.WaitForSubmittedWork), + ]), + new ResourceShutdownStage("draw frontend", + [ + new("draw dispatcher", DrawDispatcher.Dispose), + ]), + new ResourceShutdownStage("mesh adapter", + [ + new("mesh adapter", MeshAdapter.Dispose), + ]), + new ResourceShutdownStage("remaining render stack", + [ + new("texture cache", TextureCache.Dispose), + new("mesh shader", MeshShader.Dispose), + new("lighting UBO", LightingUbo.Dispose), + new("UI host", UiHost.Dispose), + ]), + new ResourceShutdownStage("frame flight owner", + [ + new("frame flights", FrameFlights.Dispose), + ])); + _shutdown.CompleteOrThrow(); } /// @@ -108,7 +139,7 @@ public static class RenderBootstrap /// public static RenderStack Create( GL gl, - DatReaderWriter.DatCollection dats, + IDatReaderWriter dats, RenderBootstrapOptions opts) { // --- Bindless detection (GameWindow ~1701-1723) --- @@ -132,14 +163,15 @@ public static class RenderBootstrap Path.Combine(shaderDir, "mesh_modern.frag")); // --- TextureCache (GameWindow ~1774) --- - var textureCache = new TextureCache(gl, dats, bindless); + var frameFlights = new GpuFrameFlightController(gl); + var textureCache = new TextureCache(gl, dats, bindless, frameFlights); // --- AnimLoader (GameWindow ~1240) --- var animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(dats); // --- WbMeshAdapter (GameWindow ~2286-2287) --- var wbLogger = NullLogger.Instance; - var meshAdapter = new Wb.WbMeshAdapter(gl, dats, wbLogger); + var meshAdapter = new Wb.WbMeshAdapter(gl, dats, wbLogger, frameFlights); // --- SequencerFactory (GameWindow ~2306-2334) --- var capturedDats = dats; @@ -216,7 +248,10 @@ public static class RenderBootstrap LightingUbo: lightingUbo, UiHost: uiHost, VitalsDatFont: vitalsDatFont, - LargeDatFont: largeDatFont); + LargeDatFont: largeDatFont) + { + FrameFlights = frameFlights, + }; // Pre-seed the font cache with the two already-uploaded atlas instances // so ResolveDatFont(0x40000000) and ResolveDatFont(0x40000001) hit the cache diff --git a/src/AcDream.App/Rendering/ResourceShutdownTransaction.cs b/src/AcDream.App/Rendering/ResourceShutdownTransaction.cs new file mode 100644 index 00000000..5f4d7167 --- /dev/null +++ b/src/AcDream.App/Rendering/ResourceShutdownTransaction.cs @@ -0,0 +1,104 @@ +namespace AcDream.App.Rendering; + +internal readonly record struct ResourceShutdownOperation(string Name, Action Execute); + +internal sealed record ResourceShutdownStage( + string Name, + ResourceShutdownOperation[] Operations); + +/// +/// Owns application-level shutdown ordering. Operations within one stage are +/// independent and are all attempted; later stages remain protected until the +/// current stage has converged. Successful operations are never replayed. +/// +internal sealed class ResourceShutdownTransaction(params ResourceShutdownStage[] stages) +{ + private sealed class StageState(int operationCount) + { + public bool[] Complete { get; } = new bool[operationCount]; + } + + private const int MaximumConsecutiveStalledPasses = 2; + private readonly ResourceShutdownStage[] _stages = + stages ?? throw new ArgumentNullException(nameof(stages)); + private readonly StageState[] _states = + stages.Select(stage => new StageState(stage.Operations.Length)).ToArray(); + private int _currentStage; + private bool _completing; + + public bool IsComplete => _currentStage == _stages.Length; + internal int CurrentStage => _currentStage; + + public void CompleteOrThrow() + { + if (_completing || IsComplete) + return; + + _completing = true; + try + { + int stalledPasses = 0; + List? latestFailures = null; + while (!IsComplete) + { + bool progressed = AdvanceCurrentStage(out List? failures); + latestFailures = failures; + if (progressed) + { + stalledPasses = 0; + continue; + } + + stalledPasses++; + if (stalledPasses < MaximumConsecutiveStalledPasses) + continue; + + ResourceShutdownStage stage = _stages[_currentStage]; + throw new AggregateException( + $"Shutdown stage '{stage.Name}' did not converge after retrying its pending operations.", + latestFailures ?? + [new InvalidOperationException("The shutdown stage made no progress and reported no failure.")]); + } + } + finally + { + _completing = false; + } + } + + private bool AdvanceCurrentStage(out List? failures) + { + ResourceShutdownStage stage = _stages[_currentStage]; + StageState state = _states[_currentStage]; + bool progressed = false; + failures = null; + + for (int i = 0; i < stage.Operations.Length; i++) + { + if (state.Complete[i]) + continue; + + ResourceShutdownOperation operation = stage.Operations[i]; + try + { + operation.Execute(); + state.Complete[i] = true; + progressed = true; + } + catch (Exception error) + { + (failures ??= []).Add(new InvalidOperationException( + $"Shutdown operation '{operation.Name}' failed in stage '{stage.Name}'.", + error)); + } + } + + if (state.Complete.All(static complete => complete)) + { + _currentStage++; + progressed = true; + } + + return progressed; + } +} diff --git a/src/AcDream.App/Rendering/RetailAlphaQueue.cs b/src/AcDream.App/Rendering/RetailAlphaQueue.cs index 01369b6d..b34e208f 100644 --- a/src/AcDream.App/Rendering/RetailAlphaQueue.cs +++ b/src/AcDream.App/Rendering/RetailAlphaQueue.cs @@ -25,7 +25,13 @@ internal static class RetailAlphaOrdering /// internal interface IRetailAlphaDrawSource { - void DrawAlphaBatch(ReadOnlySpan tokens); + /// Uploads this source's complete payload for the current sorted + /// alpha scope exactly once. Tokens arrive in final far-to-near order. + void PrepareAlphaDraws(ReadOnlySpan tokens); + + /// Draws a contiguous range from the payload prepared above. + void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount); + void ResetAlphaSubmissions(); } @@ -55,6 +61,7 @@ internal sealed class RetailAlphaQueue private readonly List _submissions = new(256); private readonly List _sources = new(4); private int[] _tokenScratch = new int[256]; + private int[] _sourceDrawOffsets = new int[4]; private long _nextSequence; public bool IsCollecting { get; private set; } @@ -109,6 +116,26 @@ internal sealed class RetailAlphaQueue _submissions.Sort(CompareRetailOrder); EnsureTokenCapacity(_submissions.Count); + EnsureSourceCapacity(_sources.Count); + Array.Clear(_sourceDrawOffsets, 0, _sources.Count); + + // Prepare each renderer once for this alpha scope. The filtered + // token sequence preserves final retail order for that source, so + // every later adjacent source-run maps to one contiguous prepared + // range without another GPU upload. + for (int sourceIndex = 0; sourceIndex < _sources.Count; sourceIndex++) + { + IRetailAlphaDrawSource source = _sources[sourceIndex]; + int sourceCount = 0; + for (int i = 0; i < _submissions.Count; i++) + { + RetailAlphaSubmission submission = _submissions[i]; + if (ReferenceEquals(submission.Source, source)) + _tokenScratch[sourceCount++] = submission.Token; + } + + source.PrepareAlphaDraws(_tokenScratch.AsSpan(0, sourceCount)); + } int start = 0; while (start < _submissions.Count) @@ -120,9 +147,10 @@ internal sealed class RetailAlphaQueue end++; int count = end - start; - for (int i = 0; i < count; i++) - _tokenScratch[i] = _submissions[start + i].Token; - source.DrawAlphaBatch(_tokenScratch.AsSpan(0, count)); + int sourceIndex = FindSourceIndex(source); + int firstPreparedDraw = _sourceDrawOffsets[sourceIndex]; + source.DrawPreparedAlphaBatch(firstPreparedDraw, count); + _sourceDrawOffsets[sourceIndex] += count; start = end; } } @@ -168,4 +196,19 @@ internal sealed class RetailAlphaQueue return; Array.Resize(ref _tokenScratch, count + 256); } + + private void EnsureSourceCapacity(int count) + { + if (_sourceDrawOffsets.Length >= count) + return; + Array.Resize(ref _sourceDrawOffsets, count + 4); + } + + private int FindSourceIndex(IRetailAlphaDrawSource source) + { + for (int i = 0; i < _sources.Count; i++) + if (ReferenceEquals(_sources[i], source)) + return i; + throw new InvalidOperationException("Retail alpha source was not registered for this frame."); + } } diff --git a/src/AcDream.App/Rendering/RetailCursorManager.cs b/src/AcDream.App/Rendering/RetailCursorManager.cs index bc2de0a6..1a73b39d 100644 --- a/src/AcDream.App/Rendering/RetailCursorManager.cs +++ b/src/AcDream.App/Rendering/RetailCursorManager.cs @@ -1,6 +1,7 @@ using AcDream.App.UI; using AcDream.Core.Textures; using DatReaderWriter; +using AcDream.Content; using DatReaderWriter.DBObjs; using Silk.NET.Core; using Silk.NET.Input; @@ -10,7 +11,7 @@ namespace AcDream.App.Rendering; /// Applies retail cursor feedback to Silk using dat MediaDescCursor art when available. public sealed class RetailCursorManager { - private readonly DatCollection _dats; + private readonly IDatReaderWriter _dats; private readonly object _datLock; private readonly RetailCursorResolver _globalCursors; private readonly Dictionary _imagesBySurface = new(); @@ -22,7 +23,7 @@ public sealed class RetailCursorManager private UiCursorMedia _lastAppliedCursor; private StandardCursor? _lastStandardCursor; - public RetailCursorManager(DatCollection dats, object datLock) + public RetailCursorManager(IDatReaderWriter dats, object datLock) { _dats = dats; _datLock = datLock; diff --git a/src/AcDream.App/Rendering/RetailCursorResolver.cs b/src/AcDream.App/Rendering/RetailCursorResolver.cs index 7ecaf011..7256d022 100644 --- a/src/AcDream.App/Rendering/RetailCursorResolver.cs +++ b/src/AcDream.App/Rendering/RetailCursorResolver.cs @@ -1,5 +1,6 @@ using AcDream.App.UI; using DatReaderWriter; +using AcDream.Content; using DatReaderWriter.DBObjs; namespace AcDream.App.Rendering; @@ -7,12 +8,12 @@ namespace AcDream.App.Rendering; /// Resolves retail global cursor enum IDs through the portal EnumIDMap chain. internal sealed class RetailCursorResolver { - private readonly DatCollection _dats; + private readonly IDatReaderWriter _dats; private readonly object _datLock; private readonly Dictionary _didByEnum = new(); private readonly HashSet _missingEnums = new(); - public RetailCursorResolver(DatCollection dats, object datLock) + public RetailCursorResolver(IDatReaderWriter dats, object datLock) { _dats = dats; _datLock = datLock; @@ -57,7 +58,7 @@ internal sealed class RetailCursorResolver { lock (_datLock) { - uint masterDid = (uint)_dats.Portal.Header.MasterMapId; + uint masterDid = (uint)_dats.Portal.Db.Header.MasterMapId; if (masterDid == 0) return 0; diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 33d5e464..0191ed01 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -18,9 +18,14 @@ public sealed class RetailPViewRenderer private readonly ClipFrame _clipFrame; private readonly EnvCellRenderer _envCells; private readonly WbDrawDispatcher _entities; + private readonly PortalVisibilityFrame _mainPortalFrameScratch = new(); + private readonly ClipFrameAssembly _clipAssemblyScratch = new(); + private readonly ViewconeCuller _viewconeScratch = new(); + private readonly RetailPViewFrameResult _frameResultScratch = new(); private static readonly ClipViewSlice NoClipSlice = new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty()); + private static readonly ClipViewSlice[] NoClipSlices = { NoClipSlice }; private readonly HashSet _oneCell = new(1); // Shell-batch scratch: all of a pass's cells collected for ONE batched @@ -28,14 +33,20 @@ public sealed class RetailPViewRenderer // frames + across look-in buildings. Spec: // docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md private readonly HashSet _shellBatch = new(); + // Transparent shell cells retain IndoorDrawPlan's far-to-near order. The + // EnvCell renderer uploads shared instance/command data once, then issues + // range-addressed MDI calls in this exact order. + private readonly List _orderedTransparentShellCells = new(); // R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame). - private readonly Dictionary> _buildingGroups = new(); + private readonly BuildingGroupScratch _buildingGroups = new(); + private readonly PortalVisibilityFrame _outdoorBuildingFrameScratch = new(); // #124: per-building look-in frames under an INTERIOR root, drawn as a // landscape-stage sub-pass (DrawBuildingLookIns) — never merged into the // main frame (see DrawInside). Rebuilt each interior-root frame. private readonly List _lookInFrames = new(); + private readonly Stack _lookInFramePool = new(); private readonly HashSet _lookInPrepareScratch = new(); // #131/#132: the late landscape phase's scene-particle owner survivors @@ -56,6 +67,7 @@ public sealed class RetailPViewRenderer // DrawCellObjectLists, RetailPViewFrameResult.DrawableCells) reads it // synchronously within the same frame it was built. private readonly HashSet _drawableCellsScratch = new(); + private readonly RetailPViewScratchRetention _scratchRetention = new(); // T2 (BR-4): retail has NO distance constant on the flood-admission chain // (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test + @@ -79,6 +91,8 @@ public sealed class RetailPViewRenderer public RetailPViewFrameResult DrawInside(RetailPViewDrawContext ctx) { ArgumentNullException.ThrowIfNull(ctx); + RecycleLookInFrames(); + ResetBuildingGroups(); var pvFrame = PortalVisibilityBuilder.Build( ctx.RootCell, @@ -86,7 +100,8 @@ public sealed class RetailPViewRenderer ctx.CellLookup, ctx.ViewProjection, buildingMembership: null, - drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); + drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ, + reuseFrame: _mainPortalFrameScratch); // R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge // the small (~2-cell) per-building views into the frame. Retail reaches building interiors via @@ -111,14 +126,17 @@ public sealed class RetailPViewRenderer // side). Outdoor roots keep the MergeNearbyBuildingFloods path above // (no depth clear under outdoor roots — the merged form is equivalent // there). - _lookInFrames.Clear(); if (!ctx.RootCell.IsOutdoorNode && ctx.NearbyBuildingCells is not null && pvFrame.OutsideView.Polygons.Count > 0) BuildInteriorRootLookIns(ctx, pvFrame); - var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame); - UploadClipFrame(ctx.SetTerrainClipUbo); + var clipAssembly = ClipFrameAssembler.Assemble( + _clipFrame, + pvFrame, + _clipAssemblyScratch); + int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2); + PrepareClipFrame(ctx.SetTerrainClipUbo, terrainUploadCount); // R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the // assembler handed a clip-slot. This feeds the Prepare filter + entity partition, @@ -161,13 +179,11 @@ public sealed class RetailPViewRenderer InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries); var partition = _partitionResult; - var result = new RetailPViewFrameResult - { - PortalFrame = pvFrame, - ClipAssembly = clipAssembly, - DrawableCells = drawableCells, - Partition = partition, - }; + RetailPViewFrameResult result = _frameResultScratch.Reset( + pvFrame, + clipAssembly, + drawableCells, + partition); ctx.EmitDiagnostics?.Invoke(result); @@ -183,7 +199,10 @@ public sealed class RetailPViewRenderer // T3 (BR-5): retail viewconeCheck — meshes are sphere-CULLED per view, // never clipped (Ghidra 0x0054c250). Built once per frame from the // assembled slices + this frame's view-projection. - var viewcone = ViewconeCuller.Build(clipAssembly, ctx.ViewProjection); + var viewcone = ViewconeCuller.Build( + clipAssembly, + ctx.ViewProjection, + _viewconeScratch); // #118: stage assignment for dynamics under an INTERIOR root. Retail // draws the OUTSIDE world's objects inside the landscape stage — @@ -224,36 +243,22 @@ public sealed class RetailPViewRenderer // R-A2: group the nearby building cells by BuildingId and run one per-building flood per group // (retail's per-building ConstructView(CBldPortal)), merging each small view into the frame. The - // grouping dict is reused across frames; inner lists are cleared each frame so a building that left - // the near set simply contributes an empty (skipped) group. + // grouping dict contains only this frame's keys; lists are pooled across frames. private void MergeNearbyBuildingFloods(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame) { - foreach (var group in _buildingGroups.Values) - group.Clear(); - - foreach (var cell in ctx.NearbyBuildingCells!) - { - // R-A2 seam fix: a cell without a BuildingId (unstamped, or outdoor-adjacent with an exit - // portal) must STILL flood — the pre-R-A2 node flood reached it via a reverse portal, so - // dropping it (the original `continue`) left holes at building/terrain seams. Key it by its - // own CellId → a singleton per-entrance flood: a cell with an exit portal seeds from it, a - // cell with none contributes nothing (same as before). BuildingId/CellId key collisions are - // harmless — BuildFromExterior seeds each exit-portal cell in a group independently. - uint groupKey = cell.BuildingId ?? cell.CellId; - if (!_buildingGroups.TryGetValue(groupKey, out var group)) - { - group = new List(); - _buildingGroups[groupKey] = group; - } - group.Add(cell); - } + RebuildBuildingGroups(ctx.NearbyBuildingCells!); foreach (var group in _buildingGroups.Values) { if (group.Count == 0) continue; var buildingFrame = PortalVisibilityBuilder.ConstructViewBuilding( - group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection, OutdoorBuildingSeedDistance); + group, + ctx.ViewerEyePos, + ctx.CellLookup, + ctx.ViewProjection, + OutdoorBuildingSeedDistance, + reuseFrame: _outdoorBuildingFrameScratch); MergeBuildingFrame(pvFrame, buildingFrame); } } @@ -277,15 +282,19 @@ public sealed class RetailPViewRenderer if (!src.CellViews.TryGetValue(cellId, out var srcView)) continue; - if (target.CellViews.TryGetValue(cellId, out var existing)) + if (!target.CellViews.TryGetValue(cellId, out var existing)) { - foreach (var p in srcView.Polygons) - existing.Add(p); - continue; + existing = target.RentCellView(); + target.CellViews[cellId] = existing; + target.OrderedVisibleCells.Add(cellId); } - target.CellViews[cellId] = srcView; - target.OrderedVisibleCells.Add(cellId); + // Copy the view polygons into storage owned by the target frame. + // Source building frames are reused immediately for the next + // building flood, so retaining their CellView reference would + // alias pooled scratch and mutate the merged main view. + foreach (var p in srcView.Polygons) + existing.Add(target.CopyPolygon(p.Vertices)); } } @@ -297,32 +306,51 @@ public sealed class RetailPViewRenderer // building self-excludes via the seed eye-side test. private void BuildInteriorRootLookIns(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame) { - foreach (var group in _buildingGroups.Values) - group.Clear(); - - foreach (var cell in ctx.NearbyBuildingCells!) - { - uint groupKey = cell.BuildingId ?? cell.CellId; - if (!_buildingGroups.TryGetValue(groupKey, out var group)) - { - group = new List(); - _buildingGroups[groupKey] = group; - } - group.Add(cell); - } + RebuildBuildingGroups(ctx.NearbyBuildingCells!); foreach (var group in _buildingGroups.Values) { if (group.Count == 0) continue; + PortalVisibilityFrame frameScratch = _lookInFramePool.Count != 0 + ? _lookInFramePool.Pop() + : new PortalVisibilityFrame(); var frame = PortalVisibilityBuilder.ConstructViewBuilding( group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection, - OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons); + OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons, + reuseFrame: frameScratch); if (frame.OrderedVisibleCells.Count > 0) _lookInFrames.Add(frame); + else + ReturnLookInFrame(frame); } } + private void RecycleLookInFrames() + { + for (int i = 0; i < _lookInFrames.Count; i++) + ReturnLookInFrame(_lookInFrames[i]); + _scratchRetention.ClearFrameBuffers( + _lookInFrames, + _lookInPrepareScratch, + _drawableCellsScratch, + _shellBatch, + _orderedTransparentShellCells); + } + + private void ReturnLookInFrame(PortalVisibilityFrame frame) + { + frame.ResetForBuild(); + if (_lookInFramePool.Count < RetailPViewScratchRetention.MaxRetainedLookInFrames) + _lookInFramePool.Push(frame); + } + + private void RebuildBuildingGroups(IReadOnlyList nearbyCells) + => _buildingGroups.Rebuild(nearbyCells); + + private void ResetBuildingGroups() + => _buildingGroups.Reset(); + // #124: draw the interior-root look-ins INSIDE the landscape stage — // retail's placement (LScape::draw → DrawBlock → DrawSortCell → // DrawBuilding runs as the FIRST call of DrawCells' outside-view branch, @@ -355,17 +383,15 @@ public sealed class RetailPViewRenderer continue; foreach (var poly in view.Polygons) { - var single = new CellView(); - single.Add(poly); - var cps = ClipPlaneSet.From(single); + var cps = ClipPlaneSet.From(poly); if (cps.IsNothingVisible) continue; - var planes = new Vector4[cps.Count]; - for (int p = 0; p < cps.Count; p++) - planes[p] = cps.Planes[p]; ctx.DrawLookInPortalPunch(new RetailPViewCellSliceContext( cellId, - new ClipViewSlice(0, new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), planes), + new ClipViewSlice( + 0, + new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), + cps.PlaneArray), Array.Empty())); } } @@ -454,7 +480,7 @@ public sealed class RetailPViewRenderer foreach (var slice in clipAssembly.OutsideViewSlices) { _clipFrame.SetTerrainClip(slice.Planes); - UploadClipFrame(ctx.SetTerrainClipUbo); + UploadTerrainClip(ctx.SetTerrainClipUbo); // T3 (BR-5): entities are never hard-clipped — retail viewcone- // CHECKS each mesh's sphere against the view (Ghidra 0x0054c250) // and draws it whole. The old per-slice entity clip routing @@ -490,7 +516,7 @@ public sealed class RetailPViewRenderer foreach (var slice in clipAssembly.OutsideViewSlices) { _clipFrame.SetTerrainClip(slice.Planes); - UploadClipFrame(ctx.SetTerrainClipUbo); + UploadTerrainClip(ctx.SetTerrainClipUbo); _entities.ClearClipRouting(); _outdoorStaticScratch.Clear(); // late: dynamics survivors @@ -595,7 +621,7 @@ public sealed class RetailPViewRenderer // the outside slice's slot + NDC AABB + planes (CPU side), the region-SSBO bytes decoded // at that slot (what mesh_modern.vert reads for routed instances), the terrain-UBO head // (what terrain/sky gate against), and the CellIdToSlot routing table. Fires AFTER - // SetTerrainClip + UploadClipFrame + SetClipRouting, BEFORE DrawLandscapeSlice — so the + // SetTerrainClip + UploadTerrainClip + SetClipRouting, BEFORE DrawLandscapeSlice — so the // printed bytes are exactly what this slice's draws consume. private void EmitClipRouteProbe(ClipFrameAssembly clipAssembly, ClipViewSlice slice, int sliceIndex) { @@ -626,7 +652,7 @@ public sealed class RetailPViewRenderer } sb.Append('}'); - // Region-SSBO content decoded at the routed slot, from the packed bytes UploadClipFrame + // Region-SSBO content decoded at the routed slot, from the packed bytes UploadRegions // just uploaded — slot stride 144: count uint at +0, planes[8] at +16. var rb = _clipFrame.RegionBytesForTest; int off = slice.Slot * ClipFrame.CellClipStrideBytes; @@ -715,17 +741,17 @@ public sealed class RetailPViewRenderer if (_shellBatch.Count > 0) _envCells.Render(WbRenderPass.Opaque, _shellBatch); - // Transparent: far→near order matters for compositing, so keep these - // per-cell in ShellPass order — but skip cells with no transparent - // geometry (most are opaque-only walls/floors), removing the bulk of the - // per-cell transparent Render calls. + // Transparent: far-to-near order matters for compositing. The ordered + // list retains ShellPass cell boundaries while EnvCellRenderer shares + // one instance/command/light upload across the complete pass. + _orderedTransparentShellCells.Clear(); foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) { - if (!_envCells.CellHasTransparent(entry.CellId)) continue; - _oneCell.Clear(); - _oneCell.Add(entry.CellId); - _envCells.Render(WbRenderPass.Transparent, _oneCell); + if (_envCells.CellHasTransparent(entry.CellId)) + _orderedTransparentShellCells.Add(entry.CellId); } + if (_orderedTransparentShellCells.Count > 0) + _envCells.RenderTransparentOrdered(_orderedTransparentShellCells); } // T1: the frame's single LAST entity pass — ALL server-spawned dynamics @@ -994,7 +1020,7 @@ public sealed class RetailPViewRenderer && slices.Length > 0) return slices; - return new[] { NoClipSlice }; + return NoClipSlices; } private void UseIndoorMembershipOnlyRouting() @@ -1026,19 +1052,25 @@ public sealed class RetailPViewRenderer animatedEntityIds: ctx.AnimatedEntityIds); } - private void RestoreNoClip(Action setTerrainClipUbo) + private void PrepareClipFrame( + Action setTerrainClipUbo, + int terrainUploadCount) { - _clipFrame.Reset(); - UploadClipFrame(setTerrainClipUbo); - UseIndoorMembershipOnlyRouting(); - } - - private void UploadClipFrame(Action setTerrainClipUbo) - { - _clipFrame.UploadShared(_gl); + // Allocate every terrain record before issuing the first draw. BufferData + // therefore never replaces the arena's backing store while an earlier + // slice can still reference it. The large region table is immutable for + // this assembled PView frame and is uploaded exactly once. + _clipFrame.ReserveTerrainUploads(_gl, terrainUploadCount); + _clipFrame.UploadRegions(_gl); _entities.SetClipRegionSsbo(_clipFrame.RegionSsbo); _envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo); - setTerrainClipUbo(_clipFrame.TerrainUbo); + UploadTerrainClip(setTerrainClipUbo); + } + + private void UploadTerrainClip(Action setTerrainClipUbo) + { + TerrainClipBufferBinding binding = _clipFrame.UploadTerrainClip(_gl); + setTerrainClipUbo(binding); } } @@ -1067,6 +1099,168 @@ public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks public Action>? DrawDynamicsParticles { get; } } +/// +/// Capacity policy for renderer-owned, one-frame scratch. Normal warmed frames +/// retain their storage; a pathological visibility spike is released at the +/// next frame boundary instead of becoming permanent process memory. +/// +internal sealed class RetailPViewScratchRetention +{ + internal const int MaxRetainedLookInFrames = 32; + internal const int MaxRetainedCellItems = 512; + internal const int CapacityTrimIdleFrames = 120; + + private int _lookInFramesUnderusedFrames; + private int _lookInPrepareUnderusedFrames; + private int _drawableCellsUnderusedFrames; + private int _shellBatchUnderusedFrames; + private int _orderedTransparentUnderusedFrames; + + internal void ClearFrameBuffers( + List lookInFrames, + HashSet lookInPrepare, + HashSet drawableCells, + HashSet shellBatch, + List orderedTransparentShellCells) + { + ClearCold( + lookInFrames, + MaxRetainedLookInFrames, + ref _lookInFramesUnderusedFrames); + ClearCold( + lookInPrepare, + MaxRetainedCellItems, + ref _lookInPrepareUnderusedFrames); + ClearCold( + drawableCells, + MaxRetainedCellItems, + ref _drawableCellsUnderusedFrames); + ClearCold(shellBatch, MaxRetainedCellItems, ref _shellBatchUnderusedFrames); + ClearCold( + orderedTransparentShellCells, + MaxRetainedCellItems, + ref _orderedTransparentUnderusedFrames); + } + + private static void ClearCold( + List values, + int maximumRetainedCapacity, + ref int underusedFrames) + { + int usedCount = values.Count; + int capacity = values.Capacity; + values.Clear(); + if (!ShouldTrim( + capacity, + usedCount, + maximumRetainedCapacity, + ref underusedFrames)) + return; + + if (capacity > maximumRetainedCapacity) + values.Capacity = 0; + } + + private static void ClearCold( + HashSet values, + int maximumRetainedCapacity, + ref int underusedFrames) + { + int usedCount = values.Count; + int capacity = values.EnsureCapacity(0); + values.Clear(); + if (!ShouldTrim( + capacity, + usedCount, + maximumRetainedCapacity, + ref underusedFrames)) + return; + + if (capacity > maximumRetainedCapacity) + values.TrimExcess(); + } + + private static bool ShouldTrim( + int capacity, + int usedCount, + int maximumRetainedCapacity, + ref int underusedFrames) + { + if (capacity <= maximumRetainedCapacity || (long)usedCount * 2L > capacity) + { + underusedFrames = 0; + return false; + } + + underusedFrames++; + if (underusedFrames < CapacityTrimIdleFrames) + return false; + + underusedFrames = 0; + return true; + } +} + +/// +/// Frame-scoped grouping for retail's per-building portal floods. Active keys +/// are rebuilt in nearby-cell encounter order every frame; the value lists are +/// retained through a bounded pool so travelling through the world cannot turn +/// every historical building id into permanent memory or per-frame scan work. +/// +internal sealed class BuildingGroupScratch +{ + internal const int MaxRetainedGroups = 256; + internal const int MaxRetainedCellsPerGroup = 256; + + private readonly Dictionary> _active = new(); + private readonly Stack> _listPool = new(); + + internal Dictionary>.ValueCollection Values => _active.Values; + internal IReadOnlyDictionary> Groups => _active; + internal int ActiveGroupCount => _active.Count; + internal int RetainedListCount => _listPool.Count; + internal int MapCapacity => _active.EnsureCapacity(0); + + internal void Rebuild(IReadOnlyList nearbyCells) + { + ArgumentNullException.ThrowIfNull(nearbyCells); + Reset(); + + for (int i = 0; i < nearbyCells.Count; i++) + { + LoadedCell cell = nearbyCells[i]; + // R-A2 seam behavior: an unstamped cell still gets a singleton + // entrance flood keyed by CellId. + uint groupKey = cell.BuildingId ?? cell.CellId; + if (!_active.TryGetValue(groupKey, out List? group)) + { + group = _listPool.Count != 0 + ? _listPool.Pop() + : new List(); + _active.Add(groupKey, group); + } + group.Add(cell); + } + } + + internal void Reset() + { + foreach (List group in _active.Values) + { + group.Clear(); + if (group.Capacity <= MaxRetainedCellsPerGroup + && _listPool.Count < MaxRetainedGroups) + { + _listPool.Push(group); + } + } + + _active.Clear(); + if (_active.EnsureCapacity(0) > MaxRetainedGroups) + _active.TrimExcess(); + } +} + public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext { public required LoadedCell RootCell { get; init; } @@ -1089,7 +1283,7 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, IReadOnlyDictionary? AnimatedById)> LandblockEntries { get; init; } - public required Action SetTerrainClipUbo { get; init; } + public required Action SetTerrainClipUbo { get; init; } public required Action DrawLandscapeSlice { get; init; } /// #131/#132: the LATE landscape phase, per slice, after the #124 @@ -1115,15 +1309,38 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext /// remains open for the final world alpha scope. public Action? FlushLandscapeAlpha { get; init; } public Action>? DrawDynamicsParticles { get; init; } + /// + /// Synchronous observation of the borrowed current-frame result. The + /// callback must copy anything it needs to retain beyond this invocation. + /// public Action? EmitDiagnostics { get; init; } } +/// +/// Borrowed renderer scratch valid only until the next +/// call. Collections and nested +/// frame objects are deliberately reused to keep the render loop allocation +/// free; consumers must copy any state they need to retain asynchronously. +/// public sealed class RetailPViewFrameResult { - public required PortalVisibilityFrame PortalFrame { get; init; } - public required ClipFrameAssembly ClipAssembly { get; init; } - public required HashSet DrawableCells { get; init; } - public required InteriorEntityPartition.Result Partition { get; init; } + public PortalVisibilityFrame PortalFrame { get; private set; } = null!; + public ClipFrameAssembly ClipAssembly { get; private set; } = null!; + public HashSet DrawableCells { get; private set; } = null!; + public InteriorEntityPartition.Result Partition { get; private set; } = null!; + + internal RetailPViewFrameResult Reset( + PortalVisibilityFrame portalFrame, + ClipFrameAssembly clipAssembly, + HashSet drawableCells, + InteriorEntityPartition.Result partition) + { + PortalFrame = portalFrame; + ClipAssembly = clipAssembly; + DrawableCells = drawableCells; + Partition = partition; + return this; + } } public readonly record struct RetailPViewLandscapeSliceContext( diff --git a/src/AcDream.App/Rendering/SceneLightingUboBinding.cs b/src/AcDream.App/Rendering/SceneLightingUboBinding.cs index 92f7e8dc..72b02277 100644 --- a/src/AcDream.App/Rendering/SceneLightingUboBinding.cs +++ b/src/AcDream.App/Rendering/SceneLightingUboBinding.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.InteropServices; +using AcDream.App.Rendering.Wb; using AcDream.Core.Lighting; using Silk.NET.OpenGL; @@ -23,27 +24,70 @@ namespace AcDream.App.Rendering; public sealed unsafe class SceneLightingUboBinding : IDisposable { private readonly GL _gl; - private readonly uint _ubo; + private uint _ubo; + private readonly List[] _buffersByFrame = [[], [], []]; + private int _frameSlot; + private int _bufferCursor; + private bool _frameStarted; private bool _disposed; + internal int DynamicBufferCount => _buffersByFrame.Sum(buffers => buffers.Count); + public SceneLightingUboBinding(GL gl) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); - _ubo = _gl.GenBuffer(); - _gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo); - // Pre-allocate with the final size; BufferSubData each frame. - _gl.BufferData( - BufferTargetARB.UniformBuffer, - (nuint)SceneLightingUbo.SizeInBytes, - (void*)0, - BufferUsageARB.DynamicDraw); - _gl.BindBuffer(BufferTargetARB.UniformBuffer, 0); + } - // Bind the buffer to the chosen binding point exactly once — shaders - // that declare this binding in their layout block will read from it - // on every draw without further intervention. - _gl.BindBufferBase(BufferTargetARB.UniformBuffer, - SceneLightingUbo.BindingPoint, _ubo); + /// + /// Resets the lighting-submission cursor for a GPU-fenced frame slot. + /// World, portal-space, and paperdoll draws can each upload different + /// lighting in one frame, so every upload receives distinct storage. + /// + public void BeginFrame(int frameSlot) + { + if ((uint)frameSlot >= (uint)_buffersByFrame.Length) + throw new ArgumentOutOfRangeException(nameof(frameSlot)); + + _frameSlot = frameSlot; + _bufferCursor = 0; + _frameStarted = true; + } + + private void ActivateNextBuffer() + { + if (!_frameStarted) + throw new InvalidOperationException("BeginFrame must be called before uploading scene lighting."); + + List buffers = _buffersByFrame[_frameSlot]; + if (_bufferCursor == buffers.Count) + { + uint buffer = TrackedGlResource.CreateBuffer( + _gl, + "SceneLighting frame UBO creation"); + try + { + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.UniformBuffer, + buffer, + 0, + SceneLightingUbo.SizeInBytes, + GLEnum.DynamicDraw, + "SceneLighting frame UBO allocation"); + buffers.Add(buffer); + } + catch + { + TrackedGlResource.DeleteBuffer( + _gl, + buffer, + 0, + "SceneLighting frame UBO rollback"); + throw; + } + } + + _ubo = buffers[_bufferCursor++]; } /// @@ -52,16 +96,30 @@ public sealed unsafe class SceneLightingUboBinding : IDisposable /// public void Upload(SceneLightingUbo data) { + ActivateNextBuffer(); _gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo); _gl.BufferSubData(BufferTargetARB.UniformBuffer, (nint)0, (nuint)SceneLightingUbo.SizeInBytes, &data); + _gl.BindBufferBase(BufferTargetARB.UniformBuffer, + SceneLightingUbo.BindingPoint, _ubo); _gl.BindBuffer(BufferTargetARB.UniformBuffer, 0); } public void Dispose() { if (_disposed) return; - _gl.DeleteBuffer(_ubo); + foreach (List buffers in _buffersByFrame) + { + foreach (uint buffer in buffers) + { + TrackedGlResource.DeleteBuffer( + _gl, + buffer, + SceneLightingUbo.SizeInBytes, + "SceneLighting frame UBO disposal"); + } + buffers.Clear(); + } _disposed = true; } } diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs index 041b57c2..270606cd 100644 --- a/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs +++ b/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.Content; using AcDream.Core.Selection; using DatReaderWriter; using DatReaderWriter.DBObjs; @@ -11,13 +12,13 @@ namespace AcDream.App.Rendering.Selection; /// internal sealed class RetailSelectionGeometryCache { - private readonly DatCollection _dats; + private readonly IDatReaderWriter _dats; private readonly object _datLock; // Render-thread owned. Dictionary permits a cached null for a GfxObj which // legitimately has no drawing BSP; ConcurrentDictionary does not. private readonly Dictionary _cache = new(); - public RetailSelectionGeometryCache(DatCollection dats, object datLock) + public RetailSelectionGeometryCache(IDatReaderWriter dats, object datLock) { _dats = dats ?? throw new ArgumentNullException(nameof(dats)); _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); diff --git a/src/AcDream.App/Rendering/Shader.cs b/src/AcDream.App/Rendering/Shader.cs index bcad4f0d..fea7ff19 100644 --- a/src/AcDream.App/Rendering/Shader.cs +++ b/src/AcDream.App/Rendering/Shader.cs @@ -6,6 +6,7 @@ namespace AcDream.App.Rendering; public sealed class Shader : IDisposable { private readonly GL _gl; + private readonly Dictionary _uniformLocations = new(StringComparer.Ordinal); public uint Program { get; } public Shader(GL gl, string vertexPath, string fragmentPath) @@ -42,39 +43,54 @@ public sealed class Shader : IDisposable public unsafe void SetMatrix4(string name, Matrix4x4 m) { - int loc = _gl.GetUniformLocation(Program, name); + int loc = GetUniformLocation(name); _gl.UniformMatrix4(loc, 1, false, (float*)&m); } public void SetInt(string name, int value) { - int loc = _gl.GetUniformLocation(Program, name); + int loc = GetUniformLocation(name); _gl.Uniform1(loc, value); } public void SetFloat(string name, float value) { - int loc = _gl.GetUniformLocation(Program, name); + int loc = GetUniformLocation(name); _gl.Uniform1(loc, value); } public void SetVec3(string name, Vector3 v) { - int loc = _gl.GetUniformLocation(Program, name); + int loc = GetUniformLocation(name); _gl.Uniform3(loc, v.X, v.Y, v.Z); } public void SetVec2(string name, Vector2 v) { - int loc = _gl.GetUniformLocation(Program, name); + int loc = GetUniformLocation(name); _gl.Uniform2(loc, v.X, v.Y); } public void SetVec4(string name, Vector4 v) { - int loc = _gl.GetUniformLocation(Program, name); + int loc = GetUniformLocation(name); _gl.Uniform4(loc, v.X, v.Y, v.Z, v.W); } - public void Dispose() => _gl.DeleteProgram(Program); + private int GetUniformLocation(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + if (_uniformLocations.TryGetValue(name, out int location)) + return location; + + location = _gl.GetUniformLocation(Program, name); + _uniformLocations.Add(name, location); + return location; + } + + public void Dispose() + { + _uniformLocations.Clear(); + _gl.DeleteProgram(Program); + } } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index bfbe4bf6..3281e19c 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -11,7 +11,7 @@ struct InstanceData { struct BatchData { uvec2 textureHandle; // bindless handle for sampler2DArray - uint textureLayer; // layer index (always 0 for per-instance composites) + uint textureLayer; // layer in the shared WB or pooled composite array uint flags; // reserved — N.5 dispatcher owns all blend state // (glBlendFunc per pass). If a future phase wants // shader-side per-batch additive flag (Decision 2 diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index a02275c5..26520bb8 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -6,6 +6,7 @@ using AcDream.Core.Meshing; using AcDream.Core.Terrain; using AcDream.Core.World; using DatReaderWriter; +using AcDream.Content; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using Silk.NET.OpenGL; @@ -45,7 +46,7 @@ namespace AcDream.App.Rendering.Sky; public sealed unsafe class SkyRenderer : IDisposable { private readonly GL _gl; - private readonly DatCollection _dats; + private readonly IDatReaderWriter _dats; private readonly Shader _shader; private readonly TextureCache _textures; private readonly SamplerCache _samplers; @@ -62,7 +63,7 @@ public sealed unsafe class SkyRenderer : IDisposable public float Near { get; set; } = 0.1f; public float Far { get; set; } = 1_000_000f; - public SkyRenderer(GL gl, DatCollection dats, Shader shader, TextureCache textures, SamplerCache samplers) + public SkyRenderer(GL gl, IDatReaderWriter dats, Shader shader, TextureCache textures, SamplerCache samplers) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); _dats = dats ?? throw new ArgumentNullException(nameof(dats)); diff --git a/src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs b/src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs new file mode 100644 index 00000000..27c31034 --- /dev/null +++ b/src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs @@ -0,0 +1,242 @@ +namespace AcDream.App.Rendering; + +/// +/// One standalone, resident bindless texture used by particle billboards. +/// Unlike entity composites, the shader always samples layer zero. +/// +internal sealed class StandaloneBindlessTextureResource +{ + public required uint SurfaceId { get; init; } + public required uint Name { get; init; } + public required ulong Handle { get; init; } + public required long Bytes { get; init; } +} + +internal interface IStandaloneBindlessTextureBackend +{ + void MakeNonResident(StandaloneBindlessTextureResource resource); + void Delete(StandaloneBindlessTextureResource resource); +} + +/// +/// Shares exact DAT-decoded particle textures between live emitter owners. +/// The final owner moves a resource into a small LRU reuse pool; exceeding +/// either cache bound logically evicts the oldest entry immediately and +/// retires its resident handle/backing texture behind the frame-flight fence. +/// Live resources are never evicted, so this policy cannot change visuals. +/// Render-thread only. +/// +internal sealed class StandaloneBindlessTextureCache : IDisposable +{ + internal const long DefaultUnownedBudgetBytes = 32L * 1024 * 1024; + internal const int DefaultMaximumUnownedCount = 256; + internal const int DefaultMaximumEvictionsPerFrame = 1; + + private readonly IStandaloneBindlessTextureBackend _backend; + private readonly GpuRetirementLedger _retirementLedger; + private readonly OwnerScopedResourceRegistry _owners = new(); + private readonly BoundedUnownedResourceCache _unowned; + private readonly Dictionary _entries = new(); + private readonly HashSet _disposeResidencyReleased = []; + private readonly HashSet _disposeDeleted = []; + private bool _disposeRequested; + private bool _disposing; + private bool _disposed; + + public StandaloneBindlessTextureCache( + IStandaloneBindlessTextureBackend backend, + IGpuResourceRetirementQueue retirementQueue, + long unownedBudgetBytes = DefaultUnownedBudgetBytes, + int maximumUnownedCount = DefaultMaximumUnownedCount) + { + _backend = backend ?? throw new ArgumentNullException(nameof(backend)); + ArgumentNullException.ThrowIfNull(retirementQueue); + _retirementLedger = new GpuRetirementLedger(retirementQueue); + _unowned = new BoundedUnownedResourceCache( + unownedBudgetBytes, + maximumUnownedCount); + } + + internal int EntryCount => _entries.Count; + internal int ActiveResourceCount => _owners.ResourceCount; + internal int OwnerCount => _owners.OwnerCount; + internal int UnownedEntryCount => _unowned.Count; + internal long UnownedBytes => _unowned.ResidentBytes; + internal int AwaitingRetirementPublicationCount => + _retirementLedger.AwaitingPublicationCount; + + public bool TryAcquire( + uint ownerId, + uint surfaceId, + out StandaloneBindlessTextureResource resource) + { + ObjectDisposedException.ThrowIf(_disposeRequested, this); + ValidateOwnerAndSurface(ownerId, surfaceId); + if (!_entries.TryGetValue(surfaceId, out resource!)) + return false; + + _owners.Acquire(ownerId, surfaceId); + _unowned.MarkOwned(surfaceId); + return true; + } + + public void AddAndAcquire( + uint ownerId, + StandaloneBindlessTextureResource resource) + { + ObjectDisposedException.ThrowIf(_disposeRequested, this); + ArgumentNullException.ThrowIfNull(resource); + ValidateOwnerAndSurface(ownerId, resource.SurfaceId); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resource.Name); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resource.Handle); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resource.Bytes); + + if (!_entries.TryAdd(resource.SurfaceId, resource)) + throw new InvalidOperationException( + $"Standalone particle surface 0x{resource.SurfaceId:X8} is already cached."); + + _owners.Acquire(ownerId, resource.SurfaceId); + } + + public void ReleaseOwner(uint ownerId) + { + ObjectDisposedException.ThrowIf(_disposeRequested, this); + if (ownerId == 0) + return; + + IReadOnlyList newlyUnowned = _owners.ReleaseOwner(ownerId); + for (int i = 0; i < newlyUnowned.Count; i++) + { + uint surfaceId = newlyUnowned[i]; + if (_entries.TryGetValue(surfaceId, out StandaloneBindlessTextureResource? resource)) + _unowned.MarkUnowned(surfaceId, resource.Bytes); + } + + } + + internal void VisitEntries(Action visitor) + { + ArgumentNullException.ThrowIfNull(visitor); + foreach (StandaloneBindlessTextureResource resource in _entries.Values) + visitor(resource); + } + + /// + /// Advances logical eviction at a bounded rate. Owner release only marks + /// resources reusable; this render-frame maintenance edge prevents a + /// portal unload from submitting hundreds of bindless destruction calls + /// to the same GPU fence serial. + /// + public void Tick(int maximumEvictions = DefaultMaximumEvictionsPerFrame) + { + ObjectDisposedException.ThrowIf(_disposeRequested, this); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumEvictions); + _retirementLedger.RetryPendingPublications(); + + for (int i = 0; i < maximumEvictions; i++) + { + if (!_unowned.TryTakeOldestOverBudget(out uint surfaceId)) + break; + if (!_entries.Remove(surfaceId, out StandaloneBindlessTextureResource? resource)) + continue; + + // The publication ledger owns this release from this point even + // when queue admission or an immediate callback throws. Never + // republish a texture after residency release has started. + _retirementLedger.Retire(new RetryableGpuResourceRelease( + () => _backend.MakeNonResident(resource), + () => _backend.Delete(resource))); + } + } + + private static void ValidateOwnerAndSurface(uint ownerId, uint surfaceId) + { + ArgumentOutOfRangeException.ThrowIfZero(ownerId); + ArgumentOutOfRangeException.ThrowIfZero(surfaceId); + } + + public void Dispose() + { + if (_disposed || _disposing) + return; + _disposeRequested = true; + _disposing = true; + + try + { + // GameWindow drains the frame-flight queue before TextureCache + // teardown. Release every handle before deleting any texture so the + // ARB_bindless_texture lifetime ordering remains explicit. + List? failures = null; + try + { + _retirementLedger.RetryPendingPublications(); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + + foreach (StandaloneBindlessTextureResource resource in _entries.Values) + { + if (_disposeResidencyReleased.Contains(resource.SurfaceId)) + continue; + try + { + _backend.MakeNonResident(resource); + _disposeResidencyReleased.Add(resource.SurfaceId); + } + catch (Exception ex) + { + (failures ??= []).Add(ex); + } + } + + foreach (StandaloneBindlessTextureResource resource in _entries.Values) + { + if (!_disposeResidencyReleased.Contains(resource.SurfaceId) + || _disposeDeleted.Contains(resource.SurfaceId)) + { + continue; + } + + try + { + _backend.Delete(resource); + _disposeDeleted.Add(resource.SurfaceId); + } + catch (Exception ex) + { + (failures ??= []).Add(ex); + } + } + + if (_disposeDeleted.Count != 0) + { + foreach (uint surfaceId in _disposeDeleted) + _entries.Remove(surfaceId); + } + + if (_entries.Count == 0 + && _retirementLedger.AwaitingPublicationCount == 0) + { + _owners.Clear(); + _unowned.Clear(); + _disposeResidencyReleased.Clear(); + _disposeDeleted.Clear(); + _disposed = true; + } + + if (failures is not null) + { + throw new AggregateException( + "One or more standalone particle textures failed to retire.", + failures); + } + } + finally + { + _disposing = false; + } + } +} diff --git a/src/AcDream.App/Rendering/TerrainAtlas.cs b/src/AcDream.App/Rendering/TerrainAtlas.cs index 29f8ed4c..471716fa 100644 --- a/src/AcDream.App/Rendering/TerrainAtlas.cs +++ b/src/AcDream.App/Rendering/TerrainAtlas.cs @@ -1,5 +1,6 @@ using AcDream.Core.Textures; using DatReaderWriter; +using AcDream.Content; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using Silk.NET.OpenGL; @@ -118,7 +119,7 @@ public sealed unsafe class TerrainAtlas : IDisposable /// for the mapping from TerrainTextureType to SurfaceTexture id, decoding each /// to RGBA8, and uploading as layers in a single GL_TEXTURE_2D_ARRAY. /// - public static TerrainAtlas Build(GL gl, DatCollection dats, Wb.BindlessSupport? bindless = null) + public static TerrainAtlas Build(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null) { var region = dats.Get(0x13000000u) ?? throw new InvalidOperationException("Region dat id 0x13000000 missing"); @@ -251,7 +252,7 @@ public sealed unsafe class TerrainAtlas : IDisposable IReadOnlyList cornerTCodes, IReadOnlyList sideTCodes, IReadOnlyList roadRCodes); private static AlphaAtlasBuildResult BuildAlphaAtlas( - GL gl, DatCollection dats, DatReaderWriter.Types.TexMerge texMerge) + GL gl, IDatReaderWriter dats, DatReaderWriter.Types.TexMerge texMerge) { var decoded = new List(); var cornerLayers = new List(); @@ -364,7 +365,7 @@ public sealed unsafe class TerrainAtlas : IDisposable cornerTCodes, sideTCodes, roadRCodes); } - private static bool TryDecodeAlphaMap(DatCollection dats, uint surfaceTextureId, out DecodedTexture decoded) + private static bool TryDecodeAlphaMap(IDatReaderWriter dats, uint surfaceTextureId, out DecodedTexture decoded) { decoded = DecodedTexture.Magenta; diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.cs index 49bbc6eb..40ed41a5 100644 --- a/src/AcDream.App/Rendering/TerrainModernRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainModernRenderer.cs @@ -39,7 +39,10 @@ public sealed unsafe class TerrainModernRenderer : IDisposable /// anisotropic level mid-session via . public TerrainAtlas Atlas => _atlas; - private readonly TerrainSlotAllocator _alloc; + private readonly GpuRetiredTerrainSlotAllocator _alloc; + private readonly GpuRetirementLedger _retirementLedger; + private RetryableResourceReleaseLedger? _disposeResources; + private bool _disposed; // Per-slot live data (index by slot integer; null entries are unused slots). private SlotData?[] _slots; @@ -51,14 +54,31 @@ public sealed unsafe class TerrainModernRenderer : IDisposable private uint _globalVao; private uint _globalVbo; private uint _globalEbo; + private long _globalVboCapacityBytes; + private long _globalEboCapacityBytes; private uint _indirectBuffer; private int _indirectCapacity; + private sealed class DynamicIndirectBuffer + { + public uint Buffer; + public int Capacity; + } + + private readonly List[] _indirectBuffersByFrame = + [[], [], []]; + private int _dynamicFrameSlot; + private int _dynamicBufferCursor; + private bool _dynamicFrameStarted; + + internal int DynamicIndirectBufferCount => + _indirectBuffersByFrame.Sum(frameBuffers => frameBuffers.Count); + // Phase U.3: terrain clip UBO (binding=2, terrain_modern.vert TerrainClip). // The shared one is created + uploaded by the GameWindow-level ClipFrame and // handed in via SetClipUbo. When 0, we bind a lazily-created no-clip fallback // (count 0 = ungated) so the shader never reads an unbound UBO at binding=2. - private uint _sharedClipUbo; + private TerrainClipBufferBinding _sharedClipBinding; private uint _fallbackClipUbo; // Cached uvec2-handle uniform locations (matrix uniforms are set by name via Shader.SetMatrix4). @@ -91,12 +111,31 @@ public sealed unsafe class TerrainModernRenderer : IDisposable Shader shader, TerrainAtlas atlas, int initialSlotCapacity = 64) + : this( + gl, + bindless, + shader, + atlas, + ImmediateGpuResourceRetirementQueue.Instance, + initialSlotCapacity) + { + } + + internal TerrainModernRenderer( + GL gl, + BindlessSupport bindless, + Shader shader, + TerrainAtlas atlas, + IGpuResourceRetirementQueue resourceRetirement, + int initialSlotCapacity = 64) { _gl = gl; _bindless = bindless; _shader = shader; _atlas = atlas; - _alloc = new TerrainSlotAllocator(initialSlotCapacity); + ArgumentNullException.ThrowIfNull(resourceRetirement); + _retirementLedger = new GpuRetirementLedger(resourceRetirement); + _alloc = new GpuRetiredTerrainSlotAllocator(initialSlotCapacity, resourceRetirement); _slots = new SlotData?[initialSlotCapacity]; _uTerrainHandleLoc = _gl.GetUniformLocation(_shader.Program, "uTerrainHandle"); @@ -105,22 +144,105 @@ public sealed unsafe class TerrainModernRenderer : IDisposable if (_uTexTilingLoc < 0) throw new InvalidOperationException("terrain_modern.frag is missing the required uTexTiling uniform."); - _globalVao = _gl.GenVertexArray(); - _globalVbo = _gl.GenBuffer(); - _globalEbo = _gl.GenBuffer(); - AllocateGpuBuffers(initialSlotCapacity); - ConfigureVao(); + try + { + _globalVao = TrackedGlResource.CreateVertexArray( + _gl, + "creating terrain global VAO"); + _globalVbo = TrackedGlResource.CreateBuffer( + _gl, + "creating terrain global vertex buffer"); + _globalEbo = TrackedGlResource.CreateBuffer( + _gl, + "creating terrain global index buffer"); + AllocateGpuBuffers(initialSlotCapacity); + ConfigureVao(_globalVao, _globalVbo, _globalEbo); + } + catch + { + TrackedGlResource.DeleteVertexArray( + _gl, + _globalVao, + "rolling back terrain global VAO"); + TrackedGlResource.DeleteBuffer( + _gl, + _globalVbo, + _globalVboCapacityBytes, + "rolling back terrain global vertex buffer"); + TrackedGlResource.DeleteBuffer( + _gl, + _globalEbo, + _globalEboCapacityBytes, + "rolling back terrain global index buffer"); + _globalVao = 0; + _globalVbo = 0; + _globalEbo = 0; + _globalVboCapacityBytes = 0; + _globalEboCapacityBytes = 0; + throw; + } - _indirectBuffer = _gl.GenBuffer(); } /// - /// Phase U.3: hand the renderer the SHARED terrain-clip UBO (binding=2) - /// created by . The renderer binds it to - /// binding=2 before its draw. Pass 0 to fall back to the internal no-clip UBO - /// (count 0 = ungated terrain). + /// Resets the indirect-command submission cursor for a GPU-fenced frame + /// slot. A retail outside view may draw terrain more than once in a frame; + /// each draw receives storage that cannot overwrite an earlier command. /// - public void SetClipUbo(uint sharedClipUbo) => _sharedClipUbo = sharedClipUbo; + public void BeginFrame(int frameSlot) + { + if ((uint)frameSlot >= (uint)_indirectBuffersByFrame.Length) + throw new ArgumentOutOfRangeException(nameof(frameSlot)); + _retirementLedger.RetryPendingPublications(); + _dynamicFrameSlot = frameSlot; + _dynamicBufferCursor = 0; + _dynamicFrameStarted = true; + } + + private void ActivateNextIndirectBuffer() + { + if (!_dynamicFrameStarted) + throw new InvalidOperationException("BeginFrame must be called before drawing terrain."); + + List frameBuffers = _indirectBuffersByFrame[_dynamicFrameSlot]; + if (_dynamicBufferCursor == frameBuffers.Count) + { + uint buffer = TrackedGlResource.CreateBuffer( + _gl, + $"creating terrain indirect buffer for frame slot {_dynamicFrameSlot}"); + try + { + frameBuffers.Add(new DynamicIndirectBuffer { Buffer = buffer }); + } + catch + { + TrackedGlResource.DeleteBuffer( + _gl, + buffer, + 0, + "rolling back terrain indirect buffer"); + throw; + } + } + + DynamicIndirectBuffer active = frameBuffers[_dynamicBufferCursor++]; + _indirectBuffer = active.Buffer; + _indirectCapacity = active.Capacity; + } + + private void PersistIndirectCapacity() + { + _indirectBuffersByFrame[_dynamicFrameSlot][_dynamicBufferCursor - 1].Capacity = + _indirectCapacity; + } + + /// + /// Hand the renderer the current aligned terrain-clip range (binding=2). + /// Each outside-view slice occupies a distinct range in the current + /// GPU-fenced frame's UBO arena. + /// + public void SetClipUbo(TerrainClipBufferBinding sharedClipBinding) => + _sharedClipBinding = sharedClipBinding; /// /// Two-tier streaming entry point. Accepts a prebuilt mesh from @@ -146,15 +268,21 @@ public sealed unsafe class TerrainModernRenderer : IDisposable $"Expected {IndicesPerLandblock} indices, got {meshData.Indices.Length}", nameof(meshData)); - if (_idToSlot.ContainsKey(landblockId)) - RemoveLandblock(landblockId); + // A prior replacement may have committed the logical slot switch + // before queue publication failed. Retry those retained physical-slot + // transactions before allocating more terrain storage. + _alloc.RetryPendingPublications(); + bool replacing = _idToSlot.TryGetValue(landblockId, out int replacedSlot); int slot = _alloc.Allocate(out var needsGrow); - if (needsGrow) + bool published = false; + try { - int newCap = Math.Max(_alloc.Capacity * 2, slot + 1); - EnsureCapacity(newCap); - } + if (needsGrow) + { + int newCap = Math.Max(_alloc.Capacity * 2, slot + 1); + EnsureCapacity(newCap); + } // Bake worldOrigin into vertex positions; capture min/max Z for AABB. var bakedVerts = new TerrainVertex[VertsPerLandblock]; @@ -179,41 +307,66 @@ public sealed unsafe class TerrainModernRenderer : IDisposable nint vboByteOffset = (nint)(slot * VertsPerLandblock * VertexSize); nint eboByteOffset = (nint)(slot * IndicesPerLandblock * IndexSize); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo); - fixed (TerrainVertex* p = bakedVerts) - { - _gl.BufferSubData(BufferTargetARB.ArrayBuffer, vboByteOffset, - (nuint)(VertsPerLandblock * VertexSize), p); - } - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + fixed (TerrainVertex* p = bakedVerts) + { + TrackedGlResource.UpdateBufferSubData( + _gl, + BufferTargetARB.ArrayBuffer, + _globalVbo, + vboByteOffset, + VertsPerLandblock * VertexSize, + p, + $"uploading terrain vertices for 0x{landblockId:X8}"); + } - _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo); - fixed (uint* p = bakedIndices) - { - _gl.BufferSubData(BufferTargetARB.ElementArrayBuffer, eboByteOffset, - (nuint)(IndicesPerLandblock * IndexSize), p); - } - _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0); + fixed (uint* p = bakedIndices) + { + TrackedGlResource.UpdateBufferSubData( + _gl, + BufferTargetARB.ElementArrayBuffer, + _globalEbo, + eboByteOffset, + IndicesPerLandblock * IndexSize, + p, + $"uploading terrain indices for 0x{landblockId:X8}"); + } - _slots[slot] = new SlotData + _slots[slot] = new SlotData + { + LandblockId = landblockId, + WorldOrigin = worldOrigin, + FirstIndex = (uint)(slot * IndicesPerLandblock), + IndexCount = IndicesPerLandblock, + AabbMin = new Vector3(worldOrigin.X, worldOrigin.Y, zMin), + AabbMax = new Vector3(worldOrigin.X + LandblockSize, worldOrigin.Y + LandblockSize, zMax), + }; + _idToSlot[landblockId] = slot; + published = true; + + if (replacing) + { + _slots[replacedSlot] = null; + _alloc.FreeAfterGpuUse(replacedSlot); + } + } + finally { - LandblockId = landblockId, - WorldOrigin = worldOrigin, - FirstIndex = (uint)(slot * IndicesPerLandblock), - IndexCount = IndicesPerLandblock, - AabbMin = new Vector3(worldOrigin.X, worldOrigin.Y, zMin), - AabbMax = new Vector3(worldOrigin.X + LandblockSize, worldOrigin.Y + LandblockSize, zMax), - }; - _idToSlot[landblockId] = slot; + if (!published) + _alloc.ReleaseUnsubmitted(slot); + } } public void RemoveLandblock(uint landblockId) { + // Removal clears the logical lookup before retirement publication. A + // retry therefore has to advance retained publications even when the + // landblock is no longer present in the map. + _alloc.RetryPendingPublications(); if (!_idToSlot.TryGetValue(landblockId, out var slot)) return; _idToSlot.Remove(landblockId); _slots[slot] = null; - _alloc.Free(slot); + _alloc.FreeAfterGpuUse(slot); // No GPU clear: the per-frame DEIC array won't reference this slot. } @@ -252,6 +405,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable ndcClipAabb); } if (_visibleSlots.Count == 0) return; + ActivateNextIndirectBuffer(); // Build DEIC array. if (_deicScratch.Length < _visibleSlots.Count) @@ -272,23 +426,31 @@ public sealed unsafe class TerrainModernRenderer : IDisposable // Grow indirect buffer if needed. if (_visibleSlots.Count > _indirectCapacity) { - _indirectCapacity = Math.Max(64, _visibleSlots.Count * 2); - _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _indirectBuffer); - _gl.BufferData(GLEnum.DrawIndirectBuffer, - (nuint)(_indirectCapacity * sizeof(DrawElementsIndirectCommand)), - null, GLEnum.DynamicDraw); - } - else - { - _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _indirectBuffer); + int grownCapacity = Math.Max(64, _visibleSlots.Count * 2); + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.DrawIndirectBuffer, + _indirectBuffer, + checked((long)_indirectCapacity * sizeof(DrawElementsIndirectCommand)), + checked((long)grownCapacity * sizeof(DrawElementsIndirectCommand)), + GLEnum.DynamicDraw, + "growing terrain indirect command buffer"); + _indirectCapacity = grownCapacity; } // Upload DEIC array. fixed (DrawElementsIndirectCommand* p = _deicScratch) { - _gl.BufferSubData(GLEnum.DrawIndirectBuffer, 0, - (nuint)(_visibleSlots.Count * sizeof(DrawElementsIndirectCommand)), p); + TrackedGlResource.UpdateBufferSubData( + _gl, + GLEnum.DrawIndirectBuffer, + _indirectBuffer, + 0, + checked((long)_visibleSlots.Count * sizeof(DrawElementsIndirectCommand)), + p, + "uploading terrain indirect commands"); } + PersistIndirectCapacity(); // Bind shader + uniforms + atlas handles. // Verified Phase W Stage 4 (T4.2): terrain projects from the camera view-proj; @@ -352,11 +514,96 @@ public sealed unsafe class TerrainModernRenderer : IDisposable public void Dispose() { - _gl.DeleteVertexArray(_globalVao); - _gl.DeleteBuffer(_globalVbo); - _gl.DeleteBuffer(_globalEbo); - _gl.DeleteBuffer(_indirectBuffer); - if (_fallbackClipUbo != 0) { _gl.DeleteBuffer(_fallbackClipUbo); _fallbackClipUbo = 0; } // Phase U.3 + if (_disposed) + return; + _retirementLedger.RetryPendingPublications(); + + if (_disposeResources is null) + { + var releases = new List<(string Name, Action Release)>(); + if (_globalVao != 0) + { + RetryableGpuResourceRelease release = + TrackedGlResource.CreateRetryableVertexArrayDeletion( + _gl, + _globalVao, + "deleting terrain global VAO"); + releases.Add(("global-vao", release.Run)); + } + if (_globalVbo != 0) + { + RetryableGpuResourceRelease release = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + _globalVbo, + _globalVboCapacityBytes, + "deleting terrain global vertex buffer"); + releases.Add(("global-vbo", release.Run)); + } + if (_globalEbo != 0) + { + RetryableGpuResourceRelease release = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + _globalEbo, + _globalEboCapacityBytes, + "deleting terrain global index buffer"); + releases.Add(("global-ebo", release.Run)); + } + for (int frame = 0; frame < _indirectBuffersByFrame.Length; frame++) + { + List frameBuffers = _indirectBuffersByFrame[frame]; + for (int index = 0; index < frameBuffers.Count; index++) + { + DynamicIndirectBuffer buffer = frameBuffers[index]; + RetryableGpuResourceRelease release = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + buffer.Buffer, + checked((long)buffer.Capacity * sizeof(DrawElementsIndirectCommand)), + "deleting terrain indirect command buffer"); + releases.Add(($"indirect-{frame}-{index}", release.Run)); + } + } + if (_fallbackClipUbo != 0) + { + RetryableGpuResourceRelease release = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + _fallbackClipUbo, + ClipFrame.TerrainUboBytes, + "deleting terrain fallback clip UBO"); + releases.Add(("fallback-clip-ubo", release.Run)); + } + _disposeResources = new RetryableResourceReleaseLedger(releases); + } + + ResourceReleaseAttempt attempt = _disposeResources.Advance(); + if (!_disposeResources.IsComplete) + { + throw attempt.ToException( + "One or more terrain GPU resources could not be released."); + } + + _globalVao = 0; + _globalVbo = 0; + _globalEbo = 0; + _globalVboCapacityBytes = 0; + _globalEboCapacityBytes = 0; + foreach (List frameBuffers in _indirectBuffersByFrame) + frameBuffers.Clear(); + _indirectBuffer = 0; + _indirectCapacity = 0; + _dynamicFrameStarted = false; + _fallbackClipUbo = 0; + _disposeResources = null; + _disposed = true; + + if (attempt.HasFailures) + { + throw attempt.ToException( + "Terrain GPU resources released with exceptional committed outcomes."); + } } // ---------------------------------------------------------------- @@ -393,28 +640,48 @@ public sealed unsafe class TerrainModernRenderer : IDisposable /// /// Phase U.3: bind the terrain clip UBO to binding=2. Prefers the shared - /// UBO (); otherwise lazily + /// UBO range (); otherwise lazily /// creates + binds a no-clip fallback (count 0 = ungated) so the shader never /// reads an unbound UBO. The fallback is std140-sized to /// and zero-filled (count 0). /// private void BindClipUboBinding2() { - if (_sharedClipUbo != 0) + if (_sharedClipBinding.IsValid) { - _gl.BindBufferBase(BufferTargetARB.UniformBuffer, - ClipFrame.TerrainClipUboBinding, _sharedClipUbo); + _sharedClipBinding.Bind(_gl); return; } if (_fallbackClipUbo == 0) { - _fallbackClipUbo = _gl.GenBuffer(); var zero = stackalloc byte[ClipFrame.TerrainUboBytes]; for (int i = 0; i < ClipFrame.TerrainUboBytes; i++) zero[i] = 0; - _gl.BindBuffer(BufferTargetARB.UniformBuffer, _fallbackClipUbo); - _gl.BufferData(BufferTargetARB.UniformBuffer, - (nuint)ClipFrame.TerrainUboBytes, zero, BufferUsageARB.DynamicDraw); + uint fallback = TrackedGlResource.CreateBuffer( + _gl, + "creating terrain fallback clip UBO"); + try + { + TrackedGlResource.AllocateBufferStorage( + _gl, + BufferTargetARB.UniformBuffer, + fallback, + 0, + ClipFrame.TerrainUboBytes, + BufferUsageARB.DynamicDraw, + zero, + "allocating terrain fallback clip UBO"); + _fallbackClipUbo = fallback; + } + catch + { + TrackedGlResource.DeleteBuffer( + _gl, + fallback, + 0, + "rolling back terrain fallback clip UBO"); + throw; + } } _gl.BindBufferBase(BufferTargetARB.UniformBuffer, ClipFrame.TerrainClipUboBinding, _fallbackClipUbo); @@ -422,23 +689,35 @@ public sealed unsafe class TerrainModernRenderer : IDisposable private void AllocateGpuBuffers(int capacitySlots) { - nuint vboBytes = (nuint)(capacitySlots * VertsPerLandblock * VertexSize); - nuint eboBytes = (nuint)(capacitySlots * IndicesPerLandblock * IndexSize); + long vboBytes = checked((long)capacitySlots * VertsPerLandblock * VertexSize); + long eboBytes = checked((long)capacitySlots * IndicesPerLandblock * IndexSize); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo); - _gl.BufferData(BufferTargetARB.ArrayBuffer, vboBytes, null, BufferUsageARB.DynamicDraw); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + TrackedGlResource.AllocateBufferStorage( + _gl, + BufferTargetARB.ArrayBuffer, + _globalVbo, + _globalVboCapacityBytes, + vboBytes, + BufferUsageARB.DynamicDraw, + "allocating terrain global vertex storage"); + _globalVboCapacityBytes = vboBytes; - _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo); - _gl.BufferData(BufferTargetARB.ElementArrayBuffer, eboBytes, null, BufferUsageARB.DynamicDraw); - _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0); + TrackedGlResource.AllocateBufferStorage( + _gl, + BufferTargetARB.ElementArrayBuffer, + _globalEbo, + _globalEboCapacityBytes, + eboBytes, + BufferUsageARB.DynamicDraw, + "allocating terrain global index storage"); + _globalEboCapacityBytes = eboBytes; } - private void ConfigureVao() + private void ConfigureVao(uint vao, uint vbo, uint ebo) { - _gl.BindVertexArray(_globalVao); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo); - _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo); + _gl.BindVertexArray(vao); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo); + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo); uint stride = (uint)VertexSize; @@ -460,6 +739,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable _gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12)); _gl.BindVertexArray(0); + GLHelpers.ThrowOnResourceError(_gl, "configuring terrain VAO"); } internal static void CollectVisibleCells( @@ -588,43 +868,129 @@ public sealed unsafe class TerrainModernRenderer : IDisposable private void EnsureCapacity(int newCapacity) { - if (newCapacity <= _alloc.Capacity) return; + if (newCapacity <= _alloc.Capacity) + return; - // Allocate new VBO + EBO at new size; copy old contents; swap; recreate VAO. - uint newVbo = _gl.GenBuffer(); - uint newEbo = _gl.GenBuffer(); + var grownSlots = new SlotData?[newCapacity]; + Array.Copy(_slots, grownSlots, _slots.Length); - nuint newVboBytes = (nuint)(newCapacity * VertsPerLandblock * VertexSize); - nuint newEboBytes = (nuint)(newCapacity * IndicesPerLandblock * IndexSize); - nuint oldVboBytes = (nuint)(_alloc.Capacity * VertsPerLandblock * VertexSize); - nuint oldEboBytes = (nuint)(_alloc.Capacity * IndicesPerLandblock * IndexSize); + long newVboBytes = checked((long)newCapacity * VertsPerLandblock * VertexSize); + long newEboBytes = checked((long)newCapacity * IndicesPerLandblock * IndexSize); + uint newVbo = 0; + uint newEbo = 0; + uint newVao = 0; + long allocatedNewVboBytes = 0; + long allocatedNewEboBytes = 0; + bool published = false; + try + { + newVbo = TrackedGlResource.CreateBuffer( + _gl, + "creating grown terrain vertex buffer"); + TrackedGlResource.AllocateBufferStorage( + _gl, + BufferTargetARB.ArrayBuffer, + newVbo, + 0, + newVboBytes, + BufferUsageARB.DynamicDraw, + "allocating grown terrain vertex buffer"); + allocatedNewVboBytes = newVboBytes; - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, newVbo); - _gl.BufferData(BufferTargetARB.ArrayBuffer, newVboBytes, null, BufferUsageARB.DynamicDraw); - _gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalVbo); - _gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newVbo); - _gl.CopyBufferSubData(CopyBufferSubDataTarget.CopyReadBuffer, CopyBufferSubDataTarget.CopyWriteBuffer, - 0, 0, oldVboBytes); - _gl.DeleteBuffer(_globalVbo); - _globalVbo = newVbo; + newEbo = TrackedGlResource.CreateBuffer( + _gl, + "creating grown terrain index buffer"); + TrackedGlResource.AllocateBufferStorage( + _gl, + BufferTargetARB.ElementArrayBuffer, + newEbo, + 0, + newEboBytes, + BufferUsageARB.DynamicDraw, + "allocating grown terrain index buffer"); + allocatedNewEboBytes = newEboBytes; - _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, newEbo); - _gl.BufferData(BufferTargetARB.ElementArrayBuffer, newEboBytes, null, BufferUsageARB.DynamicDraw); - _gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalEbo); - _gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newEbo); - _gl.CopyBufferSubData(CopyBufferSubDataTarget.CopyReadBuffer, CopyBufferSubDataTarget.CopyWriteBuffer, - 0, 0, oldEboBytes); - _gl.DeleteBuffer(_globalEbo); - _globalEbo = newEbo; + GLHelpers.ThrowOnResourceError(_gl, "copying terrain buffers (precondition)"); + _gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalVbo); + _gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newVbo); + _gl.CopyBufferSubData( + CopyBufferSubDataTarget.CopyReadBuffer, + CopyBufferSubDataTarget.CopyWriteBuffer, + 0, + 0, + checked((nuint)_globalVboCapacityBytes)); + _gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalEbo); + _gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newEbo); + _gl.CopyBufferSubData( + CopyBufferSubDataTarget.CopyReadBuffer, + CopyBufferSubDataTarget.CopyWriteBuffer, + 0, + 0, + checked((nuint)_globalEboCapacityBytes)); + GLHelpers.ThrowOnResourceError(_gl, "copying terrain buffers"); - // Recreate VAO with new buffer bindings. - _gl.DeleteVertexArray(_globalVao); - _globalVao = _gl.GenVertexArray(); - ConfigureVao(); + newVao = TrackedGlResource.CreateVertexArray( + _gl, + "creating grown terrain VAO"); + ConfigureVao(newVao, newVbo, newEbo); - // Grow slot tracking array. - Array.Resize(ref _slots, newCapacity); - _alloc.GrowTo(newCapacity); + uint oldVao = _globalVao; + uint oldVbo = _globalVbo; + uint oldEbo = _globalEbo; + long oldVboBytes = _globalVboCapacityBytes; + long oldEboBytes = _globalEboCapacityBytes; + + _globalVao = newVao; + _globalVbo = newVbo; + _globalEbo = newEbo; + _globalVboCapacityBytes = newVboBytes; + _globalEboCapacityBytes = newEboBytes; + _slots = grownSlots; + _alloc.GrowTo(newCapacity); + published = true; + + // Older submitted draws captured the former VAO/buffer bindings. + // Retire the complete old set only after the replacement is valid. + RetryableGpuResourceRelease oldVaoRelease = + TrackedGlResource.CreateRetryableVertexArrayDeletion( + _gl, + oldVao, + "retiring terrain VAO after growth"); + RetryableGpuResourceRelease oldVboRelease = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + oldVbo, + oldVboBytes, + "retiring terrain vertex buffer after growth"); + RetryableGpuResourceRelease oldEboRelease = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + oldEbo, + oldEboBytes, + "retiring terrain index buffer after growth"); + _retirementLedger.RetireMany( + [oldVaoRelease, oldVboRelease, oldEboRelease]); + } + finally + { + if (!published) + { + TrackedGlResource.DeleteVertexArray( + _gl, + newVao, + "rolling back grown terrain VAO"); + TrackedGlResource.DeleteBuffer( + _gl, + newVbo, + allocatedNewVboBytes, + "rolling back grown terrain vertex buffer"); + TrackedGlResource.DeleteBuffer( + _gl, + newEbo, + allocatedNewEboBytes, + "rolling back grown terrain index buffer"); + } + } } private sealed class SlotData diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index 8c3a969d..88e1f69d 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Numerics; using System.Runtime.InteropServices; +using AcDream.App.Rendering.Wb; using Silk.NET.OpenGL; namespace AcDream.App.Rendering; @@ -23,11 +25,25 @@ public sealed unsafe class TextRenderer : IDisposable private readonly GL _gl; private readonly Shader _shader; - private readonly uint _vao; - private readonly uint _vbo; + private uint _vao; + private uint _vbo; private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket private int _vboCapacityBytes; + private sealed class FrameBufferSet + { + public uint Vao; + public uint Vbo; + public int CapacityBytes; + public int UsedBytes; + } + + private readonly FrameBufferSet[] _frameBuffers = new FrameBufferSet[3]; + private FrameBufferSet? _activeFrameBuffer; + + internal long DynamicBufferCapacityBytes => + _frameBuffers.Sum(set => (long)set.CapacityBytes); + private readonly List _textBuf = new(8192); private readonly List _rectBuf = new(1024); // Submission-ordered sprite segments: consecutive DrawSprite calls with the @@ -65,22 +81,8 @@ public sealed unsafe class TextRenderer : IDisposable Path.Combine(shaderDir, "ui_text.vert"), Path.Combine(shaderDir, "ui_text.frag")); - _vao = _gl.GenVertexArray(); - _vbo = _gl.GenBuffer(); - - _gl.BindVertexArray(_vao); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); - - uint stride = FloatsPerVertex * sizeof(float); - _gl.EnableVertexAttribArray(0); - _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0); - _gl.EnableVertexAttribArray(1); - _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float))); - _gl.EnableVertexAttribArray(2); - _gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, stride, (void*)(4 * sizeof(float))); - - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); - _gl.BindVertexArray(0); + for (int i = 0; i < _frameBuffers.Length; i++) + _frameBuffers[i] = CreateFrameBufferSet(); // 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE // bucket (the shader multiplies texel×color → white×color = color). Lets a panel @@ -97,6 +99,63 @@ public sealed unsafe class TextRenderer : IDisposable _gl.BindTexture(TextureTarget.Texture2D, 0); } + /// + /// Selects the GPU-fenced frame slot and resets its append cursor. Every + /// UI segment rendered during the frame receives a distinct byte range; + /// later text or sprite batches cannot overwrite an earlier in-flight draw. + /// + public void BeginFrame(int frameSlot) + { + if ((uint)frameSlot >= (uint)_frameBuffers.Length) + throw new ArgumentOutOfRangeException(nameof(frameSlot)); + + FrameBufferSet set = _frameBuffers[frameSlot]; + set.UsedBytes = 0; + _activeFrameBuffer = set; + _vao = set.Vao; + _vbo = set.Vbo; + _vboCapacityBytes = set.CapacityBytes; + } + + private FrameBufferSet CreateFrameBufferSet() + { + uint vao = 0; + uint vbo = 0; + try + { + vao = TrackedGlResource.CreateVertexArray( + _gl, + "TextRenderer frame VAO creation"); + vbo = TrackedGlResource.CreateBuffer( + _gl, + "TextRenderer frame VBO creation"); + var set = new FrameBufferSet { Vao = vao, Vbo = vbo }; + + _gl.BindVertexArray(set.Vao); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.Vbo); + uint stride = FloatsPerVertex * sizeof(float); + _gl.EnableVertexAttribArray(0); + _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0); + _gl.EnableVertexAttribArray(1); + _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float))); + _gl.EnableVertexAttribArray(2); + _gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, stride, (void*)(4 * sizeof(float))); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + _gl.BindVertexArray(0); + return set; + } + catch + { + if (vbo != 0) + TrackedGlResource.DeleteBuffer( + _gl, vbo, 0, "TextRenderer frame VBO rollback"); + if (vao != 0) + TrackedGlResource.DeleteVertexArray( + _gl, vao, "TextRenderer frame VAO rollback"); + throw; + } + } + /// Begin a HUD pass. Call once per frame before any Draw* calls. public void Begin(Vector2 screenSize) { @@ -357,8 +416,8 @@ public sealed unsafe class TextRenderer : IDisposable var seg = spriteSegs[i]; if (seg.Verts.Count == 0) continue; _gl.BindTexture(TextureTarget.Texture2D, seg.Texture); - UploadBuffer(seg.Verts); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(seg.Verts.Count / FloatsPerVertex)); + int firstVertex = UploadBuffer(seg.Verts); + _gl.DrawArrays(PrimitiveType.Triangles, firstVertex, (uint)(seg.Verts.Count / FloatsPerVertex)); } } @@ -366,8 +425,8 @@ public sealed unsafe class TextRenderer : IDisposable if (rectVerts > 0) { _shader.SetInt("uUseTexture", 0); - UploadBuffer(rectBuf); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)rectVerts); + int firstVertex = UploadBuffer(rectBuf); + _gl.DrawArrays(PrimitiveType.Triangles, firstVertex, (uint)rectVerts); } // 3. Textured debug-font text glyphs on top. @@ -377,34 +436,59 @@ public sealed unsafe class TextRenderer : IDisposable _gl.ActiveTexture(TextureUnit.Texture0); _gl.BindTexture(TextureTarget.Texture2D, font.TextureId); _shader.SetInt("uTex", 0); - UploadBuffer(textBuf); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)textVerts); + int firstVertex = UploadBuffer(textBuf); + _gl.DrawArrays(PrimitiveType.Triangles, firstVertex, (uint)textVerts); } } - private void UploadBuffer(List buf) + private int UploadBuffer(List buf) { int bytes = buf.Count * sizeof(float); - if (bytes == 0) return; + if (bytes == 0) return 0; + FrameBufferSet set = _activeFrameBuffer + ?? throw new InvalidOperationException("BeginFrame must be called before rendering text."); + int byteOffset = set.UsedBytes; + int requiredBytes = checked(byteOffset + bytes); - if (bytes > _vboCapacityBytes) + if (requiredBytes > _vboCapacityBytes) { - fixed (float* p = CollectionsMarshal.AsSpan(buf)) - _gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)bytes, p, BufferUsageARB.DynamicDraw); - _vboCapacityBytes = bytes; - } - else - { - fixed (float* p = CollectionsMarshal.AsSpan(buf)) - _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)bytes, p); + int newCapacity = DynamicBufferCapacity.Grow( + _vboCapacityBytes, + requiredBytes); + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.ArrayBuffer, + _vbo, + _vboCapacityBytes, + newCapacity, + GLEnum.DynamicDraw, + "TextRenderer frame VBO growth"); + _vboCapacityBytes = newCapacity; } + + fixed (float* p = CollectionsMarshal.AsSpan(buf)) + _gl.BufferSubData(BufferTargetARB.ArrayBuffer, (nint)byteOffset, (nuint)bytes, p); + + set.UsedBytes = requiredBytes; + set.CapacityBytes = _vboCapacityBytes; + return byteOffset / (FloatsPerVertex * sizeof(float)); } public void Dispose() { _gl.DeleteTexture(_whiteTex); - _gl.DeleteBuffer(_vbo); - _gl.DeleteVertexArray(_vao); + foreach (FrameBufferSet set in _frameBuffers) + { + TrackedGlResource.DeleteBuffer( + _gl, + set.Vbo, + set.CapacityBytes, + "TextRenderer frame VBO disposal"); + TrackedGlResource.DeleteVertexArray( + _gl, + set.Vao, + "TextRenderer frame VAO disposal"); + } _shader.Dispose(); } } diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index bbc7d4b5..03ec5121 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -1,34 +1,27 @@ // src/AcDream.App/Rendering/TextureCache.cs using AcDream.Core.Textures; using AcDream.Core.World; +using AcDream.Content; using DatReaderWriter; using DatReaderWriter.DBObjs; using Silk.NET.OpenGL; using System.Linq; +using PixelFormatId = DatReaderWriter.Enums.PixelFormat; using SurfaceType = DatReaderWriter.Enums.SurfaceType; namespace AcDream.App.Rendering; -public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposable +public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable { private readonly GL _gl; - private readonly DatCollection _dats; - private readonly Dictionary _handlesBySurfaceId = new(); - private readonly Dictionary _sizeBySurfaceId = new(); - /// - /// Composite cache for surface-with-override-origtex entries (Phase 5 - /// TextureChanges). Key = (baseSurfaceId, overrideOrigTextureId), - /// value = GL texture handle. - /// - private readonly Dictionary<(uint surfaceId, uint origTexOverride), uint> _handlesByOverridden = new(); - /// - /// Composite cache for palette-overridden entries (Phase 5 SubPalettes). - /// Key = (baseSurfaceId, origTexOverride, paletteHash), value = handle. - /// paletteHash is a cheap combined hash of the PaletteOverride's ids + - /// offsets + lengths so two entities with equivalent palette setups - /// share the same decoded texture. - /// - private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), uint> _handlesByPalette = new(); + private readonly IDatReaderWriter _dats; + // Handle and decoded dimensions are one atomic cache entry. Keeping them + // in separate dictionaries allowed GetOrUpload(surfaceId) followed by the + // sized overload to upload a second GL texture and orphan the first. + private readonly Dictionary + _surfacesById = new(); + private readonly Dictionary<(uint SurfaceId, uint OrigTextureId), (int Width, int Height)> + _decodedDimensionsByTexture = new(); private uint _magentaHandle; // Direct-RenderSurface caches for UI sprites: 0x06xxxxxx RenderSurface ids @@ -44,15 +37,32 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab private readonly List _adhocHandles = new(); private readonly Wb.BindlessSupport? _bindless; + private readonly CompositeTextureArrayCache? _compositeTextures; - // Bindless / Texture2DArray parallel caches. Keys mirror the legacy three - // caches so a surface used by both the legacy (Texture2D, sampler2D) and - // modern (Texture2DArray, sampler2DArray) paths is uploaded twice — once - // per target. Each entry stores both the GL texture name (for Dispose - // cleanup) and the resident bindless handle (returned to callers). - private readonly Dictionary _bindlessBySurfaceId = new(); - private readonly Dictionary<(uint surfaceId, uint origTexOverride), (uint Name, ulong Handle)> _bindlessByOverridden = new(); - private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), (uint Name, ulong Handle)> _bindlessByPalette = new(); + // Standalone Texture2DArray caches. Shared world surfaces use WB's atlas; + // this base cache remains for consumers such as particle rendering. + // Per-entity override composites are owner-scoped but share pooled array + // storage. Retail CSurface ownership releases immediately while ImgTex + // residency remains separately purgeable; CompositeTextureArrayCache + // mirrors that split without one GL object per material composite. + private readonly StandaloneBindlessTextureCache? _particleTextures; + private readonly Dictionary<(uint surfaceId, uint origTexOverride), bool> _paletteIndexedByTexture = new(); + + internal int OwnedBindlessTextureCount => _compositeTextures?.ActiveResourceCount ?? 0; + internal int TextureOwnerCount => _compositeTextures?.OwnerCount ?? 0; + internal int CachedCompositeTextureCount => _compositeTextures?.CachedEntryCount ?? 0; + internal int CachedUnownedCompositeCount => _compositeTextures?.UnownedEntryCount ?? 0; + internal long CachedUnownedCompositeBytes => _compositeTextures?.UnownedBytes ?? 0; + internal int CompositeAtlasCount => _compositeTextures?.AtlasCount ?? 0; + internal long CompositeAtlasBytes => _compositeTextures?.AllocatedBytes ?? 0; + internal int CompositeFrameUploadCount => _compositeTextures?.FrameUploadCount ?? 0; + internal long CompositeFrameUploadBytes => _compositeTextures?.FrameUploadBytes ?? 0; + internal bool CanStartCompositeUpload => _compositeTextures?.CanStartUpload == true; + internal int CachedParticleTextureCount => _particleTextures?.EntryCount ?? 0; + internal int ActiveParticleTextureCount => _particleTextures?.ActiveResourceCount ?? 0; + internal int ParticleTextureOwnerCount => _particleTextures?.OwnerCount ?? 0; + internal int CachedUnownedParticleTextureCount => _particleTextures?.UnownedEntryCount ?? 0; + internal long CachedUnownedParticleTextureBytes => _particleTextures?.UnownedBytes ?? 0; // Phase N.6 slice 1 (2026-05-11): per-upload metadata for the // ACDREAM_DUMP_SURFACES=1 histogram dump path. Populated at upload @@ -68,11 +78,28 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab private int _dumpFrameCounter; private bool _surfaceHistogramAlreadyDumped; - public TextureCache(GL gl, DatCollection dats, Wb.BindlessSupport? bindless = null) + public TextureCache(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null) + : this(gl, dats, bindless, ImmediateGpuResourceRetirementQueue.Instance) + { + } + + internal TextureCache( + GL gl, + IDatReaderWriter dats, + Wb.BindlessSupport? bindless, + IGpuResourceRetirementQueue retirementQueue) { _gl = gl; _dats = dats; _bindless = bindless; + ArgumentNullException.ThrowIfNull(retirementQueue); + if (bindless is not null) + { + _compositeTextures = new CompositeTextureArrayCache(gl, bindless, retirementQueue); + _particleTextures = new StandaloneBindlessTextureCache( + new ParticleTextureBackend(this), + retirementQueue); + } } /// @@ -81,17 +108,7 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab /// missing or uses an unsupported format. /// public uint GetOrUpload(uint surfaceId) - { - if (_handlesBySurfaceId.TryGetValue(surfaceId, out var h)) - return h; - - var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null); - if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1") - DumpAlphaHistogram(surfaceId, decoded); - h = UploadRgba8(decoded); - _handlesBySurfaceId[surfaceId] = h; - return h; - } + => GetOrUploadSurfaceCore(surfaceId, out _, out _); /// /// Like but also returns the decoded @@ -99,19 +116,27 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab /// compute slice UVs. Cached alongside the handle. /// public uint GetOrUpload(uint surfaceId, out int width, out int height) + => GetOrUploadSurfaceCore(surfaceId, out width, out height); + + private uint GetOrUploadSurfaceCore(uint surfaceId, out int width, out int height) { - if (_handlesBySurfaceId.TryGetValue(surfaceId, out var existing) - && _sizeBySurfaceId.TryGetValue(surfaceId, out var sz)) + if (_surfacesById.TryGetValue(surfaceId, out var existing)) { - width = sz.w; height = sz.h; - return existing; + width = existing.Width; + height = existing.Height; + return existing.Handle; } - var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null); + DecodedTexture decoded = DecodeFromDats( + surfaceId, + origTextureOverride: null, + paletteOverride: null); + if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1") + DumpAlphaHistogram(surfaceId, decoded); uint h = UploadRgba8(decoded); - _handlesBySurfaceId[surfaceId] = h; - _sizeBySurfaceId[surfaceId] = (decoded.Width, decoded.Height); - width = decoded.Width; height = decoded.Height; + _surfacesById.Add(surfaceId, (h, decoded.Width, decoded.Height)); + width = decoded.Width; + height = decoded.Height; return h; } @@ -200,129 +225,228 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab } /// - /// Get or upload a texture for a Surface id but with its - /// OrigTextureId replaced by . - /// The Surface's other properties (type flags, color, translucency, - /// clipmap handling, default palette) are preserved — only the - /// SurfaceTexture lookup is swapped. This is how the server's - /// CreateObject.TextureChanges are applied at render time. - /// Caches under a composite key so multiple entities can share. + /// Acquires the exact DAT-decoded one-layer texture array for a live + /// particle emitter. Equivalent surfaces are shared; the cache ownership + /// ends with . /// - public uint GetOrUploadWithOrigTextureOverride(uint surfaceId, uint overrideOrigTextureId) + internal ulong AcquireParticleTexture(int emitterHandle, uint surfaceId) { - var key = (surfaceId, overrideOrigTextureId); - if (_handlesByOverridden.TryGetValue(key, out var h)) - return h; + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(emitterHandle); + ArgumentOutOfRangeException.ThrowIfZero(surfaceId); + StandaloneBindlessTextureCache textures = EnsureParticleTexturesAvailable(); + uint ownerId = checked((uint)emitterHandle); + if (textures.TryAcquire( + ownerId, + surfaceId, + out StandaloneBindlessTextureResource? existing)) + { + return existing.Handle; + } - var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: null); - h = UploadRgba8(decoded); - _handlesByOverridden[key] = h; - return h; + DecodedTexture decoded = DecodeFromDats( + surfaceId, + origTextureOverride: null, + paletteOverride: null); + uint name = UploadRgba8AsLayer1Array(decoded); + ulong handle = 0; + try + { + handle = _bindless!.GetResidentHandle(name); + Wb.GLHelpers.ThrowOnResourceError( + _gl, + $"making particle surface 0x{surfaceId:X8} resident"); + var resource = new StandaloneBindlessTextureResource + { + SurfaceId = surfaceId, + Name = name, + Handle = handle, + Bytes = checked((long)decoded.Width * decoded.Height * 4L), + }; + textures.AddAndAcquire(ownerId, resource); + return handle; + } + catch (Exception residencyFailure) + { + List? cleanupFailures = null; + void Attempt(Action cleanup) + { + try { cleanup(); } + catch (Exception ex) { (cleanupFailures ??= []).Add(ex); } + } + + bool residencyReleased = handle == 0; + if (handle != 0) + { + Attempt(() => + { + _bindless!.MakeNonResident(handle); + Wb.GLHelpers.ThrowOnResourceError( + _gl, + "rolling back particle texture residency"); + residencyReleased = true; + }); + } + if (residencyReleased) + Attempt(() => DeleteUploadedTexture(name)); + if (cleanupFailures is not null) + { + cleanupFailures.Insert(0, residencyFailure); + throw new AggregateException( + "Particle texture residency and rollback both failed.", + cleanupFailures); + } + throw; + } + } + + internal void ReleaseParticleTextureOwner(int emitterHandle) + { + if (emitterHandle <= 0 || _particleTextures is null) + return; + _particleTextures.ReleaseOwner(checked((uint)emitterHandle)); } /// - /// Full Phase 5 override: for palette-indexed textures (PFID_P8 / - /// PFID_INDEX16), applies 's - /// subpalette overlays on top of the texture's default palette - /// before decoding. Non-palette formats ignore the palette override. - /// Also honors if non-null. + /// Owner-scoped bindless variant for a server-supplied original-texture + /// replacement. Stores compatible composites in a pooled Texture2DArray + /// and returns its resident handle plus the assigned layer. Equivalent + /// composites are shared until their final live owner leaves. Throws if + /// BindlessSupport wasn't provided. /// - public uint GetOrUploadWithPaletteOverride( + internal BindlessTextureLocation GetOrUploadWithOrigTextureOverrideBindless( + uint ownerLocalId, uint surfaceId, - uint? overrideOrigTextureId, - PaletteOverride paletteOverride) - => GetOrUploadWithPaletteOverride(surfaceId, overrideOrigTextureId, paletteOverride, - HashPaletteOverride(paletteOverride)); - - /// - /// Overload that accepts a precomputed palette hash. Lets callers (e.g. - /// the WB draw dispatcher) compute the hash ONCE per entity and reuse - /// it across every (part, batch) lookup, avoiding the per-batch - /// FNV-1a fold over . - /// - public uint GetOrUploadWithPaletteOverride( - uint surfaceId, - uint? overrideOrigTextureId, - PaletteOverride paletteOverride, - ulong precomputedPaletteHash) + uint overrideOrigTextureId) { - uint origTexKey = overrideOrigTextureId ?? 0; - var key = (surfaceId, origTexKey, precomputedPaletteHash); - if (_handlesByPalette.TryGetValue(key, out var h)) - return h; + CompositeTextureArrayCache composites = EnsureCompositeTexturesAvailable(); + var key = new CompositeTextureKey( + CompositeTextureKind.OriginalTextureOverride, + surfaceId, + overrideOrigTextureId, + Palette: default); + if (composites.TryAcquire(ownerLocalId, key, out BindlessTextureLocation existing)) + return existing; + if (!composites.CanStartUpload) + return default; + (int width, int height) = ResolveDecodedDimensions(surfaceId, overrideOrigTextureId); + if (!composites.CanPrepareUpload(width, height)) + return default; - var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: paletteOverride); - h = UploadRgba8(decoded); - _handlesByPalette[key] = h; - return h; + DecodedTexture decoded = DecodeFromDats( + surfaceId, + origTextureOverride: overrideOrigTextureId, + paletteOverride: null); + return composites.TryAddAndAcquire(ownerLocalId, key, decoded, out BindlessTextureLocation added) + ? added + : default; } /// - /// 64-bit bindless handle variant of for the WB - /// modern rendering path. Uploads the texture as a 1-layer Texture2DArray - /// (so the shader's sampler2DArray can sample at layer 0) and returns - /// a resident bindless handle. Caches by surfaceId in a separate dictionary - /// from the legacy Texture2D path; the same surface may be uploaded twice - /// if used by both paths (acceptable transition cost — N.6 deletes the legacy - /// path). + /// Owner-scoped bindless palette composite. Applies the palette override on + /// top of the texture's default palette before decoding, stores compatible + /// composites in a pooled Texture2DArray, and returns its resident handle + /// plus the assigned layer. Structural identity is computed once per entity. /// Throws if BindlessSupport wasn't provided to the constructor. /// - public ulong GetOrUploadBindless(uint surfaceId) - { - EnsureBindlessAvailable(); - if (_bindlessBySurfaceId.TryGetValue(surfaceId, out var entry)) - return entry.Handle; - var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null); - uint name = UploadRgba8AsLayer1Array(decoded); - ulong handle = _bindless!.GetResidentHandle(name); - _bindlessBySurfaceId[surfaceId] = (name, handle); - return handle; - } - - /// - /// 64-bit bindless handle variant of - /// for the WB modern rendering path. Uploads the texture as a 1-layer - /// Texture2DArray with the override SurfaceTexture id and returns a resident - /// bindless handle. Caches under a separate composite key from the legacy - /// path. Throws if BindlessSupport wasn't provided to the constructor. - /// - public ulong GetOrUploadWithOrigTextureOverrideBindless(uint surfaceId, uint overrideOrigTextureId) - { - EnsureBindlessAvailable(); - var key = (surfaceId, overrideOrigTextureId); - if (_bindlessByOverridden.TryGetValue(key, out var entry)) - return entry.Handle; - var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: null); - uint name = UploadRgba8AsLayer1Array(decoded); - ulong handle = _bindless!.GetResidentHandle(name); - _bindlessByOverridden[key] = (name, handle); - return handle; - } - - /// - /// 64-bit bindless handle variant of - /// for the WB modern rendering path. Applies the palette override on top of - /// the texture's default palette before decoding, uploads as a 1-layer - /// Texture2DArray, and returns a resident bindless handle. Takes a - /// precomputed palette hash so the WB dispatcher can compute it once per - /// entity. Throws if BindlessSupport wasn't provided to the constructor. - /// - public ulong GetOrUploadWithPaletteOverrideBindless( + internal BindlessTextureLocation GetOrUploadWithPaletteOverrideBindless( + uint ownerLocalId, uint surfaceId, uint? overrideOrigTextureId, PaletteOverride paletteOverride, - ulong precomputedPaletteHash) + PaletteCompositeIdentity paletteIdentity) { - EnsureBindlessAvailable(); + CompositeTextureArrayCache composites = EnsureCompositeTexturesAvailable(); uint origTexKey = overrideOrigTextureId ?? 0; - var key = (surfaceId, origTexKey, precomputedPaletteHash); - if (_bindlessByPalette.TryGetValue(key, out var entry)) - return entry.Handle; - var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: paletteOverride); - uint name = UploadRgba8AsLayer1Array(decoded); - ulong handle = _bindless!.GetResidentHandle(name); - _bindlessByPalette[key] = (name, handle); - return handle; + var key = new CompositeTextureKey( + CompositeTextureKind.PaletteComposite, + surfaceId, + origTexKey, + paletteIdentity); + if (composites.TryAcquire(ownerLocalId, key, out BindlessTextureLocation existing)) + return existing; + if (!composites.CanStartUpload) + return default; + (int width, int height) = ResolveDecodedDimensions(surfaceId, overrideOrigTextureId); + if (!composites.CanPrepareUpload(width, height)) + return default; + + DecodedTexture decoded = DecodeFromDats( + surfaceId, + origTextureOverride: overrideOrigTextureId, + paletteOverride: paletteOverride); + return composites.TryAddAndAcquire(ownerLocalId, key, decoded, out BindlessTextureLocation added) + ? added + : default; + } + + /// + /// Retail applies a palette composite only to P8/INDEX16 image data. + /// Cache the resolved source format so animated entities do not reopen the + /// DAT chain every frame. + /// + internal bool IsPaletteIndexed(uint surfaceId, uint? overrideOrigTextureId) + { + uint origTexKey = overrideOrigTextureId ?? 0; + var key = (surfaceId, origTexKey); + if (_paletteIndexedByTexture.TryGetValue(key, out bool indexed)) + return indexed; + + Surface? surface = _dats.Get(surfaceId); + if (surface is null || surface.Type.HasFlag(SurfaceType.Base1Solid)) + return _paletteIndexedByTexture[key] = false; + + uint surfaceTextureId = overrideOrigTextureId ?? (uint)surface.OrigTextureId; + SurfaceTexture? texture = _dats.Get(surfaceTextureId); + if (texture is null || texture.Textures.Count == 0) + return _paletteIndexedByTexture[key] = false; + + uint renderSurfaceId = (uint)texture.Textures[0]; + if (!_dats.Portal.TryGet(renderSurfaceId, out RenderSurface? renderSurface) + && !_dats.HighRes.TryGet(renderSurfaceId, out renderSurface)) + return _paletteIndexedByTexture[key] = false; + + indexed = renderSurface.Format is PixelFormatId.PFID_P8 or PixelFormatId.PFID_INDEX16; + _paletteIndexedByTexture[key] = indexed; + return indexed; + } + + private (int Width, int Height) ResolveDecodedDimensions( + uint surfaceId, + uint? overrideOrigTextureId) + { + var key = (surfaceId, overrideOrigTextureId ?? 0); + if (_decodedDimensionsByTexture.TryGetValue(key, out var cached)) + return cached; + + Surface? surface = _dats.Get(surfaceId); + if (surface is null + || surface.Type.HasFlag(SurfaceType.Base1Solid) + || (uint)surface.OrigTextureId == 0) + return _decodedDimensionsByTexture[key] = (1, 1); + + uint surfaceTextureId = overrideOrigTextureId ?? (uint)surface.OrigTextureId; + SurfaceTexture? texture = _dats.Get(surfaceTextureId); + if (texture is null || texture.Textures.Count == 0) + return _decodedDimensionsByTexture[key] = (1, 1); + + uint renderSurfaceId = (uint)texture.Textures[0]; + if ((!_dats.Portal.TryGet(renderSurfaceId, out RenderSurface? renderSurface) + && !_dats.HighRes.TryGet(renderSurfaceId, out renderSurface)) + || renderSurface.Width <= 0 + || renderSurface.Height <= 0 + || renderSurface.SourceData is null) + return _decodedDimensionsByTexture[key] = (1, 1); + + return _decodedDimensionsByTexture[key] = (renderSurface.Width, renderSurface.Height); + } + + /// + /// Retail CSurface::Destroy (0x005361F0) releases its current + /// ImgTex. Mirror that ownership boundary for per-entity composites. + /// + public void ReleaseOwner(uint localEntityId) + { + EnsureCompositeTexturesAvailable().ReleaseOwner(localEntityId); } private void EnsureBindlessAvailable() @@ -333,6 +457,49 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab "WbDrawDispatcher requires the bindless-aware ctor overload (pass non-null BindlessSupport)."); } + private CompositeTextureArrayCache EnsureCompositeTexturesAvailable() + { + EnsureBindlessAvailable(); + return _compositeTextures!; + } + + private StandaloneBindlessTextureCache EnsureParticleTexturesAvailable() + { + EnsureBindlessAvailable(); + return _particleTextures!; + } + + private sealed class ParticleTextureBackend(TextureCache owner) + : IStandaloneBindlessTextureBackend + { + public void MakeNonResident(StandaloneBindlessTextureResource resource) + { + owner._bindless!.MakeNonResident(resource.Handle); + Wb.GLHelpers.ThrowOnResourceError( + owner._gl, + $"releasing particle texture handle {resource.Handle}"); + } + + public void Delete(StandaloneBindlessTextureResource resource) + => owner.DeleteUploadedTexture(resource.Name); + } + + /// + /// Advances bounded composite-cache maintenance once per render frame. + /// Logical owner release is immediate; at most one over-budget layer and + /// one empty backing array are physically retired in this call. + /// + public void TickCompositeTextureCache() => _compositeTextures?.Tick(); + + /// + /// Retires at most one over-budget standalone particle texture per render + /// frame. Keeping this separate from owner release avoids portal-time GPU + /// destruction bursts without changing live particle range or quality. + /// + public void TickParticleTextureCache() => _particleTextures?.Tick(); + + public void BeginCompositeTextureFrame() => _compositeTextures?.BeginFrame(); + /// /// Cheap 64-bit hash over a palette override's identity so two /// entities with the same palette setup share a decode. Internal so @@ -354,6 +521,9 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab return h; } + internal static PaletteCompositeIdentity GetPaletteIdentity(PaletteOverride palette) => + new(palette, HashPaletteOverride(palette)); + /// /// Phase N.6 slice 1: one-shot surface-format histogram dump for the /// atlas-opportunity audit. Activated by ACDREAM_DUMP_SURFACES=1; fires @@ -434,12 +604,19 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab bucketsByTriple[tripleKey] = bucketsByTriple.GetValueOrDefault(tripleKey) + 1; } - foreach (var kv in _handlesBySurfaceId) Emit(kv.Key, kv.Value); - foreach (var kv in _handlesByOverridden) Emit(kv.Key.surfaceId, kv.Value); - foreach (var kv in _handlesByPalette) Emit(kv.Key.surfaceId, kv.Value); - foreach (var kv in _bindlessBySurfaceId) Emit(kv.Key, kv.Value.Name); - foreach (var kv in _bindlessByOverridden) Emit(kv.Key.surfaceId, kv.Value.Name); - foreach (var kv in _bindlessByPalette) Emit(kv.Key.surfaceId, kv.Value.Name); + foreach (var kv in _surfacesById) Emit(kv.Key, kv.Value.Handle); + _particleTextures?.VisitEntries(resource => Emit(resource.SurfaceId, resource.Name)); + _compositeTextures?.VisitEntries((surfaceId, width, height) => + { + int bytes = checked(width * height * 4); + totalBytes += bytes; + sb.AppendLine($"0x{surfaceId:X8}, {width}, {height}, RGBA8_COMPOSITE_LAYER, {bytes}"); + bucketsByDim[(width, height)] = bucketsByDim.GetValueOrDefault((width, height)) + 1; + bucketsByFormat["RGBA8_COMPOSITE_LAYER"] = + bucketsByFormat.GetValueOrDefault("RGBA8_COMPOSITE_LAYER") + 1; + bucketsByTriple[(width, height, "RGBA8_COMPOSITE_LAYER")] = + bucketsByTriple.GetValueOrDefault((width, height, "RGBA8_COMPOSITE_LAYER")) + 1; + }); sb.AppendLine(); sb.AppendLine("# Rollups"); @@ -572,31 +749,47 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab private uint UploadRgba8(DecodedTexture decoded, bool nearest = false) { uint tex = _gl.GenTexture(); - _gl.BindTexture(TextureTarget.Texture2D, tex); + if (tex == 0) + throw new InvalidOperationException("OpenGL did not create a 2D texture."); + try + { + _gl.BindTexture(TextureTarget.Texture2D, tex); - fixed (byte* p = decoded.Rgba8) - _gl.TexImage2D( - TextureTarget.Texture2D, - 0, - InternalFormat.Rgba8, - (uint)decoded.Width, - (uint)decoded.Height, - 0, - PixelFormat.Rgba, - PixelType.UnsignedByte, - p); + fixed (byte* p = decoded.Rgba8) + _gl.TexImage2D( + TextureTarget.Texture2D, + 0, + InternalFormat.Rgba8, + (uint)decoded.Width, + (uint)decoded.Height, + 0, + PixelFormat.Rgba, + PixelType.UnsignedByte, + p); - // Point (nearest) sampling for pixel-exact UI text — bilinear softens the dat - // font's small glyphs. Other surfaces use bilinear. - int filter = nearest ? (int)TextureMinFilter.Nearest : (int)TextureMinFilter.Linear; - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, filter); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, filter); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); + // Point (nearest) sampling for pixel-exact UI text — bilinear softens the dat + // font's small glyphs. Other surfaces use bilinear. + int filter = nearest ? (int)TextureMinFilter.Nearest : (int)TextureMinFilter.Linear; + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, filter); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, filter); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); + Wb.GLHelpers.ThrowOnResourceError( + _gl, + $"uploading 2D RGBA8 texture {decoded.Width}x{decoded.Height}"); - _gl.BindTexture(TextureTarget.Texture2D, 0); - _uploadMetadata[tex] = (decoded.Width, decoded.Height, "RGBA8_DECODED"); - return tex; + TrackUploadedTexture(tex, decoded.Width, decoded.Height); + return tex; + } + catch + { + _gl.DeleteTexture(tex); + throw; + } + finally + { + _gl.BindTexture(TextureTarget.Texture2D, 0); + } } /// @@ -607,88 +800,99 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab private uint UploadRgba8AsLayer1Array(DecodedTexture decoded) { uint tex = _gl.GenTexture(); - _gl.BindTexture(TextureTarget.Texture2DArray, tex); + if (tex == 0) + throw new InvalidOperationException("OpenGL did not create a one-layer texture array."); + try + { + _gl.BindTexture(TextureTarget.Texture2DArray, tex); - fixed (byte* p = decoded.Rgba8) - _gl.TexImage3D( - TextureTarget.Texture2DArray, - 0, - InternalFormat.Rgba8, - (uint)decoded.Width, - (uint)decoded.Height, - depth: 1, - border: 0, - PixelFormat.Rgba, - PixelType.UnsignedByte, - p); + fixed (byte* p = decoded.Rgba8) + _gl.TexImage3D( + TextureTarget.Texture2DArray, + 0, + InternalFormat.Rgba8, + (uint)decoded.Width, + (uint)decoded.Height, + depth: 1, + border: 0, + PixelFormat.Rgba, + PixelType.UnsignedByte, + p); - _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); - _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); - _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); - _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); + _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); + _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); + _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); + _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); + Wb.GLHelpers.ThrowOnResourceError( + _gl, + $"uploading one-layer RGBA8 array {decoded.Width}x{decoded.Height}"); - _gl.BindTexture(TextureTarget.Texture2DArray, 0); - _uploadMetadata[tex] = (decoded.Width, decoded.Height, "RGBA8_DECODED"); - return tex; + TrackUploadedTexture(tex, decoded.Width, decoded.Height); + return tex; + } + catch + { + _gl.DeleteTexture(tex); + throw; + } + finally + { + _gl.BindTexture(TextureTarget.Texture2DArray, 0); + } + } + + private void TrackUploadedTexture(uint name, int width, int height) + { + _uploadMetadata[name] = (width, height, "RGBA8_DECODED"); + long bytes = checked((long)width * height * 4L); + Wb.GpuMemoryTracker.TrackResourceAllocation(Wb.GpuResourceType.Texture); + Wb.GpuMemoryTracker.TrackAllocation(bytes, Wb.GpuResourceType.Texture); + } + + private void DeleteUploadedTexture(uint name) + { + _gl.DeleteTexture(name); + Wb.GLHelpers.ThrowOnResourceError(_gl, $"deleting uploaded texture {name}"); + + if (_uploadMetadata.Remove(name, out var metadata)) + { + long bytes = checked((long)metadata.Width * metadata.Height * 4L); + Wb.GpuMemoryTracker.TrackDeallocation(bytes, Wb.GpuResourceType.Texture); + Wb.GpuMemoryTracker.TrackResourceDeallocation(Wb.GpuResourceType.Texture); + } } public void Dispose() { - // Phase 1: make all bindless handles non-resident BEFORE any - // DeleteTexture call. ARB_bindless_texture requires that resident - // handles be released before their backing texture is deleted — - // interleaving per-entry is UB. Single null-guard around the whole - // block (cleaner than per-call null-conditionals). - if (_bindless is not null) - { - foreach (var (_, handle) in _bindlessBySurfaceId.Values) - _bindless.MakeNonResident(handle); - foreach (var (_, handle) in _bindlessByOverridden.Values) - _bindless.MakeNonResident(handle); - foreach (var (_, handle) in _bindlessByPalette.Values) - _bindless.MakeNonResident(handle); - } + // GameWindow drains frame-flight fences before this teardown. The + // bindless caches make every handle non-resident before deleting + // their backing storage. + _particleTextures?.Dispose(); + _compositeTextures?.Dispose(); - // Phase 2: delete the Texture2DArray textures backing those handles. - foreach (var (name, _) in _bindlessBySurfaceId.Values) - _gl.DeleteTexture(name); - _bindlessBySurfaceId.Clear(); - foreach (var (name, _) in _bindlessByOverridden.Values) - _gl.DeleteTexture(name); - _bindlessByOverridden.Clear(); - foreach (var (name, _) in _bindlessByPalette.Values) - _gl.DeleteTexture(name); - _bindlessByPalette.Clear(); + _paletteIndexedByTexture.Clear(); - // Phase 3: legacy Texture2D textures. - foreach (var h in _handlesBySurfaceId.Values) - _gl.DeleteTexture(h); - _handlesBySurfaceId.Clear(); - - foreach (var h in _handlesByOverridden.Values) - _gl.DeleteTexture(h); - _handlesByOverridden.Clear(); - - foreach (var h in _handlesByPalette.Values) - _gl.DeleteTexture(h); - _handlesByPalette.Clear(); + // Legacy Texture2D textures. + foreach (var entry in _surfacesById.Values) + DeleteUploadedTexture(entry.Handle); + _surfacesById.Clear(); if (_magentaHandle != 0) { - _gl.DeleteTexture(_magentaHandle); + DeleteUploadedTexture(_magentaHandle); _magentaHandle = 0; } // RenderSurface (UI sprite) handles — pre-existing gap: this dict was populated // by GetOrUploadRenderSurface but was not swept here before this fix. foreach (var h in _handlesByRenderSurfaceId.Values) - _gl.DeleteTexture(h); + DeleteUploadedTexture(h); _handlesByRenderSurfaceId.Clear(); // Ad-hoc handles from the public UploadRgba8(byte[],int,int,bool) wrapper // (IconComposer composited icons). Not stored in any keyed cache. foreach (var h in _adhocHandles) - _gl.DeleteTexture(h); + DeleteUploadedTexture(h); _adhocHandles.Clear(); } } diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs index 95da8d45..a7be0842 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs @@ -37,6 +37,12 @@ public sealed class EntityEffectController : IAnimationHookSink private readonly Dictionary> _pendingByServerGuid = new(); private readonly Dictionary _staticOwners = new(); private readonly HashSet _syntheticOwners = new(); + private readonly HashSet _dirtyLiveOwners = + new(ReferenceEqualityComparer.Instance); + private readonly List _dirtyLiveOwnerOrder = []; + private readonly List _dirtyLiveOwnerSnapshot = []; + private readonly List _readyGuidSnapshot = []; + private uint _posePublishLocalId; private Action? _diagnosticSink = Console.WriteLine; public EntityEffectController( @@ -58,6 +64,8 @@ public sealed class EntityEffectController : IAnimationHookSink _ownerUnregistered = ownerUnregistered ?? (_ => { }); _ownerSoundTableChanged = ownerSoundTableChanged ?? ((_, _) => { }); _runner.DiagnosticSink = message => _diagnosticSink?.Invoke(message); + _poses.EffectPoseChanged += OnEffectPoseChanged; + _liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged; } public Action? DiagnosticSink @@ -68,6 +76,7 @@ public sealed class EntityEffectController : IAnimationHookSink public int PendingPacketCount => _pendingByServerGuid.Values.Sum(queue => queue.Count); public int ReadyOwnerCount => _profilesByLocalId.Count; + internal int LastPoseRefreshOwnerVisitCount { get; private set; } public void HandleDirect(PlayPhysicsScript message) { @@ -222,7 +231,9 @@ public sealed class EntityEffectController : IAnimationHookSink public void ClearNetworkState() { - foreach (uint serverGuid in _readyGenerationByServerGuid.Keys.ToArray()) + _readyGuidSnapshot.Clear(); + _readyGuidSnapshot.AddRange(_readyGenerationByServerGuid.Keys); + foreach (uint serverGuid in _readyGuidSnapshot) { if (_liveEntities.TryGetLocalEntityId(serverGuid, out uint localId)) { @@ -233,20 +244,54 @@ public sealed class EntityEffectController : IAnimationHookSink } _readyGenerationByServerGuid.Clear(); _pendingByServerGuid.Clear(); + _dirtyLiveOwners.Clear(); + _dirtyLiveOwnerOrder.Clear(); + _dirtyLiveOwnerSnapshot.Clear(); } - /// Refreshes every live root after animation/movement. + /// + /// Publishes only roots dirtied by movement, animation, cell projection, + /// or an authoritative position update. The queue is detached before + /// callbacks run, so a callback-produced mutation is retained for the + /// following update rather than invalidating this traversal. + /// public void RefreshLiveOwnerPoses() { - foreach (uint serverGuid in _readyGenerationByServerGuid.Keys.ToArray()) + LastPoseRefreshOwnerVisitCount = 0; + if (_dirtyLiveOwnerOrder.Count == 0) + return; + + _dirtyLiveOwnerSnapshot.Clear(); + _dirtyLiveOwnerSnapshot.AddRange(_dirtyLiveOwnerOrder); + _dirtyLiveOwnerOrder.Clear(); + _dirtyLiveOwners.Clear(); + foreach (LiveEntityRecord record in _dirtyLiveOwnerSnapshot) { - if (!TryGetReadyLocalId(serverGuid, out uint localId)) + if (!_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) + || !ReferenceEquals(current, record) + || !TryGetReadyLocalId(record.ServerGuid, out uint localId) + || record.WorldEntity?.Id != localId) + { continue; - RefreshLiveAnchor(serverGuid, localId); - TryReplayPending(serverGuid, localId); + } + + LastPoseRefreshOwnerVisitCount++; + RefreshLiveAnchor(record.ServerGuid, localId); + TryReplayPending(record.ServerGuid, localId); } } + /// + /// Marks an accepted authoritative root mutation. The record reference, + /// rather than only its server GUID, isolates a queued update from later + /// delete/recreate reuse of that GUID. + /// + public void MarkLiveOwnerPoseDirty(uint serverGuid) + { + if (_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)) + MarkLiveOwnerPoseDirty(record); + } + public bool PlayDirect(uint ownerLocalId, uint scriptDid) { if (!CanStartOwner(ownerLocalId)) @@ -411,6 +456,37 @@ public sealed class EntityEffectController : IAnimationHookSink return false; } + private void OnEffectPoseChanged(uint localId) + { + if (_posePublishLocalId == localId) + return; + if (_liveEntities.TryGetServerGuid(localId, out uint serverGuid) + && _liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)) + { + MarkLiveOwnerPoseDirty(record); + } + } + + private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible) + { + if (visible) + MarkLiveOwnerPoseDirty(record); + } + + private void MarkLiveOwnerPoseDirty(LiveEntityRecord record) + { + if (!_readyGenerationByServerGuid.TryGetValue( + record.ServerGuid, + out ushort readyGeneration) + || readyGeneration != record.Generation + || !_dirtyLiveOwners.Add(record)) + { + return; + } + + _dirtyLiveOwnerOrder.Add(record); + } + private void RefreshLiveAnchor(uint serverGuid, uint localId) { if (_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) @@ -423,7 +499,18 @@ public sealed class EntityEffectController : IAnimationHookSink // bookkeeping Position would collapse a weapon effect to the // parent's origin. if (record.ProjectionKind is LiveEntityProjectionKind.World) - _poses.UpdateRoot(entity); + { + uint previousPublish = _posePublishLocalId; + _posePublishLocalId = localId; + try + { + _poses.UpdateRoot(entity); + } + finally + { + _posePublishLocalId = previousPublish; + } + } Vector3 anchor = _poses.TryGetRootPose(localId, out Matrix4x4 rootWorld) ? rootWorld.Translation diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs index 1fd948fd..168596c7 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs @@ -15,7 +15,10 @@ namespace AcDream.App.Rendering.Vfx; /// (0x0051D180). This registry is the modern, read-only seam exposing /// those same final frames without coupling Core effects to the renderer. /// -public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityEffectCellSource +public sealed class EntityEffectPoseRegistry : + IEntityEffectPoseSource, + IEntityEffectCellSource, + IEntityEffectPoseChangeSource { private sealed class PoseRecord { @@ -27,6 +30,8 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE private readonly Dictionary _poses = new(); + public event Action? EffectPoseChanged; + public int Count => _poses.Count; public void Publish(WorldEntity entity, IReadOnlyList partLocal) @@ -60,17 +65,24 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE Matrix4x4 rootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation) * Matrix4x4.CreateTranslation(entity.Position); + bool changed; if (!_poses.TryGetValue(entity.Id, out PoseRecord? record)) { record = new PoseRecord(); _poses.Add(entity.Id, record); + changed = true; + } + else + { + changed = record.RootWorld != rootWorld + || record.CellId != (entity.EffectCellId ?? entity.ParentCellId ?? 0u); } record.RootWorld = rootWorld; record.CellId = entity.EffectCellId ?? entity.ParentCellId ?? 0u; if (entity.IndexedPartTransforms.Count > 0) { - CopyParts( + changed |= CopyParts( record, entity.IndexedPartTransforms, entity.IndexedPartAvailable); @@ -78,15 +90,26 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE else { if (record.PartLocal.Length != entity.MeshRefs.Count) + { record.PartLocal = new Matrix4x4[entity.MeshRefs.Count]; + changed = true; + } if (record.PartAvailable.Length != entity.MeshRefs.Count) + { record.PartAvailable = new bool[entity.MeshRefs.Count]; + changed = true; + } for (int i = 0; i < entity.MeshRefs.Count; i++) { + changed |= record.PartLocal[i] != entity.MeshRefs[i].PartTransform + || !record.PartAvailable[i]; record.PartLocal[i] = entity.MeshRefs[i].PartTransform; record.PartAvailable[i] = true; } } + + if (changed) + EffectPoseChanged?.Invoke(entity.Id); } public void Publish( @@ -100,15 +123,23 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE return; ArgumentNullException.ThrowIfNull(partLocal); + bool changed; if (!_poses.TryGetValue(localEntityId, out PoseRecord? record)) { record = new PoseRecord(); _poses.Add(localEntityId, record); + changed = true; + } + else + { + changed = record.RootWorld != rootWorld || record.CellId != cellId; } record.RootWorld = rootWorld; record.CellId = cellId; - CopyParts(record, partLocal, availability); + changed |= CopyParts(record, partLocal, availability); + if (changed) + EffectPoseChanged?.Invoke(localEntityId); } /// Refresh only the moving root while retaining current part poses. @@ -117,15 +148,36 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE ArgumentNullException.ThrowIfNull(entity); if (!_poses.TryGetValue(entity.Id, out PoseRecord? record)) return false; - record.RootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation) + Matrix4x4 rootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation) * Matrix4x4.CreateTranslation(entity.Position); - record.CellId = entity.EffectCellId ?? entity.ParentCellId ?? 0u; + uint cellId = entity.EffectCellId ?? entity.ParentCellId ?? 0u; + if (record.RootWorld == rootWorld && record.CellId == cellId) + return true; + + record.RootWorld = rootWorld; + record.CellId = cellId; + EffectPoseChanged?.Invoke(entity.Id); return true; } - public bool Remove(uint localEntityId) => _poses.Remove(localEntityId); + public bool Remove(uint localEntityId) + { + if (!_poses.Remove(localEntityId)) + return false; + EffectPoseChanged?.Invoke(localEntityId); + return true; + } - public void Clear() => _poses.Clear(); + public void Clear() + { + if (_poses.Count == 0) + return; + + uint[] removedOwners = _poses.Keys.ToArray(); + _poses.Clear(); + foreach (uint owner in removedOwners) + EffectPoseChanged?.Invoke(owner); + } public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld) { @@ -196,21 +248,32 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE return false; } - private static void CopyParts( + private static bool CopyParts( PoseRecord record, IReadOnlyList partLocal, IReadOnlyList? availability) { if (availability is not null && availability.Count != partLocal.Count) throw new ArgumentException("Part pose and availability counts must match."); + bool changed = false; if (record.PartLocal.Length != partLocal.Count) + { record.PartLocal = new Matrix4x4[partLocal.Count]; + changed = true; + } if (record.PartAvailable.Length != partLocal.Count) + { record.PartAvailable = new bool[partLocal.Count]; + changed = true; + } for (int i = 0; i < partLocal.Count; i++) { + bool partAvailable = availability is null || availability[i]; + changed |= record.PartLocal[i] != partLocal[i] + || record.PartAvailable[i] != partAvailable; record.PartLocal[i] = partLocal[i]; - record.PartAvailable[i] = availability is null || availability[i]; + record.PartAvailable[i] = partAvailable; } + return changed; } } diff --git a/src/AcDream.App/Rendering/ViewconeCuller.cs b/src/AcDream.App/Rendering/ViewconeCuller.cs index e60e864e..d686deeb 100644 --- a/src/AcDream.App/Rendering/ViewconeCuller.cs +++ b/src/AcDream.App/Rendering/ViewconeCuller.cs @@ -32,63 +32,131 @@ namespace AcDream.App.Rendering; /// public sealed class ViewconeCuller { - private readonly Dictionary _cellPlanes = new(); - private Vector4[][] _outsidePlanes = Array.Empty(); + private const int MaxRetainedCellPlaneSets = 512; + private const int MaxRetainedPlanesPerCell = 256; + private const int MaxRetainedSlicesPerCell = 64; + + private readonly Dictionary _cellPlanes = new(); + private readonly Stack _planeSetPool = new(); + private PlaneSet _outsidePlanes = new(); + + private readonly record struct SliceRange(int Start, int Count); + private readonly record struct LiftedPlane(Vector4 Equation, float NormalLength); + + /// + /// Contiguous per-cell plane storage. Reusing two Lists per visible cell + /// avoids rebuilding a jagged array graph on every render frame while + /// retaining the exact slice boundaries used by retail's any-view test. + /// + private sealed class PlaneSet + { + public List Planes { get; } = new(); + public List Slices { get; } = new(); + + public bool IsRetainable => + Planes.Capacity <= MaxRetainedPlanesPerCell + && Slices.Capacity <= MaxRetainedSlicesPerCell; + + public void Reset() + { + Planes.Clear(); + Slices.Clear(); + } + } /// True when the outside view is a full-screen pass-all (the /// synthetic outdoor root) — every outside-test passes. public bool OutsideIsFullScreen { get; private set; } - public static ViewconeCuller Build(ClipFrameAssembly assembly, in Matrix4x4 viewProjection) + public static ViewconeCuller Build( + ClipFrameAssembly assembly, + in Matrix4x4 viewProjection, + ViewconeCuller? reuse = null) { ArgumentNullException.ThrowIfNull(assembly); - var culler = new ViewconeCuller(); + var culler = reuse ?? new ViewconeCuller(); + culler.Reset(); foreach (var (cellId, slices) in assembly.CellIdToViewSlices) { - var lifted = new Vector4[slices.Length][]; + PlaneSet lifted = culler.RentPlaneSet(); for (int s = 0; s < slices.Length; s++) - lifted[s] = LiftPlanes(slices[s].Planes, viewProjection); + AppendLiftedSlice(lifted, slices[s].Planes, viewProjection); culler._cellPlanes[cellId] = lifted; } var outside = assembly.OutsideViewSlices; - var outsideLifted = new Vector4[outside.Length][]; bool fullScreen = false; for (int s = 0; s < outside.Length; s++) { - outsideLifted[s] = LiftPlanes(outside[s].Planes, viewProjection); + AppendLiftedSlice(culler._outsidePlanes, outside[s].Planes, viewProjection); if (outside[s].Planes.Length == 0) fullScreen = true; } - culler._outsidePlanes = outsideLifted; culler.OutsideIsFullScreen = fullScreen; return culler; } - private static Vector4[] LiftPlanes(Vector4[] clipPlanes, in Matrix4x4 m) + private void Reset() { - if (clipPlanes.Length == 0) - return Array.Empty(); - var world = new Vector4[clipPlanes.Length]; + foreach (PlaneSet set in _cellPlanes.Values) + { + set.Reset(); + if (set.IsRetainable && _planeSetPool.Count < MaxRetainedCellPlaneSets) + _planeSetPool.Push(set); + } + _cellPlanes.Clear(); + if (_outsidePlanes.IsRetainable) + _outsidePlanes.Reset(); + else + _outsidePlanes = new PlaneSet(); + OutsideIsFullScreen = false; + } + + private PlaneSet RentPlaneSet() + { + PlaneSet result = _planeSetPool.Count != 0 + ? _planeSetPool.Pop() + : new PlaneSet(); + result.Reset(); + return result; + } + + private static void AppendLiftedSlice( + PlaneSet destination, + Vector4[] clipPlanes, + in Matrix4x4 m) + { + int start = destination.Planes.Count; for (int i = 0; i < clipPlanes.Length; i++) { var p = clipPlanes[i]; - world[i] = new Vector4( + var equation = new Vector4( m.M11 * p.X + m.M12 * p.Y + m.M13 * p.Z + m.M14 * p.W, m.M21 * p.X + m.M22 * p.Y + m.M23 * p.Z + m.M24 * p.W, m.M31 * p.X + m.M32 * p.Y + m.M33 * p.Z + m.M34 * p.W, m.M41 * p.X + m.M42 * p.Y + m.M43 * p.Z + m.M44 * p.W); + float normalLength = MathF.Sqrt( + equation.X * equation.X + + equation.Y * equation.Y + + equation.Z * equation.Z); + destination.Planes.Add(new LiftedPlane(equation, normalLength)); } - return world; + destination.Slices.Add(new SliceRange(start, clipPlanes.Length)); } - private static bool SphereInsidePlanes(Vector4[] planes, in Vector3 center, float radius) + private static bool SphereInsidePlanes( + PlaneSet set, + SliceRange slice, + in Vector3 center, + float radius) { - for (int i = 0; i < planes.Length; i++) + int end = slice.Start + slice.Count; + for (int i = slice.Start; i < end; i++) { - var l = planes[i]; - float nLen = MathF.Sqrt(l.X * l.X + l.Y * l.Y + l.Z * l.Z); + LiftedPlane plane = set.Planes[i]; + Vector4 l = plane.Equation; + float nLen = plane.NormalLength; if (nLen < 1e-12f) continue; // degenerate plane — no constraint float dist = l.X * center.X + l.Y * center.Y + l.Z * center.Z + l.W; @@ -103,10 +171,10 @@ public sealed class ViewconeCuller /// retail). A zero-plane slice is pass-all. public bool SphereVisibleInCell(uint cellId, in Vector3 center, float radius) { - if (!_cellPlanes.TryGetValue(cellId, out var slices)) + if (!_cellPlanes.TryGetValue(cellId, out PlaneSet? set)) return false; - for (int s = 0; s < slices.Length; s++) - if (SphereInsidePlanes(slices[s], center, radius)) + for (int s = 0; s < set.Slices.Count; s++) + if (SphereInsidePlanes(set, set.Slices[s], center, radius)) return true; return false; } @@ -118,8 +186,8 @@ public sealed class ViewconeCuller { if (OutsideIsFullScreen) return true; - for (int s = 0; s < _outsidePlanes.Length; s++) - if (SphereInsidePlanes(_outsidePlanes[s], center, radius)) + for (int s = 0; s < _outsidePlanes.Slices.Count; s++) + if (SphereInsidePlanes(_outsidePlanes, _outsidePlanes.Slices[s], center, radius)) return true; return false; } @@ -128,8 +196,12 @@ public sealed class ViewconeCuller /// slice; its statics pre-filter tests against exactly that slice). public bool SphereVisibleInOutsideSlice(int sliceIndex, in Vector3 center, float radius) { - if ((uint)sliceIndex >= (uint)_outsidePlanes.Length) + if ((uint)sliceIndex >= (uint)_outsidePlanes.Slices.Count) return false; - return SphereInsidePlanes(_outsidePlanes[sliceIndex], center, radius); + return SphereInsidePlanes( + _outsidePlanes, + _outsidePlanes.Slices[sliceIndex], + center, + radius); } } diff --git a/src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs b/src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs index 20b9278c..f741dace 100644 --- a/src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs +++ b/src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs @@ -23,5 +23,14 @@ public sealed class AcSurfaceMetadataTable public bool TryLookup(ulong gfxObjId, int surfaceIdx, out AcSurfaceMetadata meta) => _table.TryGetValue((gfxObjId, surfaceIdx), out meta!); + public void Remove(ulong gfxObjId) + { + foreach ((ulong Id, int SurfaceIndex) key in _table.Keys) + { + if (key.Id == gfxObjId) + _table.TryRemove(key, out _); + } + } + public void Clear() => _table.Clear(); } diff --git a/src/AcDream.App/Rendering/Wb/ContiguousRangeAllocator.cs b/src/AcDream.App/Rendering/Wb/ContiguousRangeAllocator.cs index 9ce0370b..7f9d5264 100644 --- a/src/AcDream.App/Rendering/Wb/ContiguousRangeAllocator.cs +++ b/src/AcDream.App/Rendering/Wb/ContiguousRangeAllocator.cs @@ -26,6 +26,10 @@ internal sealed class ContiguousRangeAllocator public int HighWaterMark { get; private set; } public int Free => Capacity - Used; public int LargestFreeRange => _free.Count == 0 ? 0 : _free.Max(range => range.Length); + public int TrailingFreeLength => + _free.Count != 0 && _free[^1].End == Capacity + ? _free[^1].Length + : 0; public bool TryAllocate(int length, out MeshBufferRange allocation) { @@ -73,6 +77,30 @@ internal sealed class ContiguousRangeAllocator InsertAndCoalesce(new MeshBufferRange(oldCapacity, newCapacity - oldCapacity)); } + /// + /// Removes an unused tail after the matching physical GPU buffer has been + /// replaced. Live offsets are unchanged, so no render-data rewrite is + /// required. + /// + public void Shrink(int newCapacity) + { + if (newCapacity <= 0 || newCapacity >= Capacity) + throw new ArgumentOutOfRangeException(nameof(newCapacity)); + if (newCapacity < HighWaterMark) + throw new InvalidOperationException("Cannot trim a GPU arena through a live allocation."); + + MeshBufferRange tail = _free.Count == 0 ? default : _free[^1]; + if (tail.End != Capacity || tail.Offset > newCapacity) + throw new InvalidOperationException("The requested GPU arena tail is not wholly free."); + + if (tail.Offset == newCapacity) + _free.RemoveAt(_free.Count - 1); + else + _free[^1] = new MeshBufferRange(tail.Offset, newCapacity - tail.Offset); + Capacity = newCapacity; + RecalculateHighWaterMark(); + } + public void Release(MeshBufferRange allocation) { if (allocation.Length <= 0 @@ -84,6 +112,7 @@ internal sealed class ContiguousRangeAllocator InsertAndCoalesce(allocation); Used = checked(Used - allocation.Length); + RecalculateHighWaterMark(); } private void InsertAndCoalesce(MeshBufferRange released) @@ -114,4 +143,11 @@ internal sealed class ContiguousRangeAllocator _free.Insert(index, new MeshBufferRange(start, end - start)); } + + private void RecalculateHighWaterMark() + { + HighWaterMark = _free.Count != 0 && _free[^1].End == Capacity + ? _free[^1].Offset + : Capacity; + } } diff --git a/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs b/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs index 6303220b..6d7bd377 100644 --- a/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs @@ -5,13 +5,21 @@ using AcDream.Core.World; namespace AcDream.App.Rendering.Wb; +/// +/// The exact owner is still inside a reference transition, so its requested +/// logical removal has been queued and must be retried by the live-entity +/// teardown owner after the transition unwinds. +/// +public sealed class EntityPresentationRemovalDeferredException(uint serverGuid) + : InvalidOperationException( + $"Live entity 0x{serverGuid:X8} presentation removal is deferred until its active reference transition completes."); + /// /// Routes server-spawned (CreateObject) entities through the /// per-instance rendering path. Server entities always carry per-instance -/// customizations (palette overrides, texture changes, part swaps) that -/// don't fit WB's atlas key, so they bypass the atlas and use the existing -/// -/// path which already hash-keys overrides for caching. +/// customizations (palette overrides, texture changes, part swaps). Shared +/// surfaces use the WB atlas while per-instance texture composites are owned +/// by the live entity and released with it. /// /// /// Companion to : that adapter handles @@ -21,17 +29,6 @@ namespace AcDream.App.Rendering.Wb; /// /// /// -/// Per-entity texture decode: when entity.PaletteOverride is -/// non-null, the adapter calls -/// -/// once per surface id that is known at spawn time (those on -/// ). Surfaces whose ids are only -/// discoverable by opening the GfxObj dat are decoded lazily by the draw -/// dispatcher (Task 22) on first use — that matches the existing -/// StaticMeshRenderer behavior. -/// -/// -/// /// Sequencer factory: the adapter is constructed with a /// Func<WorldEntity, AnimationSequencer> factory so tests can /// inject a stub without needing a live DatCollection or MotionTable. @@ -47,24 +44,67 @@ namespace AcDream.App.Rendering.Wb; /// public sealed class EntitySpawnAdapter { - private readonly ITextureCachePerInstance _textureCache; + private readonly IEntityTextureLifetime _textureLifetime; private readonly Func _sequencerFactory; private readonly IWbMeshAdapter? _meshAdapter; - // Per-server-guid state. Written on OnCreate, released on OnRemove. + // One logical owner per server GUID. Animated state survives projection + // suspension, while the resident bit controls the shorter GPU-presentation + // lifetime. The exact WorldEntity reference makes delayed visibility edges + // from a displaced GUID generation harmless. // Single-threaded: called only from the render thread (same as GpuWorldState). - private readonly Dictionary _stateByGuid = new(); + private readonly Dictionary _ownersByGuid = new(); - // Per-server-guid set of GfxObj ids registered with the mesh adapter, - // so OnRemove can decrement each. Per-instance entities don't go through - // LandblockSpawnAdapter, so without this their meshes would never load - // (WB doesn't know they exist). - private readonly Dictionary> _meshIdsByGuid = new(); + private sealed class Owner( + WorldEntity entity, + AnimatedEntityState state, + HashSet meshIds) + { + public WorldEntity Entity { get; } = entity; + public AnimatedEntityState State { get; } = state; + public HashSet MeshIds { get; set; } = meshIds; + public HashSet MeshReferencesHeld { get; } = new(); + public bool IsPresentationResident { get; set; } + public bool TextureReleaseRequired { get; set; } + public PresentationTransition Transition { get; set; } + public bool RemovalPending { get; set; } - /// - /// Per-instance texture decode path. In production this is the - /// instance (which implements - /// ); in tests it is a capturing mock. + public bool HasPresentationResources + { + get + { + if (TextureReleaseRequired) + return true; + + return MeshReferencesHeld.Count != 0; + } + } + + public bool IsFullyResident + { + get + { + if (!IsPresentationResident || !TextureReleaseRequired) + return false; + return MeshReferencesHeld.SetEquals(MeshIds); + } + } + + public bool IsFullySuspended => + !IsPresentationResident && !HasPresentationResources; + } + + private enum PresentationTransition + { + None, + Resuming, + Suspending, + ChangingAppearance, + } + + /// + /// Per-entity texture lifetime owner. Production uses + /// ; tests use a recording implementation. /// /// /// Factory that builds an for a given @@ -74,20 +114,19 @@ public sealed class EntitySpawnAdapter /// returns a stub sequencer. /// /// - /// Optional WB mesh adapter. When non-null, - /// registers each unique MeshRef.GfxObjId with the adapter so WB - /// background-loads the mesh data; decrements the - /// matching ref counts. When null, the adapter only tracks per-instance - /// state without driving WB lifecycle (test mode + flag-off mode). + /// Optional WB mesh adapter. When non-null, presentation residency + /// registers each unique MeshRef.GfxObjId so WB background-loads + /// the mesh data. Projection suspension or logical removal balances those + /// references. When null, the adapter only tracks per-instance state. /// public EntitySpawnAdapter( - ITextureCachePerInstance textureCache, + IEntityTextureLifetime textureLifetime, Func sequencerFactory, IWbMeshAdapter? meshAdapter = null) { - ArgumentNullException.ThrowIfNull(textureCache); + ArgumentNullException.ThrowIfNull(textureLifetime); ArgumentNullException.ThrowIfNull(sequencerFactory); - _textureCache = textureCache; + _textureLifetime = textureLifetime; _sequencerFactory = sequencerFactory; _meshAdapter = meshAdapter; } @@ -105,29 +144,6 @@ public sealed class EntitySpawnAdapter // are handled by LandblockSpawnAdapter, not here. if (entity.ServerGuid == 0) return null; - // Pre-warm the per-instance texture cache for surfaces whose ids are - // already known at spawn time (those appearing as keys in - // MeshRef.SurfaceOverrides). GfxObj sub-mesh surface ids that aren't - // covered by SurfaceOverrides are decoded lazily by the draw - // dispatcher on first use — consistent with StaticMeshRenderer. - if (entity.PaletteOverride is { } paletteOverride) - { - foreach (var meshRef in entity.MeshRefs) - { - if (meshRef.SurfaceOverrides is null) continue; - - // SurfaceOverrides maps surfaceId → origTextureOverride (may be 0 - // meaning "no texture swap, just the palette override applies"). - foreach (var (surfaceId, origTexOverride) in meshRef.SurfaceOverrides) - { - _textureCache.GetOrUploadWithPaletteOverride( - surfaceId, - origTexOverride == 0 ? null : origTexOverride, - paletteOverride); - } - } - } - // A.5 T18: populate cached AABB so WalkEntities reads from the cache // rather than recomputing Position±5 per frame. Called here because // all entity-state initialization (position, rotation) is complete @@ -147,41 +163,228 @@ public sealed class EntitySpawnAdapter foreach (var po in entity.PartOverrides) state.SetPartOverride(po.PartIndex, po.GfxObjId); - _stateByGuid[entity.ServerGuid] = state; + HashSet meshIds = _meshAdapter is null + ? [] + : CollectMeshIds(entity.MeshRefs, entity.PartOverrides); - // Register each unique GfxObj id with WB so the meshes background-load. + // Snapshot each unique GfxObj id for the shorter presentation lifetime. // Includes both the entity's natural MeshRefs AND any server-sent // PartOverride GfxObjs (weapons, clothing, helmets) — those replace the // Setup default and need their own mesh data uploaded. - if (_meshAdapter is not null) + // Construct the replacement completely before displacing a live owner. + // Sequencer/appearance construction is allowed to fail; in that case + // the prior GUID incarnation and its presentation references remain + // valid. Retirement is also completed before replacement publication: + // a failed texture or mesh release therefore leaves the prior owner in + // the dictionary, with its per-resource progress available to retry. + var replacementOwner = new Owner(entity, state, meshIds); + if (_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? displacedOwner)) { - var unique = new HashSet(); - foreach (var meshRef in entity.MeshRefs) - unique.Add((ulong)meshRef.GfxObjId); - foreach (var po in entity.PartOverrides) - unique.Add((ulong)po.GfxObjId); + if (displacedOwner.RemovalPending) + { + throw new EntityPresentationRemovalDeferredException(entity.ServerGuid); + } - _meshIdsByGuid[entity.ServerGuid] = unique; - foreach (var id in unique) _meshAdapter.IncrementRefCount(id); + if (displacedOwner.Transition != PresentationTransition.None) + { + throw new InvalidOperationException( + $"Live entity 0x{entity.ServerGuid:X8} replacement was requested while its presentation transition was already in progress."); + } + + if (displacedOwner.HasPresentationResources) + { + if (!SuspendPresentation(displacedOwner)) + { + throw new InvalidOperationException( + $"Live entity 0x{entity.ServerGuid:X8} replacement was requested while its presentation teardown was already in progress."); + } + } + + _ownersByGuid[entity.ServerGuid] = replacementOwner; + } + else + { + _ownersByGuid.Add(entity.ServerGuid, replacementOwner); } return state; } + /// + /// Changes only the shorter presentation/GPU lifetime of an already-created + /// live entity. A visible projection acquires WB mesh references; suspension + /// releases those references and every owner-scoped composite texture. The + /// animated state remains registered and no create-time scripts are replayed. + /// Duplicate edges and edges for an older incarnation of a reused server GUID + /// are ignored. A failed release keeps the published resident state and the + /// exact unfinished resources so the same edge can be retried safely. + /// + /// true when this call applied a residency edge. + public bool SetPresentationResident(WorldEntity entity, bool resident) + { + ArgumentNullException.ThrowIfNull(entity); + if (!_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? owner) + || !ReferenceEquals(owner.Entity, entity) + || owner.RemovalPending + || (resident ? owner.IsFullyResident : owner.IsFullySuspended)) + { + return false; + } + + return resident + ? ResumePresentation(owner) + : SuspendPresentation(owner); + } + + /// + /// Reconciles the mesh-reference set for an in-place retail + /// SmartBox::UpdateVisualDesc mutation. Added meshes are acquired + /// before makes the new appearance + /// visible. Only then is the exact new mesh set published and superseded + /// references released. A failed release remains represented by + /// and is retried by the next + /// residency edge or logical teardown. + /// runs after that commit point but + /// before retirement, so dependent pose/cache publication cannot leave the + /// new entity appearance paired with rolled-back mesh ownership. + /// + /// + /// The exact reference is the incarnation token. + /// A delayed appearance callback from a GUID-reused owner is ignored. The + /// method is render-thread only; a re-entrant request is rejected before + /// its publication callback can mutate the active owner. + /// + /// + /// true when the matching owner's appearance was published; + /// otherwise false for a stale, removing, or re-entrant owner. + /// + public bool OnAppearanceChanged( + WorldEntity entity, + IReadOnlyList meshRefs, + IReadOnlyList partOverrides, + Action publishAppearance, + Action? afterPublication = null) + { + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(meshRefs); + ArgumentNullException.ThrowIfNull(partOverrides); + ArgumentNullException.ThrowIfNull(publishAppearance); + + if (!_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? owner) + || !ReferenceEquals(owner.Entity, entity) + || owner.RemovalPending + || owner.Transition != PresentationTransition.None) + { + return false; + } + + HashSet nextMeshIds = _meshAdapter is null + ? [] + : CollectMeshIds(meshRefs, partOverrides); + owner.Transition = PresentationTransition.ChangingAppearance; + var acquiredThisTransition = new List(); + bool meshSetPublished = false; + + try + { + if (owner.IsPresentationResident && _meshAdapter is not null) + AcquireMissingMeshReferences(owner, nextMeshIds, acquiredThisTransition); + + publishAppearance(); + + // Publication is the commit point: every desired resident mesh is + // owned before this assignment. Superseded references may remain + // temporarily held if their release reports a retryable failure, + // but they are no longer part of the published appearance set. + owner.MeshIds = nextMeshIds; + meshSetPublished = true; + afterPublication?.Invoke(); + + List? releaseFailures = owner.IsPresentationResident + ? ReleaseMeshReferencesOutside(owner, nextMeshIds) + : ReleaseAllMeshReferences(owner); + if (releaseFailures is not null) + { + throw new AggregateException( + $"Live entity 0x{owner.Entity.ServerGuid:X8} appearance mesh retirement failed.", + releaseFailures); + } + + return true; + } + catch (Exception acquireOrPublicationFailure) when (!meshSetPublished) + { + // Acquisition failed before publication. Preserve the prior exact + // mesh set and undo only references acquired by this transition. + List? rollbackFailures = RollBackAcquiredMeshReferences( + owner, + acquiredThisTransition); + if (rollbackFailures is null) + throw; + + rollbackFailures.Insert(0, acquireOrPublicationFailure); + throw new AggregateException( + $"Live entity 0x{owner.Entity.ServerGuid:X8} appearance publication and mesh rollback failed.", + rollbackFailures); + } + finally + { + owner.Transition = PresentationTransition.None; + } + } + /// /// Release the per-entity state for . Called /// on RemoveObject. Unknown guids (never spawned, or already - /// removed) are silently ignored. + /// removed) are silently ignored. Cleanup failure leaves the owner registered + /// and propagates the exception; a later call resumes its unfinished releases. /// public void OnRemove(uint serverGuid) - { - _stateByGuid.Remove(serverGuid); + => _ = TryRemove(serverGuid, expectedEntity: null); - if (_meshAdapter is not null && _meshIdsByGuid.TryGetValue(serverGuid, out var ids)) + private bool TryRemove(uint serverGuid, WorldEntity? expectedEntity) + { + if (!_ownersByGuid.TryGetValue(serverGuid, out Owner? owner) + || (expectedEntity is not null && !ReferenceEquals(owner.Entity, expectedEntity))) { - foreach (var id in ids) _meshAdapter.DecrementRefCount(id); - _meshIdsByGuid.Remove(serverGuid); + return false; } + + owner.RemovalPending = true; + if (owner.Transition != PresentationTransition.None) + throw new EntityPresentationRemovalDeferredException(serverGuid); + + // A resident owner still holds both mesh and potentially-lazy texture + // resources. A suspended owner released them at the visibility edge, + // so logical removal must not decrement or release a second time. Do + // not remove the dictionary entry until cleanup succeeds: otherwise a + // release exception would orphan its remaining references and make a + // later OnRemove retry impossible. + if (owner.HasPresentationResources && !SuspendPresentation(owner)) + return false; + + if (_ownersByGuid.TryGetValue(serverGuid, out Owner? current) + && ReferenceEquals(current, owner)) + { + _ownersByGuid.Remove(serverGuid); + return true; + } + + + return false; + } + + /// + /// Releases a logical owner only when is the exact + /// incarnation currently registered for its server GUID. This is the live + /// runtime teardown entry point: a delayed callback from an older generation + /// cannot remove a replacement that reused the same GUID. + /// + /// true when the matching owner was removed. + public bool OnRemove(WorldEntity entity) + { + ArgumentNullException.ThrowIfNull(entity); + return TryRemove(entity.ServerGuid, entity); } /// @@ -190,5 +393,209 @@ public sealed class EntitySpawnAdapter /// been removed. /// public AnimatedEntityState? GetState(uint serverGuid) - => _stateByGuid.TryGetValue(serverGuid, out var s) ? s : null; + => _ownersByGuid.TryGetValue(serverGuid, out Owner? owner) + ? owner.State + : null; + + private bool ResumePresentation(Owner owner) + { + if (owner.Transition != PresentationTransition.None) + return false; + + owner.Transition = PresentationTransition.Resuming; + var acquiredThisTransition = new List(); + bool desiredMeshSetAcquired = false; + try + { + if (_meshAdapter is not null) + AcquireMissingMeshReferences(owner, owner.MeshIds, acquiredThisTransition); + desiredMeshSetAcquired = true; + + owner.TextureReleaseRequired = true; + owner.IsPresentationResident = true; + + List? releaseFailures = ReleaseMeshReferencesOutside( + owner, + owner.MeshIds); + if (releaseFailures is not null) + { + throw new AggregateException( + $"Live entity 0x{owner.Entity.ServerGuid:X8} presentation mesh reconciliation failed.", + releaseFailures); + } + + return true; + } + catch (Exception acquireFailure) when (!desiredMeshSetAcquired) + { + List? rollbackFailures = RollBackAcquiredMeshReferences( + owner, + acquiredThisTransition); + if (rollbackFailures is null) + throw; + + rollbackFailures.Insert(0, acquireFailure); + throw new AggregateException( + $"Live entity 0x{owner.Entity.ServerGuid:X8} presentation resume and rollback failed.", + rollbackFailures); + } + finally + { + owner.Transition = PresentationTransition.None; + } + } + + private bool SuspendPresentation(Owner owner) + { + if (owner.Transition != PresentationTransition.None) + return false; + + // IsPresentationResident deliberately remains true until every release + // succeeds. Transition prevents a re-entrant duplicate edge from + // releasing the same resource twice, while the completion markers keep + // partial progress retryable after an exception. + owner.Transition = PresentationTransition.Suspending; + + List? failures = null; + if (owner.TextureReleaseRequired) + { + try + { + _textureLifetime.ReleaseOwner(owner.Entity.Id); + owner.TextureReleaseRequired = false; + } + catch (Exception error) + { + (failures ??= new List()).Add(error); + } + } + + List? meshFailures = ReleaseAllMeshReferences(owner); + if (meshFailures is not null) + (failures ??= new List()).AddRange(meshFailures); + + if (failures is not null) + { + owner.Transition = PresentationTransition.None; + throw new AggregateException( + $"Live entity 0x{owner.Entity.ServerGuid:X8} presentation suspension failed.", + failures); + } + + owner.IsPresentationResident = false; + owner.Transition = PresentationTransition.None; + return true; + } + + private static HashSet CollectMeshIds( + IReadOnlyList meshRefs, + IReadOnlyList partOverrides) + { + var unique = new HashSet(); + for (int i = 0; i < meshRefs.Count; i++) + unique.Add(meshRefs[i].GfxObjId); + for (int i = 0; i < partOverrides.Count; i++) + unique.Add(partOverrides[i].GfxObjId); + return unique; + } + + private void AcquireMissingMeshReferences( + Owner owner, + HashSet desiredMeshIds, + List acquiredThisTransition) + { + if (_meshAdapter is null) + return; + + foreach (ulong meshId in desiredMeshIds) + { + if (owner.MeshReferencesHeld.Contains(meshId)) + continue; + + try + { + _meshAdapter.IncrementRefCount(meshId); + owner.MeshReferencesHeld.Add(meshId); + acquiredThisTransition.Add(meshId); + } + catch (MeshReferenceMutationException error) + { + if (error.MutationCommitted) + { + owner.MeshReferencesHeld.Add(meshId); + acquiredThisTransition.Add(meshId); + } + + throw; + } + } + } + + private List? RollBackAcquiredMeshReferences( + Owner owner, + List acquiredThisTransition) + { + if (_meshAdapter is null) + return null; + + List? failures = null; + for (int i = acquiredThisTransition.Count - 1; i >= 0; i--) + { + ulong meshId = acquiredThisTransition[i]; + if (!owner.MeshReferencesHeld.Contains(meshId)) + continue; + + try + { + _meshAdapter.DecrementRefCount(meshId); + owner.MeshReferencesHeld.Remove(meshId); + } + catch (Exception error) + { + if (error is MeshReferenceMutationException { MutationCommitted: true }) + owner.MeshReferencesHeld.Remove(meshId); + (failures ??= new List()).Add(error); + } + } + + return failures; + } + + private List? ReleaseMeshReferencesOutside( + Owner owner, + HashSet desiredMeshIds) + { + if (_meshAdapter is null || owner.MeshReferencesHeld.Count == 0) + return null; + + List? failures = null; + ulong[] heldSnapshot = [.. owner.MeshReferencesHeld]; + foreach (ulong meshId in heldSnapshot) + { + if (desiredMeshIds.Contains(meshId)) + continue; + + try + { + _meshAdapter.DecrementRefCount(meshId); + owner.MeshReferencesHeld.Remove(meshId); + } + catch (Exception error) + { + if (error is MeshReferenceMutationException { MutationCommitted: true }) + owner.MeshReferencesHeld.Remove(meshId); + (failures ??= new List()).Add(error); + } + } + + return failures; + } + + private List? ReleaseAllMeshReferences(Owner owner) + { + if (_meshAdapter is null || owner.MeshReferencesHeld.Count == 0) + return null; + + return ReleaseMeshReferencesOutside(owner, []); + } } diff --git a/src/AcDream.App/Rendering/Wb/EnvCellMeshPreparationScheduler.cs b/src/AcDream.App/Rendering/Wb/EnvCellMeshPreparationScheduler.cs index f8fe92e1..0bc14a6c 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellMeshPreparationScheduler.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellMeshPreparationScheduler.cs @@ -1,9 +1,11 @@ namespace AcDream.App.Rendering.Wb; /// -/// Starts CPU mesh extraction for a completed EnvCell build without publishing -/// that build to the live renderer. ObjectMeshManager owns its thread-safe work -/// queue; the render-thread commit remains a small atomic state replacement. +/// Starts CPU mesh extraction after the completed EnvCell build has been +/// published and its geometry ids have acquired render ownership. This order +/// lets ObjectMeshManager cancel queued work as soon as a landblock leaves the +/// current streaming generation; stale portal destinations never keep decoder +/// jobs or surface lists alive. /// public static class EnvCellMeshPreparationScheduler { @@ -11,8 +13,11 @@ public static class EnvCellMeshPreparationScheduler EnvCellLandblockBuild build, ObjectMeshManager meshManager) { + var scheduled = new HashSet(); foreach (var shell in build.Shells) { + if (!scheduled.Add(shell.GeometryId)) + continue; _ = meshManager.PrepareEnvCellGeomMeshDataAsync( shell.GeometryId, shell.EnvironmentId, diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 6be8d96d..575b4a60 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -66,15 +66,24 @@ public sealed unsafe class EnvCellRenderer : IDisposable private readonly List> _listPool = new(); private int _poolIndex = 0; + // PrepareRenderBatches used to construct and dispose two ThreadLocal dictionary trees every + // frame. At outdoor view distances those trees grow thousands of InstanceData entries, so the + // temporary List backing arrays alone accounted for ~11 MB of allocations in a five-second + // Caul trace. Keep one scratch arena per participating worker thread: Reset clears counts and + // dictionaries while retaining their capacity. The published visibility snapshot still owns + // separate pooled output lists, preserving the existing atomic-swap lifetime. + private readonly ThreadLocal _prepareScratch = + new(() => new PrepareScratch(), trackAllValues: true); + // Modern-MDI scratch buffers (single slot — we re-upload every frame). // WB BaseObjectRenderManager.cs:43-48: _scratchMdiCommandBuffers, _scratchModernBatchBuffers, _modernInstanceBuffers // We collapse the ring-of-3 to a single slot since we have no persistent/consolidated draws. private uint _mdiCommandBuffer; - private int _mdiCommandCapacity = 1024; + private int _mdiCommandCapacity; private uint _modernInstanceBuffer; - private int _modernInstanceCapacity = 1024; + private int _modernInstanceCapacity; private uint _modernBatchBuffer; - private int _modernBatchCapacity = 1024; + private int _modernBatchCapacity; // mesh_modern.vert's SSBO InstanceData is only mat4 transform. The CPU // InstanceData below also carries CellId/Flags for filtering, so upload a // packed transform array instead of the 80-byte CPU struct. @@ -85,6 +94,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable // indexed by the same BaseInstance + gl_InstanceID the shader uses for // binding=0. ALL ZEROS in U.3 ⇒ slot 0 ⇒ no-clip. U.4 populates real slots. private uint _clipSlotBuffer; + private int _clipSlotCapacity; private uint[] _clipSlotData = Array.Empty(); // A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state, @@ -92,11 +102,47 @@ public sealed unsafe class EnvCellRenderer : IDisposable // left bound. binding=4 = global point-light snapshot (same data/indices as the // dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance. private uint _globalLightsSsbo; // binding=4 + private int _globalLightsCapacity; private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16]; private uint _instLightSetSsbo; // binding=5 + private int _instLightSetCapacity; private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject]; private System.Collections.Generic.IReadOnlyList? _pointSnapshot; - private readonly System.Collections.Generic.Dictionary _cellLightSetCache = new(); + private sealed class CachedCellLightSet + { + public int FrameGeneration; + public readonly int[] Indices = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject]; + } + + private readonly System.Collections.Generic.Dictionary _cellLightSetCache = new(); + private readonly List _cellLightRemovalScratch = new(); + private int _lightFrameGeneration; + + private sealed class DynamicBufferSet + { + public uint MdiCommandBuffer; + public uint ModernInstanceBuffer; + public uint ModernBatchBuffer; + public uint ClipSlotBuffer; + public uint GlobalLightsSsbo; + public uint InstanceLightSetSsbo; + public int MdiCommandCapacity; + public int ModernInstanceCapacity; + public int ModernBatchCapacity; + public int ClipSlotCapacity; + public int GlobalLightsCapacity; + public int InstanceLightSetCapacity; + } + + private readonly List[] _dynamicBufferSetsByFrame = + [[], [], []]; + private int _dynamicFrameSlot; + private int _dynamicBufferSetCursor; + private bool _dynamicFrameStarted; + private DynamicBufferSet? _activeDynamicBufferSet; + + internal int DynamicBufferSetCount => + _dynamicBufferSetsByFrame.Sum(frameSets => frameSets.Count); // Phase U.3: SHARED per-cell clip-region SSBO (binding=2) handed in via // SetClipRegionSsbo (the GameWindow-level ClipFrame buffer). When 0, we bind @@ -108,6 +154,27 @@ public sealed unsafe class EnvCellRenderer : IDisposable // WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>() private DrawElementsIndirectCommand[] _commands = Array.Empty(); private ModernBatchData[] _modernBatches = Array.Empty(); + private readonly List _prepareLandblocks = new(); + private readonly List _renderInstances = new(); + private readonly List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> _renderDrawCalls = new(); + private readonly Dictionary> _filteredGroups = new(); + private readonly HashSet> _filteredOwnedLists = new(); + private readonly List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)>[] _batchesByCullGroup = + Enumerable.Range(0, 8) + .Select(_ => new List<(ObjectRenderBatch, int, int)>()) + .ToArray(); + private readonly List _activeCullGroups = new(8); + private readonly HashSet _transparentCellIds = new(); + private readonly List _drawCallRanges = new(); + private readonly List _mdiDrawRanges = new(); + + private readonly record struct DrawCallRange(int First, int Count); + internal readonly record struct MdiDrawRange(int GroupIndex, int FirstCommand, int CommandCount); + // Unfiltered rendering is retained for diagnostic callers only. Build its + // global grouping lazily instead of duplicating every prepared gameplay + // instance in both a per-cell and global tree each frame. + private readonly Dictionary> _activeSnapshotGlobalGroups = new(); + private readonly List _activeSnapshotGlobalGfxObjIds = new(); // Static render-state tracking — matches WB BaseObjectRenderManager.cs:24-28. // Shared across all manager instances on the same GL context. @@ -115,6 +182,8 @@ public sealed unsafe class EnvCellRenderer : IDisposable private static CullMode? _currentCullMode; public bool NeedsPrepare { get; private set; } = true; + private RetryableResourceReleaseLedger? _disposeResources; + private bool _disposing; public bool IsDisposed { get; private set; } public LastFrameStats Stats => _lastFrameStats; @@ -205,56 +274,23 @@ public sealed unsafe class EnvCellRenderer : IDisposable public void Initialize(AcDream.App.Rendering.Shader shader) { _shader = shader; - AllocateMdiBuffers(); _initialized = true; } - // --------------------------------------------------------------------------- - // AllocateMdiBuffers - // Mirrors WB BaseObjectRenderManager.cs:62-127 (slot-0 only). - // --------------------------------------------------------------------------- - - private void AllocateMdiBuffers() + /// Resets the per-frame submission cursor for the GPU-fenced slot. + public void BeginFrame(int frameSlot) { - // MDI command buffer (DrawIndirectBuffer) - _gl.GenBuffers(1, out _mdiCommandBuffer); - _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer); - _gl.BufferData(GLEnum.DrawIndirectBuffer, - (nuint)(_mdiCommandCapacity * sizeof(DrawElementsIndirectCommand)), null, GLEnum.DynamicDraw); - - // Per-frame scratch instance SSBO (binding=0) - _gl.GenBuffers(1, out _modernInstanceBuffer); - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw); - - // Per-batch data SSBO (binding=1) - _gl.GenBuffers(1, out _modernBatchBuffer); - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(_modernBatchCapacity * sizeof(ModernBatchData)), null, GLEnum.DynamicDraw); - - // Phase U.3: per-instance clip-slot SSBO (binding=3), sized to the - // instance capacity. Uploaded all-zeros each frame in RenderModernMDIInternal. - _gl.GenBuffers(1, out _clipSlotBuffer); - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _clipSlotBuffer); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw); - - // A7 Fix D (D-2): binding=4 global lights + binding=5 per-instance light set. - _gl.GenBuffers(1, out _globalLightsSsbo); - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(_globalLightData.Length * sizeof(float)), null, GLEnum.DynamicDraw); - - _gl.GenBuffers(1, out _instLightSetSsbo); - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(_modernInstanceCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)), - null, GLEnum.DynamicDraw); - - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, 0); - _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); + if ((uint)frameSlot >= (uint)_dynamicBufferSetsByFrame.Length) + throw new ArgumentOutOfRangeException(nameof(frameSlot)); + _dynamicFrameSlot = frameSlot; + _dynamicBufferSetCursor = 0; + _dynamicFrameStarted = true; + _activeDynamicBufferSet = null; + if (++_lightFrameGeneration == 0) + { + _cellLightSetCache.Clear(); + _lightFrameGeneration = 1; + } } /// @@ -390,6 +426,15 @@ public sealed unsafe class EnvCellRenderer : IDisposable public void RemoveLandblock(uint landblockId) { _landblocks.TryRemove(landblockId, out _); + uint cellPrefix = landblockId & 0xFFFF0000u; + _cellLightRemovalScratch.Clear(); + foreach (uint cellId in _cellLightSetCache.Keys) + { + if ((cellId & 0xFFFF0000u) == cellPrefix) + _cellLightRemovalScratch.Add(cellId); + } + foreach (uint cellId in _cellLightRemovalScratch) + _cellLightSetCache.Remove(cellId); NeedsPrepare = true; } @@ -424,8 +469,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable { _poolIndex = 0; _activeSnapshot = new EnvCellVisibilitySnapshot(); - _activeSnapshotGlobalGroups = new Dictionary>(); - _activeSnapshotGlobalGfxObjIds = new List(); + _transparentCellIds.Clear(); NeedsPrepare = false; } return; @@ -439,7 +483,8 @@ public sealed unsafe class EnvCellRenderer : IDisposable // WB EnvCellRenderManager.cs:262: // Filter loaded landblocks by GpuReady + Instances non-empty. - var landblocks = new List(); + List landblocks = _prepareLandblocks; + landblocks.Clear(); foreach (var lb in _landblocks.Values) { if (centerLbX.HasValue && centerLbY.HasValue && renderRadius.HasValue) @@ -456,14 +501,11 @@ public sealed unsafe class EnvCellRenderer : IDisposable } if (landblocks.Count == 0) return; - // WB EnvCellRenderManager.cs:265-267: - // Use ThreadLocal to avoid contention on ConcurrentDictionaries during parallel grouping. - using var threadLocalBatchedByCell = - new ThreadLocal>>>( - () => new(), trackAllValues: true); - using var threadLocalGlobalGroups = - new ThreadLocal>>( - () => new(), trackAllValues: true); + // WB EnvCellRenderManager.cs:265-267: worker-local grouping avoids contention. The scratch + // arenas persist so their backing arrays can be reused; Parallel.ForEach has completed before + // the merge below reads them, and Reset runs before the next workers start. + foreach (PrepareScratch scratch in _prepareScratch.Values) + scratch.Reset(); // WB EnvCellRenderManager.cs:269: var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }; @@ -476,8 +518,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable var testResult = _frustum.TestBox(lb.TotalEnvCellBounds); if (testResult == FrustumTestResult.Outside) return; - var lbBatchedByCell = threadLocalBatchedByCell.Value!; - var lbGlobalGroups = threadLocalGlobalGroups.Value!; + PrepareScratch scratch = _prepareScratch.Value!; // WB EnvCellRenderManager.cs:279-295: fast path — LB fully inside. if (testResult == FrustumTestResult.Inside) @@ -486,19 +527,20 @@ public sealed unsafe class EnvCellRenderer : IDisposable foreach (var instanceData in instances) { if (filter != null && !filter.Contains(instanceData.CellId)) continue; - AddToGroups(lbBatchedByCell, lbGlobalGroups, instanceData.CellId, gfxObjId, instanceData); + AddToGroups(scratch, instanceData.CellId, gfxObjId, instanceData); } foreach (var (gfxObjId, instances) in lb.StaticPartGroups) foreach (var instanceData in instances) { if (filter != null && !filter.Contains(instanceData.CellId)) continue; - AddToGroups(lbBatchedByCell, lbGlobalGroups, instanceData.CellId, gfxObjId, instanceData); + AddToGroups(scratch, instanceData.CellId, gfxObjId, instanceData); } return; } // WB EnvCellRenderManager.cs:298-324: slow path — per-cell frustum test. - var visibleCells = new HashSet(); + HashSet visibleCells = scratch.VisibleCells; + visibleCells.Clear(); foreach (var kvp in lb.EnvCellBounds) { var cellId = kvp.Key; @@ -513,13 +555,13 @@ public sealed unsafe class EnvCellRenderer : IDisposable foreach (var instanceData in instances) { if (visibleCells.Contains(instanceData.CellId)) - AddToGroups(lbBatchedByCell, lbGlobalGroups, instanceData.CellId, gfxObjId, instanceData); + AddToGroups(scratch, instanceData.CellId, gfxObjId, instanceData); } foreach (var (gfxObjId, instances) in lb.StaticPartGroups) foreach (var instanceData in instances) { if (visibleCells.Contains(instanceData.CellId)) - AddToGroups(lbBatchedByCell, lbGlobalGroups, instanceData.CellId, gfxObjId, instanceData); + AddToGroups(scratch, instanceData.CellId, gfxObjId, instanceData); } } } @@ -528,13 +570,10 @@ public sealed unsafe class EnvCellRenderer : IDisposable // WB EnvCellRenderManager.cs:327-373: merge thread-locals + atomic swap. var newBatchedByCell = new Dictionary>>(); - var newVisibleGroups = new Dictionary>(); - var newVisibleGfxObjIds = new List(); - // WB EnvCellRenderManager.cs:333-347: merge per-cell batches. - foreach (var localBatchedByCell in threadLocalBatchedByCell.Values) + foreach (PrepareScratch scratch in _prepareScratch.Values) { - foreach (var cellKvp in localBatchedByCell) + foreach (var cellKvp in scratch.BatchedByCell) { if (!newBatchedByCell.TryGetValue(cellKvp.Key, out var gfxDict)) { @@ -553,21 +592,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable } } - // WB EnvCellRenderManager.cs:349-358: merge global groups. - foreach (var localGlobalGroups in threadLocalGlobalGroups.Values) - { - foreach (var kvp in localGlobalGroups) - { - if (!newVisibleGroups.TryGetValue(kvp.Key, out var list)) - { - list = GetPooledList(); - newVisibleGroups[kvp.Key] = list; - newVisibleGfxObjIds.Add(kvp.Key); - } - list.AddRange(kvp.Value); - } - } - // WB EnvCellRenderManager.cs:361-372: atomic swap under _renderLock. // // FIX 2026-05-28 (pool aliasing root cause): capture _poolIndex's @@ -584,22 +608,38 @@ public sealed unsafe class EnvCellRenderer : IDisposable BatchedByCell = newBatchedByCell, VisibleLandblocks = landblocks, PostPreparePoolIndex = _poolIndex, - // VisibleGroups / VisibleGfxObjIds are stored as extra fields below. }; - // Stash the global groups on the snapshot for use in the unfiltered render path. - _activeSnapshotGlobalGroups = newVisibleGroups; - _activeSnapshotGlobalGfxObjIds = newVisibleGfxObjIds; + RebuildTransparentCellIndex(newBatchedByCell); _poolIndex = 0; NeedsPrepare = false; } } - // Extra fields to carry global groups out of PrepareRenderBatches into Render(). - // WB stores these in VisibilitySnapshot; we keep them as sibling fields since our - // EnvCellVisibilitySnapshot only exposes BatchedByCell (per spec Task 4). - private Dictionary> _activeSnapshotGlobalGroups = new(); - private List _activeSnapshotGlobalGfxObjIds = new(); + private void RebuildTransparentCellIndex( + Dictionary>> batchedByCell) + { + _transparentCellIds.Clear(); + foreach ((uint cellId, Dictionary> groups) in batchedByCell) + { + foreach ((ulong gfxObjId, List transforms) in groups) + { + if (transforms.Count == 0) + continue; + ObjectRenderData? renderData = _meshManager.TryGetRenderData(gfxObjId); + if (renderData is null) + continue; + for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++) + { + if (!renderData.Batches[batchIndex].IsTransparent) + continue; + _transparentCellIds.Add(cellId); + goto NextCell; + } + } + NextCell:; + } + } // --------------------------------------------------------------------------- // AddToGroups (static helper) @@ -607,34 +647,65 @@ public sealed unsafe class EnvCellRenderer : IDisposable // --------------------------------------------------------------------------- private static void AddToGroups( - Dictionary>> batchedByCell, - Dictionary> globalGroups, + PrepareScratch scratch, uint cellId, ulong gfxObjId, InstanceData data) { - // WB EnvCellRenderManager.cs:377-381: add to global grouping. - if (!globalGroups.TryGetValue(gfxObjId, out var globalList)) - { - globalList = new List(); - globalGroups[gfxObjId] = globalList; - } - globalList.Add(data); - - // WB EnvCellRenderManager.cs:383-392: add to per-cell grouping. + Dictionary>> batchedByCell = scratch.BatchedByCell; + // The gameplay renderer always consumes cell-filtered PView batches. + // Keeping a second global copy doubled every prepared instance and was + // only useful to WorldBuilder's editor-wide unfiltered path. if (!batchedByCell.TryGetValue(cellId, out var gfxDict)) { - gfxDict = new Dictionary>(); + gfxDict = scratch.RentGfxDictionary(); batchedByCell[cellId] = gfxDict; } if (!gfxDict.TryGetValue(gfxObjId, out var list)) { - list = new List(); + list = scratch.RentList(); batchedByCell[cellId][gfxObjId] = list; } list.Add(data); } + private sealed class PrepareScratch + { + public readonly Dictionary>> BatchedByCell = new(); + public readonly HashSet VisibleCells = new(); + + private readonly List>> _gfxDictionaryPool = new(); + private readonly List> _listPool = new(); + private int _gfxDictionaryIndex; + private int _listIndex; + + public void Reset() + { + BatchedByCell.Clear(); + VisibleCells.Clear(); + _gfxDictionaryIndex = 0; + _listIndex = 0; + } + + public Dictionary> RentGfxDictionary() + { + if (_gfxDictionaryIndex == _gfxDictionaryPool.Count) + _gfxDictionaryPool.Add(new Dictionary>()); + Dictionary> value = _gfxDictionaryPool[_gfxDictionaryIndex++]; + value.Clear(); + return value; + } + + public List RentList() + { + if (_listIndex == _listPool.Count) + _listPool.Add(new List()); + List value = _listPool[_listIndex++]; + value.Clear(); + return value; + } + } + // --------------------------------------------------------------------------- // Render // Verbatim port of WB EnvCellRenderManager.cs:395-511. @@ -651,7 +722,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable public void Render(WbRenderPass renderPass) { // WB EnvCellRenderManager.cs:396: - Render(renderPass, null); + RenderCore(renderPass, null, null); } /// @@ -665,6 +736,24 @@ public sealed unsafe class EnvCellRenderer : IDisposable /// Source: WB EnvCellRenderManager.cs:399-511 (verbatim minus selection highlights). /// public void Render(WbRenderPass renderPass, HashSet? filter) + => RenderCore(renderPass, filter, null); + + /// + /// Draws transparent cell shells in the supplied far-to-near PView order. + /// All instance/command buffers are uploaded once, while command ranges + /// retain the same per-cell cull/blend ordering as the former one-Render- + /// call-per-cell path. + /// + public void RenderTransparentOrdered(IReadOnlyList orderedCellIds) + { + ArgumentNullException.ThrowIfNull(orderedCellIds); + RenderCore(WbRenderPass.Transparent, null, orderedCellIds); + } + + private void RenderCore( + WbRenderPass renderPass, + HashSet? filter, + IReadOnlyList? orderedCellIds) { // WB EnvCellRenderManager.cs:400: if (!_initialized || _shader is null || _shader.Program == 0) return; @@ -721,11 +810,40 @@ public sealed unsafe class EnvCellRenderer : IDisposable // 2026-05-28 cull-state cache fix above. _shader.SetMatrix4("uViewProjection", _lastViewProjection); - var allInstances = new List(); - var drawCalls = new List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)>(); + List allInstances = _renderInstances; + List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls = + _renderDrawCalls; + allInstances.Clear(); + drawCalls.Clear(); + _drawCallRanges.Clear(); - if (filter == null) + if (orderedCellIds is not null) { + for (int cellIndex = 0; cellIndex < orderedCellIds.Count; cellIndex++) + { + uint cellId = orderedCellIds[cellIndex]; + if (!snapshot.BatchedByCell.TryGetValue(cellId, out var cellGroups)) + continue; + + int firstDrawCall = drawCalls.Count; + foreach ((ulong gfxObjId, List transforms) in cellGroups) + { + if (transforms.Count == 0) + continue; + ObjectRenderData? renderData = _meshManager.TryGetRenderData(gfxObjId); + if (renderData is null || renderData.IsSetup) + continue; + drawCalls.Add((renderData, gfxObjId, transforms.Count, allInstances.Count)); + allInstances.AddRange(transforms); + } + int drawCallCount = drawCalls.Count - firstDrawCall; + if (drawCallCount > 0) + _drawCallRanges.Add(new DrawCallRange(firstDrawCall, drawCallCount)); + } + } + else if (filter is null) + { + RebuildUnfilteredGroups(snapshot); // WB EnvCellRenderManager.cs:418-429: optimized path — global groups. foreach (var gfxObjId in _activeSnapshotGlobalGfxObjIds) { @@ -744,8 +862,10 @@ public sealed unsafe class EnvCellRenderer : IDisposable { // WB EnvCellRenderManager.cs:431-468: filtered path. // Group by gfxObjId within the filtered cells to minimize draw calls. - var filteredGroups = new Dictionary>(); - var ownedLists = new HashSet>(); + Dictionary> filteredGroups = _filteredGroups; + HashSet> ownedLists = _filteredOwnedLists; + filteredGroups.Clear(); + ownedLists.Clear(); foreach (var cellId in filter) { @@ -799,7 +919,14 @@ public sealed unsafe class EnvCellRenderer : IDisposable { // WB uses: if (_useModernRendering) { RenderModernMDI(...) } else { legacy } // We always use modern (Phase N.5 mandatory). - RenderModernMDIInternal(_shader, drawCalls, allInstances, renderPass); + if (_drawCallRanges.Count == 0 && drawCalls.Count > 0) + _drawCallRanges.Add(new DrawCallRange(0, drawCalls.Count)); + RenderModernMDIInternal( + _shader, + drawCalls, + allInstances, + _drawCallRanges, + renderPass); } // WB EnvCellRenderManager.cs:486-510: selection/hover highlights — DROPPED (no editor state). @@ -819,7 +946,9 @@ public sealed unsafe class EnvCellRenderer : IDisposable // gathered. // Update frame stats for probe emission at the call site. - _lastFrameStats.CellsRendered = filter?.Count ?? snapshot.BatchedByCell.Count; + _lastFrameStats.CellsRendered = orderedCellIds?.Count + ?? filter?.Count + ?? snapshot.BatchedByCell.Count; _lastFrameStats.TrianglesDrawn = 0; foreach (var dc in drawCalls) _lastFrameStats.TrianglesDrawn += (dc.renderData.Batches.Count > 0 @@ -878,20 +1007,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable /// transparent draws. Read-only; mirrors the [shell] probe's batch scan. /// public bool CellHasTransparent(uint cellId) - { - var snapshot = _activeSnapshot; - if (snapshot is null || !snapshot.BatchedByCell.TryGetValue(cellId, out var gfxDict)) - return false; - foreach (var (gfxObjId, transforms) in gfxDict) - { - if (transforms.Count == 0) continue; - var rd = _meshManager.TryGetRenderData(gfxObjId); - if (rd is null) continue; - foreach (var b in rd.Batches) - if (b.IsTransparent) return true; - } - return false; - } + => _transparentCellIds.Contains(cellId); // --------------------------------------------------------------------------- // GetCellLightSet (A7 Fix D D-2 helper) @@ -904,9 +1020,15 @@ public sealed unsafe class EnvCellRenderer : IDisposable // Cached per frame; unused slots are -1 (shader adds no point light there). private int[] GetCellLightSet(uint cellId) { - if (_cellLightSetCache.TryGetValue(cellId, out var cached)) return cached; + if (!_cellLightSetCache.TryGetValue(cellId, out CachedCellLightSet? cached)) + { + cached = new CachedCellLightSet(); + _cellLightSetCache.Add(cellId, cached); + } + if (cached.FrameGeneration == _lightFrameGeneration) + return cached.Indices; - var set = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject]; + int[] set = cached.Indices; System.Array.Fill(set, -1); var snap = _pointSnapshot; @@ -928,7 +1050,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable // let the portal set flip as the flood shifted → floor-lighting flap. AcDream.Core.Lighting.LightManager.SelectForCell(snap, center, radius, set); } - _cellLightSetCache[cellId] = set; + cached.FrameGeneration = _lightFrameGeneration; return set; } @@ -939,10 +1061,136 @@ public sealed unsafe class EnvCellRenderer : IDisposable // issues glMultiDrawElementsIndirect. // --------------------------------------------------------------------------- + private void ActivateNextDynamicBufferSet() + { + if (!_dynamicFrameStarted) + throw new InvalidOperationException("BeginFrame must be called before drawing EnvCells."); + + List slotSets = _dynamicBufferSetsByFrame[_dynamicFrameSlot]; + if (_dynamicBufferSetCursor == slotSets.Count) + slotSets.Add(CreateDynamicBufferSet()); + + DynamicBufferSet set = slotSets[_dynamicBufferSetCursor++]; + _activeDynamicBufferSet = set; + _mdiCommandBuffer = set.MdiCommandBuffer; + _modernInstanceBuffer = set.ModernInstanceBuffer; + _modernBatchBuffer = set.ModernBatchBuffer; + _clipSlotBuffer = set.ClipSlotBuffer; + _globalLightsSsbo = set.GlobalLightsSsbo; + _instLightSetSsbo = set.InstanceLightSetSsbo; + _mdiCommandCapacity = set.MdiCommandCapacity; + _modernInstanceCapacity = set.ModernInstanceCapacity; + _modernBatchCapacity = set.ModernBatchCapacity; + _clipSlotCapacity = set.ClipSlotCapacity; + _globalLightsCapacity = set.GlobalLightsCapacity; + _instLightSetCapacity = set.InstanceLightSetCapacity; + } + + private DynamicBufferSet CreateDynamicBufferSet() + { + var set = new DynamicBufferSet(); + try + { + set.MdiCommandBuffer = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell MDI buffer"); + set.ModernInstanceBuffer = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell instance SSBO"); + set.ModernBatchBuffer = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell batch SSBO"); + set.ClipSlotBuffer = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell clip-slot SSBO"); + set.GlobalLightsSsbo = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell global-light SSBO"); + set.InstanceLightSetSsbo = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell light-set SSBO"); + return set; + } + catch (Exception creationFailure) + { + try { DeleteDynamicBufferSet(set); } + catch (Exception cleanupFailure) + { + throw new AggregateException( + "EnvCell dynamic-buffer creation and rollback failed.", + creationFailure, + cleanupFailure); + } + throw; + } + } + + private void RebuildUnfilteredGroups(EnvCellVisibilitySnapshot snapshot) + { + foreach (List instances in _activeSnapshotGlobalGroups.Values) + instances.Clear(); + _activeSnapshotGlobalGfxObjIds.Clear(); + + foreach (Dictionary> cellGroups in snapshot.BatchedByCell.Values) + { + foreach ((ulong gfxObjId, List transforms) in cellGroups) + { + if (!_activeSnapshotGlobalGroups.TryGetValue(gfxObjId, out List? combined)) + { + combined = new List(transforms.Count); + _activeSnapshotGlobalGroups.Add(gfxObjId, combined); + } + if (combined.Count == 0) + _activeSnapshotGlobalGfxObjIds.Add(gfxObjId); + combined.AddRange(transforms); + } + } + } + + private void DeleteDynamicBufferSet(DynamicBufferSet set) + { + List? failures = null; + void Attempt(uint buffer, long bytes, string name) + { + try { TrackedGlResource.DeleteBuffer(_gl, buffer, bytes, $"deleting {name}"); } + catch (Exception ex) { (failures ??= []).Add(ex); } + } + + Attempt( + set.MdiCommandBuffer, + (long)set.MdiCommandCapacity * sizeof(DrawElementsIndirectCommand), + "EnvCell MDI buffer"); + Attempt( + set.ModernInstanceBuffer, + (long)set.ModernInstanceCapacity * sizeof(Matrix4x4), + "EnvCell instance SSBO"); + Attempt( + set.ModernBatchBuffer, + (long)set.ModernBatchCapacity * sizeof(ModernBatchData), + "EnvCell batch SSBO"); + Attempt(set.ClipSlotBuffer, (long)set.ClipSlotCapacity * sizeof(uint), "EnvCell clip-slot SSBO"); + Attempt( + set.GlobalLightsSsbo, + (long)set.GlobalLightsCapacity + * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight + * sizeof(float), + "EnvCell global-light SSBO"); + Attempt( + set.InstanceLightSetSsbo, + (long)set.InstanceLightSetCapacity + * AcDream.Core.Lighting.LightManager.MaxLightsPerObject + * sizeof(int), + "EnvCell light-set SSBO"); + + if (failures is not null) + throw new AggregateException("One or more EnvCell dynamic buffers failed to delete.", failures); + } + + private void PersistActiveDynamicBufferCapacities() + { + DynamicBufferSet set = _activeDynamicBufferSet + ?? throw new InvalidOperationException("No dynamic EnvCell buffer set is active."); + set.MdiCommandCapacity = _mdiCommandCapacity; + set.ModernInstanceCapacity = _modernInstanceCapacity; + set.ModernBatchCapacity = _modernBatchCapacity; + set.ClipSlotCapacity = _clipSlotCapacity; + set.GlobalLightsCapacity = _globalLightsCapacity; + set.InstanceLightSetCapacity = _instLightSetCapacity; + } + private void RenderModernMDIInternal( AcDream.App.Rendering.Shader shader, List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls, List allInstances, + IReadOnlyList drawCallRanges, WbRenderPass renderPass) { // WB BaseObjectRenderManager.cs:710-713: @@ -951,15 +1199,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable int passIdx = (int)renderPass; if (passIdx < 0 || passIdx > 2) return; - // A7 Fix D (D-2): per-frame per-cell light-set cache (built lazily in - // GetCellLightSet below). Clear once here so each cell gets a fresh lookup - // using this frame's _pointSnapshot. Called for EVERY pass (opaque AND - // transparent); the cache entries are stable within a frame since PointSnapshot - // doesn't change between Render calls, so clearing once (at the opaque pass) - // and leaving stale entries for the transparent pass would also be correct, but - // clearing both is safe and matches WbDrawDispatcher's per-call ComputeEntityLightSet. - _cellLightSetCache.Clear(); - // §4 outdoor full-world flap (2026-06-10): hoisted from below the SSBO uploads. // Without the global VAO nothing can draw, and returning AFTER the pass state // was established leaked it (same early-out shape as the totalDraws==0 leak — @@ -971,42 +1210,41 @@ public sealed unsafe class EnvCellRenderer : IDisposable shader.Use(); shader.SetInt("uFilterByCell", 0); - // WB BaseObjectRenderManager.cs:718-740: group batches by CullMode + additive flag. - var batchesByCullMode = new Dictionary>(); + // WB BaseObjectRenderManager.cs:718-740: count the pass-filtered batches. + // A normal render has one range. The ordered transparent-shell path has + // one range per cell, retaining retail's far-to-near cell order while + // sharing a single set of buffer uploads for the entire shell pass. int totalDraws = 0; - - foreach (var call in drawCalls) + for (int rangeIndex = 0; rangeIndex < drawCallRanges.Count; rangeIndex++) { - foreach (var batch in call.renderData.Batches) + DrawCallRange range = drawCallRanges[rangeIndex]; + int rangeEnd = Math.Min(range.First + range.Count, drawCalls.Count); + for (int callIndex = range.First; callIndex < rangeEnd; callIndex++) { - // WB BaseObjectRenderManager.cs:723-731: pass-filter. - if (renderPass != WbRenderPass.SinglePass) + var call = drawCalls[callIndex]; + foreach (var batch in call.renderData.Batches) { - if (batch.IsAdditive) + // WB BaseObjectRenderManager.cs:723-731: pass-filter. + if (renderPass != WbRenderPass.SinglePass) { - if (renderPass == WbRenderPass.Opaque) continue; + if (batch.IsAdditive) + { + if (renderPass == WbRenderPass.Opaque) continue; + } + else if (!batch.IsTransparent) + { + if (renderPass == WbRenderPass.Transparent) continue; + } } - else if (!batch.IsTransparent) - { - if (renderPass == WbRenderPass.Transparent) continue; - } - } - // WB BaseObjectRenderManager.cs:732-740: - var cullMode = batch.CullMode; - var groupIdx = (int)cullMode + (batch.IsAdditive ? 4 : 0); - if (!batchesByCullMode.TryGetValue(groupIdx, out var list)) - { - list = new List<(ObjectRenderBatch, int, int)>(); - batchesByCullMode[groupIdx] = list; + totalDraws++; } - list.Add((batch, call.count, call.offset)); - totalDraws++; } } // WB BaseObjectRenderManager.cs:743: if (totalDraws == 0) return; + ActivateNextDynamicBufferSet(); // Phase U.4 ROOT-CAUSE FIX (cell-shell "transparent walls / only bluish // background, flickering when moving"): establish this pass's BLEND + DepthMask @@ -1040,31 +1278,79 @@ public sealed unsafe class EnvCellRenderer : IDisposable // WB BaseObjectRenderManager.cs:745-759: resize buffers if needed. if (totalDraws > _mdiCommandCapacity) { - _mdiCommandCapacity = Math.Max(_mdiCommandCapacity * 2, totalDraws); - _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer); - _gl.BufferData(GLEnum.DrawIndirectBuffer, - (nuint)(_mdiCommandCapacity * sizeof(DrawElementsIndirectCommand)), null, GLEnum.DynamicDraw); + int grownMdiCapacity = Math.Max(_mdiCommandCapacity * 2, totalDraws); + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.DrawIndirectBuffer, + _mdiCommandBuffer, + (long)_mdiCommandCapacity * sizeof(DrawElementsIndirectCommand), + (long)grownMdiCapacity * sizeof(DrawElementsIndirectCommand), + GLEnum.DynamicDraw, + $"growing EnvCell MDI buffer to {grownMdiCapacity} commands"); + _mdiCommandCapacity = grownMdiCapacity; - _modernBatchCapacity = _mdiCommandCapacity; - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(_modernBatchCapacity * sizeof(ModernBatchData)), null, GLEnum.DynamicDraw); + int grownBatchCapacity = grownMdiCapacity; + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.ShaderStorageBuffer, + _modernBatchBuffer, + (long)_modernBatchCapacity * sizeof(ModernBatchData), + (long)grownBatchCapacity * sizeof(ModernBatchData), + GLEnum.DynamicDraw, + $"growing EnvCell batch SSBO to {grownBatchCapacity} batches"); + _modernBatchCapacity = grownBatchCapacity; } int uniqueInstanceCount = allInstances.Count; if (uniqueInstanceCount > _modernInstanceCapacity) { - _modernInstanceCapacity = Math.Max(_modernInstanceCapacity * 2, uniqueInstanceCount); - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw); + int grownInstanceCapacity = Math.Max(_modernInstanceCapacity * 2, uniqueInstanceCount); + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.ShaderStorageBuffer, + _modernInstanceBuffer, + (long)_modernInstanceCapacity * sizeof(Matrix4x4), + (long)grownInstanceCapacity * sizeof(Matrix4x4), + GLEnum.DynamicDraw, + $"growing EnvCell instance SSBO to {grownInstanceCapacity} instances"); + _modernInstanceCapacity = grownInstanceCapacity; + } - // Phase U.3: keep the clip-slot buffer (binding=3) sized to the - // instance buffer so instanceClipSlot[BaseInstance + gl_InstanceID] - // is always in range. - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _clipSlotBuffer); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw); + // Phase U.3: keep the clip-slot buffer (binding=3) sized to the + // instance prefix so instanceClipSlot[BaseInstance + gl_InstanceID] + // is always in range. It owns an independent committed capacity so a + // failed allocation can never publish the instance buffer's growth as + // if both resources had succeeded. + if (uniqueInstanceCount > _clipSlotCapacity) + { + int grownClipCapacity = Math.Max(_clipSlotCapacity * 2, uniqueInstanceCount); + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.ShaderStorageBuffer, + _clipSlotBuffer, + (long)_clipSlotCapacity * sizeof(uint), + (long)grownClipCapacity * sizeof(uint), + GLEnum.DynamicDraw, + $"growing EnvCell clip-slot SSBO to {grownClipCapacity} instances"); + _clipSlotCapacity = grownClipCapacity; + } + + if (uniqueInstanceCount > _instLightSetCapacity) + { + int grownLightSetCapacity = Math.Max(_instLightSetCapacity * 2, uniqueInstanceCount); + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.ShaderStorageBuffer, + _instLightSetSsbo, + (long)_instLightSetCapacity + * AcDream.Core.Lighting.LightManager.MaxLightsPerObject + * sizeof(int), + (long)grownLightSetCapacity + * AcDream.Core.Lighting.LightManager.MaxLightsPerObject + * sizeof(int), + GLEnum.DynamicDraw, + $"growing EnvCell light-set SSBO to {grownLightSetCapacity} instances"); + _instLightSetCapacity = grownLightSetCapacity; } // WB BaseObjectRenderManager.cs:761-762: grow scratch arrays. @@ -1073,34 +1359,91 @@ public sealed unsafe class EnvCellRenderer : IDisposable if (_modernBatches.Length < totalDraws) Array.Resize(ref _modernBatches, Math.Max(_modernBatches.Length * 2, totalDraws)); - // WB BaseObjectRenderManager.cs:764-781: build commands array. + // WB BaseObjectRenderManager.cs:718-781: group and build commands. + // Group independently inside each ordered cell range. This preserves + // the old per-cell draw ordering exactly; only the repeated CPU-side + // buffer uploads have been coalesced. + _mdiDrawRanges.Clear(); int cmdIndex = 0; - foreach (var group in batchesByCullMode) + for (int rangeIndex = 0; rangeIndex < drawCallRanges.Count; rangeIndex++) { - foreach (var item in group.Value) - { - _modernBatches[cmdIndex] = new ModernBatchData - { - TextureHandle = item.batch.BindlessTextureHandle, - TextureIndex = (uint)item.batch.TextureIndex, - }; + _activeCullGroups.Clear(); + for (int groupIndex = 0; groupIndex < _batchesByCullGroup.Length; groupIndex++) + _batchesByCullGroup[groupIndex].Clear(); - _commands[cmdIndex] = new DrawElementsIndirectCommand + DrawCallRange range = drawCallRanges[rangeIndex]; + int rangeEnd = Math.Min(range.First + range.Count, drawCalls.Count); + for (int callIndex = range.First; callIndex < rangeEnd; callIndex++) + { + var call = drawCalls[callIndex]; + foreach (var batch in call.renderData.Batches) { - Count = (uint)item.batch.IndexCount, - InstanceCount = (uint)item.instanceCount, - FirstIndex = item.batch.FirstIndex, - BaseVertex = (int)item.batch.BaseVertex, - BaseInstance = (uint)item.instanceOffset, - }; - cmdIndex++; + if (renderPass != WbRenderPass.SinglePass) + { + if (batch.IsAdditive) + { + if (renderPass == WbRenderPass.Opaque) continue; + } + else if (!batch.IsTransparent) + { + if (renderPass == WbRenderPass.Transparent) continue; + } + } + + int groupIndex = (int)batch.CullMode + (batch.IsAdditive ? 4 : 0); + List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)> group = + _batchesByCullGroup[groupIndex]; + if (group.Count == 0) + _activeCullGroups.Add(groupIndex); + group.Add((batch, call.count, call.offset)); + } + } + + for (int activeIndex = 0; activeIndex < _activeCullGroups.Count; activeIndex++) + { + int groupIndex = _activeCullGroups[activeIndex]; + List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)> group = + _batchesByCullGroup[groupIndex]; + int firstCommand = cmdIndex; + foreach (var item in group) + { + _modernBatches[cmdIndex] = new ModernBatchData + { + TextureHandle = item.batch.BindlessTextureHandle, + TextureIndex = (uint)item.batch.TextureIndex, + }; + + _commands[cmdIndex] = new DrawElementsIndirectCommand + { + Count = (uint)item.batch.IndexCount, + InstanceCount = (uint)item.instanceCount, + FirstIndex = item.batch.FirstIndex, + BaseVertex = (int)item.batch.BaseVertex, + BaseInstance = (uint)item.instanceOffset, + }; + cmdIndex++; + } + + int commandCount = cmdIndex - firstCommand; + if (commandCount == 0) + continue; + + // Adjacent cells frequently resolve to the same cull/blend + // state. Their commands are already contiguous and remain in + // strict cell order, so one MDI call can cover the complete run + // without changing alpha compositing or gl_DrawID indexing. + AppendMdiDrawRange( + _mdiDrawRanges, + groupIndex, + firstCommand, + commandCount); } } - // WB BaseObjectRenderManager.cs:784-805: upload (with orphaning). + // WB BaseObjectRenderManager.cs:784-805 upload. Retain capacity and + // update the active prefix so portal frames cannot enqueue an unbounded + // chain of retired driver allocations. _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer); - _gl.BufferData(GLEnum.DrawIndirectBuffer, - (nuint)(totalDraws * sizeof(DrawElementsIndirectCommand)), null, GLEnum.DynamicDraw); fixed (DrawElementsIndirectCommand* ptr = _commands) { _gl.BufferSubData(GLEnum.DrawIndirectBuffer, 0, @@ -1108,8 +1451,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable } _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw); if (_gpuInstanceTransforms.Length < uniqueInstanceCount) Array.Resize(ref _gpuInstanceTransforms, Math.Max(_gpuInstanceTransforms.Length * 2, uniqueInstanceCount)); for (int i = 0; i < uniqueInstanceCount; i++) @@ -1121,8 +1462,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable } _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(totalDraws * sizeof(ModernBatchData)), null, GLEnum.DynamicDraw); fixed (ModernBatchData* ptr = _modernBatches) { _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, @@ -1152,8 +1491,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable ? (uint)slot : 0u; } _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _clipSlotBuffer); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(uniqueInstanceCount * sizeof(uint)), null, GLEnum.DynamicDraw); fixed (uint* ptr = _clipSlotData) { _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, @@ -1182,20 +1519,34 @@ public sealed unsafe class EnvCellRenderer : IDisposable int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); int glUploadCount = lightCount > 0 ? lightCount : 1; _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), - null, GLEnum.DynamicDraw); + if (glUploadCount > _globalLightsCapacity) + { + int grownGlobalLightCapacity = Math.Max(_globalLightsCapacity * 2, glUploadCount); + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.ShaderStorageBuffer, + _globalLightsSsbo, + (long)_globalLightsCapacity + * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight + * sizeof(float), + (long)grownGlobalLightCapacity + * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight + * sizeof(float), + GLEnum.DynamicDraw, + $"growing EnvCell global-light SSBO to {grownGlobalLightCapacity} lights"); + _globalLightsCapacity = grownGlobalLightCapacity; + } fixed (float* gp = _globalLightData) _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, (nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp); _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(uniqueInstanceCount * lightStride * sizeof(int)), null, GLEnum.DynamicDraw); fixed (int* lp = _lightSetData) _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, (nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp); + PersistActiveDynamicBufferCapacities(); + // WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier. // (globalVao validated at the top of the method — a return here would leak the // pass state established above.) @@ -1218,10 +1569,13 @@ public sealed unsafe class EnvCellRenderer : IDisposable _gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit); // WB BaseObjectRenderManager.cs:821-847: issue per-group multi-draw calls. - int currentDrawOffset = 0; - foreach (var group in batchesByCullMode) + // The ranges retain ordered-cell boundaries, so transparent geometry + // stays far-to-near even though all command data was uploaded once. + for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++) { - var cullMode = (CullMode)(group.Key % 4); + MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex]; + int groupIndex = drawRange.GroupIndex; + var cullMode = (CullMode)(groupIndex % 4); // Phase A8 visual-gate evidence: cell meshes use CullMode.Landblock // uniformly, but the room surfaces need to be visible from inside // under acdream's current global winding state. Render cell polys @@ -1232,7 +1586,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable SetCullMode(cullMode); } - bool isAdditive = group.Key >= 4; + bool isAdditive = groupIndex >= 4; if (isAdditive) { _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); @@ -1244,16 +1598,13 @@ public sealed unsafe class EnvCellRenderer : IDisposable shader.SetInt("uRenderPass", (int)renderPass); } - shader.SetInt("uDrawIDOffset", currentDrawOffset); - int numDraws = group.Value.Count; + shader.SetInt("uDrawIDOffset", drawRange.FirstCommand); _gl.MultiDrawElementsIndirect( PrimitiveType.Triangles, DrawElementsType.UnsignedShort, - (void*)(currentDrawOffset * sizeof(DrawElementsIndirectCommand)), - (uint)numDraws, + (void*)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)), + (uint)drawRange.CommandCount, (uint)sizeof(DrawElementsIndirectCommand)); - - currentDrawOffset += numDraws; } // Phase U.4: leave a clean opaque-default render state (mirrors WbDrawDispatcher's @@ -1267,6 +1618,33 @@ public sealed unsafe class EnvCellRenderer : IDisposable _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); } + internal static void AppendMdiDrawRange( + List ranges, + int groupIndex, + int firstCommand, + int commandCount) + { + ArgumentNullException.ThrowIfNull(ranges); + if (commandCount <= 0) + return; + + if (ranges.Count > 0) + { + MdiDrawRange previous = ranges[^1]; + if (previous.GroupIndex == groupIndex + && previous.FirstCommand + previous.CommandCount == firstCommand) + { + ranges[^1] = previous with + { + CommandCount = checked(previous.CommandCount + commandCount), + }; + return; + } + } + + ranges.Add(new MdiDrawRange(groupIndex, firstCommand, commandCount)); + } + // --------------------------------------------------------------------------- // #176 seam-draw probe (ACDREAM_PROBE_SEAMDRAW) — throwaway apparatus. // The in-engine replacement for the RenderDoc pixel-history the pipeline @@ -1411,14 +1789,41 @@ public sealed unsafe class EnvCellRenderer : IDisposable if (_fallbackClipRegionSsbo == 0) { - _gl.GenBuffers(1, out _fallbackClipRegionSsbo); - // One CellClip slot, all zeros: count 0 ⇒ shader passes every plane. - var zero = new byte[AcDream.App.Rendering.ClipFrame.CellClipStrideBytes]; - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _fallbackClipRegionSsbo); - fixed (byte* p = zero) + uint fallback = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell fallback clip SSBO"); + bool allocated = false; + try { - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)AcDream.App.Rendering.ClipFrame.CellClipStrideBytes, p, GLEnum.DynamicDraw); + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.ShaderStorageBuffer, + fallback, + 0, + AcDream.App.Rendering.ClipFrame.CellClipStrideBytes, + GLEnum.DynamicDraw, + "allocating EnvCell fallback clip SSBO"); + allocated = true; + // One CellClip slot, all zeros: count 0 ⇒ shader passes every plane. + Span zero = stackalloc byte[AcDream.App.Rendering.ClipFrame.CellClipStrideBytes]; + zero.Clear(); + fixed (byte* p = zero) + { + _gl.BufferSubData( + GLEnum.ShaderStorageBuffer, + 0, + (nuint)zero.Length, + p); + } + GLHelpers.ThrowOnResourceError(_gl, "initializing EnvCell fallback clip SSBO"); + _fallbackClipRegionSsbo = fallback; + } + catch + { + TrackedGlResource.DeleteBuffer( + _gl, + fallback, + allocated ? AcDream.App.Rendering.ClipFrame.CellClipStrideBytes : 0, + "rolling back EnvCell fallback clip SSBO"); + throw; } } _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, @@ -1465,15 +1870,123 @@ public sealed unsafe class EnvCellRenderer : IDisposable public void Dispose() { - if (IsDisposed) return; - IsDisposed = true; + if (IsDisposed || _disposing) return; + _disposing = true; + try + { + if (_disposeResources is null) + { + var releases = new List<(string Name, Action Release)> + { + ("prepare-scratch", _prepareScratch.Dispose), + }; - if (_mdiCommandBuffer != 0) { _gl.DeleteBuffer(_mdiCommandBuffer); _mdiCommandBuffer = 0; } - if (_modernInstanceBuffer != 0){ _gl.DeleteBuffer(_modernInstanceBuffer); _modernInstanceBuffer = 0; } - if (_modernBatchBuffer != 0) { _gl.DeleteBuffer(_modernBatchBuffer); _modernBatchBuffer = 0; } - if (_clipSlotBuffer != 0) { _gl.DeleteBuffer(_clipSlotBuffer); _clipSlotBuffer = 0; } // Phase U.3 - if (_fallbackClipRegionSsbo != 0) { _gl.DeleteBuffer(_fallbackClipRegionSsbo); _fallbackClipRegionSsbo = 0; } // Phase U.3 - if (_globalLightsSsbo != 0) { _gl.DeleteBuffer(_globalLightsSsbo); _globalLightsSsbo = 0; } // A7 Fix D (D-2) - if (_instLightSetSsbo != 0) { _gl.DeleteBuffer(_instLightSetSsbo); _instLightSetSsbo = 0; } // A7 Fix D (D-2) + for (int frame = 0; frame < _dynamicBufferSetsByFrame.Length; frame++) + { + List frameSets = _dynamicBufferSetsByFrame[frame]; + for (int index = 0; index < frameSets.Count; index++) + { + DynamicBufferSet set = frameSets[index]; + AddTrackedBufferRelease( + releases, + set.MdiCommandBuffer, + (long)set.MdiCommandCapacity * sizeof(DrawElementsIndirectCommand), + $"dynamic-{frame}-{index}-mdi", + "deleting EnvCell MDI buffer"); + AddTrackedBufferRelease( + releases, + set.ModernInstanceBuffer, + (long)set.ModernInstanceCapacity * sizeof(Matrix4x4), + $"dynamic-{frame}-{index}-instances", + "deleting EnvCell instance SSBO"); + AddTrackedBufferRelease( + releases, + set.ModernBatchBuffer, + (long)set.ModernBatchCapacity * sizeof(ModernBatchData), + $"dynamic-{frame}-{index}-batches", + "deleting EnvCell batch SSBO"); + AddTrackedBufferRelease( + releases, + set.ClipSlotBuffer, + (long)set.ClipSlotCapacity * sizeof(uint), + $"dynamic-{frame}-{index}-clip-slots", + "deleting EnvCell clip-slot SSBO"); + AddTrackedBufferRelease( + releases, + set.GlobalLightsSsbo, + (long)set.GlobalLightsCapacity + * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight + * sizeof(float), + $"dynamic-{frame}-{index}-global-lights", + "deleting EnvCell global-light SSBO"); + AddTrackedBufferRelease( + releases, + set.InstanceLightSetSsbo, + (long)set.InstanceLightSetCapacity + * AcDream.Core.Lighting.LightManager.MaxLightsPerObject + * sizeof(int), + $"dynamic-{frame}-{index}-light-sets", + "deleting EnvCell light-set SSBO"); + } + } + + AddTrackedBufferRelease( + releases, + _fallbackClipRegionSsbo, + AcDream.App.Rendering.ClipFrame.CellClipStrideBytes, + "fallback-clip-region", + "deleting EnvCell fallback clip SSBO"); + _disposeResources = new RetryableResourceReleaseLedger(releases); + } + + ResourceReleaseAttempt attempt = _disposeResources.Advance(); + if (!_disposeResources.IsComplete) + { + throw attempt.ToException( + "One or more EnvCell renderer resources could not be released."); + } + + foreach (List frameSets in _dynamicBufferSetsByFrame) + frameSets.Clear(); + _activeDynamicBufferSet = null; + _dynamicFrameStarted = false; + _mdiCommandBuffer = 0; + _modernInstanceBuffer = 0; + _modernBatchBuffer = 0; + _clipSlotBuffer = 0; + _globalLightsSsbo = 0; + _instLightSetSsbo = 0; + _fallbackClipRegionSsbo = 0; + _disposeResources = null; + IsDisposed = true; + + if (attempt.HasFailures) + { + throw attempt.ToException( + "EnvCell renderer resources released with exceptional committed outcomes."); + } + } + finally + { + _disposing = false; + } + } + + private void AddTrackedBufferRelease( + List<(string Name, Action Release)> releases, + uint buffer, + long capacityBytes, + string name, + string context) + { + if (buffer == 0) + return; + RetryableGpuResourceRelease release = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + buffer, + capacityBytes, + context); + releases.Add((name, release.Run)); } } diff --git a/src/AcDream.App/Rendering/Wb/GLHelpers.cs b/src/AcDream.App/Rendering/Wb/GLHelpers.cs index 564d5ce2..8e0640d6 100644 --- a/src/AcDream.App/Rendering/Wb/GLHelpers.cs +++ b/src/AcDream.App/Rendering/Wb/GLHelpers.cs @@ -15,6 +15,32 @@ namespace AcDream.App.Rendering.Wb { Device = device; } + /// + /// Always-on error boundary for resource transactions. Most render-path + /// checks remain Debug-only because glGetError is a synchronous + /// driver call; allocation/upload code must not publish CPU state after + /// OpenGL reported OOM, context loss, or a rejected transfer in Release. + /// Call exactly once before committing each transaction. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ThrowOnResourceError(GL gl, string context) { + GLEnum error = gl.GetError(); + if (error == GLEnum.NoError) + return; + + var errors = new System.Text.StringBuilder(); + do { + if (errors.Length != 0) + errors.Append(", "); + errors.Append(error).Append(" (").Append(GetErrorDetails(error)).Append(')'); + error = gl.GetError(); + } while (error != GLEnum.NoError); + + string message = $"OpenGL resource transaction failed: {errors}. Context: {context}"; + Logger?.LogError(message); + throw new InvalidOperationException(message); + } + #if DEBUG private static bool _loggedVersion = false; @@ -140,46 +166,6 @@ namespace AcDream.App.Rendering.Wb { return info.ToString(); } - /// - /// Validates texture completeness for mipmapping - /// - public static bool ValidateTextureMipmapStatus(GL gl, GLEnum target, out string errorMessage) { - try { - gl.GetTexLevelParameter(target, 0, GetTextureParameter.TextureWidth, out int width); - gl.GetTexLevelParameter(target, 0, GetTextureParameter.TextureHeight, out int height); - gl.GetTexLevelParameter(target, 0, GetTextureParameter.TextureInternalFormat, out int format); - - if (width == 0 || height == 0) { - errorMessage = "Texture has zero dimensions"; - return false; - } - - // Check if format is valid for mipmap generation - var internalFormat = (InternalFormat)format; - if (IsCompressedFormat(internalFormat)) { - errorMessage = $"Compressed format {internalFormat} does not support automatic mipmap generation"; - return false; - } - - errorMessage = String.Empty; - return true; - } - catch (Exception ex) { - errorMessage = $"Exception during validation: {ex.Message}"; - return false; - } - } - - private static bool IsCompressedFormat(InternalFormat format) { - return format == InternalFormat.CompressedRgbaS3TCDxt1Ext || - format == InternalFormat.CompressedRgbaS3TCDxt3Ext || - format == InternalFormat.CompressedRgbaS3TCDxt5Ext || - format == InternalFormat.CompressedRgbS3TCDxt1Ext || - format == InternalFormat.CompressedSrgbAlphaS3TCDxt1Ext || - format == InternalFormat.CompressedSrgbAlphaS3TCDxt3Ext || - format == InternalFormat.CompressedSrgbAlphaS3TCDxt5Ext; - } - /// /// Logs current OpenGL state for debugging /// diff --git a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs index 5d010fe5..6ebce5c0 100644 --- a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs +++ b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs @@ -1,6 +1,7 @@ using AcDream.Content; using Chorizite.Core.Render.Enums; using Silk.NET.OpenGL; +using AcDream.App.Rendering; namespace AcDream.App.Rendering.Wb; @@ -9,6 +10,64 @@ internal sealed record GlobalMeshAllocation( MeshBufferRange Indices, IReadOnlyList BatchFirstIndices); +internal readonly record struct GlobalMeshUploadPlan( + long UploadBytes, + long AllocationBytes, + long CopyBytes, + int NewBufferCount); + +internal readonly record struct GlobalMeshMaintenanceStep( + long AllocationBytes, + long CopyBytes, + int NewBufferCount, + bool Completed); + +/// +/// Retains the staged GL name and its exact release cursor while an aborted +/// arena migration is being unwound. The owner may only forget the migration +/// after this ticket has converged. +/// +internal sealed class GlobalMeshMigrationAbortTicket +{ + private readonly RetryableGpuResourceRelease _release; + + public GlobalMeshMigrationAbortTicket( + uint buffer, + long capacityBytes, + RetryableGpuResourceRelease release) + { + if (buffer == 0) + throw new ArgumentOutOfRangeException(nameof(buffer)); + ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes); + Buffer = buffer; + CapacityBytes = capacityBytes; + _release = release ?? throw new ArgumentNullException(nameof(release)); + } + + public uint Buffer { get; } + public long CapacityBytes { get; } + public bool IsComplete => _release.IsComplete; + + public void Advance() => _release.Run(); +} + +internal static class GlobalMeshVaoAccounting +{ + public static void TrackAllocation() => + GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO); + + public static void TrackDeallocation() => + GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.VAO); +} + +internal enum GlobalMeshCapacityResult +{ + Ready, + MigrationStarted, + MigrationInProgress, + NeedsReclamation, +} + /// /// Shared modern-rendering vertex/index buffers with reclaimable ranges. /// ObjectMeshManager owns allocation lifetime and releases a mesh's ranges @@ -16,37 +75,196 @@ internal sealed record GlobalMeshAllocation( /// public sealed class GlobalMeshBuffer : IDisposable { + internal const int InitialVertexCapacity = 1024 * 1024; + internal const int InitialIndexCapacity = 3 * 1024 * 1024; + internal const int VertexGrowthQuantum = 256 * 1024; + internal const int IndexGrowthQuantum = 1024 * 1024; + internal const long MaximumVertexBufferBytes = 384L * 1024 * 1024; + internal const long MaximumIndexBufferBytes = 128L * 1024 * 1024; + // Worst legal dual-buffer overlap: just-under-384 MiB old vertex store + + // 384 MiB destination + the 128 MiB active index store. No route can + // accumulate a second staged/retired generation beyond this ceiling. + internal const long MaximumPhysicalArenaBytes = 896L * 1024 * 1024; + internal static readonly int MaximumVertexCapacity = checked( + (int)(MaximumVertexBufferBytes / VertexPositionNormalTexture.Size)); + internal const int MaximumIndexCapacity = + (int)(MaximumIndexBufferBytes / sizeof(ushort)); + private readonly GL _gl; - private readonly ContiguousRangeAllocator _vertices; - private readonly ContiguousRangeAllocator _indices; + private readonly GpuRetirementLedger _retirementLedger; + private readonly GpuRetiredRangeAllocator _vertices; + private readonly GpuRetiredRangeAllocator _indices; + private BufferMigration? _migration; + private GlobalMeshMigrationAbortTicket? _migrationAbort; + private long _retiredCapacityBytes; + private bool _disposed; + private RetryableResourceReleaseLedger? _disposeResources; + + private enum BufferKind + { + Vertices, + Indices, + } + + private sealed record BufferMigration( + BufferKind Kind, + uint OldBuffer, + uint NewBuffer, + int OldCapacity, + int NewCapacity, + long OldCapacityBytes, + long NewCapacityBytes, + long CopyBytes) + { + public long CopiedBytes { get; set; } + } public uint VAO { get; private set; } public uint VBO { get; private set; } public uint IBO { get; private set; } + internal long UploadCount { get; private set; } + internal long UploadedBytes { get; private set; } + internal long CapacityBytes => + (long)_vertices.Capacity * VertexPositionNormalTexture.Size + + (long)_indices.Capacity * sizeof(ushort); + internal long PhysicalCapacityBytes => checked( + CapacityBytes + (_migration?.NewCapacityBytes ?? 0) + _retiredCapacityBytes); + internal bool IsMigrationInProgress => _migration is not null; + internal bool HasPendingReclamation => + _migration is not null + || _vertices.PendingReleaseCount != 0 + || _indices.PendingReleaseCount != 0 + || _retiredCapacityBytes != 0; + internal int VertexHighWaterMark => _vertices.HighWaterMark; + internal int IndexHighWaterMark => _indices.HighWaterMark; + + internal GlobalMeshUploadPlan PlanUpload(int vertexCount, int indexCount) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _retirementLedger.RetryPendingPublications(); + RetryPendingMigrationAbort(); + if (_migration is not null) + throw new InvalidOperationException("Upload planning is unavailable while a backing-buffer migration is in progress."); + ArgumentOutOfRangeException.ThrowIfNegative(vertexCount); + ArgumentOutOfRangeException.ThrowIfNegative(indexCount); + long allocationBytes = 0; + long copyBytes = 0; + int newBuffers = 0; + + if (vertexCount > _vertices.LargestFreeRange) + { + int newCapacity = CalculateGrowthCapacity( + _vertices.Capacity, _vertices.TrailingFreeLength, + vertexCount, VertexGrowthQuantum, MaximumVertexCapacity); + allocationBytes = checked(allocationBytes + + (long)newCapacity * VertexPositionNormalTexture.Size); + copyBytes = checked(copyBytes + + (long)_vertices.HighWaterMark * VertexPositionNormalTexture.Size); + newBuffers++; + } + if (indexCount > _indices.LargestFreeRange) + { + int newCapacity = CalculateGrowthCapacity( + _indices.Capacity, _indices.TrailingFreeLength, + indexCount, IndexGrowthQuantum, MaximumIndexCapacity); + allocationBytes = checked(allocationBytes + (long)newCapacity * sizeof(ushort)); + copyBytes = checked(copyBytes + (long)_indices.HighWaterMark * sizeof(ushort)); + newBuffers++; + } + + return new GlobalMeshUploadPlan( + checked((long)vertexCount * VertexPositionNormalTexture.Size + + (long)indexCount * sizeof(ushort)), + allocationBytes, + copyBytes, + newBuffers); + } public GlobalMeshBuffer(GL gl) + : this(gl, ImmediateGpuResourceRetirementQueue.Instance) { - _gl = gl; - _vertices = new ContiguousRangeAllocator(1024 * 1024); // ~32 MB - _indices = new ContiguousRangeAllocator(3 * 1024 * 1024); // ~6 MB + } + + internal GlobalMeshBuffer(GL gl, IGpuResourceRetirementQueue retirement) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + ArgumentNullException.ThrowIfNull(retirement); + _retirementLedger = new GpuRetirementLedger(retirement); + _vertices = new GpuRetiredRangeAllocator(InitialVertexCapacity, retirement); // ~32 MB + _indices = new GpuRetiredRangeAllocator(InitialIndexCapacity, retirement); // ~6 MB InitBuffers(); } private unsafe void InitBuffers() { - _gl.GenVertexArrays(1, out uint vao); - VAO = vao; - _gl.BindVertexArray(VAO); + uint vao = 0; + uint vbo = 0; + uint ibo = 0; + long vertexBytes = (long)_vertices.Capacity * VertexPositionNormalTexture.Size; + long indexBytes = (long)_indices.Capacity * sizeof(ushort); + bool vaoTracked = false; + bool vertexTracked = false; + bool indexTracked = false; - _gl.GenBuffers(1, out uint vbo); - VBO = vbo; - _gl.BindBuffer(GLEnum.ArrayBuffer, VBO); - _gl.BufferData( - GLEnum.ArrayBuffer, - (nuint)(_vertices.Capacity * VertexPositionNormalTexture.Size), - null, - GLEnum.StaticDraw); + try + { + _gl.GenVertexArrays(1, out vao); + _gl.GenBuffers(1, out vbo); + _gl.GenBuffers(1, out ibo); + if (vao == 0 || vbo == 0 || ibo == 0) + throw new InvalidOperationException("OpenGL did not create the global mesh-buffer objects."); + _gl.BindVertexArray(vao); + _gl.BindBuffer(GLEnum.ArrayBuffer, vbo); + _gl.BufferData(GLEnum.ArrayBuffer, ToNativeSize(vertexBytes), null, GLEnum.StaticDraw); + ConfigureVertexAttributes(); + + _gl.BindBuffer(GLEnum.ElementArrayBuffer, ibo); + _gl.BufferData(GLEnum.ElementArrayBuffer, ToNativeSize(indexBytes), null, GLEnum.StaticDraw); + GLHelpers.ThrowOnResourceError( + _gl, + $"creating global mesh buffers ({vertexBytes} vertex bytes, {indexBytes} index bytes)"); + + GlobalMeshVaoAccounting.TrackAllocation(); + vaoTracked = true; + GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer); + GpuMemoryTracker.TrackAllocation(vertexBytes, GpuResourceType.Buffer); + vertexTracked = true; + GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer); + GpuMemoryTracker.TrackAllocation(indexBytes, GpuResourceType.Buffer); + indexTracked = true; + + VAO = vao; + VBO = vbo; + IBO = ibo; + } + catch + { + if (ibo != 0) _gl.DeleteBuffer(ibo); + if (vbo != 0) _gl.DeleteBuffer(vbo); + if (vao != 0) _gl.DeleteVertexArray(vao); + if (indexTracked) + { + GpuMemoryTracker.TrackDeallocation(indexBytes, GpuResourceType.Buffer); + GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer); + } + if (vertexTracked) + { + GpuMemoryTracker.TrackDeallocation(vertexBytes, GpuResourceType.Buffer); + GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer); + } + if (vaoTracked) + GlobalMeshVaoAccounting.TrackDeallocation(); + throw; + } + finally + { + _gl.BindVertexArray(0); + } + } + + private unsafe void ConfigureVertexAttributes() + { int stride = VertexPositionNormalTexture.Size; _gl.EnableVertexAttribArray(0); _gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0); @@ -54,23 +272,17 @@ public sealed class GlobalMeshBuffer : IDisposable _gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float))); _gl.EnableVertexAttribArray(2); _gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float))); - - _gl.GenBuffers(1, out uint ibo); - IBO = ibo; - _gl.BindBuffer(GLEnum.ElementArrayBuffer, IBO); - _gl.BufferData( - GLEnum.ElementArrayBuffer, - (nuint)(_indices.Capacity * sizeof(ushort)), - null, - GLEnum.StaticDraw); - - _gl.BindVertexArray(0); } internal unsafe GlobalMeshAllocation UploadMesh( VertexPositionNormalTexture[] vertices, IReadOnlyList indexBatches) { + ObjectDisposedException.ThrowIf(_disposed, this); + _retirementLedger.RetryPendingPublications(); + RetryPendingMigrationAbort(); + if (_migration is not null) + throw new InvalidOperationException("A mesh upload cannot mutate the arena while a backing-buffer migration is in progress."); ArgumentNullException.ThrowIfNull(vertices); ArgumentNullException.ThrowIfNull(indexBatches); if (vertices.Length == 0) @@ -94,7 +306,7 @@ public sealed class GlobalMeshBuffer : IDisposable } catch { - _vertices.Release(vertexRange); + _vertices.ReleaseUnsubmitted(vertexRange); throw; } @@ -104,14 +316,19 @@ public sealed class GlobalMeshBuffer : IDisposable _gl.BindBuffer(GLEnum.ArrayBuffer, VBO); fixed (VertexPositionNormalTexture* ptr = vertices) { + long vertexOffsetBytes = checked((long)vertexRange.Offset * VertexPositionNormalTexture.Size); + long vertexUploadBytes = checked((long)vertices.Length * VertexPositionNormalTexture.Size); _gl.BufferSubData( GLEnum.ArrayBuffer, - (nint)(vertexRange.Offset * VertexPositionNormalTexture.Size), - (nuint)(vertices.Length * VertexPositionNormalTexture.Size), + ToNativeOffset(vertexOffsetBytes), + ToNativeSize(vertexUploadBytes), ptr); } - _gl.BindBuffer(GLEnum.ElementArrayBuffer, IBO); + // ElementArrayBuffer binding is VAO state. Use the neutral copy + // target for staging so uploads cannot mutate whichever VAO the + // preceding render pass happened to leave bound. + _gl.BindBuffer(GLEnum.CopyWriteBuffer, IBO); int indexOffset = indexRange.Offset; for (int i = 0; i < indexBatches.Count; i++) { @@ -121,44 +338,88 @@ public sealed class GlobalMeshBuffer : IDisposable { fixed (ushort* ptr = batch) { + long indexOffsetBytes = checked((long)indexOffset * sizeof(ushort)); + long indexUploadBytes = checked((long)batch.Length * sizeof(ushort)); _gl.BufferSubData( - GLEnum.ElementArrayBuffer, - (nint)(indexOffset * sizeof(ushort)), - (nuint)(batch.Length * sizeof(ushort)), + GLEnum.CopyWriteBuffer, + ToNativeOffset(indexOffsetBytes), + ToNativeSize(indexUploadBytes), ptr); } - indexOffset += batch.Length; + indexOffset = checked(indexOffset + batch.Length); } } + GLHelpers.ThrowOnResourceError( + _gl, + $"uploading global mesh ({vertices.Length} vertices, {totalIndices} indices)"); } catch { - _indices.Release(indexRange); - _vertices.Release(vertexRange); + _indices.ReleaseUnsubmitted(indexRange); + _vertices.ReleaseUnsubmitted(vertexRange); throw; } + UploadCount++; + UploadedBytes = checked(UploadedBytes + + checked((long)vertices.Length * VertexPositionNormalTexture.Size) + + checked((long)totalIndices * sizeof(ushort))); return new GlobalMeshAllocation(vertexRange, indexRange, firstIndices); } internal void Release(GlobalMeshAllocation allocation) { ArgumentNullException.ThrowIfNull(allocation); - _indices.Release(allocation.Indices); - _vertices.Release(allocation.Vertices); + _indices.ReleaseAfterGpuUse(allocation.Indices); + _vertices.ReleaseAfterGpuUse(allocation.Vertices); + } + + // Narrow seams for ObjectMeshManager's per-resource retirement ledger. + // The ordinary Release method remains the convenience API; a retryable + // owner uses these seams so one accepted range is never submitted twice. + internal void ReleaseIndexRange(GlobalMeshAllocation allocation) + { + ArgumentNullException.ThrowIfNull(allocation); + _indices.ReleaseAfterGpuUse(allocation.Indices); + } + + internal void ReleaseVertexRange(GlobalMeshAllocation allocation) + { + ArgumentNullException.ThrowIfNull(allocation); + _vertices.ReleaseAfterGpuUse(allocation.Vertices); + } + + /// + /// Rolls back a mesh transaction which never published render data and + /// therefore can never have been referenced by a submitted draw. + /// + internal void Abort(GlobalMeshAllocation allocation) + { + ArgumentNullException.ThrowIfNull(allocation); + _indices.ReleaseUnsubmitted(allocation.Indices); + _vertices.ReleaseUnsubmitted(allocation.Vertices); + } + + // Failed uploads were never submitted, so these matching seams return + // each range immediately while preserving independent rollback progress. + internal void AbortIndexRange(GlobalMeshAllocation allocation) + { + ArgumentNullException.ThrowIfNull(allocation); + _indices.ReleaseUnsubmitted(allocation.Indices); + } + + internal void AbortVertexRange(GlobalMeshAllocation allocation) + { + ArgumentNullException.ThrowIfNull(allocation); + _vertices.ReleaseUnsubmitted(allocation.Vertices); } private MeshBufferRange AllocateVertices(int count) { if (_vertices.TryAllocate(count, out MeshBufferRange allocation)) return allocation; - - int newCapacity = GrowCapacity(_vertices.Capacity, count); - ResizeVBO(newCapacity); - _vertices.Grow(newCapacity); - if (!_vertices.TryAllocate(count, out allocation)) - throw new InvalidOperationException("Failed to allocate a vertex range after growing the buffer."); - return allocation; + throw new InvalidOperationException( + "Vertex capacity was not migrated before the staged mesh upload was admitted."); } private MeshBufferRange AllocateIndices(int count) @@ -166,87 +427,523 @@ public sealed class GlobalMeshBuffer : IDisposable if (_indices.TryAllocate(count, out MeshBufferRange allocation)) return allocation; - int newCapacity = GrowCapacity(_indices.Capacity, count); - ResizeIBO(newCapacity); - _indices.Grow(newCapacity); - if (!_indices.TryAllocate(count, out allocation)) - throw new InvalidOperationException("Failed to allocate an index range after growing the buffer."); - return allocation; + throw new InvalidOperationException( + "Index capacity was not migrated before the staged mesh upload was admitted."); } - private static int GrowCapacity(int capacity, int requiredContiguousLength) + internal static int CalculateGrowthCapacity( + int capacity, + int trailingFreeLength, + int requiredContiguousLength, + int growthQuantum, + int maximumCapacity = int.MaxValue) { - int minimum = checked(capacity + requiredContiguousLength); - int doubled = capacity <= int.MaxValue / 2 ? capacity * 2 : int.MaxValue; - return Math.Max(doubled, minimum); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + ArgumentOutOfRangeException.ThrowIfNegative(trailingFreeLength); + ArgumentOutOfRangeException.ThrowIfGreaterThan(trailingFreeLength, capacity); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(requiredContiguousLength); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(growthQuantum); + ArgumentOutOfRangeException.ThrowIfLessThan(maximumCapacity, capacity); + + long missing = Math.Max(0L, (long)requiredContiguousLength - trailingFreeLength); + if (missing == 0) + return capacity; + + long minimum = checked((long)capacity + missing); + if (minimum > maximumCapacity) + throw new NotSupportedException( + $"A contiguous range of {requiredContiguousLength:N0} elements exceeds the supported arena capacity {maximumCapacity:N0}."); + + // A 3:2 geometric destination amortizes the immutable-prefix copy. + // Rounding only the missing tail caused every few uploads to allocate + // another buffer and recopy the full prefix (quadratic route cost). + long geometric = checked((long)capacity + Math.Max((long)growthQuantum, capacity / 2L)); + long target = Math.Min(maximumCapacity, Math.Max(minimum, geometric)); + return RoundUpToLimit(target, growthQuantum, maximumCapacity); } - private unsafe void ResizeVBO(int newCapacity) + internal static bool TryCalculateTrimCapacity( + int capacity, + int highWaterMark, + int initialCapacity, + int growthQuantum, + out int trimmedCapacity) { - _gl.GenBuffers(1, out uint newVbo); - _gl.BindBuffer(GLEnum.ArrayBuffer, newVbo); - _gl.BufferData( - GLEnum.ArrayBuffer, - (nuint)(newCapacity * VertexPositionNormalTexture.Size), - null, - GLEnum.StaticDraw); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + ArgumentOutOfRangeException.ThrowIfNegative(highWaterMark); + ArgumentOutOfRangeException.ThrowIfGreaterThan(highWaterMark, capacity); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(initialCapacity); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(growthQuantum); - _gl.BindBuffer(GLEnum.CopyReadBuffer, VBO); - _gl.BindBuffer(GLEnum.CopyWriteBuffer, newVbo); - _gl.CopyBufferSubData( - GLEnum.CopyReadBuffer, - GLEnum.CopyWriteBuffer, - 0, - 0, - (nuint)(_vertices.HighWaterMark * VertexPositionNormalTexture.Size)); + trimmedCapacity = capacity; + if (capacity <= initialCapacity) + return false; - _gl.DeleteBuffer(VBO); - VBO = newVbo; + // Capacity-based hysteresis, not a settle timer: retain 100% headroom + // above the live tail and shrink only when the result is no more than + // one third of the current arena. A destination revisit therefore has + // to more than double its live prefix before growth can resume. + long withHeadroom = checked( + highWaterMark + Math.Max((long)growthQuantum, highWaterMark)); + int target = Math.Max( + initialCapacity, + RoundUpToLimit(withHeadroom, growthQuantum, int.MaxValue)); + if (target > capacity / 3) + return false; - _gl.BindVertexArray(VAO); - _gl.BindBuffer(GLEnum.ArrayBuffer, VBO); - int stride = VertexPositionNormalTexture.Size; - _gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0); - _gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float))); - _gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float))); - _gl.BindVertexArray(0); + trimmedCapacity = target; + return true; } - private unsafe void ResizeIBO(int newCapacity) + internal GlobalMeshCapacityResult EnsureUploadCapacity( + int vertexCount, + int indexCount, + out GlobalMeshMaintenanceStep step) { - _gl.GenBuffers(1, out uint newIbo); - _gl.BindBuffer(GLEnum.ElementArrayBuffer, newIbo); - _gl.BufferData( - GLEnum.ElementArrayBuffer, - (nuint)(newCapacity * sizeof(ushort)), - null, - GLEnum.StaticDraw); + ObjectDisposedException.ThrowIf(_disposed, this); + _retirementLedger.RetryPendingPublications(); + RetryPendingMigrationAbort(); + ArgumentOutOfRangeException.ThrowIfNegative(vertexCount); + ArgumentOutOfRangeException.ThrowIfNegative(indexCount); + if (vertexCount > MaximumVertexCapacity) + throw new NotSupportedException( + $"Mesh requires {vertexCount:N0} vertices; the supported per-arena maximum is {MaximumVertexCapacity:N0}."); + if (indexCount > MaximumIndexCapacity) + throw new NotSupportedException( + $"Mesh requires {indexCount:N0} indices; the supported per-arena maximum is {MaximumIndexCapacity:N0}."); - _gl.BindBuffer(GLEnum.CopyReadBuffer, IBO); - _gl.BindBuffer(GLEnum.CopyWriteBuffer, newIbo); - _gl.CopyBufferSubData( - GLEnum.CopyReadBuffer, - GLEnum.CopyWriteBuffer, - 0, - 0, - (nuint)(_indices.HighWaterMark * sizeof(ushort))); + step = default; + if (_migration is not null) + return GlobalMeshCapacityResult.MigrationInProgress; + if (vertexCount <= _vertices.LargestFreeRange + && indexCount <= _indices.LargestFreeRange) + { + return GlobalMeshCapacityResult.Ready; + } - _gl.DeleteBuffer(IBO); - IBO = newIbo; + BufferKind kind; + int targetCapacity; + long copyBytes; + if (vertexCount > _vertices.LargestFreeRange) + { + long minimum = checked( + (long)_vertices.Capacity + + Math.Max(0L, (long)vertexCount - _vertices.TrailingFreeLength)); + if (minimum > MaximumVertexCapacity) + return GlobalMeshCapacityResult.NeedsReclamation; + kind = BufferKind.Vertices; + targetCapacity = CalculateGrowthCapacity( + _vertices.Capacity, + _vertices.TrailingFreeLength, + vertexCount, + VertexGrowthQuantum, + MaximumVertexCapacity); + copyBytes = checked((long)_vertices.HighWaterMark * VertexPositionNormalTexture.Size); + } + else + { + long minimum = checked( + (long)_indices.Capacity + + Math.Max(0L, (long)indexCount - _indices.TrailingFreeLength)); + if (minimum > MaximumIndexCapacity) + return GlobalMeshCapacityResult.NeedsReclamation; + kind = BufferKind.Indices; + targetCapacity = CalculateGrowthCapacity( + _indices.Capacity, + _indices.TrailingFreeLength, + indexCount, + IndexGrowthQuantum, + MaximumIndexCapacity); + copyBytes = checked((long)_indices.HighWaterMark * sizeof(ushort)); + } - _gl.BindVertexArray(VAO); - _gl.BindBuffer(GLEnum.ElementArrayBuffer, IBO); - _gl.BindVertexArray(0); + long newBytes = CapacityBytesFor(kind, targetCapacity); + if (newBytes > MaximumPhysicalArenaBytes - PhysicalCapacityBytes) + return GlobalMeshCapacityResult.NeedsReclamation; + + BeginMigration(kind, targetCapacity, copyBytes); + step = new GlobalMeshMaintenanceStep(newBytes, 0, 1, false); + return GlobalMeshCapacityResult.MigrationStarted; + } + + /// + /// Copies at most of the immutable + /// live prefix into the staged backing store. The active VAO continues to + /// reference the old store until the final chunk succeeds, then one atomic + /// VAO rebind publishes the destination. + /// + internal GlobalMeshMaintenanceStep AdvanceMigration(long maximumCopyBytes) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _retirementLedger.RetryPendingPublications(); + RetryPendingMigrationAbort(); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumCopyBytes); + BufferMigration? migration = _migration; + if (migration is null) + return default; + + long chunk = CalculateCopyChunk( + migration.CopyBytes, + migration.CopiedBytes, + maximumCopyBytes); + try + { + if (chunk != 0) + { + _gl.BindBuffer(GLEnum.CopyReadBuffer, migration.OldBuffer); + _gl.BindBuffer(GLEnum.CopyWriteBuffer, migration.NewBuffer); + _gl.CopyBufferSubData( + GLEnum.CopyReadBuffer, + GLEnum.CopyWriteBuffer, + ToNativeOffset(migration.CopiedBytes), + ToNativeOffset(migration.CopiedBytes), + ToNativeSize(chunk)); + GLHelpers.ThrowOnResourceError( + _gl, + $"migrating {migration.Kind} arena bytes " + + $"{migration.CopiedBytes:N0}..{migration.CopiedBytes + chunk:N0}"); + migration.CopiedBytes = checked(migration.CopiedBytes + chunk); + } + + bool complete = migration.CopiedBytes == migration.CopyBytes; + if (complete) + CommitMigration(migration); + return new GlobalMeshMaintenanceStep(0, chunk, 0, complete); + } + catch (Exception migrationError) + { + try + { + AbortMigration(migration); + } + catch (Exception abortError) + { + throw new AggregateException( + "Global mesh migration failed and its staged buffer could not yet be released.", + migrationError, + abortError); + } + throw; + } + } + + /// + /// Starts a staged shrink of one cold arena. Copy size no longer prevents + /// reclamation: services any prefix over as + /// many bounded frames as necessary. + /// + internal bool TryTrimUnusedTail(out GlobalMeshMaintenanceStep step) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _retirementLedger.RetryPendingPublications(); + RetryPendingMigrationAbort(); + step = default; + if (_migration is not null) + return false; + bool trimVertices = TryCalculateTrimCapacity( + _vertices.Capacity, _vertices.HighWaterMark, + InitialVertexCapacity, VertexGrowthQuantum, + out int vertexCapacity); + bool trimIndices = TryCalculateTrimCapacity( + _indices.Capacity, _indices.HighWaterMark, + InitialIndexCapacity, IndexGrowthQuantum, + out int indexCapacity); + + long vertexSaving = trimVertices + ? (long)(_vertices.Capacity - vertexCapacity) * VertexPositionNormalTexture.Size + : 0; + long indexSaving = trimIndices + ? (long)(_indices.Capacity - indexCapacity) * sizeof(ushort) + : 0; + if (vertexSaving == 0 && indexSaving == 0) + return false; + + BufferKind kind; + int capacity; + long copyBytes; + if (vertexSaving >= indexSaving) + { + kind = BufferKind.Vertices; + capacity = vertexCapacity; + copyBytes = checked((long)_vertices.HighWaterMark * VertexPositionNormalTexture.Size); + } + else + { + kind = BufferKind.Indices; + capacity = indexCapacity; + copyBytes = checked((long)_indices.HighWaterMark * sizeof(ushort)); + } + + long newBytes = CapacityBytesFor(kind, capacity); + if (newBytes > MaximumPhysicalArenaBytes - PhysicalCapacityBytes) + return false; + BeginMigration(kind, capacity, copyBytes); + step = new GlobalMeshMaintenanceStep(newBytes, 0, 1, false); + return true; + } + + internal static long CalculateCopyChunk(long totalBytes, long copiedBytes, long maximumCopyBytes) + { + ArgumentOutOfRangeException.ThrowIfNegative(totalBytes); + ArgumentOutOfRangeException.ThrowIfNegative(copiedBytes); + ArgumentOutOfRangeException.ThrowIfGreaterThan(copiedBytes, totalBytes); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumCopyBytes); + return Math.Min(totalBytes - copiedBytes, maximumCopyBytes); + } + + private unsafe void BeginMigration(BufferKind kind, int newCapacity, long copyBytes) + { + if (_migration is not null || _migrationAbort is not null) + throw new InvalidOperationException("Only one global mesh backing buffer may migrate at a time."); + int oldCapacity = kind == BufferKind.Vertices ? _vertices.Capacity : _indices.Capacity; + uint oldBuffer = kind == BufferKind.Vertices ? VBO : IBO; + long oldBytes = CapacityBytesFor(kind, oldCapacity); + long newBytes = CapacityBytesFor(kind, newCapacity); + uint newBuffer = 0; + + try + { + _gl.GenBuffers(1, out newBuffer); + if (newBuffer == 0) + throw new InvalidOperationException($"OpenGL did not create a staged {kind} arena buffer."); + _gl.BindBuffer(GLEnum.CopyWriteBuffer, newBuffer); + _gl.BufferData(GLEnum.CopyWriteBuffer, ToNativeSize(newBytes), null, GLEnum.StaticDraw); + GLHelpers.ThrowOnResourceError( + _gl, + $"allocating staged {kind} arena buffer ({newBytes:N0} bytes)"); + } + catch + { + if (newBuffer != 0) + _gl.DeleteBuffer(newBuffer); + throw; + } + + GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer); + GpuMemoryTracker.TrackAllocation(newBytes, GpuResourceType.Buffer); + _migration = new BufferMigration( + kind, + oldBuffer, + newBuffer, + oldCapacity, + newCapacity, + oldBytes, + newBytes, + copyBytes); + } + + private void CommitMigration(BufferMigration migration) + { + try + { + _gl.BindVertexArray(VAO); + if (migration.Kind == BufferKind.Vertices) + { + _gl.BindBuffer(GLEnum.ArrayBuffer, migration.NewBuffer); + ConfigureVertexAttributes(); + } + else + { + _gl.BindBuffer(GLEnum.ElementArrayBuffer, migration.NewBuffer); + } + GLHelpers.ThrowOnResourceError(_gl, $"publishing staged {migration.Kind} arena buffer"); + } + catch + { + _gl.BindVertexArray(VAO); + if (migration.Kind == BufferKind.Vertices) + { + _gl.BindBuffer(GLEnum.ArrayBuffer, migration.OldBuffer); + ConfigureVertexAttributes(); + } + else + { + _gl.BindBuffer(GLEnum.ElementArrayBuffer, migration.OldBuffer); + } + _gl.BindVertexArray(0); + throw; + } + finally + { + _gl.BindVertexArray(0); + } + + if (migration.Kind == BufferKind.Vertices) + { + VBO = migration.NewBuffer; + if (migration.NewCapacity > migration.OldCapacity) + _vertices.Grow(migration.NewCapacity); + else + _vertices.Shrink(migration.NewCapacity); + } + else + { + IBO = migration.NewBuffer; + if (migration.NewCapacity > migration.OldCapacity) + _indices.Grow(migration.NewCapacity); + else + _indices.Shrink(migration.NewCapacity); + } + + _migration = null; + _retiredCapacityBytes = checked(_retiredCapacityBytes + migration.OldCapacityBytes); + RetryableGpuResourceRelease oldBufferRelease = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + migration.OldBuffer, + migration.OldCapacityBytes, + $"retiring replaced global {migration.Kind} arena buffer {migration.OldBuffer}"); + _retirementLedger.Retire(new RetryableGpuResourceRelease( + oldBufferRelease.Run, + () => _retiredCapacityBytes = checked( + _retiredCapacityBytes - migration.OldCapacityBytes))); + } + + private void AbortMigration(BufferMigration migration) + { + if (!ReferenceEquals(_migration, migration)) + return; + _migrationAbort ??= new GlobalMeshMigrationAbortTicket( + migration.NewBuffer, + migration.NewCapacityBytes, + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + migration.NewBuffer, + migration.NewCapacityBytes, + $"aborting staged global {migration.Kind} arena buffer {migration.NewBuffer}")); + RetryPendingMigrationAbort(); + } + + private void RetryPendingMigrationAbort() + { + GlobalMeshMigrationAbortTicket? ticket = _migrationAbort; + if (ticket is null) + return; + + try + { + ticket.Advance(); + } + finally + { + if (ticket.IsComplete) + { + BufferMigration migration = _migration + ?? throw new InvalidOperationException( + "A staged-buffer abort ticket outlived its migration record."); + if (migration.NewBuffer != ticket.Buffer + || migration.NewCapacityBytes != ticket.CapacityBytes) + { + throw new InvalidOperationException( + "A staged-buffer abort ticket no longer matches its migration record."); + } + + _migrationAbort = null; + _migration = null; + } + } + } + + private static long CapacityBytesFor(BufferKind kind, int capacity) => kind switch + { + BufferKind.Vertices => checked((long)capacity * VertexPositionNormalTexture.Size), + BufferKind.Indices => checked((long)capacity * sizeof(ushort)), + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + + private static int RoundUpToLimit(long value, int quantum, int maximum) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + long remainder = value % quantum; + long rounded = remainder == 0 ? value : checked(value + quantum - remainder); + if (rounded > maximum) + rounded = maximum; + return checked((int)rounded); + } + + private static nint ToNativeOffset(long value) + { + ArgumentOutOfRangeException.ThrowIfNegative(value); + if (IntPtr.Size == 4 && value > int.MaxValue) + throw new NotSupportedException("The requested GPU byte offset exceeds this process's native pointer range."); + return checked((nint)value); + } + + private static nuint ToNativeSize(long value) + { + ArgumentOutOfRangeException.ThrowIfNegative(value); + if (UIntPtr.Size == 4 && value > uint.MaxValue) + throw new NotSupportedException("The requested GPU byte count exceeds this process's native pointer range."); + return checked((nuint)value); } public void Dispose() { - if (VAO != 0) - _gl.DeleteVertexArray(VAO); - if (VBO != 0) - _gl.DeleteBuffer(VBO); - if (IBO != 0) - _gl.DeleteBuffer(IBO); + if (_disposed) + return; + _retirementLedger.RetryPendingPublications(); + RetryPendingMigrationAbort(); + + if (_disposeResources is null) + { + var releases = new List<(string Name, Action Release)>(); + if (_migration is { } migration) + { + RetryableGpuResourceRelease release = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + migration.NewBuffer, + migration.NewCapacityBytes, + $"deleting staged global {migration.Kind} arena buffer {migration.NewBuffer}"); + releases.Add(("staged-migration-buffer", release.Run)); + } + + if (VAO != 0) + { + RetryableGpuResourceRelease release = + TrackedGlResource.CreateRetryableVertexArrayDeletion( + _gl, + VAO, + $"deleting global mesh vertex array {VAO}", + GlobalMeshVaoAccounting.TrackDeallocation); + releases.Add(("global-vao", release.Run)); + } + if (VBO != 0) + { + RetryableGpuResourceRelease release = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + VBO, + (long)_vertices.Capacity * VertexPositionNormalTexture.Size, + $"deleting global mesh vertex buffer {VBO}"); + releases.Add(("global-vbo", release.Run)); + } + if (IBO != 0) + { + RetryableGpuResourceRelease release = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + IBO, + (long)_indices.Capacity * sizeof(ushort), + $"deleting global mesh index buffer {IBO}"); + releases.Add(("global-ibo", release.Run)); + } + _disposeResources = new RetryableResourceReleaseLedger(releases); + } + + ResourceReleaseAttempt attempt = _disposeResources.Advance(); + if (!_disposeResources.IsComplete) + throw attempt.ToException( + "One or more global mesh-buffer resources could not be released."); + + _migration = null; + _migrationAbort = null; VAO = VBO = IBO = 0; + _disposeResources = null; + _disposed = true; + + if (attempt.HasFailures) + throw attempt.ToException( + "Global mesh-buffer resources released with exceptional committed outcomes."); } } diff --git a/src/AcDream.App/Rendering/Wb/GpuRetiredRangeAllocator.cs b/src/AcDream.App/Rendering/Wb/GpuRetiredRangeAllocator.cs new file mode 100644 index 00000000..085eb90f --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/GpuRetiredRangeAllocator.cs @@ -0,0 +1,103 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Rendering.Wb; + +/// +/// A contiguous GPU-buffer suballocator whose released ranges become +/// reusable only after the frame-retirement queue says that older draws have +/// finished. This prevents BufferSubData from overwriting a range that +/// an in-flight draw still reads. +/// +internal sealed class GpuRetiredRangeAllocator +{ + private readonly ContiguousRangeAllocator _allocator; + private readonly GpuRetirementLedger _retirementLedger; + private readonly Dictionary _pendingReleases = []; + private int _pendingReleaseCount; + private int _pendingReleaseLength; + + public GpuRetiredRangeAllocator(int capacity, IGpuResourceRetirementQueue retirement) + { + _allocator = new ContiguousRangeAllocator(capacity); + _retirementLedger = new GpuRetirementLedger( + retirement ?? throw new ArgumentNullException(nameof(retirement))); + } + + public int Capacity => _allocator.Capacity; + public int Used => _allocator.Used; + public int HighWaterMark => _allocator.HighWaterMark; + public int LargestFreeRange => _allocator.LargestFreeRange; + public int TrailingFreeLength => _allocator.TrailingFreeLength; + public int PendingReleaseCount => _pendingReleaseCount; + public int PendingReleaseLength => _pendingReleaseLength; + + public bool TryAllocate(int length, out MeshBufferRange allocation) => + _allocator.TryAllocate(length, out allocation); + + public void Grow(int newCapacity) => _allocator.Grow(newCapacity); + + public void Shrink(int newCapacity) => _allocator.Shrink(newCapacity); + + public void ReleaseAfterGpuUse(MeshBufferRange allocation) + { + if (_pendingReleases.TryGetValue(allocation, out RetryableGpuResourceRelease? pending)) + { + // The caller retries this method when queue publication failed. + // Re-publish the retained transaction instead of accounting for a + // second logical release of the same physical range. + try + { + _retirementLedger.RetryPendingPublication(pending); + } + catch when (pending.IsComplete) + { + // An immediate retirement queue may surface a wrapper failure + // after the retained transaction itself fully committed. + } + return; + } + + int nextPendingCount = checked(_pendingReleaseCount + 1); + int nextPendingLength = checked(_pendingReleaseLength + allocation.Length); + var release = new RetryableGpuResourceRelease( + () => _allocator.Release(allocation), + () => _pendingReleaseCount = checked(_pendingReleaseCount - 1), + () => _pendingReleaseLength = checked( + _pendingReleaseLength - allocation.Length), + () => + { + if (!_pendingReleases.Remove(allocation)) + { + throw new InvalidOperationException( + "GPU buffer range retirement lost its ownership record."); + } + }); + _pendingReleases.Add(allocation, release); + _pendingReleaseCount = nextPendingCount; + _pendingReleaseLength = nextPendingLength; + + try + { + _retirementLedger.Retire(release); + } + catch when (release.IsComplete) + { + // Completion is stronger than publication acknowledgement. The + // physical range and its accounting already converged. + } + } + + /// + /// Releases a range that failed before it could be submitted in a draw. + /// + public void ReleaseUnsubmitted(MeshBufferRange allocation) + { + if (_pendingReleases.ContainsKey(allocation)) + { + throw new InvalidOperationException( + "A GPU-submitted range cannot be released as unsubmitted."); + } + + _allocator.Release(allocation); + } +} diff --git a/src/AcDream.App/Rendering/Wb/GroupKey.cs b/src/AcDream.App/Rendering/Wb/GroupKey.cs index 110a3354..cda45879 100644 --- a/src/AcDream.App/Rendering/Wb/GroupKey.cs +++ b/src/AcDream.App/Rendering/Wb/GroupKey.cs @@ -12,7 +12,6 @@ namespace AcDream.App.Rendering.Wb; /// without depending on dispatcher internals. /// internal readonly record struct GroupKey( - uint Ibo, uint FirstIndex, int BaseVertex, int IndexCount, diff --git a/src/AcDream.App/Rendering/Wb/IEntityTextureLifetime.cs b/src/AcDream.App/Rendering/Wb/IEntityTextureLifetime.cs new file mode 100644 index 00000000..dc6c3745 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/IEntityTextureLifetime.cs @@ -0,0 +1,12 @@ +namespace AcDream.App.Rendering.Wb; + +/// +/// Logical-owner lifetime seam for per-entity texture composites. Retail +/// CSurface::Destroy (0x005361F0) releases its current ImgTex; +/// live-object teardown must do the same for modern bindless composites. +/// +public interface IEntityTextureLifetime +{ + /// Release every composite acquired by one local entity id. + void ReleaseOwner(uint localEntityId); +} diff --git a/src/AcDream.App/Rendering/Wb/ITextureCachePerInstance.cs b/src/AcDream.App/Rendering/Wb/ITextureCachePerInstance.cs deleted file mode 100644 index 491f11d4..00000000 --- a/src/AcDream.App/Rendering/Wb/ITextureCachePerInstance.cs +++ /dev/null @@ -1,22 +0,0 @@ -using AcDream.Core.World; - -namespace AcDream.App.Rendering.Wb; - -/// -/// Seam interface over the per-instance palette-override decode path in -/// . Extracted so -/// can be tested without a live GL context. -/// -public interface ITextureCachePerInstance -{ - /// - /// Decode (or return cached) the palette-overridden texture for - /// . Delegates to - /// in - /// production. - /// - uint GetOrUploadWithPaletteOverride( - uint surfaceId, - uint? overrideOrigTextureId, - PaletteOverride paletteOverride); -} diff --git a/src/AcDream.App/Rendering/Wb/IWbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/IWbMeshAdapter.cs index 67ed747a..3ade216b 100644 --- a/src/AcDream.App/Rendering/Wb/IWbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/IWbMeshAdapter.cs @@ -1,5 +1,25 @@ namespace AcDream.App.Rendering.Wb; +/// +/// Reports the physical outcome when a mesh-reference callback cannot provide +/// the normal strong exception guarantee. lets +/// a transactional owner reconcile its marker without guessing whether a +/// throwing backend already changed the reference count. +/// +public sealed class MeshReferenceMutationException : Exception +{ + public MeshReferenceMutationException( + string message, + bool mutationCommitted, + Exception innerException) + : base(message, innerException) + { + MutationCommitted = mutationCommitted; + } + + public bool MutationCommitted { get; } +} + /// /// Mockable interface over so adapters that /// drive ref-count lifecycle (e.g. LandblockSpawnAdapter, EntitySpawnAdapter) @@ -7,7 +27,17 @@ namespace AcDream.App.Rendering.Wb; /// public interface IWbMeshAdapter { + /// + /// Acquires one logical reference. A normal exception guarantees that no + /// reference was acquired; + /// explicitly reports the exceptional backend case where it was committed. + /// void IncrementRefCount(ulong id); + + /// + /// Releases one logical reference under the same committed-outcome contract + /// as . + /// void DecrementRefCount(ulong id); /// diff --git a/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs b/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs index bb89bc21..2e5949a4 100644 --- a/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs @@ -9,20 +9,24 @@ namespace AcDream.App.Rendering.Wb; /// entities (procedural / dat-hydrated, identified by /// ServerGuid == 0) drive ref counts. Server-spawned entities /// (per-instance tier) are skipped — those go through -/// EntitySpawnAdapter + TextureCache.GetOrUploadWithPaletteOverride +/// EntitySpawnAdapter and the owner-scoped texture path /// (see Phase N.4 spec, Architecture → Two-tier rendering split). /// /// /// On load: walks the landblock's atlas-tier entities, collects unique /// GfxObj ids from their MeshRefs, calls /// IncrementRefCount per id, and pins each specialized EnvCell geometry -/// id without starting generic GfxObj decode. Snapshots both id-sets per -/// landblock so unload can match the load 1:1. +/// id without starting generic GfxObj decode. Each reference has an explicit +/// desired/held marker so a throwing backend cannot make the logical snapshot +/// disagree with the physical reference count. /// /// /// -/// On unload: looks up both snapshots, calls DecrementRefCount per id, -/// drops the snapshots. Unknown / never-loaded landblocks no-op. +/// On unload: releases only references whose held marker is still set. A +/// before-commit failure remains retryable; an after-commit +/// advances the marker before the +/// exception is propagated. The registration is dropped only after every held +/// reference has been released. Unknown / never-loaded landblocks no-op. /// /// /// @@ -42,15 +46,21 @@ namespace AcDream.App.Rendering.Wb; /// public sealed class LandblockSpawnAdapter { - private readonly IWbMeshAdapter _adapter; + private sealed class ReferenceRegistration + { + public bool Desired; + public bool Held; + } - // Maps landblock id → unique GfxObj ids registered for that landblock. - // Written on load, read+cleared on unload. Single-threaded (streaming worker). - private readonly Dictionary> _idsByLandblock = new(); - // EnvCell shells are prepared through PrepareEnvCellGeomMeshDataAsync rather - // than generic GfxObj loading, but still require explicit lifetime pins. - // Keep their synthetic ids separate so registration uses the no-decode pin. - private readonly Dictionary> _additionalReadinessIdsByLandblock = new(); + private sealed class LandblockRegistration + { + public bool WantsLoaded; + public Dictionary Ordinary { get; } = new(); + public Dictionary Prepared { get; } = new(); + } + + private readonly IWbMeshAdapter _adapter; + private readonly Dictionary _registrations = new(); public LandblockSpawnAdapter(IWbMeshAdapter adapter) { @@ -81,33 +91,40 @@ public sealed class LandblockSpawnAdapter unique.Add((ulong)meshRef.GfxObjId); } - if (!_idsByLandblock.TryGetValue(landblock.LandblockId, out var registered)) + HashSet? preparedIds = additionalReadinessIds is null + ? null + : new HashSet(additionalReadinessIds); + + if (!_registrations.TryGetValue(landblock.LandblockId, out var registration)) { - _idsByLandblock[landblock.LandblockId] = unique; - foreach (var id in unique) _adapter.IncrementRefCount(id); + registration = new LandblockRegistration { WantsLoaded = true }; + _registrations.Add(landblock.LandblockId, registration); } - else + else if (!registration.WantsLoaded) { - foreach (var id in unique) - { - if (registered.Add(id)) - _adapter.IncrementRefCount(id); - } + // This is a new load edge that arrived while a preceding unload + // still had unfinished releases. The new snapshot replaces the old + // desired set. Any still-held overlap remains acquired; obsolete + // residual references are released by Reconcile below. + MarkAllUndesired(registration.Ordinary); + MarkAllUndesired(registration.Prepared); + registration.WantsLoaded = true; } - if (!_additionalReadinessIdsByLandblock.TryGetValue( - landblock.LandblockId, - out var additional)) - { - additional = new HashSet(); - _additionalReadinessIdsByLandblock[landblock.LandblockId] = additional; - } - if (additionalReadinessIds is not null) - { - foreach (var id in additionalReadinessIds) - if (additional.Add(id)) - _adapter.PinPreparedRenderData(id); - } + MarkDesired(registration.Ordinary, unique); + if (preparedIds is not null) + MarkDesired(registration.Prepared, preparedIds); + + List? failures = null; + ReleaseUndesired(registration.Ordinary, ref failures); + ReleaseUndesired(registration.Prepared, ref failures); + PruneReleasedUndesired(registration.Ordinary); + PruneReleasedUndesired(registration.Prepared); + AcquireDesired(registration.Ordinary, prepared: false, ref failures); + AcquireDesired(registration.Prepared, prepared: true, ref failures); + ThrowFailures( + failures, + $"Landblock 0x{landblock.LandblockId:X8} mesh-reference acquisition did not fully converge."); } /// @@ -117,17 +134,19 @@ public sealed class LandblockSpawnAdapter /// public bool IsLandblockRenderReady(uint landblockId) { - if (!_idsByLandblock.TryGetValue(landblockId, out var registered) - || !_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional)) - { + if (!_registrations.TryGetValue(landblockId, out var registration) + || !registration.WantsLoaded) return false; - } - foreach (var id in registered) - if (!_adapter.IsRenderDataReady(id)) + foreach (var pair in registration.Ordinary) + if (!pair.Value.Desired + || !pair.Value.Held + || !_adapter.IsRenderDataReady(pair.Key)) return false; - foreach (var id in additional) - if (!_adapter.IsRenderDataReady(id)) + foreach (var pair in registration.Prepared) + if (!pair.Value.Desired + || !pair.Value.Held + || !_adapter.IsRenderDataReady(pair.Key)) return false; return true; } @@ -139,11 +158,127 @@ public sealed class LandblockSpawnAdapter /// public void OnLandblockUnloaded(uint landblockId) { - if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return; - foreach (var id in unique) _adapter.DecrementRefCount(id); - if (_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional)) - foreach (var id in additional) _adapter.DecrementRefCount(id); - _idsByLandblock.Remove(landblockId); - _additionalReadinessIdsByLandblock.Remove(landblockId); + if (!_registrations.TryGetValue(landblockId, out var registration)) + return; + + registration.WantsLoaded = false; + MarkAllUndesired(registration.Ordinary); + MarkAllUndesired(registration.Prepared); + + List? failures = null; + ReleaseUndesired(registration.Ordinary, ref failures); + ReleaseUndesired(registration.Prepared, ref failures); + PruneReleasedUndesired(registration.Ordinary); + PruneReleasedUndesired(registration.Prepared); + + // Even an after-commit backend exception may report failure after the + // final physical release. Drop the registration before propagating in + // that case so a caller retry cannot decrement the same reference. + if (registration.Ordinary.Count == 0 && registration.Prepared.Count == 0) + _registrations.Remove(landblockId); + + ThrowFailures( + failures, + $"Landblock 0x{landblockId:X8} mesh-reference release did not fully converge."); + } + + private static void MarkDesired( + Dictionary registrations, + IEnumerable ids) + { + foreach (ulong id in ids) + { + if (!registrations.TryGetValue(id, out var reference)) + { + reference = new ReferenceRegistration(); + registrations.Add(id, reference); + } + + reference.Desired = true; + } + } + + private static void MarkAllUndesired( + Dictionary registrations) + { + foreach (var reference in registrations.Values) + reference.Desired = false; + } + + private void AcquireDesired( + Dictionary registrations, + bool prepared, + ref List? failures) + { + foreach (var pair in registrations) + { + ReferenceRegistration reference = pair.Value; + if (!reference.Desired || reference.Held) + continue; + + try + { + if (prepared) + _adapter.PinPreparedRenderData(pair.Key); + else + _adapter.IncrementRefCount(pair.Key); + reference.Held = true; + } + catch (Exception error) + { + if (error is MeshReferenceMutationException { MutationCommitted: true }) + reference.Held = true; + (failures ??= new List()).Add(error); + } + } + } + + private void ReleaseUndesired( + Dictionary registrations, + ref List? failures) + { + foreach (var pair in registrations) + { + ReferenceRegistration reference = pair.Value; + if (reference.Desired || !reference.Held) + continue; + + try + { + _adapter.DecrementRefCount(pair.Key); + reference.Held = false; + } + catch (Exception error) + { + if (error is MeshReferenceMutationException { MutationCommitted: true }) + reference.Held = false; + (failures ??= new List()).Add(error); + } + } + } + + private static void PruneReleasedUndesired( + Dictionary registrations) + { + List? released = null; + foreach (var pair in registrations) + { + if (!pair.Value.Desired && !pair.Value.Held) + (released ??= new List()).Add(pair.Key); + } + + if (released is null) + return; + foreach (ulong id in released) + registrations.Remove(id); + } + + private static void ThrowFailures(List? failures, string message) + { + if (failures is null) + return; + if (failures.Count == 1) + throw failures[0]; + throw new AggregateException(message, failures); } } diff --git a/src/AcDream.App/Rendering/Wb/ManagedGLTexture.cs b/src/AcDream.App/Rendering/Wb/ManagedGLTexture.cs index 827dc8a3..31e252e4 100644 --- a/src/AcDream.App/Rendering/Wb/ManagedGLTexture.cs +++ b/src/AcDream.App/Rendering/Wb/ManagedGLTexture.cs @@ -19,9 +19,6 @@ namespace AcDream.App.Rendering.Wb { public int Height { get; private set; } public TextureFormat Format => TextureFormat.RGBA8; - public ulong BindlessHandle { get; private set; } - public ulong BindlessWrapHandle { get; private set; } - public ulong BindlessClampHandle { get; private set; } /// public ManagedGLTexture(OpenGLGraphicsDevice device, byte[]? source, int width, int height, TextureParameters? texParams = null) { @@ -54,12 +51,10 @@ namespace AcDream.App.Rendering.Wb { if (p.EnableAnisotropicFiltering && _device.RenderSettings.EnableAnisotropicFiltering) { - float maxAnisotropy = 0f; - GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out maxAnisotropy); - - if (maxAnisotropy > 0) + if (_device.MaxSupportedAnisotropy > 0) { - GL.TexParameter(GLEnum.Texture2D, GLEnum.TextureMaxAnisotropy, maxAnisotropy); + GL.TexParameter(GLEnum.Texture2D, GLEnum.TextureMaxAnisotropy, + _device.MaxSupportedAnisotropy); } } @@ -72,15 +67,6 @@ namespace AcDream.App.Rendering.Wb { GpuMemoryTracker.TrackAllocation(CalculateSize(), GpuResourceType.Texture); - if (_device.HasBindless && _device.BindlessExtension != null) { - BindlessHandle = _device.BindlessExtension.GetTextureHandle(_texture); - BindlessWrapHandle = _device.BindlessExtension.GetTextureSamplerHandle(_texture, _device.WrapSampler); - BindlessClampHandle = _device.BindlessExtension.GetTextureSamplerHandle(_texture, _device.ClampSampler); - - _device.BindlessExtension.MakeTextureHandleResident(BindlessHandle); - _device.BindlessExtension.MakeTextureHandleResident(BindlessWrapHandle); - _device.BindlessExtension.MakeTextureHandleResident(BindlessClampHandle); - } } private long CalculateSize() { @@ -112,12 +98,6 @@ namespace AcDream.App.Rendering.Wb { GL.GetInteger(GLEnum.TextureBinding2D, out int oldBinding); GL.BindTexture(GLEnum.Texture2D, _texture); - bool wasResident = false; - if (BindlessHandle != 0 && _device.BindlessExtension != null && _device.BindlessExtension.IsTextureHandleResident(BindlessHandle)) { - _device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle); - wasResident = true; - } - fixed (byte* ptr = data) { GL.TexSubImage2D( GLEnum.Texture2D, @@ -135,10 +115,6 @@ namespace AcDream.App.Rendering.Wb { // Generate mipmaps if needed GL.GenerateMipmap(GLEnum.Texture2D); - if (wasResident && BindlessHandle != 0 && _device.BindlessExtension != null) { - _device.BindlessExtension.MakeTextureHandleResident(BindlessHandle); - } - GL.BindTexture(GLEnum.Texture2D, (uint)oldBinding); GL.ActiveTexture((GLEnum)oldActiveTexture); GLHelpers.CheckErrors(GL); @@ -172,20 +148,6 @@ namespace AcDream.App.Rendering.Wb { protected void ReleaseTexture() { _device.QueueGLAction(GL => { - if (_device.BindlessExtension != null) { - if (BindlessHandle != 0) { - _device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle); - BindlessHandle = 0; - } - if (BindlessWrapHandle != 0) { - _device.BindlessExtension.MakeTextureHandleNonResident(BindlessWrapHandle); - BindlessWrapHandle = 0; - } - if (BindlessClampHandle != 0) { - _device.BindlessExtension.MakeTextureHandleNonResident(BindlessClampHandle); - BindlessClampHandle = 0; - } - } if (_texture != 0) { GL.DeleteTexture(_texture); GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture); diff --git a/src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs b/src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs index 0ca01a58..f745959e 100644 --- a/src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs +++ b/src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs @@ -6,6 +6,7 @@ using TextureHelpers = AcDream.Core.Rendering.Wb.TextureHelpers; using Microsoft.Extensions.Logging; using Silk.NET.OpenGL; using System.Runtime.InteropServices; +using AcDream.App.Rendering; namespace AcDream.App.Rendering.Wb { public class ManagedGLTextureArray : ITextureArray { @@ -18,14 +19,15 @@ namespace AcDream.App.Rendering.Wb { private readonly bool _isCompressed; private int _mipmapDirtyCount = 0; private readonly object _mipmapLock = new object(); - private uint _pboId; - private int _pboSize; private readonly List _pendingUpdates = new(); + private int _disposeQueued; + private int _disposePublicationQueued; + private int _disposeRetirementAccepted; + private RetryableGpuResourceRelease? _disposeRelease; private struct TextureLayerUpdate { public int Layer; - public int Offset; - public int Size; + public required byte[] Data; public PixelFormat? UploadPixelFormat; public PixelType? UploadPixelType; } @@ -36,13 +38,12 @@ namespace AcDream.App.Rendering.Wb { public int Size { get; private set; } public TextureFormat Format { get; private set; } public nint NativePtr { get; private set; } - public ulong BindlessHandle { get; private set; } public ulong BindlessWrapHandle { get; private set; } public ulong BindlessClampHandle { get; private set; } public long TotalSizeInBytes => CalculateTotalSize(); /// - /// #105 diagnostic: staged layer updates (PBO writes + pending list) not yet + /// #105 diagnostic: staged layer updates (retained decoded payloads) not yet /// applied to the GL texture by . Layers with /// a pending update sample UNDEFINED content (TexStorage3D contents) until the /// flush runs — a stuck non-zero count at standstill is the white-walls mechanism. @@ -69,67 +70,121 @@ namespace AcDream.App.Rendering.Wb { _isCompressed = IsCompressedFormat(format); GLHelpers.CheckErrors(GL); - NativePtr = (nint)GL.GenTexture(); - if (NativePtr == 0) { - throw new InvalidOperationException("Failed to generate texture array."); - } - GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Texture); + uint textureName = 0; + ulong wrapHandle = 0; + ulong clampHandle = 0; + bool textureTracked = false; + bool textureBytesTracked = false; + bool wrapResident = false; + bool clampResident = false; + long textureBytes = CalculateTotalSize(); - GLHelpers.CheckErrors(GL); + try { + textureName = GL.GenTexture(); + if (textureName == 0) + throw new InvalidOperationException("Failed to generate texture array."); + GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Texture); + textureTracked = true; - GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr); - GLHelpers.CheckErrors(GL); + GL.BindTexture(GLEnum.Texture2DArray, textureName); - int maxDimension = Math.Max(width, height); - int mipLevels = (int)Math.Floor(Math.Log2(maxDimension)) + 1; + int maxDimension = Math.Max(width, height); + int mipLevels = (int)Math.Floor(Math.Log2(maxDimension)) + 1; - GL.TexStorage3D(GLEnum.Texture2DArray, (uint)mipLevels, format.ToGL(), (uint)width, (uint)height, - (uint)size); - GLHelpers.CheckErrorsWithContext(GL, - $"Creating texture array storage (Format={format}, Size={width}x{height}x{size}, MipLevels={mipLevels})"); + GL.TexStorage3D(GLEnum.Texture2DArray, (uint)mipLevels, format.ToGL(), (uint)width, (uint)height, + (uint)size); + GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMinFilter, + (int)p.MinFilter); + GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMaxLevel, mipLevels - 1); + GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMagFilter, (int)p.MagFilter); + GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapS, (int)p.WrapS); + GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapT, (int)p.WrapT); - GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMinFilter, - (int)p.MinFilter); - GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMaxLevel, (int)mipLevels - 1); - - GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMagFilter, (int)p.MagFilter); - - GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapS, (int)p.WrapS); - GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapT, (int)p.WrapT); - - if (p.EnableAnisotropicFiltering && graphicsDevice.RenderSettings.EnableAnisotropicFiltering) { - float maxAnisotropy = 0f; - GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out maxAnisotropy); - - if (maxAnisotropy > 0) { - GL.TexParameter(GLEnum.Texture2DArray, GLEnum.TextureMaxAnisotropy, maxAnisotropy); + if (p.EnableAnisotropicFiltering + && graphicsDevice.RenderSettings.EnableAnisotropicFiltering + && graphicsDevice.MaxSupportedAnisotropy > 0) { + GL.TexParameter( + GLEnum.Texture2DArray, + GLEnum.TextureMaxAnisotropy, + graphicsDevice.MaxSupportedAnisotropy); } + + if (format == TextureFormat.A8) { + GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleR, (int)GLEnum.One); + GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleG, (int)GLEnum.One); + GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleB, (int)GLEnum.One); + GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleA, (int)GLEnum.Red); + } + + GLHelpers.ThrowOnResourceError( + GL, + $"creating texture array {format} {width}x{height}x{size} ({mipLevels} mip levels)"); + GpuMemoryTracker.TrackAllocation(textureBytes, GpuResourceType.Texture); + textureBytesTracked = true; + + if (_device.HasBindless && _device.BindlessExtension != null) { + wrapHandle = _device.BindlessExtension.GetTextureSamplerHandle(textureName, _device.WrapSampler); + clampHandle = _device.BindlessExtension.GetTextureSamplerHandle(textureName, _device.ClampSampler); + _device.BindlessExtension.MakeTextureHandleResident(wrapHandle); + wrapResident = true; + _device.BindlessExtension.MakeTextureHandleResident(clampHandle); + clampResident = true; + GLHelpers.ThrowOnResourceError(GL, "making texture-array sampler handles resident"); + } + + NativePtr = (nint)textureName; + BindlessWrapHandle = wrapHandle; + BindlessClampHandle = clampHandle; } - - // Set texture swizzle for single-channel formats - if (format == TextureFormat.A8) { - GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleR, (int)GLEnum.One); - GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleG, (int)GLEnum.One); - GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleB, (int)GLEnum.One); - GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleA, (int)GLEnum.Red); + catch (Exception constructionFailure) { + // Constructor failure cannot use Dispose: the object was never + // published and queued teardown would make retries accumulate + // invalid resident handles. Attempt every independent cleanup. + List? cleanupFailures = null; + void Attempt(Action cleanup) { + try { cleanup(); } + catch (Exception ex) { (cleanupFailures ??= []).Add(ex); } + } + if (_device.BindlessExtension != null) { + if (clampResident) + Attempt(() => { + _device.BindlessExtension.MakeTextureHandleNonResident(clampHandle); + GLHelpers.ThrowOnResourceError(GL, "rolling back clamp texture-array handle"); + clampResident = false; + }); + if (wrapResident) + Attempt(() => { + _device.BindlessExtension.MakeTextureHandleNonResident(wrapHandle); + GLHelpers.ThrowOnResourceError(GL, "rolling back wrap texture-array handle"); + wrapResident = false; + }); + } + // Deleting a texture while either bindless sampler handle is + // still resident is undefined. A pre-commit residency failure + // therefore retains the texture instead of risking a driver + // reset during constructor rollback. + if (textureName != 0 && !clampResident && !wrapResident) + Attempt(() => { + GL.DeleteTexture(textureName); + GLHelpers.ThrowOnResourceError(GL, "rolling back texture array"); + if (textureBytesTracked) + GpuMemoryTracker.TrackDeallocation(textureBytes, GpuResourceType.Texture); + if (textureTracked) + GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture); + }); + if (cleanupFailures is not null) { + cleanupFailures.Insert(0, constructionFailure); + throw new AggregateException( + "Texture-array construction and rollback both failed.", + cleanupFailures); + } + throw; } - - GLHelpers.CheckErrors(GL); - - GpuMemoryTracker.TrackAllocation(CalculateTotalSize(), GpuResourceType.Texture); - - if (_device.HasBindless && _device.BindlessExtension != null) { - BindlessHandle = _device.BindlessExtension.GetTextureHandle((uint)NativePtr); - BindlessWrapHandle = _device.BindlessExtension.GetTextureSamplerHandle((uint)NativePtr, _device.WrapSampler); - BindlessClampHandle = _device.BindlessExtension.GetTextureSamplerHandle((uint)NativePtr, _device.ClampSampler); - - _device.BindlessExtension.MakeTextureHandleResident(BindlessHandle); - _device.BindlessExtension.MakeTextureHandleResident(BindlessWrapHandle); - _device.BindlessExtension.MakeTextureHandleResident(BindlessClampHandle); + finally { + GL.ActiveTexture(TextureUnit.Texture0); + GL.BindTexture(GLEnum.Texture2DArray, 0); + RenderStateCache.CurrentAtlas = 0; } - - _pboId = GL.GenBuffer(); - GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer); } public long CalculateTotalSize() { @@ -151,7 +206,7 @@ namespace AcDream.App.Rendering.Wb { return totalSize; } - private bool IsCompressedFormat(TextureFormat format) { + private static bool IsCompressedFormat(TextureFormat format) { return format == TextureFormat.DXT1 || format == TextureFormat.DXT3 || format == TextureFormat.DXT5; @@ -220,127 +275,156 @@ namespace AcDream.App.Rendering.Wb { $"Layer index {layer} is out of range [0, {Size - 1}] (Slot={Slot})."); } - int currentPboOffset = 0; + ValidateUploadPayload( + Format, + Width, + Height, + data.Length, + uploadPixelFormat, + uploadPixelType); + lock (_mipmapLock) { - if (_pendingUpdates.Count > 0) { - var lastUpdate = _pendingUpdates[^1]; - currentPboOffset = lastUpdate.Offset + lastUpdate.Size; - } - - // Align to 4 bytes for safety - currentPboOffset = (currentPboOffset + 3) & ~3; - - if (currentPboOffset + data.Length > _pboSize) { - // Flush existing updates first because BufferData will orphan/clear the PBO - if (_pendingUpdates.Count > 0) { - ProcessDirtyUpdatesInternal(); - } - currentPboOffset = 0; - - int newSize = Math.Max(_pboSize * 2, data.Length); - newSize = Math.Max(newSize, GetExpectedDataSize() * 4); // Initial size 4 layers - - GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId); - GL.BufferData(GLEnum.PixelUnpackBuffer, (nuint)newSize, (void*)0, GLEnum.StreamDraw); - - if (_pboSize > 0) { - GpuMemoryTracker.TrackDeallocation(_pboSize, GpuResourceType.Buffer); - } - _pboSize = newSize; - GpuMemoryTracker.TrackAllocation(_pboSize, GpuResourceType.Buffer); - } - else { - GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId); - } - - fixed (byte* ptr = data) { - GL.BufferSubData(GLEnum.PixelUnpackBuffer, (nint)currentPboOffset, (nuint)data.Length, ptr); - } - GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0); - - _pendingUpdates.Add(new TextureLayerUpdate { + // Retain the immutable decoded payload until the once-per-frame + // atlas flush. The former per-atlas PBO permanently reserved + // several MiB for every array and duplicated each upload + // through BufferSubData before TexSubImage3D. + var update = new TextureLayerUpdate { Layer = layer, - Offset = currentPboOffset, - Size = data.Length, + Data = data, UploadPixelFormat = uploadPixelFormat, UploadPixelType = uploadPixelType - }); + }; + int existingIndex = _pendingUpdates.FindLastIndex(pending => pending.Layer == layer); + if (existingIndex >= 0) + _pendingUpdates[existingIndex] = update; + else + _pendingUpdates.Add(update); _needsMipmapRegeneration = true; - _mipmapDirtyCount++; + if (existingIndex < 0) + _mipmapDirtyCount++; } } - public void ProcessDirtyUpdates() { + public long ProcessDirtyUpdates() { lock (_mipmapLock) { - ProcessDirtyUpdatesInternal(); + return ProcessDirtyUpdatesInternal(generateMipmaps: true); } } - private unsafe void ProcessDirtyUpdatesInternal() { - if (_pendingUpdates.Count == 0 && !_needsMipmapRegeneration) return; + private unsafe long ProcessDirtyUpdatesInternal(bool generateMipmaps) { + if (_pendingUpdates.Count == 0 + && (!generateMipmaps || !_needsMipmapRegeneration)) return 0; + + long generatedBytes = 0; GLHelpers.CheckErrors(GL); - GL.GetInteger(GLEnum.ActiveTexture, out int oldActiveTexture); + // This runs in WbMeshAdapter.Tick before any draw pass. Establish + // the upload phase's canonical texture state directly instead of + // synchronously querying driver state for every dirty array. + GL.ActiveTexture(TextureUnit.Texture0); RenderStateCache.CurrentAtlas = 0; - GL.GetInteger(GLEnum.TextureBinding2DArray, out int oldBinding); - GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr); + bool mipmapWorkCompleted = false; + try { + GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr); - bool wasResident = false; - if (BindlessHandle != 0 && _device.BindlessExtension != null && _device.BindlessExtension.IsTextureHandleResident(BindlessHandle)) { - _device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle); - wasResident = true; - } + if (_pendingUpdates.Count > 0) { + // A non-zero pixel-unpack binding changes pointer arguments + // into byte offsets. Direct client-memory uploads therefore + // establish the canonical zero binding once for the batch. + GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0); + GL.PixelStore(PixelStoreParameter.UnpackAlignment, 1); + GL.PixelStore(PixelStoreParameter.UnpackRowLength, 0); + GL.PixelStore(PixelStoreParameter.UnpackSkipRows, 0); + GL.PixelStore(PixelStoreParameter.UnpackSkipPixels, 0); - if (_pendingUpdates.Count > 0) { - GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId); + foreach (var update in _pendingUpdates) { + fixed (byte* data = update.Data) { + if (_isCompressed) { + var internalFormat = Format.ToCompressedGL(); + GL.CompressedTexSubImage3D( + GLEnum.Texture2DArray, + 0, + 0, + 0, + update.Layer, + (uint)Width, + (uint)Height, + 1, + internalFormat, + (uint)update.Data.Length, + data); + } + else { + var pixelFormat = update.UploadPixelFormat ?? Format.ToPixelFormat(); + var pixelType = update.UploadPixelType ?? Format.ToPixelType(); + GL.TexSubImage3D( + GLEnum.Texture2DArray, + 0, + 0, + 0, + update.Layer, + (uint)Width, + (uint)Height, + 1, + pixelFormat, + pixelType, + data); + } + } + } + } - foreach (var update in _pendingUpdates) { + if (generateMipmaps && _needsMipmapRegeneration && _mipmapDirtyCount > 0) { if (_isCompressed) { - var internalFormat = Format.ToCompressedGL(); - GL.CompressedTexSubImage3D(GLEnum.Texture2DArray, 0, 0, 0, update.Layer, - (uint)Width, (uint)Height, 1, internalFormat, (uint)update.Size, (void*)update.Offset); + _logger.LogDebug("Skipping automatic mipmap generation for compressed texture array (Slot={Slot})", Slot); } else { - var pixelFormat = update.UploadPixelFormat ?? Format.ToPixelFormat(); - var pixelType = update.UploadPixelType ?? Format.ToPixelType(); - GL.TexSubImage3D(GLEnum.Texture2DArray, 0, 0, 0, update.Layer, (uint)Width, (uint)Height, 1, - pixelFormat, pixelType, (void*)update.Offset); + try { + // Width, height and format were validated when the + // immutable storage was allocated. Re-reading them + // here forced three CPU/GPU synchronization points + // for every dirty atlas without adding safety. + GL.GenerateMipmap(GLEnum.Texture2DArray); + generatedBytes = TotalSizeInBytes; + } + catch (Exception ex) { + _logger.LogWarning(ex, "Failed to generate mipmaps for texture array (Slot={Slot}); retaining upload state for retry.", Slot); + throw; + } } } + // Release builds must observe transfer/OOM/context errors + // before the pending offsets and dirty mip state are cleared. + // One check covers every layer in this array plus its single + // mip generation, keeping the synchronization cost bounded by + // dirty arrays rather than uploaded textures. + GLHelpers.ThrowOnResourceError( + GL, + $"committing texture-array updates (Slot={Slot}, Layers={_pendingUpdates.Count})"); + mipmapWorkCompleted = generateMipmaps + && _needsMipmapRegeneration + && _mipmapDirtyCount > 0; + } + finally { GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0); - _pendingUpdates.Clear(); + GL.BindTexture(GLEnum.Texture2DArray, 0); + GL.ActiveTexture(TextureUnit.Texture0); } - if (_needsMipmapRegeneration && _mipmapDirtyCount > 0) { - if (_isCompressed) { - _logger.LogDebug("Skipping automatic mipmap generation for compressed texture array (Slot={Slot})", Slot); - } - else if (!GLHelpers.ValidateTextureMipmapStatus(GL, GLEnum.Texture2DArray, out var errorMessage)) { - _logger.LogWarning("Mipmap validation failed for texture array (Slot={Slot}): {Error}", Slot, errorMessage); - } - else { - try { - GL.GenerateMipmap(GLEnum.Texture2DArray); - } - catch (Exception ex) { - _logger.LogWarning(ex, "Failed to generate mipmaps for texture array (Slot={Slot}).", Slot); - } - } + // Commit CPU-side completion only after glGetError confirms the + // uploads/mipmap work succeeded. If the driver rejects an + // operation, the retained payloads and dirty flags remain intact and the + // atlas stays in ObjectMeshManager's dirty set for a later retry. + _pendingUpdates.Clear(); + if (mipmapWorkCompleted) { _mipmapDirtyCount = 0; _needsMipmapRegeneration = false; } - - if (wasResident && BindlessHandle != 0 && _device.BindlessExtension != null) { - _device.BindlessExtension.MakeTextureHandleResident(BindlessHandle); - } - - GL.BindTexture(GLEnum.Texture2DArray, (uint)oldBinding); - GL.ActiveTexture((GLEnum)oldActiveTexture); - GLHelpers.CheckErrors(GL); + return generatedBytes; } private void ClearLayerForMipmap(int layer) { @@ -351,17 +435,51 @@ namespace AcDream.App.Rendering.Wb { } private int GetExpectedDataSize() { - if (_isCompressed) { - return TextureHelpers.GetCompressedLayerSize(Width, Height, Format); + return CalculateExpectedDataSize(Format, Width, Height); + } + + internal static int CalculateExpectedDataSize(TextureFormat format, int width, int height) { + if (IsCompressedFormat(format)) + return TextureHelpers.GetCompressedLayerSize(width, height, format); + + return format switch { + TextureFormat.RGBA8 => checked(width * height * 4), + TextureFormat.RGB8 => checked(width * height * 3), + TextureFormat.A8 => checked(width * height), + TextureFormat.Rgba32f => checked(width * height * 16), + _ => throw new NotSupportedException($"Unsupported format {format}") + }; + } + + internal static void ValidateUploadPayload( + TextureFormat format, + int width, + int height, + int dataLength, + PixelFormat? uploadPixelFormat, + PixelType? uploadPixelType) { + int expectedBytes = CalculateExpectedDataSize(format, width, height); + if (dataLength != expectedBytes) { + throw new ArgumentException( + $"Texture-array layer payload has {dataLength} bytes; expected exactly {expectedBytes} " + + $"for {format} {width}x{height}.", + nameof(dataLength)); } - return Format switch { - TextureFormat.RGBA8 => Width * Height * 4, - TextureFormat.RGB8 => Width * Height * 3, - TextureFormat.A8 => Width * Height * 1, - TextureFormat.Rgba32f => Width * Height * 16, - _ => throw new NotSupportedException($"Unsupported format {Format}") - }; + if (IsCompressedFormat(format)) { + if (uploadPixelFormat.HasValue || uploadPixelType.HasValue) + throw new ArgumentException("Compressed texture uploads cannot specify pixel format/type overrides."); + return; + } + + PixelFormat expectedFormat = format.ToPixelFormat(); + PixelType expectedType = format.ToPixelType(); + if ((uploadPixelFormat ?? expectedFormat) != expectedFormat + || (uploadPixelType ?? expectedType) != expectedType) { + throw new ArgumentException( + $"Upload descriptor {uploadPixelFormat}/{uploadPixelType} does not match " + + $"the {expectedFormat}/{expectedType} transfer required by {format}."); + } } public void RemoveLayer(int layer) { @@ -376,15 +494,8 @@ namespace AcDream.App.Rendering.Wb { _usedLayers[layer] = false; - // Make layer defined for mipmap completeness (uncompressed only) - if (!_isCompressed) { - ClearLayerForMipmap(layer); - } - - lock (_mipmapLock) { - _mipmapDirtyCount++; // Mark dirty to regen - _needsMipmapRegeneration = true; - } + // An unreferenced layer needs no clear or whole-array mip + // regeneration before AddTexture overwrites it on reuse. } public bool IsLayerUsed(int layer) { @@ -396,6 +507,22 @@ namespace AcDream.App.Rendering.Wb { return _usedLayers.Count(x => x); } + /// + /// True once disposal is durably owned by a queued GL publication, + /// the frame-retirement queue, or a completed retained release. A + /// caller may only commit its own logical disposal after this becomes + /// true; otherwise a synchronous enqueue failure still needs retry. + /// + internal bool HasDurableDisposeOwnership { + get { + if (Volatile.Read(ref _disposeQueued) == 0) + return false; + return Volatile.Read(ref _disposePublicationQueued) != 0 + || Volatile.Read(ref _disposeRetirementAccepted) != 0 + || Volatile.Read(ref _disposeRelease) is null; + } + } + public void Unbind() { GL.BindTexture(GLEnum.Texture2DArray, 0); GLHelpers.CheckErrors(GL); @@ -409,37 +536,95 @@ namespace AcDream.App.Rendering.Wb { } public void Dispose() { - _device.QueueGLAction(GL => { - if (_device.BindlessExtension != null) { - if (BindlessHandle != 0) { - _device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle); - BindlessHandle = 0; + if (Interlocked.CompareExchange(ref _disposeQueued, 1, 0) != 0) { + ScheduleDisposeRelease(); + return; + } + + uint textureName = (uint)NativePtr; + ulong bindlessWrapHandle = BindlessWrapHandle; + ulong bindlessClampHandle = BindlessClampHandle; + long textureBytes = CalculateTotalSize(); + + NativePtr = 0; + BindlessWrapHandle = 0; + BindlessClampHandle = 0; + + _disposeRelease = new RetryableGpuResourceRelease( + () => { + if (_device.BindlessExtension != null && bindlessWrapHandle != 0) + GLHelpers.ThrowOnResourceError(GL, "releasing wrap texture-array handle (precondition)"); + }, + () => { + if (_device.BindlessExtension != null && bindlessWrapHandle != 0) { + _device.BindlessExtension.MakeTextureHandleNonResident(bindlessWrapHandle); + GLHelpers.ThrowOnResourceError(GL, "releasing wrap texture-array handle"); } - if (BindlessWrapHandle != 0) { - _device.BindlessExtension.MakeTextureHandleNonResident(BindlessWrapHandle); - BindlessWrapHandle = 0; + }, + () => { + if (_device.BindlessExtension != null && bindlessClampHandle != 0) + GLHelpers.ThrowOnResourceError(GL, "releasing clamp texture-array handle (precondition)"); + }, + () => { + if (_device.BindlessExtension != null && bindlessClampHandle != 0) { + _device.BindlessExtension.MakeTextureHandleNonResident(bindlessClampHandle); + GLHelpers.ThrowOnResourceError(GL, "releasing clamp texture-array handle"); } - if (BindlessClampHandle != 0) { - _device.BindlessExtension.MakeTextureHandleNonResident(BindlessClampHandle); - BindlessClampHandle = 0; + }, + () => { + if (textureName != 0) + GLHelpers.ThrowOnResourceError(GL, $"deleting texture array {textureName} (precondition)"); + }, + () => { + if (textureName != 0) { + GL.DeleteTexture(textureName); + GLHelpers.ThrowOnResourceError(GL, $"deleting texture array {textureName}"); } - } - if (NativePtr != 0) { - GL.DeleteTexture((uint)NativePtr); - GLHelpers.CheckErrors(GL); - GpuMemoryTracker.TrackDeallocation(CalculateTotalSize(), GpuResourceType.Texture); - GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture); - NativePtr = 0; - } - if (_pboId != 0) { - GL.DeleteBuffer(_pboId); - GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer); - if (_pboSize > 0) { - GpuMemoryTracker.TrackDeallocation(_pboSize, GpuResourceType.Buffer); + }, + () => { + if (textureName != 0) + GpuMemoryTracker.TrackDeallocation(textureBytes, GpuResourceType.Texture); + }, + () => { + if (textureName != 0) + GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture); + }, + () => _disposeRelease = null); + + ScheduleDisposeRelease(); + } + + private void ScheduleDisposeRelease(bool forNextPass = false) { + RetryableGpuResourceRelease? release = _disposeRelease; + if (release is null || release.IsComplete || Volatile.Read(ref _disposeRetirementAccepted) != 0) + return; + if (Interlocked.CompareExchange(ref _disposePublicationQueued, 1, 0) != 0) + return; + + try { + Action publish = GL => { + Volatile.Write(ref _disposePublicationQueued, 0); + try { + _device.RetireGpuResource(release.Run); + Volatile.Write(ref _disposeRetirementAccepted, 1); } - _pboId = 0; - } - }); + catch { + // Retire may fail before accepting the callback, or an + // immediate queue may surface a partial release. The + // release cursor makes this next-pass retry exact. + ScheduleDisposeRelease(forNextPass: true); + throw; + } + }; + if (forNextPass) + _device.QueueGLActionForNextPass(publish); + else + _device.QueueGLAction(publish); + } + catch { + Volatile.Write(ref _disposePublicationQueued, 0); + throw; + } } } } diff --git a/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs b/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs index a91b9bfc..2bb880d7 100644 --- a/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs +++ b/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs @@ -3,6 +3,23 @@ using AcDream.Content; namespace AcDream.App.Rendering.Wb; +internal enum MeshStageResult +{ + Staged, + AlreadyStaged, + HighWater, +} + +/// +/// Immutable identity for one staging claim. Object ids can be released and +/// reacquired while a render-thread upload is in flight; the generation keeps +/// a stale completion or retry from consuming the replacement claim. +/// +internal readonly record struct MeshUploadQueueItem( + ObjectMeshData Data, + long SourceBytes, + ulong Generation); + /// /// Deduplicated CPU-to-GPU staging queue. One object id may be queued or /// in-flight at a time; a retry retains ownership until upload succeeds or @@ -10,24 +27,209 @@ namespace AcDream.App.Rendering.Wb; /// internal sealed class MeshUploadStagingQueue { - private readonly ConcurrentQueue _queue = new(); - private readonly ConcurrentDictionary _ownedIds = new(); + internal const int DefaultMaximumCount = 256; + internal const long DefaultMaximumBytes = 128L * 1024 * 1024; + internal const long DefaultMaximumSingleEntryBytes = 128L * 1024 * 1024; - public bool Stage(ObjectMeshData data) + private readonly object _gate = new(); + private readonly Queue _queue = new(); + private readonly Dictionary _generationById = new(); + private readonly int _maximumCount; + private readonly long _maximumBytes; + private readonly long _maximumSingleEntryBytes; + private long _queuedBytes; + private long _claimedBytes; + private ulong _nextGeneration; + + private readonly record struct Entry(ObjectMeshData Data, long Bytes, ulong Generation) { - if (!_ownedIds.TryAdd(data.ObjectId, 0)) - return false; - - data.UploadAttempts = 0; - _queue.Enqueue(data); - return true; + public MeshUploadQueueItem Item => new(Data, Bytes, Generation); } - public bool TryDequeue(out ObjectMeshData? data) => _queue.TryDequeue(out data); + public MeshUploadStagingQueue( + int maximumCount = DefaultMaximumCount, + long maximumBytes = DefaultMaximumBytes, + long maximumSingleEntryBytes = 0) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumCount, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1); + ArgumentOutOfRangeException.ThrowIfNegative(maximumSingleEntryBytes); + if (maximumSingleEntryBytes == 0) + maximumSingleEntryBytes = Math.Max(maximumBytes, DefaultMaximumSingleEntryBytes); + ArgumentOutOfRangeException.ThrowIfLessThan(maximumSingleEntryBytes, maximumBytes); + _maximumCount = maximumCount; + _maximumBytes = maximumBytes; + _maximumSingleEntryBytes = maximumSingleEntryBytes; + } - public void Requeue(ObjectMeshData data) => _queue.Enqueue(data); + public bool Stage(ObjectMeshData data) + => TryStage(data) == MeshStageResult.Staged; - public void Complete(ulong objectId) => _ownedIds.TryRemove(objectId, out _); + public MeshStageResult TryStage(ObjectMeshData data) + { + ArgumentNullException.ThrowIfNull(data); + long bytes = ObjectMeshManager.EstimateUploadBytes(data); + lock (_gate) + { + if (_generationById.ContainsKey(data.ObjectId)) + return MeshStageResult.AlreadyStaged; + + if (bytes > _maximumSingleEntryBytes) + throw new NotSupportedException( + $"Mesh 0x{data.ObjectId:X10} requires {bytes:N0} staged bytes; " + + $"the supported per-object maximum is {_maximumSingleEntryBytes:N0} bytes."); + + // Permit one explicitly bounded oversized head item so unusual + // content cannot starve. Every other producer observes the strict + // count/byte watermark; active decoders retain their result in the + // bounded CPU cache and retry staging after the consumer drains. + if (_generationById.Count != 0 + && (_generationById.Count >= _maximumCount + || bytes > _maximumBytes - Math.Min(_claimedBytes, _maximumBytes))) + { + return MeshStageResult.HighWater; + } + + ulong generation = checked(++_nextGeneration); + _generationById.Add(data.ObjectId, generation); + data.UploadAttempts = 0; + _queue.Enqueue(new Entry(data, bytes, generation)); + _queuedBytes = checked(_queuedBytes + bytes); + _claimedBytes = checked(_claimedBytes + bytes); + return MeshStageResult.Staged; + } + } + + public bool TryDequeue(out MeshUploadQueueItem item) + { + lock (_gate) + { + if (!_queue.TryDequeue(out Entry entry)) + { + item = default; + return false; + } + _queuedBytes -= entry.Bytes; + item = entry.Item; + return true; + } + } + + public bool TryPeek(out MeshUploadQueueItem item) + { + lock (_gate) + { + if (!_queue.TryPeek(out Entry entry)) + { + item = default; + return false; + } + item = entry.Item; + return true; + } + } + + public int Count { get { lock (_gate) return _queue.Count; } } + public int ClaimCount { get { lock (_gate) return _generationById.Count; } } + public long QueuedBytes { get { lock (_gate) return _queuedBytes; } } + public long ClaimedBytes { get { lock (_gate) return _claimedBytes; } } + public bool IsAtHighWater + { + get + { + lock (_gate) + return _generationById.Count >= _maximumCount || _claimedBytes >= _maximumBytes; + } + } + + public void Requeue(MeshUploadQueueItem item) + { + ArgumentNullException.ThrowIfNull(item.Data); + lock (_gate) + { + ValidateCurrentGeneration(item); + if (item.SourceBytes > _maximumSingleEntryBytes) + throw new InvalidOperationException("Cannot retry mesh data after staging ownership completed."); + _queue.Enqueue(new Entry(item.Data, item.SourceBytes, item.Generation)); + _queuedBytes = checked(_queuedBytes + item.SourceBytes); + } + } + + public void Complete(MeshUploadQueueItem item) + { + lock (_gate) + { + ValidateCurrentGeneration(item); + _generationById.Remove(item.Data.ObjectId); + _claimedBytes = checked(_claimedBytes - item.SourceBytes); + } + } + + /// + /// Completes a dequeued, now-unowned generation and atomically hands the + /// same CPU payload to an owner that raced in while it was dequeued. A + /// concurrent cache hit either sees the old staging claim and this method + /// requeues, or runs after the claim is removed and stages for itself; the + /// mesh cannot fall through the gap between those operations. + /// + public bool CompleteOrRestageIfOwned( + MeshUploadQueueItem item, + MeshOwnershipCounter ownership) + { + ArgumentNullException.ThrowIfNull(item.Data); + ArgumentNullException.ThrowIfNull(ownership); + lock (_gate) + { + ValidateCurrentGeneration(item); + _generationById.Remove(item.Data.ObjectId); + _claimedBytes = checked(_claimedBytes - item.SourceBytes); + if (!ownership.IsOwned(item.Data.ObjectId)) + return false; + + ulong generation = checked(++_nextGeneration); + _generationById.Add(item.Data.ObjectId, generation); + item.Data.UploadAttempts = 0; + _queue.Enqueue(new Entry(item.Data, item.SourceBytes, generation)); + _queuedBytes = checked(_queuedBytes + item.SourceBytes); + _claimedBytes = checked(_claimedBytes + item.SourceBytes); + return true; + } + } + + public int DiscardUnownedPrefix(MeshOwnershipCounter ownership, int maximum) + { + ArgumentNullException.ThrowIfNull(ownership); + ArgumentOutOfRangeException.ThrowIfNegative(maximum); + int discarded = 0; + lock (_gate) + { + while (discarded < maximum + && _queue.TryPeek(out Entry entry) + && !ownership.IsOwned(entry.Data.ObjectId)) + { + _queue.Dequeue(); + _queuedBytes -= entry.Bytes; + if (_generationById.TryGetValue(entry.Data.ObjectId, out ulong generation) + && generation == entry.Generation) + { + _generationById.Remove(entry.Data.ObjectId); + _claimedBytes = checked(_claimedBytes - entry.Bytes); + } + discarded++; + } + } + return discarded; + } + + private void ValidateCurrentGeneration(MeshUploadQueueItem item) + { + if (!_generationById.TryGetValue(item.Data.ObjectId, out ulong current) + || current != item.Generation) + { + throw new InvalidOperationException( + $"Stale mesh-upload generation {item.Generation} for 0x{item.Data.ObjectId:X10}; current={current}."); + } + } } /// @@ -63,46 +265,74 @@ internal sealed class MeshOwnershipCounter internal sealed class CpuMeshUploadCache { private readonly Dictionary _data = new(); + private readonly Dictionary _bytesById = new(); private readonly LinkedList _lru = new(); private readonly int _capacity; + private readonly long _byteCapacity; + private long _residentBytes; - public CpuMeshUploadCache(int capacity) + public CpuMeshUploadCache(int capacity, long byteCapacity = 128L * 1024 * 1024) { if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); + ArgumentOutOfRangeException.ThrowIfLessThan(byteCapacity, 1); _capacity = capacity; + _byteCapacity = byteCapacity; } + internal int Count { get { lock (_data) return _data.Count; } } + internal long ResidentBytes { get { lock (_data) return _residentBytes; } } + public bool TryGetAndStage( ulong id, MeshUploadStagingQueue staging, - out ObjectMeshData? data) + out ObjectMeshData? data, + out MeshStageResult stageResult) { lock (_data) { - if (!_data.TryGetValue(id, out data)) + if (!_data.TryGetValue(id, out data)) { + stageResult = default; return false; + } _lru.Remove(id); _lru.AddLast(id); - staging.Stage(data); + stageResult = staging.TryStage(data); return true; } } - public void Store(ObjectMeshData data) + public bool Store(ObjectMeshData data) { + ArgumentNullException.ThrowIfNull(data); + long bytes = ObjectMeshManager.EstimateUploadBytes(data); + // Reject before touching the replacement/LRU state. The prior loop + // evicted everything and then published one arbitrarily large entry, + // allowing the cache-hit path to replay an unbounded payload forever. + if (bytes > _byteCapacity) + return false; lock (_data) { - if (!_data.ContainsKey(data.ObjectId) && _data.Count >= _capacity) + if (_bytesById.Remove(data.ObjectId, out long replacedBytes)) + _residentBytes -= replacedBytes; + + while (_lru.Count != 0 + && (!_data.ContainsKey(data.ObjectId) && _data.Count >= _capacity + || (_data.Count != 0 && bytes > _byteCapacity - _residentBytes))) { ulong oldest = _lru.First!.Value; _lru.RemoveFirst(); _data.Remove(oldest); + if (_bytesById.Remove(oldest, out long oldestBytes)) + _residentBytes -= oldestBytes; } _data[data.ObjectId] = data; + _bytesById[data.ObjectId] = bytes; + _residentBytes = checked(_residentBytes + bytes); _lru.Remove(data.ObjectId); _lru.AddLast(data.ObjectId); + return true; } } @@ -111,7 +341,9 @@ internal sealed class CpuMeshUploadCache lock (_data) { _data.Clear(); + _bytesById.Clear(); _lru.Clear(); + _residentBytes = 0; } } } diff --git a/src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs b/src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs new file mode 100644 index 00000000..3682ad16 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs @@ -0,0 +1,186 @@ +namespace AcDream.App.Rendering.Wb; + +internal readonly record struct MeshUploadCost( + long SourceBytes, + long ArrayAllocationBytes, + long MipmapBytes, + int NewArrayCount, + long BufferUploadBytes = 0, + long BufferAllocationBytes = 0, + long BufferCopyBytes = 0, + int NewBufferCount = 0, + ulong AdmissionKey = 0); + +internal readonly record struct MeshUploadBudgetLimits( + int MaximumObjects, + long MaximumSourceBytes, + long MaximumArrayAllocationBytes, + long MaximumMipmapBytes, + int MaximumNewArrays, + long MaximumBufferUploadBytes = long.MaxValue, + long MaximumBufferAllocationBytes = long.MaxValue, + long MaximumBufferCopyBytes = long.MaxValue, + int MaximumNewBuffers = int.MaxValue, + long MaximumSingleSourceBytes = long.MaxValue, + long MaximumSingleArrayAllocationBytes = long.MaxValue, + long MaximumSingleMipmapBytes = long.MaxValue, + int MaximumSingleNewArrays = int.MaxValue, + long MaximumSingleBufferUploadBytes = long.MaxValue); + +/// +/// Pure prefix-admission policy for render-thread mesh uploads. Ordinary +/// uploads fit one frame in every independent CPU/GPU dimension. A physically +/// indivisible FIFO head may exceed a soft frame budget only when it is below +/// the explicit single-operation ceiling; it runs immediately rather than +/// accumulating fictional credits across idle frames. Global-buffer growth +/// and prefix copies are never admitted here: they are real, chunked +/// maintenance operations driven by . +/// +internal sealed class MeshUploadFrameBudget +{ + private readonly MeshUploadBudgetLimits _limits; + + public MeshUploadFrameBudget(MeshUploadBudgetLimits limits) + { + ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumObjects, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSourceBytes, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumArrayAllocationBytes, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumMipmapBytes, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumNewArrays, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumBufferUploadBytes, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumBufferAllocationBytes, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumBufferCopyBytes, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumNewBuffers, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleSourceBytes, limits.MaximumSourceBytes); + ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleArrayAllocationBytes, limits.MaximumArrayAllocationBytes); + ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleMipmapBytes, limits.MaximumMipmapBytes); + ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleNewArrays, limits.MaximumNewArrays); + ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleBufferUploadBytes, limits.MaximumBufferUploadBytes); + _limits = limits; + } + + public int ObjectCount { get; private set; } + public long SourceBytes { get; private set; } + public long ArrayAllocationBytes { get; private set; } + public long MipmapBytes { get; private set; } + public int NewArrayCount { get; private set; } + public long BufferUploadBytes { get; private set; } + public long BufferAllocationBytes { get; private set; } + public long BufferCopyBytes { get; private set; } + public int NewBufferCount { get; private set; } + public void Reset() + { + ObjectCount = 0; + SourceBytes = 0; + ArrayAllocationBytes = 0; + MipmapBytes = 0; + NewArrayCount = 0; + BufferUploadBytes = 0; + BufferAllocationBytes = 0; + BufferCopyBytes = 0; + NewBufferCount = 0; + } + + public bool TryAdmit(MeshUploadCost cost) + { + Validate(cost); + if (cost.BufferAllocationBytes != 0 + || cost.BufferCopyBytes != 0 + || cost.NewBufferCount != 0) + { + throw new InvalidOperationException( + "Global-buffer allocation/copy work must be progressed as real maintenance before upload admission."); + } + + bool oversizedHead = ObjectCount == 0 && ExceedsSingleFrame(cost); + if (oversizedHead) + { + if (cost.SourceBytes > _limits.MaximumSingleSourceBytes + || cost.ArrayAllocationBytes > _limits.MaximumSingleArrayAllocationBytes + || cost.MipmapBytes > _limits.MaximumSingleMipmapBytes + || cost.NewArrayCount > _limits.MaximumSingleNewArrays + || cost.BufferUploadBytes > _limits.MaximumSingleBufferUploadBytes) + { + throw new NotSupportedException( + $"Mesh upload generation {cost.AdmissionKey} exceeds the supported single-operation ceiling."); + } + + Admit(cost); + return true; + } + + if (ObjectCount >= _limits.MaximumObjects + || WouldExceed(SourceBytes, cost.SourceBytes, _limits.MaximumSourceBytes) + || WouldExceed(ArrayAllocationBytes, cost.ArrayAllocationBytes, _limits.MaximumArrayAllocationBytes) + || WouldExceed(MipmapBytes, cost.MipmapBytes, _limits.MaximumMipmapBytes) + || cost.NewArrayCount > _limits.MaximumNewArrays - NewArrayCount + || WouldExceed(BufferUploadBytes, cost.BufferUploadBytes, _limits.MaximumBufferUploadBytes) + || WouldExceed(BufferAllocationBytes, cost.BufferAllocationBytes, _limits.MaximumBufferAllocationBytes) + || WouldExceed(BufferCopyBytes, cost.BufferCopyBytes, _limits.MaximumBufferCopyBytes) + || cost.NewBufferCount > _limits.MaximumNewBuffers - NewBufferCount) + { + return false; + } + + Admit(cost); + return true; + } + + /// Records work that actually executed this frame. + public void RecordBufferMaintenance(long allocationBytes, long copyBytes, int newBufferCount) + { + ArgumentOutOfRangeException.ThrowIfNegative(allocationBytes); + ArgumentOutOfRangeException.ThrowIfNegative(copyBytes); + ArgumentOutOfRangeException.ThrowIfNegative(newBufferCount); + if (WouldExceed(BufferAllocationBytes, allocationBytes, _limits.MaximumBufferAllocationBytes) + || WouldExceed(BufferCopyBytes, copyBytes, _limits.MaximumBufferCopyBytes) + || newBufferCount > _limits.MaximumNewBuffers - NewBufferCount) + { + throw new InvalidOperationException( + "Executed global-buffer maintenance exceeded its real per-frame bound."); + } + BufferAllocationBytes = checked(BufferAllocationBytes + allocationBytes); + BufferCopyBytes = checked(BufferCopyBytes + copyBytes); + NewBufferCount = checked(NewBufferCount + newBufferCount); + } + + private bool ExceedsSingleFrame(MeshUploadCost cost) => + cost.SourceBytes > _limits.MaximumSourceBytes + || cost.ArrayAllocationBytes > _limits.MaximumArrayAllocationBytes + || cost.MipmapBytes > _limits.MaximumMipmapBytes + || cost.NewArrayCount > _limits.MaximumNewArrays + || cost.BufferUploadBytes > _limits.MaximumBufferUploadBytes + || cost.BufferAllocationBytes > _limits.MaximumBufferAllocationBytes + || cost.BufferCopyBytes > _limits.MaximumBufferCopyBytes + || cost.NewBufferCount > _limits.MaximumNewBuffers; + + private void Admit(MeshUploadCost cost) + { + ObjectCount++; + SourceBytes = checked(SourceBytes + cost.SourceBytes); + ArrayAllocationBytes = checked(ArrayAllocationBytes + cost.ArrayAllocationBytes); + MipmapBytes = checked(MipmapBytes + cost.MipmapBytes); + NewArrayCount = checked(NewArrayCount + cost.NewArrayCount); + BufferUploadBytes = checked(BufferUploadBytes + cost.BufferUploadBytes); + BufferAllocationBytes = checked(BufferAllocationBytes + cost.BufferAllocationBytes); + BufferCopyBytes = checked(BufferCopyBytes + cost.BufferCopyBytes); + NewBufferCount = checked(NewBufferCount + cost.NewBufferCount); + } + + // Keep the comparison overflow-free even when a bounded indivisible head + // has truthfully exceeded a soft per-frame budget. + private static bool WouldExceed(long current, long added, long maximum) => + added > maximum - Math.Min(current, maximum); + + private static void Validate(MeshUploadCost cost) + { + ArgumentOutOfRangeException.ThrowIfNegative(cost.SourceBytes); + ArgumentOutOfRangeException.ThrowIfNegative(cost.ArrayAllocationBytes); + ArgumentOutOfRangeException.ThrowIfNegative(cost.MipmapBytes); + ArgumentOutOfRangeException.ThrowIfNegative(cost.NewArrayCount); + ArgumentOutOfRangeException.ThrowIfNegative(cost.BufferUploadBytes); + ArgumentOutOfRangeException.ThrowIfNegative(cost.BufferAllocationBytes); + ArgumentOutOfRangeException.ThrowIfNegative(cost.BufferCopyBytes); + ArgumentOutOfRangeException.ThrowIfNegative(cost.NewBufferCount); + } +} diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index 3d9d7ae8..ac46c942 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -20,11 +20,13 @@ using AcDream.Core.Rendering.Wb; using PixelFormat = Silk.NET.OpenGL.PixelFormat; using BoundingBox = Chorizite.Core.Lib.BoundingBox; -namespace AcDream.App.Rendering.Wb { +namespace AcDream.App.Rendering.Wb +{ /// /// GPU-side render data created on the main thread. /// - public class ObjectRenderData { + public class ObjectRenderData + { public uint VAO { get; set; } public uint VBO { get; set; } public int VertexCount { get; set; } @@ -59,12 +61,20 @@ namespace AcDream.App.Rendering.Wb { /// Estimated GPU memory usage in bytes. public long MemorySize { get; set; } + + /// + /// Physical bytes owned outside . Modern + /// vertex/index ranges are deliberately excluded because the arena's + /// backing-store capacity is accounted once at its owner. + /// + internal long NonArenaGpuBytes { get; set; } } /// /// A single GPU draw batch: IBO + texture array layer. /// - public class ObjectRenderBatch { + public class ObjectRenderBatch + { public uint IBO { get; set; } public int IndexCount { get; set; } public TextureAtlasManager Atlas { get; set; } = null!; @@ -89,7 +99,8 @@ namespace AcDream.App.Rendering.Wb { /// Key design: mesh data is prepared on background threads via PrepareMeshData(), /// then GPU resources are created on the main thread via UploadMeshData(). /// - public class ObjectMeshManager : IDisposable { + public class ObjectMeshManager : IDisposable + { private readonly OpenGLGraphicsDevice _graphicsDevice; private readonly IDatReaderWriter _dats; private readonly ILogger _logger; @@ -105,7 +116,24 @@ namespace AcDream.App.Rendering.Wb { internal IDatReaderWriter Dats => _dats; public bool IsDisposed { get; private set; } + private readonly object _disposeGate = new(); + private bool _disposeCompleted; + private bool _disposeRunning; + private bool _workersQuiesced; + private bool _workSignalDisposed; private readonly ConcurrentDictionary _renderData = new(); + // A render-data entry remains published until every one of its physical + // resources has either released or reported a committed exceptional + // outcome. Accessors hide entries in this map because a partially + // retired mesh is no longer drawable, while retaining the entry keeps + // the unfinished resources reachable for an exact later retry. + private readonly Dictionary _objectReleases = new(); + private readonly Queue _objectReleaseQueue = new(); + // Failed upload rollback owns resources which were never published. + // Keep its per-resource ledger by object id so the bounded upload retry + // cannot allocate another copy until the prior rollback converges. + private readonly Dictionary _uploadRollbacks = new(); + private readonly Queue _uploadRollbackQueue = new(); private readonly MeshOwnershipCounter _ownership = new(); private readonly ConcurrentDictionary _boundsCache = new(); private readonly ConcurrentDictionary> _preparationTasks = new(); @@ -114,16 +142,26 @@ namespace AcDream.App.Rendering.Wb { private readonly LinkedList _lruList = new(); private readonly long _maxGpuMemory = 1024 * 1024 * 1024; // 1GB private readonly int _maxCachedObjects = 50; // Max number of cached objects (count-based limit) - private long _currentGpuMemory = 0; + private long _currentNonArenaGpuMemory; // Shared atlases grouped by (Width, Height, Format) private readonly Dictionary<(int Width, int Height, TextureFormat Format), List> _globalAtlases = new(); + // Render-thread-owned set of arrays whose base layer changed since the + // last flush. Walking every atlas every frame (and after every uploaded + // object) made steady-state CPU cost grow with every area ever visited. + private readonly HashSet _dirtyAtlases = new(); + private long _atlasUseSequence; + private const long RetainedEmptyAtlasBudgetBytes = 64L * 1024 * 1024; + private const int RetainedEmptyAtlasCountLimit = 32; + private readonly AcDream.App.Rendering.BoundedUnownedResourceCache + _safeEmptyAtlases = new(RetainedEmptyAtlasBudgetBytes, RetainedEmptyAtlasCountLimit); // CPU-side cache for prepared mesh data (to avoid re-reading/decoding from DAT) private readonly int _maxCpuCacheSize = 100; private readonly CpuMeshUploadCache _cpuMeshCache; private readonly MeshUploadStagingQueue _stagedMeshData = new(); + private volatile bool _arenaBackpressured; /// #125: how many times a failed GL upload is re-staged before /// giving up loudly. Small — a transient GL error clears on the next @@ -136,39 +174,201 @@ namespace AcDream.App.Rendering.Wb { /// re-staged for a later frame. The caller (the per-frame Tick drain) /// collects the re-stages and re-enqueues them AFTER the drain loop — /// never inside it — so a deterministic failure can't spin the queue in - /// a single frame. Increments the mesh-data's own attempt counter (resets - /// on re-prepare) and gives up loudly past . + /// a single frame. increments the mesh + /// data's own counter only when new upload work actually starts (not + /// while a prior rollback waits); this drain gives up loudly past + /// . /// - public bool UploadOrRequeue(ObjectMeshData meshData) { - if (UploadMeshData(meshData) is not null) { - _stagedMeshData.Complete(meshData.ObjectId); + internal bool UploadOrRequeue(MeshUploadQueueItem item) + { + ObjectMeshData meshData = item.Data; + if (!_ownership.IsOwned(meshData.ObjectId)) + { + _stagedMeshData.CompleteOrRestageIfOwned(item, _ownership); + return false; + } + if (UploadMeshData(meshData) is not null) + { + _stagedMeshData.Complete(item); return false; // success (incl. legitimate 0-vertex → empty render data) } - if (HasRenderData(meshData.ObjectId)) { - _stagedMeshData.Complete(meshData.ObjectId); + if (HasRenderData(meshData.ObjectId)) + { + _stagedMeshData.Complete(item); return false; // raced to present by another path } - meshData.UploadAttempts++; + if (_objectReleases.ContainsKey(meshData.ObjectId) + || _uploadRollbacks.ContainsKey(meshData.ObjectId)) + { + // Cleanup is a separately owned transaction, not another GL + // upload attempt. Keep this generation staged while its exact + // old resources retry one bounded pass per frame. + return true; + } if (meshData.UploadAttempts < MaxUploadRetries) return true; // re-stage for next frame - _stagedMeshData.Complete(meshData.ObjectId); + _stagedMeshData.Complete(item); Console.WriteLine($"[up-retry] 0x{meshData.ObjectId:X10} upload failed {meshData.UploadAttempts}x — giving up (was the #125 silent sticky drop; a GL error is being surfaced, not hidden)"); return false; } - internal bool TryDequeueStagedMeshData(out ObjectMeshData? data) => - _stagedMeshData.TryDequeue(out data); + internal bool TryDequeueStagedMeshData(out MeshUploadQueueItem item) => + _stagedMeshData.TryDequeue(out item); - internal void RequeueStagedMeshData(ObjectMeshData data) => - _stagedMeshData.Requeue(data); + internal bool TryPeekStagedMeshData(out MeshUploadQueueItem item) => + _stagedMeshData.TryPeek(out item); + + internal int StagedMeshCount => _stagedMeshData.ClaimCount; + internal long StagedMeshBytes => _stagedMeshData.ClaimedBytes; + internal bool StagingAtHighWater => _stagedMeshData.IsAtHighWater; + + internal int DiscardUnownedStagedPrefix(int maximum) => + _stagedMeshData.DiscardUnownedPrefix(_ownership, maximum); + + internal bool IsOwned(ulong id) => _ownership.IsOwned(id); + + internal void RequeueStagedMeshData(MeshUploadQueueItem item) => + _stagedMeshData.Requeue(item); + + internal void RejectUnsupportedStagedUpload( + MeshUploadQueueItem item, + NotSupportedException error) + { + ArgumentNullException.ThrowIfNull(error); + _stagedMeshData.Complete(item); + lock (_pendingRequests) + _terminalPreparationFailures.Add(item.Data.ObjectId); + _logger.LogError( + error, + "Mesh 0x{Id:X10} generation {Generation} exceeds an explicit GPU upload limit", + item.Data.ObjectId, + item.Generation); + } + + internal void SetArenaBackpressure(bool enabled) + { + _arenaBackpressured = enabled; + if (!enabled) + ResumePreparationWorkers(); + } public GlobalMeshBuffer? GlobalBuffer { get; } private readonly bool _useModernRendering; + internal (int RenderData, int AtlasArrays, int UnusedLru, long EstimatedBytes) Diagnostics + { + get + { + int atlasArrays = 0; + foreach (List atlases in _globalAtlases.Values) + atlasArrays += atlases.Count; + long physicalBytes = CalculateTrackedGpuBytes( + _currentNonArenaGpuMemory, + GlobalBuffer?.PhysicalCapacityBytes ?? 0); + return (_renderData.Count, atlasArrays, _lruList.Count, physicalBytes); + } + } + internal (int Count, long Bytes) CpuCacheDiagnostics => + (_cpuMeshCache.Count, _cpuMeshCache.ResidentBytes); - private readonly List<(ulong Id, bool IsSetup, TaskCompletionSource Tcs, CancellationToken Ct)> _pendingRequests = new(); - private int _activeWorkers = 0; + private sealed class PreparationRequest( + ulong id, + bool isSetup, + EnvCellGeomRequest? envCell, + ObjectMeshData? cachedData, + TaskCompletionSource completion, + CancellationTokenSource cancellation) + { + private readonly object _cancellationGate = new(); + private bool _cancelStarted; + private bool _cancelInProgress; + private bool _disposeRequested; + private bool _cancellationDisposed; + + public ulong Id { get; } = id; + public bool IsSetup { get; } = isSetup; + public EnvCellGeomRequest? EnvCell { get; } = envCell; + public ObjectMeshData? CachedData { get; } = cachedData; + public TaskCompletionSource Completion { get; } = completion; + public CancellationTokenSource Cancellation { get; } = cancellation; + + // Cancellation callbacks are user-extensible and run synchronously. + // Never invoke them while ObjectMeshManager's queue lock is held. + // The small request-local protocol also prevents the worker's + // terminal Dispose from racing the detached cancellation call. + public void Cancel() + { + lock (_cancellationGate) + { + if (_cancellationDisposed || _cancelStarted) + return; + _cancelStarted = true; + _cancelInProgress = true; + } + + try + { + Cancellation.Cancel(); + } + finally + { + bool dispose; + lock (_cancellationGate) + { + _cancelInProgress = false; + dispose = _disposeRequested && !_cancellationDisposed; + if (dispose) + _cancellationDisposed = true; + } + if (dispose) + Cancellation.Dispose(); + } + } + + public void DisposeCancellation() + { + lock (_cancellationGate) + { + if (_cancellationDisposed) + return; + if (_cancelInProgress) + { + _disposeRequested = true; + return; + } + _cancellationDisposed = true; + } + Cancellation.Dispose(); + } + } + + // LIFO preserves destination locality, while the id->node index makes + // release/cancellation O(1). The former List.FindIndex hot path was + // O(N) for every missing-mesh lookup and became O(N^2) per frame when + // portal streaming reached backpressure. + private readonly LinkedList _pendingRequests = new(); + private readonly Dictionary> _pendingRequestById = new(); + private readonly Dictionary _activePreparationById = new(); + private readonly Dictionary _envCellDescriptors = new(); + private readonly HashSet _terminalPreparationFailures = new(); + private readonly HashSet _workerTasks = new(); + private readonly ManualResetEventSlim _preparationWorkAvailable = new(false); private const int MaxParallelLoads = 4; - public ObjectMeshManager(OpenGLGraphicsDevice graphicsDevice, IDatReaderWriter dats, ILogger logger) { + + private sealed class ObjectReleaseTicket( + ulong id, + ObjectRenderData data, + long reclaimableBytes, + RetryableResourceReleaseLedger resources) + { + public ulong Id { get; } = id; + public ObjectRenderData Data { get; } = data; + public long ReclaimableBytes { get; } = reclaimableBytes; + public RetryableResourceReleaseLedger Resources { get; } = resources; + public bool IsQueued { get; set; } + } + + public ObjectMeshManager(OpenGLGraphicsDevice graphicsDevice, IDatReaderWriter dats, ILogger logger) + { _graphicsDevice = graphicsDevice; _dats = dats; _logger = logger; @@ -178,10 +378,21 @@ namespace AcDream.App.Rendering.Wb { // Prepare* call. ConcurrentQueue.Enqueue is thread-safe for the // extractor's up-to-4 concurrent decode workers. _cpuMeshCache = new CpuMeshUploadCache(_maxCpuCacheSize); - _extractor = new MeshExtractor(_dats, _logger, data => _stagedMeshData.Stage(data)); + _extractor = new MeshExtractor(_dats, _logger, data => + { + // Side-staged particle/setup dependencies obey the same bounded + // producer policy as primary results. Keeping them in the CPU + // cache makes a later explicit owner able to re-arm upload. + _cpuMeshCache.Store(data); + if (_ownership.IsOwned(data.ObjectId)) + _stagedMeshData.Stage(data); + }); _useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless; - if (_useModernRendering) { - GlobalBuffer = new GlobalMeshBuffer(_graphicsDevice.GL); + if (_useModernRendering) + { + GlobalBuffer = new GlobalMeshBuffer( + _graphicsDevice.GL, + _graphicsDevice.ResourceRetirement); } } @@ -189,28 +400,12 @@ namespace AcDream.App.Rendering.Wb { /// Get existing GPU render data for an object, or null if not yet uploaded. /// Increments reference count. /// - public ObjectRenderData? GetRenderData(ulong id) { - if (_renderData.TryGetValue(id, out var data)) { - _ownership.Acquire(id); - - if (data.IsSetup) { - foreach (var (partId, _) in data.SetupParts) { - IncrementRefCount(partId); - } - } - else { - // Increment ref counts for all textures in this GfxObj - foreach (var batch in data.Batches) { - if (batch.Atlas != null) { - batch.Atlas.AddTexture(batch.Key, Array.Empty()); - } - } - } - - // If it was in LRU, remove it as it's now in use - lock (_lruList) { - _lruList.Remove(id); - } + public ObjectRenderData? GetRenderData(ulong id) + { + if (!_objectReleases.ContainsKey(id) + && _renderData.TryGetValue(id, out var data)) + { + IncrementRefCount(id); return data; } @@ -220,43 +415,100 @@ namespace AcDream.App.Rendering.Wb { /// /// Check if GPU render data exists for an object. /// - public bool HasRenderData(ulong id) => _renderData.ContainsKey(id); + public bool HasRenderData(ulong id) => + !_objectReleases.ContainsKey(id) + && _renderData.ContainsKey(id); /// /// Get existing GPU render data without modifying reference count. /// Use this for render-loop lookups where you don't want to affect lifecycle. /// - public ObjectRenderData? TryGetRenderData(ulong id) { - return _renderData.TryGetValue(id, out var data) ? data : null; + public ObjectRenderData? TryGetRenderData(ulong id) + { + return !_objectReleases.ContainsKey(id) + && _renderData.TryGetValue(id, out var data) + ? data + : null; } /// /// Increment reference count for an object (e.g. when a landblock starts using it). /// - public void IncrementRefCount(ulong id) { - _ownership.Acquire(id); - lock (_lruList) { - _lruList.Remove(id); - } - } - - public void GenerateMipmaps() { - foreach (var atlasList in _globalAtlases.Values) { - foreach (var atlas in atlasList) { - atlas.TextureArray.ProcessDirtyUpdates(); + public void IncrementRefCount(ulong id) + { + lock (_pendingRequests) + { + _ownership.Acquire(id); + lock (_lruList) + { + _lruList.Remove(id); } } } + public (int Arrays, long Bytes) GenerateMipmaps() + { + int generatedArrays = 0; + long generatedBytes = 0; + foreach (TextureAtlasManager atlas in _dirtyAtlases) + { + long bytes = atlas.TextureArray.ProcessDirtyUpdates(); + if (bytes > 0) + { + generatedArrays++; + generatedBytes = checked(generatedBytes + bytes); + } + } + _dirtyAtlases.Clear(); + return (generatedArrays, generatedBytes); + } + + /// + /// Retains a small LRU of empty arrays so recurring texture size classes + /// reuse their immutable storage and resident sampler handles. + /// Once that idle pool exceeds its byte/count budget, retires at most + /// one GPU-safe array per frame. Logical emptiness alone is insufficient: + /// every returned layer + /// must first pass its frame fence. + /// + internal bool EvictOneEmptyAtlas() + { + if (!_safeEmptyAtlases.TryTakeOldestOverBudget(out TextureAtlasManager victim)) + return false; + + var key = (victim.Width, victim.Height, victim.Format); + if (_globalAtlases.TryGetValue(key, out List? list)) + { + list.Remove(victim); + if (list.Count == 0) + _globalAtlases.Remove(key); + } + _dirtyAtlases.Remove(victim); + victim.Dispose(); + return true; + } + + private void OnAtlasGpuSafeEmpty(TextureAtlasManager atlas) + { + if (IsDisposed || !atlas.IsGpuSafeEmpty || _safeEmptyAtlases.Contains(atlas)) + return; + _safeEmptyAtlases.MarkUnowned(atlas, atlas.AllocatedBytes); + } + + private void MarkAtlasActive(TextureAtlasManager atlas) => _safeEmptyAtlases.MarkOwned(atlas); + /// /// #105 diagnostic: counts staged-but-unflushed texture layer updates across all /// shared atlases (see ). /// Render thread only — _globalAtlases is render-thread-owned. /// - public (int PendingUpdates, int ArraysWithPending, int TotalArrays) GetPendingTextureUpdateStats() { + public (int PendingUpdates, int ArraysWithPending, int TotalArrays) GetPendingTextureUpdateStats() + { int pending = 0, arraysWith = 0, total = 0; - foreach (var atlasList in _globalAtlases.Values) { - foreach (var atlas in atlasList) { + foreach (var atlasList in _globalAtlases.Values) + { + foreach (var atlas in atlasList) + { total++; int p = atlas.TextureArray.PendingUpdateCount; if (p > 0) { arraysWith++; pending += p; } @@ -268,13 +520,45 @@ namespace AcDream.App.Rendering.Wb { /// /// Decrement reference count and unload GPU resources if no longer needed. /// - public void DecrementRefCount(ulong id) { - var newCount = _ownership.Release(id); - if (newCount <= 0) { - // Instead of unloading, move to LRU - lock (_lruList) { - _lruList.Remove(id); - _lruList.AddLast(id); + public void DecrementRefCount(ulong id) + { + (PreparationRequest? Pending, PreparationRequest? Active) canceled = default; + bool finalOwner; + lock (_pendingRequests) + { + finalOwner = _ownership.Count(id) <= 1; + if (finalOwner) + canceled = DetachPendingPreparationLocked(id); + } + + // Cancellation callbacks are arbitrary synchronous code and can + // throw. Run them before committing the reference decrement. A + // failure therefore leaves ownership unchanged and makes the same + // DecrementRefCount call safe to retry; the detached cancellation + // protocol is idempotent for that retry. + if (finalOwner) + CancelDetachedPreparation(canceled); + + lock (_pendingRequests) + { + int newCount = _ownership.Release(id); + if (newCount > 0) + return; + + _envCellDescriptors.Remove(id); + _terminalPreparationFailures.Remove(id); + if (_renderData.ContainsKey(id)) + { + // Instead of unloading, move resident data to LRU. + lock (_lruList) + { + _lruList.Remove(id); + _lruList.AddLast(id); + } + } + else + { + _ownership.Remove(id); } } } @@ -282,47 +566,193 @@ namespace AcDream.App.Rendering.Wb { /// /// Decrement reference count and unload if no longer needed. /// - public void ReleaseRenderData(ulong id) { - if (_ownership.IsOwned(id)) { - var newCount = _ownership.Release(id); - if (newCount <= 0) { - // Instead of unloading, move to LRU - lock (_lruList) { - _lruList.Remove(id); - _lruList.AddLast(id); + public void ReleaseRenderData(ulong id) + { + (PreparationRequest? Pending, PreparationRequest? Active) canceled = default; + lock (_pendingRequests) + { + if (_ownership.IsOwned(id)) + { + var newCount = _ownership.Release(id); + if (newCount <= 0) + { + _envCellDescriptors.Remove(id); + _terminalPreparationFailures.Remove(id); + canceled = DetachPendingPreparationLocked(id); + if (_renderData.ContainsKey(id)) + { + lock (_lruList) + { + _lruList.Remove(id); + _lruList.AddLast(id); + } + } + else + { + _ownership.Remove(id); + } } } } + CancelDetachedPreparation(canceled); + } + + internal (int Count, long Bytes) ReclaimUnusedResources( + int maximumCount, + long maximumBytes, + bool forceArenaReclamation = false) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumCount, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1); + RetryPendingAtlasRetirements(); + // Rollbacks own unpublished resources and therefore have no LRU + // node to wake them. Advance a bounded snapshot every render tick + // even if the requesting owner disappeared after the upload failed. + AdvancePendingUploadRollbacks(maximumCount); + + (int reclaimedCount, long reclaimedBytes) = + AdvancePendingObjectReleases(maximumCount, maximumBytes); + int candidateBudget; + lock (_lruList) + candidateBudget = _lruList.Count; + int attemptedCandidates = 0; + + while (reclaimedCount < maximumCount + && attemptedCandidates < candidateBudget) + { + ulong idToEvict; + lock (_lruList) + { + long physicalArenaBytes = GlobalBuffer?.PhysicalCapacityBytes ?? 0; + if (!forceArenaReclamation + && IsWithinGpuCacheBudget( + _currentNonArenaGpuMemory, + physicalArenaBytes, + _maxGpuMemory) + && _lruList.Count <= _maxCachedObjects) + { + break; + } + + // Stale owned nodes are bookkeeping-only and safe to discard + // while searching. Real destruction is bounded independently + // by both object count and bytes so admission of up to eight + // meshes cannot outrun a one-object reclamation service. + if (_lruList.Count == 0) + break; + idToEvict = _lruList.First!.Value; + _lruList.RemoveFirst(); + } + attemptedCandidates++; + + lock (_pendingRequests) + { + if (!_ownership.IsOwned(idToEvict)) + { + long bytes = GetObjectReclaimableBytes(idToEvict); + if (!FitsReclamationBudget(bytes, reclaimedBytes, maximumBytes)) + { + lock (_lruList) + _lruList.AddLast(idToEvict); + // This candidate is indivisible within the current + // byte allowance, but a smaller later object may + // still fit. Rotate it and inspect each original + // LRU node at most once so one large mesh cannot + // starve all reclamation forever. + continue; + } + + if (TryAdvanceObjectRelease(idToEvict, out long completedBytes)) + { + reclaimedBytes = checked(reclaimedBytes + completedBytes); + reclaimedCount++; + } + // An unfinished release moves from the ordinary LRU to + // _objectReleases. That dedicated queue advances once + // at the start of a later frame, including when a new + // logical owner acquired the id in the meantime. + } + } + } + + return (reclaimedCount, reclaimedBytes); + } + + internal static bool FitsReclamationBudget( + long candidateBytes, + long alreadyReclaimedBytes, + long maximumBytes) + { + ArgumentOutOfRangeException.ThrowIfNegative(candidateBytes); + ArgumentOutOfRangeException.ThrowIfNegative(alreadyReclaimedBytes); + ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1); + return candidateBytes <= maximumBytes - Math.Min(alreadyReclaimedBytes, maximumBytes); + } + + private void RetryPendingAtlasRetirements() + { + List? failures = null; + foreach (List atlases in _globalAtlases.Values) + { + for (int i = 0; i < atlases.Count; i++) + { + try { atlases[i].RetryPendingRetirements(); } + catch (Exception error) { (failures ??= []).Add(error); } + } + } + if (failures is not null) + { + throw new AggregateException( + "One or more texture-atlas layer retirements could not be published.", + failures); + } } - private void EvictOldResources(long neededBytes = 0) { - lock (_lruList) { - // Evict based on memory OR count limit - while ((_currentGpuMemory + neededBytes) > _maxGpuMemory || _lruList.Count > _maxCachedObjects) { - var idToEvict = _lruList.First!.Value; - _lruList.RemoveFirst(); + internal bool EvictOneOldResource() => + ReclaimUnusedResources(1, long.MaxValue).Count != 0; - if (!_ownership.IsOwned(idToEvict)) { - UnloadObject(idToEvict); - _ownership.Remove(idToEvict); - } - } + private long GetReclaimableBytes(ObjectRenderData data) + { + if (_useModernRendering && data.GlobalAllocation is { } allocation) + { + return checked( + (long)allocation.Vertices.Length * VertexPositionNormalTexture.Size + + (long)allocation.Indices.Length * sizeof(ushort)); } + return Math.Max(0, data.NonArenaGpuBytes); } /// /// Force evict all unused objects from the cache. /// Use this when navigating away from a view or changing filters to free memory. /// - public void EvictAllUnused() { - lock (_lruList) { - while (_lruList.Count > 0) { - var idToEvict = _lruList.First!.Value; - _lruList.RemoveFirst(); + public void EvictAllUnused() + { + AdvancePendingUploadRollbacks(Math.Max(1, _uploadRollbackQueue.Count)); + AdvancePendingObjectReleases( + Math.Max(1, _objectReleaseQueue.Count), + long.MaxValue); - if (!_ownership.IsOwned(idToEvict)) { - UnloadObject(idToEvict); - _ownership.Remove(idToEvict); + int candidateBudget; + lock (_lruList) + candidateBudget = _lruList.Count; + + for (int attempted = 0; attempted < candidateBudget; attempted++) + { + ulong idToEvict; + lock (_lruList) + { + if (_lruList.Count == 0) + break; + idToEvict = _lruList.First!.Value; + _lruList.RemoveFirst(); + } + + lock (_pendingRequests) + { + if (!_ownership.IsOwned(idToEvict)) + { + TryAdvanceObjectRelease(idToEvict, out _); } } } @@ -331,173 +761,398 @@ namespace AcDream.App.Rendering.Wb { _cpuMeshCache.Clear(); } - public struct EnvCellGeomRequest { + public struct EnvCellGeomRequest + { public uint EnvironmentId; public ushort CellStructure; public List Surfaces; } - private readonly ConcurrentDictionary _pendingEnvCellRequests = new(); - /// /// Phase 1 (Background Thread): Prepare CPU-side mesh data for deduplicated EnvCell geometry. /// - public Task PrepareEnvCellGeomMeshDataAsync(ulong geomId, uint environmentId, ushort cellStructure, List surfaces, CancellationToken ct = default) { + public Task PrepareEnvCellGeomMeshDataAsync(ulong geomId, uint environmentId, ushort cellStructure, List surfaces, CancellationToken ct = default) + { if (IsDisposed || HasRenderData(geomId)) return Task.FromResult(null); - // Check CPU cache first - if (_cpuMeshCache.TryGetAndStage(geomId, _stagedMeshData, out var cachedData)) - return Task.FromResult(cachedData); - - // Return existing task if already running or queued - if (_preparationTasks.TryGetValue(geomId, out var existing)) { - return existing; + var envCell = new EnvCellGeomRequest + { + EnvironmentId = environmentId, + CellStructure = cellStructure, + Surfaces = surfaces + }; + lock (_pendingRequests) + { + _envCellDescriptors[geomId] = envCell; + // An explicit schema-bearing schedule is a new opportunity to + // prepare this immutable DAT object (for example, a later + // landblock sharing geometry after an earlier failure). + _terminalPreparationFailures.Remove(geomId); } - var tcs = new TaskCompletionSource(); - var task = tcs.Task; - _preparationTasks[geomId] = task; + ObjectMeshData? deferredCachedData = null; + if (_cpuMeshCache.TryGetAndStage( + geomId, + _stagedMeshData, + out ObjectMeshData? cachedData, + out MeshStageResult cacheStage)) + { + if (cacheStage != MeshStageResult.HighWater) + return Task.FromResult(cachedData); + deferredCachedData = cachedData; + } - lock (_pendingRequests) { - if (IsDisposed) { + lock (_pendingRequests) + { + if (_preparationTasks.TryGetValue(geomId, out Task? existing) + && !existing.IsFaulted + && !existing.IsCanceled) + { + bool canceledActiveGeneration = + _activePreparationById.TryGetValue(geomId, out PreparationRequest? active) + && active.Cancellation.IsCancellationRequested + && ReferenceEquals(existing, active.Completion.Task); + if (!canceledActiveGeneration) + return existing; + } + _preparationTasks.TryRemove(geomId, out _); + + var tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + Task task = tcs.Task; + if (IsDisposed) + { tcs.TrySetCanceled(); - _preparationTasks.TryRemove(geomId, out _); return task; } - // Special handling for EnvCell geometry - we need to store the cell data for the worker - _pendingEnvCellRequests[geomId] = new EnvCellGeomRequest { - EnvironmentId = environmentId, - CellStructure = cellStructure, - Surfaces = surfaces - }; - _pendingRequests.Add((geomId, false, tcs, ct)); - if (_activeWorkers < MaxParallelLoads) { - _activeWorkers++; - Task.Run(ProcessQueueAsync); - } + var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct); + _preparationTasks[geomId] = task; + var request = new PreparationRequest( + geomId, + false, + envCell, + deferredCachedData, + tcs, + cancellation); + _pendingRequestById.Add(geomId, _pendingRequests.AddLast(request)); + StartPreparationWorkersLocked(); + return task; } - - return task; } - public Task PrepareMeshDataAsync(ulong id, bool isSetup, CancellationToken ct = default) { + public Task PrepareMeshDataAsync(ulong id, bool isSetup, CancellationToken ct = default) + { if (IsDisposed || HasRenderData(id)) return Task.FromResult(null); - // Check CPU cache first - if (_cpuMeshCache.TryGetAndStage(id, _stagedMeshData, out var cachedData)) - return Task.FromResult(cachedData); + lock (_pendingRequests) + _terminalPreparationFailures.Remove(id); - // Return existing task if already running or queued - if (_preparationTasks.TryGetValue(id, out var existing)) { - if (!existing.IsFaulted && !existing.IsCanceled) { - lock (_pendingRequests) { - int idx = _pendingRequests.FindIndex(r => r.Id == id); - if (idx >= 0) { - var req = _pendingRequests[idx]; - _pendingRequests.RemoveAt(idx); - _pendingRequests.Add(req); - } - } - return existing; + ObjectMeshData? deferredCachedData = null; + if (_cpuMeshCache.TryGetAndStage( + id, + _stagedMeshData, + out ObjectMeshData? cachedData, + out MeshStageResult cacheStage)) + { + if (cacheStage != MeshStageResult.HighWater) + return Task.FromResult(cachedData); + deferredCachedData = cachedData; + } + + lock (_pendingRequests) + { + if (_preparationTasks.TryGetValue(id, out Task? existing) + && !existing.IsFaulted + && !existing.IsCanceled) + { + bool canceledActiveGeneration = + _activePreparationById.TryGetValue(id, out PreparationRequest? active) + && active.Cancellation.IsCancellationRequested + && ReferenceEquals(existing, active.Completion.Task); + if (!canceledActiveGeneration) + return existing; } _preparationTasks.TryRemove(id, out _); - } - var tcs = new TaskCompletionSource(); - var task = tcs.Task; - _preparationTasks[id] = task; - - lock (_pendingRequests) { - if (IsDisposed) { + var tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + Task task = tcs.Task; + if (IsDisposed) + { tcs.TrySetCanceled(); - _preparationTasks.TryRemove(id, out _); return task; } - _pendingRequests.Add((id, isSetup, tcs, ct)); - if (_activeWorkers < MaxParallelLoads) { - _activeWorkers++; - Task.Run(ProcessQueueAsync); - } + var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct); + _preparationTasks[id] = task; + EnvCellGeomRequest? envCell = _envCellDescriptors.TryGetValue(id, out EnvCellGeomRequest descriptor) + ? descriptor + : null; + var request = new PreparationRequest( + id, + isSetup, + envCell, + deferredCachedData, + tcs, + cancellation); + _pendingRequestById.Add(id, _pendingRequests.AddLast(request)); + StartPreparationWorkersLocked(); + return task; } - - return task; } - private async Task ProcessQueueAsync() { - try { - while (true) { - ulong id; - bool isSetup; - TaskCompletionSource tcs; - CancellationToken ct; + private void ProcessQueue() + { + while (true) + { + _preparationWorkAvailable.Wait(); + PreparationRequest request; - lock (_pendingRequests) { - // IsDisposed re-check: lets Dispose() drain the queue and - // observe _activeWorkers reach 0 before the dats unmap. - if (IsDisposed || _pendingRequests.Count == 0) { + lock (_pendingRequests) + { + // IsDisposed re-check lets Dispose cancel and join every + // tracked worker before the DAT mappings are released. + if (IsDisposed + || _pendingRequests.Count == 0 + || _stagedMeshData.IsAtHighWater + || _arenaBackpressured) + { + _preparationWorkAvailable.Reset(); + if (IsDisposed) return; - } - - // LIFO: Pick the most recent request - var index = _pendingRequests.Count - 1; - (id, isSetup, tcs, ct) = _pendingRequests[index]; - _pendingRequests.RemoveAt(index); + continue; } - try { - ObjectMeshData? data = null; - if (_pendingEnvCellRequests.TryRemove(id, out var req)) { + // LIFO: pick the most recently requested destination + // mesh without scanning/reordering the whole queue. + LinkedListNode? node = _pendingRequests.Last; + while (node is not null && _activePreparationById.ContainsKey(node.Value.Id)) + node = node.Previous; + if (node is null) + { + _preparationWorkAvailable.Reset(); + continue; + } + request = node.Value; + _pendingRequests.Remove(node); + _pendingRequestById.Remove(request.Id); + _activePreparationById.Add(request.Id, request); + } + + ulong id = request.Id; + bool isSetup = request.IsSetup; + TaskCompletionSource tcs = request.Completion; + CancellationToken ct = request.Cancellation.Token; + + ObjectMeshData? completionResult = null; + Exception? completionError = null; + bool completionCanceled = false; + CancellationToken completionCancellation = ct; + + try + { + if (ct.IsCancellationRequested) + { + completionCanceled = true; + } + else + { + + ObjectMeshData? data = request.CachedData; + if (data is null && request.EnvCell is { } req) + { uint envId = 0x0D000000u | req.EnvironmentId; - if (_dats.Portal.TryGet(envId, out var environment)) { - if (environment.Cells.TryGetValue(req.CellStructure, out var cellStruct)) { - data = _extractor.PrepareCellStructMeshData(id, cellStruct, req.Surfaces, Matrix4x4.Identity, CancellationToken.None); + if (_dats.Portal.TryGet(envId, out var environment)) + { + if (environment.Cells.TryGetValue(req.CellStructure, out var cellStruct)) + { + data = _extractor.PrepareCellStructMeshData(id, cellStruct, req.Surfaces, Matrix4x4.Identity, ct); // TEMP diagnostic #105 (strip with fix): a null prep here means // this deduplicated cell geometry will NEVER render anywhere. if (data == null) Console.WriteLine($"[geom-null] prepare-null geom=0x{id:X10} env=0x{envId:X8} cs=0x{req.CellStructure:X4}"); } - else { + else + { Console.WriteLine($"[geom-null] cellstruct-missing geom=0x{id:X10} env=0x{envId:X8} cs=0x{req.CellStructure:X4}"); } } - else { + else + { Console.WriteLine($"[geom-null] env-read-failed geom=0x{id:X10} env=0x{envId:X8}"); } } - else { + else if (data is null) + { // TEMP diagnostic #105 (strip with fix): an EnvCell geom id (bit 33) // whose pending request vanished gets misrouted to the generic path, // where its hash-derived low bits resolve to nothing -> silent null. if ((id & 0x2_0000_0000UL) != 0) Console.WriteLine($"[geom-misroute] envcell geom 0x{id:X10} had no pending request — generic path will null it"); // If it's a direct setup or gfxobj, make sure background loads don't abort half-way - data = _extractor.PrepareMeshData(id, isSetup, CancellationToken.None); + data = _extractor.PrepareMeshData(id, isSetup, ct); } - if (data != null) { + if (ct.IsCancellationRequested) + { + completionCanceled = true; + } + else if (data != null) + { + // Preserve completed work in the bounded CPU + // cache even if its last owner disappeared + // during this at-most-four-worker decode. _cpuMeshCache.Store(data); - _stagedMeshData.Stage(data); } - tcs.TrySetResult(data); - } - catch (OperationCanceledException) { - tcs.TrySetCanceled(ct); - } - catch (Exception ex) { - _logger.LogError(ex, "Error preparing mesh data for 0x{Id:X8}", id); - tcs.TrySetException(ex); - } - finally { - _preparationTasks.TryRemove(id, out _); + if (!completionCanceled && data != null && _ownership.IsOwned(id)) + { + // A decoder that started before the watermark may + // finish after it. Keep its immutable payload in + // the bounded CPU cache above, but do not let four + // concurrent workers punch an unbounded byte hole + // through the staging queue. Point-of-use rearming + // stages the cache entry once consumer space exists. + _stagedMeshData.TryStage(data); + } + if (!completionCanceled) + completionResult = data; } } - } - finally { - lock (_pendingRequests) { - _activeWorkers--; + catch (OperationCanceledException ex) + { + completionCanceled = true; + completionCancellation = ex.CancellationToken; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error preparing mesh data for 0x{Id:X8}", id); + completionError = ex; + } + finally + { + lock (_pendingRequests) + { + if (_activePreparationById.TryGetValue(id, out PreparationRequest? active) + && ReferenceEquals(active, request)) + { + _activePreparationById.Remove(id); + } + // Publish only after removing this generation from + // the active map. A replacement can never race into + // an id still occupied by the terminal generation. + if (completionError is not null) + tcs.TrySetException(completionError); + else if (completionCanceled) + tcs.TrySetCanceled(completionCancellation.IsCancellationRequested + ? completionCancellation + : default); + else + tcs.TrySetResult(completionResult); + + if (_preparationTasks.TryGetValue(id, out Task? current) + && ReferenceEquals(current, tcs.Task)) + { + _preparationTasks.TryRemove(id, out _); + } + if (!completionCanceled && (completionError is not null || completionResult is null)) + _terminalPreparationFailures.Add(id); + else if (completionResult is not null) + _terminalPreparationFailures.Remove(id); + if (!IsDisposed + && _pendingRequests.Count != 0 + && !_stagedMeshData.IsAtHighWater + && !_arenaBackpressured) + { + _preparationWorkAvailable.Set(); + } + } + request.DisposeCancellation(); } } } + private void StartPreparationWorkersLocked() + { + if (IsDisposed) + return; + + // Keep a fixed, sleeping worker set once preparation begins. The + // former high-water path destroyed and recreated up to four + // Task.Run workers on every upload/drain cycle during portals, + // producing needless thread-pool and GC churn. + while (_workerTasks.Count < MaxParallelLoads) + { + Task worker = Task.Run(ProcessQueue); + _workerTasks.Add(worker); + _ = worker.ContinueWith( + OnPreparationWorkerCompleted, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + if (_pendingRequests.Count != 0 + && !_stagedMeshData.IsAtHighWater + && !_arenaBackpressured) + _preparationWorkAvailable.Set(); + } + + private void OnPreparationWorkerCompleted(Task worker) + { + if (worker.IsFaulted) + _logger.LogError(worker.Exception, "Mesh preparation worker terminated unexpectedly."); + + lock (_pendingRequests) + { + _workerTasks.Remove(worker); + StartPreparationWorkersLocked(); + } + } + + internal void ResumePreparationWorkers() + { + lock (_pendingRequests) + StartPreparationWorkersLocked(); + } + + /// + /// Readiness barrier used by the streaming publisher. Missing owned + /// data is re-armed through its retained schema, so synthetic EnvCell + /// IDs can never fall into generic GfxObj decoding after cancellation. + /// A deterministic DAT failure is retained until the next explicit + /// ownership schedule instead of being retried every render frame. + /// + internal bool EnsureRenderDataReady(ulong id) + { + if (HasRenderData(id)) + return true; + + EnvCellGeomRequest? envCell; + lock (_pendingRequests) + { + if (IsDisposed + || !_ownership.IsOwned(id) + || _terminalPreparationFailures.Contains(id)) + { + return false; + } + envCell = _envCellDescriptors.TryGetValue(id, out EnvCellGeomRequest descriptor) + ? descriptor + : null; + } + + if (envCell is { } req) + { + _ = PrepareEnvCellGeomMeshDataAsync( + id, + req.EnvironmentId, + req.CellStructure, + req.Surfaces); + } + else + { + _ = PrepareMeshDataAsync(id, isSetup: false); + } + return false; + } + /// /// Phase 1 (Background Thread): Prepare CPU-side mesh data from DAT. /// This loads vertices, indices, and texture data but creates NO GPU resources. @@ -506,17 +1161,68 @@ namespace AcDream.App.Rendering.Wb { /// MP1a (2026-07-05): delegates to , /// the verbatim-moved GL-free extraction dispatcher. See . /// - public ObjectMeshData? PrepareMeshData(ulong id, bool isSetup, CancellationToken ct = default) { + public ObjectMeshData? PrepareMeshData(ulong id, bool isSetup, CancellationToken ct = default) + { return _extractor.PrepareMeshData(id, isSetup, ct); } /// /// Cancel preparation tasks for IDs that are no longer needed. /// - public void CancelStagedUploads(IEnumerable ids) { - foreach (var id in ids) { - _preparationTasks.TryRemove(id, out _); + public void CancelStagedUploads(IEnumerable ids) + { + foreach (ulong id in ids) + CancelPendingPreparation(id); + } + + private void CancelPendingPreparation(ulong id) + { + (PreparationRequest? Pending, PreparationRequest? Active) canceled; + lock (_pendingRequests) + canceled = DetachPendingPreparationLocked(id); + CancelDetachedPreparation(canceled); + } + + private (PreparationRequest? Pending, PreparationRequest? Active) DetachPendingPreparationLocked(ulong id) + { + PreparationRequest? pending = null; + if (_pendingRequestById.Remove(id, out LinkedListNode? node)) + { + _pendingRequests.Remove(node); + pending = node.Value; + if (_preparationTasks.TryGetValue(id, out Task? current) + && ReferenceEquals(current, pending.Completion.Task)) + { + _preparationTasks.TryRemove(id, out _); + } } + // Stop obsolete destination work as soon as its final owner leaves. + // A same-id reacquisition sees this still-active task until its + // terminal publication, then the point-of-use retry starts a fresh + // generation; no replacement can collide in the active map. + _activePreparationById.TryGetValue(id, out PreparationRequest? active); + return (pending, active); + } + + private static void CancelDetachedPreparation( + (PreparationRequest? Pending, PreparationRequest? Active) canceled) + { + List? failures = null; + void Attempt(Action action) + { + try { action(); } + catch (Exception ex) { (failures ??= []).Add(ex); } + } + if (canceled.Pending is { } pending) + { + Attempt(pending.Cancel); + pending.Completion.TrySetCanceled(pending.Cancellation.Token); + Attempt(pending.DisposeCancellation); + } + if (canceled.Active is { } active) + Attempt(active.Cancel); + if (failures is not null) + throw new AggregateException("Mesh preparation cancellation failed.", failures); } /// @@ -524,30 +1230,45 @@ namespace AcDream.App.Rendering.Wb { /// Creates VAO, VBO, IBOs, and texture arrays. /// Must be called from the GL thread. /// - public ObjectRenderData? UploadMeshData(ObjectMeshData meshData) { - try { - if (_renderData.TryGetValue(meshData.ObjectId, out var existing)) { - _preparationTasks.TryRemove(meshData.ObjectId, out _); + public ObjectRenderData? UploadMeshData(ObjectMeshData meshData) + { + bool uploadAttempted = false; + try + { + // A failed eviction or failed rollback owns physical resources + // for this id. Resume those exact ledgers before admitting a + // retry; allocating another copy here would compound the leak. + if (_objectReleases.ContainsKey(meshData.ObjectId) + && !TryAdvanceObjectRelease(meshData.ObjectId, out _)) + { + return null; + } + if (!TryAdvanceUploadRollback(meshData.ObjectId)) + return null; + + if (_renderData.TryGetValue(meshData.ObjectId, out var existing)) + { UpdateLruAfterUpload(meshData.ObjectId); return existing; } - // Estimated size - evict before allocation - long estimatedSize = meshData.IsSetup ? 1024 : - (meshData.Vertices.Length * VertexPositionNormalTexture.Size) + - meshData.TextureBatches.Values.SelectMany(l => l).Sum(b => (long)b.Indices.Count * sizeof(ushort)); - - EvictOldResources(estimatedSize); - - _preparationTasks.TryRemove(meshData.ObjectId, out _); - if (meshData.IsSetup) { + uploadAttempted = true; + if (meshData.IsSetup) + { // Upload EnvCell geometry if present to ensure it's in _renderData - if (meshData.EnvCellGeometry != null) { - UploadMeshData(meshData.EnvCellGeometry); + if (meshData.EnvCellGeometry != null) + { + if (UploadMeshData(meshData.EnvCellGeometry) is null) + { + throw new InvalidOperationException( + $"Nested EnvCell geometry 0x{meshData.EnvCellGeometry.ObjectId:X10} " + + $"failed while uploading setup 0x{meshData.ObjectId:X10}."); + } } // Setup objects are multi-part - each part needs its own render data - var data = new ObjectRenderData { + var data = new ObjectRenderData + { IsSetup = true, SetupParts = meshData.SetupParts, ParticleEmitters = meshData.ParticleEmitters, @@ -558,12 +1279,42 @@ namespace AcDream.App.Rendering.Wb { SelectionSphere = meshData.SelectionSphere, MemorySize = 1024 // Small overhead for the setup itself }; - _renderData.TryAdd(meshData.ObjectId, data); - _currentGpuMemory += data.MemorySize; + var acquiredParts = new List(meshData.SetupParts.Count); + try + { + // Acquire and schedule every dependency before publishing + // the setup. A partial schedule must not become a sticky, + // permanently incomplete render-data cache entry. + foreach (var (partId, _) in meshData.SetupParts) + { + IncrementRefCount(partId); + acquiredParts.Add(partId); + _ = PrepareMeshDataAsync(partId, isSetup: false); + } - // Increment ref counts for all parts - foreach (var (partId, _) in meshData.SetupParts) { - IncrementRefCount(partId); + if (!_renderData.TryAdd(meshData.ObjectId, data)) + throw new InvalidOperationException( + $"Setup 0x{meshData.ObjectId:X10} was published concurrently."); + _currentNonArenaGpuMemory = checked( + _currentNonArenaGpuMemory + data.NonArenaGpuBytes); + } + catch (Exception setupFailure) + { + RetryableResourceReleaseLedger rollback = + CreateSetupPartRollback(acquiredParts, DecrementRefCount); + ResourceReleaseAttempt attempt = rollback.Advance(); + if (!rollback.IsComplete) + { + _uploadRollbacks[meshData.ObjectId] = rollback; + _uploadRollbackQueue.Enqueue(meshData.ObjectId); + } + if (!attempt.HasFailures) + throw; + throw new AggregateException( + $"Setup 0x{meshData.ObjectId:X10} upload and dependency rollback failed.", + setupFailure, + attempt.ToException( + $"Setup 0x{meshData.ObjectId:X10} retained unfinished part references.")); } UpdateLruAfterUpload(meshData.ObjectId); @@ -572,7 +1323,8 @@ namespace AcDream.App.Rendering.Wb { } var renderData = UploadGfxObjMeshData(meshData); - if (renderData == null) { + if (renderData == null) + { // 0-vertex mesh: every polygon was gated out at extraction. #119 // (2026-06-11) dat-verified this is LEGITIMATE for all-no-draw // models (all polys NoPos + Base1Solid surfaces — retail's @@ -591,7 +1343,8 @@ namespace AcDream.App.Rendering.Wb { renderData.DIDDegrade = meshData.DIDDegrade; renderData.SelectionSphere = meshData.SelectionSphere; _renderData.TryAdd(meshData.ObjectId, renderData); - _currentGpuMemory += renderData.MemorySize; + _currentNonArenaGpuMemory = checked( + _currentNonArenaGpuMemory + renderData.NonArenaGpuBytes); UpdateLruAfterUpload(meshData.ObjectId); // Keep the bounded CPU cache's texture payload intact. GPU LRU @@ -599,14 +1352,303 @@ namespace AcDream.App.Rendering.Wb { // clearing these bytes made a cache hit produce blank textures. return renderData; } - catch (Exception ex) { + catch (Exception ex) + { + if (uploadAttempted) + meshData.UploadAttempts++; _logger.LogError(ex, "Error uploading mesh data for 0x{Id:X8}", meshData.ObjectId); return null; } } - private void UpdateLruAfterUpload(ulong id) { - lock (_lruList) { + /// + /// Conservative main-thread upload work estimate used by the per-frame + /// staging budget. Texture bytes are intentionally counted even when a + /// shared atlas may deduplicate them; overestimating delays work by one + /// frame, whereas underestimating can recreate the destination spike. + /// + internal static long EstimateUploadBytes(ObjectMeshData meshData) + { + ArgumentNullException.ThrowIfNull(meshData); + return meshData.GetEstimatedUploadBytes(); + } + + internal static long CalculateNonArenaGeometryBytes( + bool usesGlobalArena, + long geometryBytes) + { + ArgumentOutOfRangeException.ThrowIfNegative(geometryBytes); + return usesGlobalArena ? 0 : geometryBytes; + } + + internal static long CalculateTrackedGpuBytes( + long nonArenaBytes, + long physicalArenaBytes) + { + ArgumentOutOfRangeException.ThrowIfNegative(nonArenaBytes); + ArgumentOutOfRangeException.ThrowIfNegative(physicalArenaBytes); + return checked(nonArenaBytes + physicalArenaBytes); + } + + internal static bool IsWithinGpuCacheBudget( + long nonArenaBytes, + long physicalArenaBytes, + long maximumBytes) + { + ArgumentOutOfRangeException.ThrowIfNegative(nonArenaBytes); + ArgumentOutOfRangeException.ThrowIfNegative(physicalArenaBytes); + ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1); + return nonArenaBytes <= maximumBytes - Math.Min(maximumBytes, physicalArenaBytes); + } + + private sealed class UploadAtlasPlan + { + public TextureAtlasManager? Existing { get; init; } + public required int Capacity { get; init; } + public required long TotalArrayBytes { get; init; } + public int AvailableSlots { get; set; } + public bool Touched { get; set; } + public HashSet PlannedKeys { get; } = new(); + + public bool HasTexture(TextureKey key) => + PlannedKeys.Contains(key) || Existing?.HasTexture(key) == true; + } + + /// + /// Plans the actual GL work the next object would trigger against the + /// current atlas inventory. This includes array storage, global-buffer + /// growth/copies, and one full mip generation per newly-dirtied array—not merely the + /// source byte arrays held by ObjectMeshData. + /// + internal MeshUploadCost PlanUploadCost( + ObjectMeshData meshData, + IReadOnlySet mipmapsAlreadyBudgeted, + ulong queueGeneration) + { + ArgumentNullException.ThrowIfNull(meshData); + ArgumentNullException.ThrowIfNull(mipmapsAlreadyBudgeted); + if (HasRenderData(meshData.ObjectId)) + return default; + + var plans = new Dictionary< + (int Width, int Height, TextureFormat Format), + List>(); + long arrayAllocationBytes = 0; + long mipmapBytes = 0; + int newArrayCount = 0; + + PlanTextureWork( + meshData, + plans, + mipmapsAlreadyBudgeted, + ref arrayAllocationBytes, + ref mipmapBytes, + ref newArrayCount); + + GlobalMeshUploadPlan bufferPlan = PlanGlobalBufferWork(meshData); + + return new MeshUploadCost( + EstimateUploadBytes(meshData), + arrayAllocationBytes, + mipmapBytes, + newArrayCount, + bufferPlan.UploadBytes, + bufferPlan.AllocationBytes, + bufferPlan.CopyBytes, + bufferPlan.NewBufferCount, + queueGeneration); + } + + private GlobalMeshUploadPlan PlanGlobalBufferWork(ObjectMeshData meshData) + { + (int vertexCount, int indexCount) = GetGlobalMeshElementCounts(meshData); + if (vertexCount == 0 || indexCount == 0 || GlobalBuffer is null) + return default; + return GlobalBuffer.PlanUpload(vertexCount, indexCount); + } + + internal GlobalMeshCapacityResult EnsureGlobalBufferCapacity( + MeshUploadQueueItem item, + out GlobalMeshMaintenanceStep step) + { + if (GlobalBuffer is null) + { + step = default; + return GlobalMeshCapacityResult.Ready; + } + (int vertexCount, int indexCount) = GetGlobalMeshElementCounts(item.Data); + return GlobalBuffer.EnsureUploadCapacity(vertexCount, indexCount, out step); + } + + private (int Vertices, int Indices) GetGlobalMeshElementCounts(ObjectMeshData meshData) + { + if (meshData.IsSetup) + { + return meshData.EnvCellGeometry is { } nested + && !HasRenderData(nested.ObjectId) + ? GetGlobalMeshElementCounts(nested) + : default; + } + if (meshData.Vertices.Length == 0) + return default; + + int indexCount = 0; + foreach (List batches in meshData.TextureBatches.Values) + { + foreach (TextureBatchData batch in batches) + indexCount = checked(indexCount + batch.Indices.Count); + } + return (meshData.Vertices.Length, indexCount); + } + + private void PlanTextureWork( + ObjectMeshData meshData, + Dictionary<(int Width, int Height, TextureFormat Format), List> plans, + IReadOnlySet mipmapsAlreadyBudgeted, + ref long arrayAllocationBytes, + ref long mipmapBytes, + ref int newArrayCount) + { + if (meshData.IsSetup) + { + if (meshData.EnvCellGeometry is { } nested + && !HasRenderData(nested.ObjectId)) + { + PlanTextureWork( + nested, + plans, + mipmapsAlreadyBudgeted, + ref arrayAllocationBytes, + ref mipmapBytes, + ref newArrayCount); + } + return; + } + if (meshData.Vertices.Length == 0) + return; + + foreach (var (format, batches) in meshData.TextureBatches) + { + if (!plans.TryGetValue(format, out List? atlasPlans)) + { + atlasPlans = new List(); + if (_globalAtlases.TryGetValue(format, out List? existingAtlases)) + { + foreach (TextureAtlasManager existing in existingAtlases) + { + atlasPlans.Add(new UploadAtlasPlan + { + Existing = existing, + Capacity = existing.TotalSlots, + AvailableSlots = existing.AvailableSlots, + TotalArrayBytes = existing.TextureArray.TotalSizeInBytes, + }); + } + } + plans.Add(format, atlasPlans); + } + + foreach (TextureBatchData batch in batches) + { + if (batch.Indices.Count == 0) + continue; + + UploadAtlasPlan? selected = null; + for (int i = 0; i < atlasPlans.Count; i++) + { + UploadAtlasPlan candidate = atlasPlans[i]; + if (candidate.HasTexture(batch.Key)) + { + selected = candidate; + break; + } + } + if (selected is null) + { + for (int i = 0; i < atlasPlans.Count; i++) + { + UploadAtlasPlan candidate = atlasPlans[i]; + if (candidate.AvailableSlots > 0) + { + selected = candidate; + break; + } + } + } + + if (selected is null) + { + int capacity = TextureAtlasManager.CalculateInitialCapacity( + format.Width, + format.Height, + format.Format); + long totalArrayBytes = TextureAtlasManager.CalculateArrayBytes( + format.Width, + format.Height, + format.Format); + selected = new UploadAtlasPlan + { + Capacity = capacity, + AvailableSlots = capacity, + TotalArrayBytes = totalArrayBytes, + }; + atlasPlans.Add(selected); + arrayAllocationBytes = checked(arrayAllocationBytes + totalArrayBytes); + newArrayCount++; + } + + if (selected.HasTexture(batch.Key)) + continue; + + selected.PlannedKeys.Add(batch.Key); + selected.AvailableSlots--; + if (!selected.Touched) + { + bool mipAlreadyBudgeted = selected.Existing is not null + && mipmapsAlreadyBudgeted.Contains(selected.Existing); + if (!mipAlreadyBudgeted) + mipmapBytes = checked(mipmapBytes + selected.TotalArrayBytes); + selected.Touched = true; + } + } + } + } + + internal static MeshUploadCost CalculateNewAtlasFirstUploadCost( + int width, + int height, + TextureFormat format, + int uploadBytes, + long sourceBytes = 0) => + CalculateNewAtlasUploadCost(width, height, format, [uploadBytes], sourceBytes); + + internal static MeshUploadCost CalculateNewAtlasUploadCost( + int width, + int height, + TextureFormat format, + IReadOnlyList uploadBytes, + long sourceBytes = 0) + { + ArgumentOutOfRangeException.ThrowIfLessThan(width, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(height, 1); + ArgumentNullException.ThrowIfNull(uploadBytes); + ArgumentOutOfRangeException.ThrowIfNegative(sourceBytes); + long arrayBytes = TextureAtlasManager.CalculateArrayBytes(width, height, format); + for (int i = 0; i < uploadBytes.Count; i++) + ArgumentOutOfRangeException.ThrowIfNegative(uploadBytes[i]); + return new MeshUploadCost(sourceBytes, arrayBytes, arrayBytes, 1); + } + + internal void AddDirtyAtlasesTo(ISet destination) + { + ArgumentNullException.ThrowIfNull(destination); + destination.UnionWith(_dirtyAtlases); + } + + private void UpdateLruAfterUpload(ulong id) + { + lock (_lruList) + { _lruList.Remove(id); if (!_ownership.MarkUploadComplete(id)) _lruList.AddLast(id); @@ -616,12 +1658,15 @@ namespace AcDream.App.Rendering.Wb { /// /// Gets bounding box for an object (for frustum culling). /// - public (Vector3 Min, Vector3 Max)? GetBounds(ulong id, bool isSetup) { - if (_boundsCache.TryGetValue(id, out var cachedBounds)) { + public (Vector3 Min, Vector3 Max)? GetBounds(ulong id, bool isSetup) + { + if (_boundsCache.TryGetValue(id, out var cachedBounds)) + { return cachedBounds; } - try { + try + { (Vector3 Min, Vector3 Max)? result = null; uint datId = (uint)(id & 0xFFFFFFFFu); var resolutions = _dats.ResolveId(datId).ToList(); @@ -631,7 +1676,8 @@ namespace AcDream.App.Rendering.Wb { var type = selectedResolution.Type; var db = selectedResolution.Database; - if (type == DBObjType.Setup) { + if (type == DBObjType.Setup) + { var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue); bool hasBounds = false; @@ -640,17 +1686,22 @@ namespace AcDream.App.Rendering.Wb { _extractor.CollectParts(datId, Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, CancellationToken.None); result = hasBounds ? (min, max) : null; } - else if (type == DBObjType.EnvCell) { + else if (type == DBObjType.EnvCell) + { if (!db.TryGet(datId, out var envCell)) return null; // If bit 32 is set, this is a request for the cell's synthetic geometry only - if ((id & 0x1_0000_0000UL) != 0) { + if ((id & 0x1_0000_0000UL) != 0) + { uint envId = 0x0D000000u | envCell.EnvironmentId; - if (_dats.Portal.TryGet(envId, out var environment)) { - if (environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) { + if (_dats.Portal.TryGet(envId, out var environment)) + { + if (environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) + { var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue); - foreach (var vert in cellStruct.VertexArray.Vertices.Values) { + foreach (var vert in cellStruct.VertexArray.Vertices.Values) + { min = Vector3.Min(min, vert.Origin); max = Vector3.Max(max, vert.Origin); } @@ -658,7 +1709,8 @@ namespace AcDream.App.Rendering.Wb { } } } - else { + else + { var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue); bool hasBounds = false; @@ -668,14 +1720,16 @@ namespace AcDream.App.Rendering.Wb { result = hasBounds ? (min, max) : null; } } - else { + else + { if (!db.TryGet(datId, out var gfxObj)) return null; result = _extractor.ComputeBounds(gfxObj, Vector3.One); } _boundsCache[id] = result; return result; } - catch (Exception ex) { + catch (Exception ex) + { _logger.LogError(ex, "Error computing bounds for 0x{Id:X8}", id); return null; } @@ -689,14 +1743,16 @@ namespace AcDream.App.Rendering.Wb { /// dictionary-orphaned polys are physics/no-draw geometry). Returns null /// when the model has no drawing BSP (caller draws everything). /// - internal static HashSet? CollectDrawingBspPolygonIds(GfxObj gfxObj) { + internal static HashSet? CollectDrawingBspPolygonIds(GfxObj gfxObj) + { if (gfxObj.DrawingBSP?.Root is null) return null; var ids = new HashSet(); CollectDrawingBspPolygonIds(gfxObj.DrawingBSP.Root, ids); return ids; } - private static void CollectDrawingBspPolygonIds(DatReaderWriter.Types.DrawingBSPNode node, HashSet ids) { + private static void CollectDrawingBspPolygonIds(DatReaderWriter.Types.DrawingBSPNode node, HashSet ids) + { if (node.Polygons is not null) foreach (var pid in node.Polygons) ids.Add((ushort)pid); @@ -708,7 +1764,8 @@ namespace AcDream.App.Rendering.Wb { #region Private: GPU Upload - private unsafe ObjectRenderData? UploadGfxObjMeshData(ObjectMeshData meshData) { + private unsafe ObjectRenderData? UploadGfxObjMeshData(ObjectMeshData meshData) + { if (meshData.Vertices.Length == 0) return null; var gl = _graphicsDevice.GL; @@ -719,149 +1776,319 @@ namespace AcDream.App.Rendering.Wb { .Select(batch => batch.Indices.ToArray()) .ToArray(); GlobalMeshAllocation? globalAllocation = null; - - if (_useModernRendering) { - // One mesh owns one vertex range and one contiguous index - // range. The former append path duplicated the full vertex - // array per material and never reclaimed evicted ranges. - vao = GlobalBuffer!.VAO; - vbo = GlobalBuffer!.VBO; - } - else { - gl.GenVertexArrays(1, out vao); - gl.BindVertexArray(vao); - - gl.GenBuffers(1, out vbo); - gl.BindBuffer(GLEnum.ArrayBuffer, vbo); - fixed (VertexPositionNormalTexture* ptr = meshData.Vertices) { - gl.BufferData(GLEnum.ArrayBuffer, (nuint)(meshData.Vertices.Length * VertexPositionNormalTexture.Size), ptr, GLEnum.StaticDraw); - } - GpuMemoryTracker.TrackAllocation(meshData.Vertices.Length * VertexPositionNormalTexture.Size, GpuResourceType.Buffer); - - int stride = VertexPositionNormalTexture.Size; - // Position (location 0) - gl.EnableVertexAttribArray(0); - gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0); - // Normal (location 1) - gl.EnableVertexAttribArray(1); - gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float))); - // TexCoord (location 2) - gl.EnableVertexAttribArray(2); - gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float))); - - // Instance data (shared VBO) - gl.BindBuffer(GLEnum.ArrayBuffer, _graphicsDevice.InstanceVBO); - for (uint i = 0; i < 4; i++) { - var loc = 3 + i; - gl.EnableVertexAttribArray(loc); - gl.VertexAttribPointer(loc, 4, GLEnum.Float, false, (uint)sizeof(InstanceData), (void*)(i * 16)); - gl.VertexAttribDivisor(loc, 1); - } - gl.EnableVertexAttribArray(8); - gl.VertexAttribIPointer(8, 1, GLEnum.UnsignedInt, (uint)sizeof(InstanceData), (void*)64); - gl.VertexAttribDivisor(8, 1); - } - var renderBatches = new List(); + var acquiredTextures = new List<(TextureAtlasManager Atlas, TextureKey Key)>(); + var legacyIndexBuffers = new List<(uint Name, int Bytes)>(); - foreach (var (format, batches) in meshData.TextureBatches) { - foreach (var batch in batches) { - if (batch.Indices.Count == 0) continue; + try + { + if (_useModernRendering) + { + // One mesh owns one vertex range and one contiguous index + // range. The former append path duplicated the full vertex + // array per material and never reclaimed evicted ranges. + vao = GlobalBuffer!.VAO; + vbo = GlobalBuffer!.VBO; + } + else + { + gl.GenVertexArrays(1, out vao); + gl.BindVertexArray(vao); - uint ibo = 0; - TextureAtlasManager? atlasManager = null; - int textureIndex = 0; - uint firstIndex = 0; - int batchBaseVertex = 0; - - // Find or create a shared atlas with free space - if (!_globalAtlases.TryGetValue(format, out var atlasList)) { - atlasList = new List(); - _globalAtlases[format] = atlasList; + gl.GenBuffers(1, out vbo); + gl.BindBuffer(GLEnum.ArrayBuffer, vbo); + fixed (VertexPositionNormalTexture* ptr = meshData.Vertices) + { + gl.BufferData(GLEnum.ArrayBuffer, (nuint)(meshData.Vertices.Length * VertexPositionNormalTexture.Size), ptr, GLEnum.StaticDraw); } + GpuMemoryTracker.TrackAllocation(meshData.Vertices.Length * VertexPositionNormalTexture.Size, GpuResourceType.Buffer); - atlasManager = atlasList.FirstOrDefault(a => a.FreeSlots > 0 || a.HasTexture(batch.Key)); - if (atlasManager == null) { - atlasManager = new TextureAtlasManager(_graphicsDevice, format.Width, format.Height, format.Format); - atlasList.Add(atlasManager); + int stride = VertexPositionNormalTexture.Size; + // Position (location 0) + gl.EnableVertexAttribArray(0); + gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0); + // Normal (location 1) + gl.EnableVertexAttribArray(1); + gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float))); + // TexCoord (location 2) + gl.EnableVertexAttribArray(2); + gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float))); + + // Instance data (shared VBO) + gl.BindBuffer(GLEnum.ArrayBuffer, _graphicsDevice.InstanceVBO); + for (uint i = 0; i < 4; i++) + { + var loc = 3 + i; + gl.EnableVertexAttribArray(loc); + gl.VertexAttribPointer(loc, 4, GLEnum.Float, false, (uint)sizeof(InstanceData), (void*)(i * 16)); + gl.VertexAttribDivisor(loc, 1); } + gl.EnableVertexAttribArray(8); + gl.VertexAttribIPointer(8, 1, GLEnum.UnsignedInt, (uint)sizeof(InstanceData), (void*)64); + gl.VertexAttribDivisor(8, 1); + } - // MP1a: AcDream.Content is Silk.NET-free — the extraction records - // carry Content-owned UploadPixelFormat/UploadPixelType enums whose - // underlying values are the GL ABI constants (numerically identical - // to Silk.NET.OpenGL.PixelFormat/PixelType), so this lifted nullable - // cast is value- and null-preserving. - textureIndex = atlasManager.AddTexture(batch.Key, batch.TextureData, - (PixelFormat?)batch.UploadPixelFormat, (PixelType?)batch.UploadPixelType); + // Allocate the shared vertex/index range before acquiring texture + // references. A buffer-growth failure therefore leaves every atlas + // untouched; later failures still roll this allocation back below. + if (_useModernRendering && modernIndexBatches.Length != 0) + globalAllocation = GlobalBuffer!.UploadMesh(meshData.Vertices, modernIndexBatches); - if (_useModernRendering) { - ibo = GlobalBuffer!.IBO; - } - else { - gl.GenBuffers(1, out ibo); - gl.BindBuffer(GLEnum.ElementArrayBuffer, ibo); - var indexArray = batch.Indices.ToArray(); - fixed (ushort* iptr = indexArray) { - gl.BufferData(GLEnum.ElementArrayBuffer, (nuint)(indexArray.Length * sizeof(ushort)), iptr, GLEnum.StaticDraw); + foreach (var (format, batches) in meshData.TextureBatches) + { + foreach (var batch in batches) + { + if (batch.Indices.Count == 0) continue; + + uint ibo = 0; + TextureAtlasManager? atlasManager = null; + int textureIndex = 0; + uint firstIndex = 0; + int batchBaseVertex = 0; + + // Find or create a shared atlas with free space + if (!_globalAtlases.TryGetValue(format, out var atlasList)) + { + atlasList = new List(); + _globalAtlases[format] = atlasList; } - GpuMemoryTracker.TrackAllocation(indexArray.Length * sizeof(ushort), GpuResourceType.Buffer); + + // Existing-key lookup must win across the entire atlas + // family. Choosing an earlier reclaimed slot first + // duplicates a layer already resident in a later array + // every time portal churn revisits that texture. + atlasManager = atlasList.FirstOrDefault(a => a.HasTexture(batch.Key)) + ?? atlasList.FirstOrDefault(a => a.AvailableSlots > 0); + if (atlasManager == null) + { + atlasManager = new TextureAtlasManager( + _graphicsDevice, + format.Width, + format.Height, + format.Format, + OnAtlasGpuSafeEmpty); + atlasList.Add(atlasManager); + } + atlasManager.LastUseSequence = ++_atlasUseSequence; + + // MP1a: AcDream.Content is Silk.NET-free — the extraction records + // carry Content-owned UploadPixelFormat/UploadPixelType enums whose + // underlying values are the GL ABI constants (numerically identical + // to Silk.NET.OpenGL.PixelFormat/PixelType), so this lifted nullable + // cast is value- and null-preserving. + bool uploadsNewLayer = !atlasManager.HasTexture(batch.Key); + try + { + textureIndex = atlasManager.AddTexture(batch.Key, batch.TextureData, + (PixelFormat?)batch.UploadPixelFormat, (PixelType?)batch.UploadPixelType); + } + catch + { + if (atlasManager.IsGpuSafeEmpty) + OnAtlasGpuSafeEmpty(atlasManager); + throw; + } + acquiredTextures.Add((atlasManager, batch.Key)); + // AddTexture may first publish a previously failed + // layer return, whose empty-atlas observer marks this + // array unowned. Reassert active ownership only after + // the new acquisition commits so that callback cannot + // leave a live atlas in the empty-array eviction LRU. + MarkAtlasActive(atlasManager); + if (uploadsNewLayer) + _dirtyAtlases.Add(atlasManager); + + if (_useModernRendering) + { + ibo = GlobalBuffer!.IBO; + } + else + { + gl.GenBuffers(1, out ibo); + gl.BindBuffer(GLEnum.ElementArrayBuffer, ibo); + var indexArray = batch.Indices.ToArray(); + fixed (ushort* iptr = indexArray) + { + gl.BufferData(GLEnum.ElementArrayBuffer, (nuint)(indexArray.Length * sizeof(ushort)), iptr, GLEnum.StaticDraw); + } + GpuMemoryTracker.TrackAllocation(indexArray.Length * sizeof(ushort), GpuResourceType.Buffer); + legacyIndexBuffers.Add((ibo, indexArray.Length * sizeof(ushort))); + } + + ulong bindlessHandle = batch.HasWrappingUVs + ? atlasManager.TextureArray.BindlessWrapHandle + : atlasManager.TextureArray.BindlessClampHandle; + + renderBatches.Add(new ObjectRenderBatch + { + IBO = ibo, + IndexCount = batch.Indices.Count, + Atlas = atlasManager!, + TextureIndex = textureIndex, + TextureSize = (format.Width, format.Height), + TextureFormat = format.Format, + IsTransparent = batch.IsTransparent, + IsAdditive = batch.IsAdditive, + HasWrappingUVs = batch.HasWrappingUVs, + Key = batch.Key, + CullMode = batch.CullMode, + FirstIndex = firstIndex, + BaseVertex = (uint)batchBaseVertex, + BindlessTextureHandle = bindlessHandle, + }); + } + } + + if (_useModernRendering && globalAllocation is not null) + { + if (renderBatches.Count != globalAllocation.BatchFirstIndices.Count) + { + throw new InvalidOperationException("Global mesh batch allocation count mismatch."); } - ulong bindlessHandle = batch.HasWrappingUVs - ? atlasManager.TextureArray.BindlessWrapHandle - : atlasManager.TextureArray.BindlessClampHandle; - - renderBatches.Add(new ObjectRenderBatch { - IBO = ibo, - IndexCount = batch.Indices.Count, - Atlas = atlasManager!, - TextureIndex = textureIndex, - TextureSize = (format.Width, format.Height), - TextureFormat = format.Format, - IsTransparent = batch.IsTransparent, - IsAdditive = batch.IsAdditive, - HasWrappingUVs = batch.HasWrappingUVs, - Key = batch.Key, - CullMode = batch.CullMode, - FirstIndex = firstIndex, - BaseVertex = (uint)batchBaseVertex, - BindlessTextureHandle = bindlessHandle, - }); - } - } - - if (_useModernRendering && modernIndexBatches.Length != 0) { - globalAllocation = GlobalBuffer!.UploadMesh(meshData.Vertices, modernIndexBatches); - if (renderBatches.Count != globalAllocation.BatchFirstIndices.Count) { - GlobalBuffer.Release(globalAllocation); - throw new InvalidOperationException("Global mesh batch allocation count mismatch."); + for (int i = 0; i < renderBatches.Count; i++) + { + renderBatches[i].BaseVertex = (uint)globalAllocation.Vertices.Offset; + renderBatches[i].FirstIndex = (uint)globalAllocation.BatchFirstIndices[i]; + } } - for (int i = 0; i < renderBatches.Count; i++) { - renderBatches[i].BaseVertex = (uint)globalAllocation.Vertices.Offset; - renderBatches[i].FirstIndex = (uint)globalAllocation.BatchFirstIndices[i]; + long geometryBytes = checked( + (long)meshData.Vertices.Length * VertexPositionNormalTexture.Size + + renderBatches.Sum(b => (long)b.IndexCount * sizeof(ushort))); + var renderData = new ObjectRenderData + { + VAO = vao, + VBO = vbo, + VertexCount = meshData.Vertices.Length, + Batches = renderBatches, + GlobalAllocation = globalAllocation, + ParticleEmitters = meshData.ParticleEmitters, + DIDDegrade = meshData.DIDDegrade, + CPUPositions = meshData.Vertices.Select(v => v.Position).ToArray(), + CPUIndices = meshData.TextureBatches.Values.SelectMany(l => l).SelectMany(b => b.Indices).ToArray(), + CPUEdgeLines = meshData.EdgeLines, + MemorySize = geometryBytes, + NonArenaGpuBytes = CalculateNonArenaGeometryBytes( + _useModernRendering, + geometryBytes), + }; + + if (!_useModernRendering) + { + gl.BindVertexArray(0); } + return renderData; + } + catch (Exception uploadFailure) + { + RetryableResourceReleaseLedger rollback = CreateUploadRollback( + meshData, + gl, + vao, + vbo, + globalAllocation, + acquiredTextures, + legacyIndexBuffers); + ResourceReleaseAttempt attempt = rollback.Advance(); + if (!rollback.IsComplete) + { + _uploadRollbacks[meshData.ObjectId] = rollback; + _uploadRollbackQueue.Enqueue(meshData.ObjectId); + } + + if (!attempt.HasFailures) + throw; + + throw new AggregateException( + $"Mesh 0x{meshData.ObjectId:X10} upload and rollback failed.", + uploadFailure, + attempt.ToException( + $"Mesh 0x{meshData.ObjectId:X10} upload rollback had unfinished resources.")); + } + } + + private RetryableResourceReleaseLedger CreateUploadRollback( + ObjectMeshData meshData, + GL gl, + uint vao, + uint vbo, + GlobalMeshAllocation? globalAllocation, + IReadOnlyList<(TextureAtlasManager Atlas, TextureKey Key)> acquiredTextures, + IReadOnlyList<(uint Name, int Bytes)> legacyIndexBuffers) + { + var releases = new List<(string Name, Action Release)>(); + + if (globalAllocation is not null) + { + releases.Add(( + "global-index-range", + () => GlobalBuffer!.AbortIndexRange(globalAllocation))); + releases.Add(( + "global-vertex-range", + () => GlobalBuffer!.AbortVertexRange(globalAllocation))); } - var renderData = new ObjectRenderData { - VAO = vao, - VBO = vbo, - VertexCount = meshData.Vertices.Length, - Batches = renderBatches, - GlobalAllocation = globalAllocation, - ParticleEmitters = meshData.ParticleEmitters, - DIDDegrade = meshData.DIDDegrade, - CPUPositions = meshData.Vertices.Select(v => v.Position).ToArray(), - CPUIndices = meshData.TextureBatches.Values.SelectMany(l => l).SelectMany(b => b.Indices).ToArray(), - CPUEdgeLines = meshData.EdgeLines, - MemorySize = (meshData.Vertices.Length * VertexPositionNormalTexture.Size) + - renderBatches.Sum(b => (long)b.IndexCount * sizeof(ushort)) - }; - - if (!_useModernRendering) { - gl.BindVertexArray(0); + // AddTexture is a reference-counted acquisition even when the + // pixels already existed. Each acquisition owns an independent + // marker because one texture-release failure must not skip the + // remaining layers or replay successful decrements later. + for (int i = acquiredTextures.Count - 1; i >= 0; i--) + { + int releaseIndex = i; + releases.Add(( + $"atlas-texture-{releaseIndex}", + () => ReleaseAtlasTexture( + acquiredTextures[releaseIndex].Atlas, + acquiredTextures[releaseIndex].Key))); } - return renderData; + + if (!_useModernRendering) + { + for (int i = 0; i < legacyIndexBuffers.Count; i++) + { + int bufferIndex = i; + releases.Add(( + $"legacy-index-buffer-{bufferIndex}-delete", + () => gl.DeleteBuffer(legacyIndexBuffers[bufferIndex].Name))); + releases.Add(( + $"legacy-index-buffer-{bufferIndex}-accounting", + () => GpuMemoryTracker.TrackDeallocation( + legacyIndexBuffers[bufferIndex].Bytes, + GpuResourceType.Buffer))); + } + + if (vbo != 0) + { + releases.Add(("legacy-vertex-buffer-delete", () => gl.DeleteBuffer(vbo))); + releases.Add(( + "legacy-vertex-buffer-accounting", + () => GpuMemoryTracker.TrackDeallocation( + meshData.Vertices.Length * VertexPositionNormalTexture.Size, + GpuResourceType.Buffer))); + } + if (vao != 0) + releases.Add(("legacy-vertex-array-delete", () => gl.DeleteVertexArray(vao))); + } + + return new RetryableResourceReleaseLedger(releases); + } + + internal static RetryableResourceReleaseLedger CreateSetupPartRollback( + IReadOnlyList acquiredParts, + Action releasePart) + { + ArgumentNullException.ThrowIfNull(acquiredParts); + ArgumentNullException.ThrowIfNull(releasePart); + var releases = new List<(string Name, Action Release)>(acquiredParts.Count); + for (int i = acquiredParts.Count - 1; i >= 0; i--) + { + int releaseIndex = i; + releases.Add(( + $"setup-part-{releaseIndex}", + () => releasePart(acquiredParts[releaseIndex]))); + } + return new RetryableResourceReleaseLedger(releases); } #endregion @@ -870,23 +2097,30 @@ namespace AcDream.App.Rendering.Wb { #region Raycasting - public bool IntersectMesh(ObjectRenderData renderData, Matrix4x4 transform, Vector3 rayOrigin, Vector3 rayDirection, out float distance, out Vector3 normal) { + public bool IntersectMesh(ObjectRenderData renderData, Matrix4x4 transform, Vector3 rayOrigin, Vector3 rayDirection, out float distance, out Vector3 normal) + { return IntersectMeshInternal(renderData, transform, rayOrigin, rayDirection, 0, out distance, out normal); } - private bool IntersectMeshInternal(ObjectRenderData renderData, Matrix4x4 transform, Vector3 rayOrigin, Vector3 rayDirection, int depth, out float distance, out Vector3 normal) { + private bool IntersectMeshInternal(ObjectRenderData renderData, Matrix4x4 transform, Vector3 rayOrigin, Vector3 rayDirection, int depth, out float distance, out Vector3 normal) + { distance = float.MaxValue; normal = Vector3.UnitZ; bool hit = false; if (depth > 32) return false; // Prevent stack overflow from circular setups - if (renderData.IsSetup) { - foreach (var part in renderData.SetupParts) { + if (renderData.IsSetup) + { + foreach (var part in renderData.SetupParts) + { var partData = TryGetRenderData(part.GfxObjId); - if (partData != null) { - if (IntersectMeshInternal(partData, part.Transform * transform, rayOrigin, rayDirection, depth + 1, out float d, out Vector3 n)) { - if (d < distance) { + if (partData != null) + { + if (IntersectMeshInternal(partData, part.Transform * transform, rayOrigin, rayDirection, depth + 1, out float d, out Vector3 n)) + { + if (d < distance) + { distance = d; normal = n; hit = true; @@ -897,12 +2131,15 @@ namespace AcDream.App.Rendering.Wb { return hit; } - if (renderData.CPUPositions.Length == 0 || renderData.CPUIndices.Length == 0) { + if (renderData.CPUPositions.Length == 0 || renderData.CPUIndices.Length == 0) + { // Fallback to sphere if no CPU mesh data - if (renderData.SelectionSphere != null && renderData.SelectionSphere.Radius > 0.001f) { + if (renderData.SelectionSphere != null && renderData.SelectionSphere.Radius > 0.001f) + { var worldOrigin = Vector3.Transform(renderData.SelectionSphere.Origin, transform); float radius = renderData.SelectionSphere.Radius * transform.Translation.Length(); // Rough scale - if (GeometryUtils.RayIntersectsSphere(rayOrigin, rayDirection, worldOrigin, radius, out distance)) { + if (GeometryUtils.RayIntersectsSphere(rayOrigin, rayDirection, worldOrigin, radius, out distance)) + { normal = Vector3.Normalize(rayOrigin + rayDirection * distance - worldOrigin); return true; } @@ -916,18 +2153,21 @@ namespace AcDream.App.Rendering.Wb { Vector3 localDirection = Vector3.Normalize(Vector3.TransformNormal(rayDirection, invTransform)); // Iterate through triangles - for (int i = 0; i < renderData.CPUIndices.Length; i += 3) { + for (int i = 0; i < renderData.CPUIndices.Length; i += 3) + { Vector3 v0 = renderData.CPUPositions[renderData.CPUIndices[i]]; Vector3 v1 = renderData.CPUPositions[renderData.CPUIndices[i + 1]]; Vector3 v2 = renderData.CPUPositions[renderData.CPUIndices[i + 2]]; - if (GeometryUtils.RayIntersectsTriangle(localOrigin, localDirection, v0, v1, v2, out float t)) { + if (GeometryUtils.RayIntersectsTriangle(localOrigin, localDirection, v0, v1, v2, out float t)) + { // Convert t back to world space distance Vector3 hitPointLocal = localOrigin + localDirection * t; Vector3 hitPointWorld = Vector3.Transform(hitPointLocal, transform); float worldDist = Vector3.Distance(rayOrigin, hitPointWorld); - if (worldDist < distance) { + if (worldDist < distance) + { distance = worldDist; // Calculate normal in local space and transform to world space @@ -935,7 +2175,8 @@ namespace AcDream.App.Rendering.Wb { normal = Vector3.Normalize(Vector3.TransformNormal(localNormal, transform)); // Ensure normal faces the ray - if (Vector3.Dot(normal, rayDirection) > 0) { + if (Vector3.Dot(normal, rayDirection) > 0) + { normal = -normal; } @@ -949,71 +2190,286 @@ namespace AcDream.App.Rendering.Wb { #endregion - private void UnloadObject(ulong key) { - if (!_renderData.TryGetValue(key, out var data)) return; + private long GetObjectReclaimableBytes(ulong key) + { + if (_objectReleases.TryGetValue(key, out ObjectReleaseTicket? release)) + return release.ReclaimableBytes; + return _renderData.TryGetValue(key, out ObjectRenderData? data) + ? GetReclaimableBytes(data) + : 0; + } - var gl = _graphicsDevice.GL; - if (!_useModernRendering) { - if (data.VAO != 0) gl.DeleteVertexArray(data.VAO); - if (data.VBO != 0) { - gl.DeleteBuffer(data.VBO); - GpuMemoryTracker.TrackDeallocation(data.VertexCount * VertexPositionNormalTexture.Size, GpuResourceType.Buffer); - } + private ObjectReleaseTicket? GetOrCreateObjectRelease(ulong key) + { + if (_objectReleases.TryGetValue(key, out ObjectReleaseTicket? existing)) + return existing; + if (!_renderData.TryGetValue(key, out ObjectRenderData? data)) + return null; - foreach (var batch in data.Batches) { - if (batch.IBO != 0) { - gl.DeleteBuffer(batch.IBO); - GpuMemoryTracker.TrackDeallocation(batch.IndexCount * sizeof(ushort), GpuResourceType.Buffer); - } - if (batch.Atlas != null) { - batch.Atlas.ReleaseTexture(batch.Key); - if (batch.Atlas.UsedSlots == 0) { - batch.Atlas.Dispose(); - var keyTuple = (batch.TextureSize.Width, batch.TextureSize.Height, batch.TextureFormat); - if (_globalAtlases.TryGetValue(keyTuple, out var list)) { - list.Remove(batch.Atlas); - } - } - } + var releases = new List<(string Name, Action Release)>(); + GL gl = _graphicsDevice.GL; + if (_useModernRendering) + { + if (data.GlobalAllocation is { } allocation) + { + releases.Add(( + "global-index-range", + () => GlobalBuffer!.ReleaseIndexRange(allocation))); + releases.Add(( + "global-vertex-range", + () => GlobalBuffer!.ReleaseVertexRange(allocation))); } } - else { - if (data.GlobalAllocation is { } allocation) { - GlobalBuffer!.Release(allocation); - data.GlobalAllocation = null; + else + { + if (data.VAO != 0) + releases.Add(("legacy-vertex-array-delete", () => gl.DeleteVertexArray(data.VAO))); + if (data.VBO != 0) + { + releases.Add(("legacy-vertex-buffer-delete", () => gl.DeleteBuffer(data.VBO))); + releases.Add(( + "legacy-vertex-buffer-accounting", + () => GpuMemoryTracker.TrackDeallocation( + data.VertexCount * VertexPositionNormalTexture.Size, + GpuResourceType.Buffer))); } - foreach (var batch in data.Batches) { - if (batch.Atlas != null) { - batch.Atlas.ReleaseTexture(batch.Key); - if (batch.Atlas.UsedSlots == 0) { - batch.Atlas.Dispose(); - var keyTuple = (batch.TextureSize.Width, batch.TextureSize.Height, batch.TextureFormat); - if (_globalAtlases.TryGetValue(keyTuple, out var list)) { - list.Remove(batch.Atlas); - } - } - } + + for (int i = 0; i < data.Batches.Count; i++) + { + int batchIndex = i; + ObjectRenderBatch batch = data.Batches[batchIndex]; + if (batch.IBO == 0) + continue; + releases.Add(( + $"legacy-index-buffer-{batchIndex}-delete", + () => gl.DeleteBuffer(data.Batches[batchIndex].IBO))); + releases.Add(( + $"legacy-index-buffer-{batchIndex}-accounting", + () => GpuMemoryTracker.TrackDeallocation( + data.Batches[batchIndex].IndexCount * sizeof(ushort), + GpuResourceType.Buffer))); } } - if (data.IsSetup) { - foreach (var (partId, _) in data.SetupParts) { - DecrementRefCount(partId); + for (int i = 0; i < data.Batches.Count; i++) + { + int batchIndex = i; + ObjectRenderBatch batch = data.Batches[batchIndex]; + if (batch.Atlas is null) + continue; + releases.Add(( + $"atlas-texture-{batchIndex}", + () => ReleaseAtlasTexture( + data.Batches[batchIndex].Atlas, + data.Batches[batchIndex].Key))); + } + + if (data.IsSetup) + { + for (int i = 0; i < data.SetupParts.Count; i++) + { + int partIndex = i; + releases.Add(( + $"setup-part-{partIndex}", + () => DecrementRefCount(data.SetupParts[partIndex].GfxObjId))); } } - _currentGpuMemory -= data.MemorySize; - _renderData.TryRemove(key, out _); - lock (_lruList) { + releases.Add(( + "non-arena-memory-accounting", + () => _currentNonArenaGpuMemory = checked( + _currentNonArenaGpuMemory - data.NonArenaGpuBytes))); + + var ticket = new ObjectReleaseTicket( + key, + data, + GetReclaimableBytes(data), + new RetryableResourceReleaseLedger(releases)); + _objectReleases.Add(key, ticket); + return ticket; + } + + private bool TryAdvanceObjectRelease(ulong key, out long reclaimedBytes) + { + reclaimedBytes = 0; + ObjectReleaseTicket? ticket = GetOrCreateObjectRelease(key); + if (ticket is null) + { + if (!_ownership.IsOwned(key)) + _ownership.Remove(key); + return false; + } + + ResourceReleaseAttempt attempt = ticket.Resources.Advance(); + if (!ticket.Resources.IsComplete) + { + if (!ticket.IsQueued) + { + ticket.IsQueued = true; + _objectReleaseQueue.Enqueue(key); + } + LogReleaseFailure( + key, + "resource release", + ticket.Resources.RemainingCount, + attempt); + return false; + } + + if (_renderData.TryGetValue(key, out ObjectRenderData? current) + && ReferenceEquals(current, ticket.Data)) + { + _renderData.TryRemove(key, out _); + } + _objectReleases.Remove(key); + if (!_ownership.IsOwned(key)) + _ownership.Remove(key); + lock (_lruList) _lruList.Remove(key); + reclaimedBytes = ticket.ReclaimableBytes; + LogReleaseFailure(key, "resource release", 0, attempt); + return true; + } + + private static void ReleaseAtlasTexture( + TextureAtlasManager atlas, + TextureKey key) + { + try + { + atlas.ReleaseTexture(key); + } + catch (Exception error) when (!atlas.HasTexture(key)) + { + // TextureAtlasManager removes the logical key before it asks + // the frame-fence owner to recycle the physical layer. An + // observer/publication failure can therefore throw after the + // decrement committed. Translate that observable state into + // the shared committed-outcome contract so no later cleanup + // decrements the same acquisition again. + throw new MeshReferenceMutationException( + $"Texture {key} was released from atlas slot {atlas.Slot}, but its retirement callback failed.", + mutationCommitted: true, + error); + } + } + + private bool TryAdvanceUploadRollback(ulong key) + { + if (!_uploadRollbacks.TryGetValue(key, out RetryableResourceReleaseLedger? rollback)) + return true; + + ResourceReleaseAttempt attempt = rollback.Advance(); + if (!rollback.IsComplete) + { + LogReleaseFailure( + key, + "upload rollback", + rollback.RemainingCount, + attempt); + return false; + } + + _uploadRollbacks.Remove(key); + LogReleaseFailure(key, "upload rollback", 0, attempt); + return true; + } + + private void LogReleaseFailure( + ulong key, + string operation, + int remaining, + ResourceReleaseAttempt attempt) + { + if (!attempt.HasFailures) + return; + _logger.LogError( + attempt.ToException( + $"Mesh 0x{key:X10} {operation} reported exceptional resource outcomes."), + "Mesh 0x{Id:X10} {Operation} completed {Completed} of {Attempted} attempted stages; {Remaining} remain", + key, + operation, + attempt.CompletedCount, + attempt.AttemptedCount, + remaining); + } + + private (int Count, long Bytes) AdvancePendingObjectReleases( + int maximumCount, + long maximumBytes) + { + int count = 0; + long bytes = 0; + int attempts = Math.Min(maximumCount, _objectReleaseQueue.Count); + for (int i = 0; i < attempts; i++) + { + ulong key = _objectReleaseQueue.Dequeue(); + if (!_objectReleases.TryGetValue(key, out ObjectReleaseTicket? ticket)) + continue; + ticket.IsQueued = false; + if (!FitsReclamationBudget( + ticket.ReclaimableBytes, + bytes, + maximumBytes)) + { + ticket.IsQueued = true; + _objectReleaseQueue.Enqueue(key); + continue; + } + if (!TryAdvanceObjectRelease(key, out long completedBytes)) + continue; + + bytes = checked(bytes + completedBytes); + count++; + } + return (count, bytes); + } + + private void AdvancePendingUploadRollbacks(int maximumCount) + { + int attempts = Math.Min(maximumCount, _uploadRollbackQueue.Count); + for (int i = 0; i < attempts; i++) + { + ulong key = _uploadRollbackQueue.Dequeue(); + if (!_uploadRollbacks.ContainsKey(key)) + continue; + try + { + TryAdvanceUploadRollback(key); + } + finally + { + if (_uploadRollbacks.ContainsKey(key)) + _uploadRollbackQueue.Enqueue(key); + } } } #endregion - public void Dispose() { - if (IsDisposed) return; + public void Dispose() + { + lock (_disposeGate) + { + if (_disposeCompleted) + return; + if (_disposeRunning) + return; + _disposeRunning = true; + try + { + DisposeCore(); + _disposeCompleted = true; + } + finally + { + _disposeRunning = false; + } + } + } + private void DisposeCore() + { // Quiesce the background decode workers BEFORE returning: the owner // disposes the DatCollection right after this adapter chain, which // unmaps the dats' memory-mapped views. A worker still inside @@ -1023,58 +2479,150 @@ namespace AcDream.App.Rendering.Wb { // queue lock publishes it to workers, which re-check it before every // dequeue; draining the queue means each worker exits after at most // its current (millisecond-scale) item. - lock (_pendingRequests) { - IsDisposed = true; - foreach (var (id, _, tcs, _) in _pendingRequests) { - tcs.TrySetCanceled(); - _preparationTasks.TryRemove(id, out _); + List? failures = null; + static void Capture(ref List? failures, Action action) + { + try { action(); } + catch (Exception ex) { (failures ??= []).Add(ex); } + } + if (!_workersQuiesced) + { + PreparationRequest[] pendingToCancel; + PreparationRequest[] activeToCancel; + lock (_pendingRequests) + { + IsDisposed = true; + pendingToCancel = _pendingRequests.ToArray(); + activeToCancel = _activePreparationById.Values.ToArray(); + foreach (PreparationRequest request in pendingToCancel) + _preparationTasks.TryRemove(request.Id, out _); + _pendingRequests.Clear(); + _pendingRequestById.Clear(); + _envCellDescriptors.Clear(); + _terminalPreparationFailures.Clear(); } - _pendingRequests.Clear(); - _pendingEnvCellRequests.Clear(); - } - var deadline = System.Environment.TickCount64 + 10_000; - while (System.Environment.TickCount64 < deadline) { - lock (_pendingRequests) { - if (_activeWorkers == 0) break; + foreach (PreparationRequest request in pendingToCancel) + { + Capture(ref failures, request.Cancel); + request.Completion.TrySetCanceled(request.Cancellation.Token); + Capture(ref failures, request.DisposeCancellation); } - Thread.Sleep(5); - } - lock (_pendingRequests) { - if (_activeWorkers > 0) - _logger.LogError( - "Dispose: {Count} mesh-decode workers still active after 10s — dat teardown may race in-flight reads", - _activeWorkers); - } + foreach (PreparationRequest request in activeToCancel) + Capture(ref failures, request.Cancel); - _graphicsDevice.QueueGLAction(gl => { - foreach (var data in _renderData.Values) { - if (!_useModernRendering) { - if (data.VAO != 0) gl.DeleteVertexArray(data.VAO); - if (data.VBO != 0) { - gl.DeleteBuffer(data.VBO); - GpuMemoryTracker.TrackDeallocation(data.VertexCount * VertexPositionNormalTexture.Size, GpuResourceType.Buffer); - } - foreach (var batch in data.Batches) { - if (batch.IBO != 0) { - gl.DeleteBuffer(batch.IBO); - GpuMemoryTracker.TrackDeallocation(batch.IndexCount * sizeof(ushort), GpuResourceType.Buffer); - } - } + // Release every persistent worker from its zero-CPU wait so it can + // observe IsDisposed and terminate before DAT mappings are freed. + Capture(ref failures, _preparationWorkAvailable.Set); + Task[] workers; + lock (_pendingRequests) + workers = _workerTasks.ToArray(); + Capture(ref failures, () => Task.WhenAll(workers).GetAwaiter().GetResult()); + lock (_pendingRequests) + { + _workerTasks.RemoveWhere(worker => worker.IsCompleted); + if (_workerTasks.Count != 0) + { + (failures ??= []).Add( + new InvalidOperationException("Mesh workers remained live after their join completed.")); + } + else + { + _workersQuiesced = true; } } - _renderData.Clear(); + } - foreach (var atlasList in _globalAtlases.Values) { - foreach (var atlas in atlasList) { - atlas.Dispose(); + // First converge every exact per-object and failed-upload ledger. + // Atlas arrays and the global arena are backing stores for these + // entries and cannot be destroyed until no child release remains. + ulong[] objectIds = _renderData.Keys + .Concat(_objectReleases.Keys) + .Distinct() + .ToArray(); + for (int i = 0; i < objectIds.Length; i++) + { + ulong id = objectIds[i]; + try + { + if (!TryAdvanceObjectRelease(id, out _)) + (failures ??= []).Add(new InvalidOperationException( + $"Mesh 0x{id:X10} still has unfinished resource-release stages.")); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } + ulong[] rollbackIds = _uploadRollbacks.Keys.ToArray(); + for (int i = 0; i < rollbackIds.Length; i++) + { + ulong id = rollbackIds[i]; + try + { + if (!TryAdvanceUploadRollback(id)) + (failures ??= []).Add(new InvalidOperationException( + $"Mesh 0x{id:X10} still has unfinished upload-rollback stages.")); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } + + if (_objectReleases.Count != 0 || _uploadRollbacks.Count != 0) + { + throw new AggregateException( + "One or more mesh resource transactions remain unfinished.", + failures ?? [new InvalidOperationException("Mesh resource teardown did not converge.")]); + } + + // Every layer release has now committed logically. Publish any + // callback which previously failed before queue acceptance. + Capture(ref failures, RetryPendingAtlasRetirements); + + bool atlasFailure = false; + foreach (List atlasList in _globalAtlases.Values) + { + foreach (TextureAtlasManager atlas in atlasList) + { + try { atlas.Dispose(); } + catch (Exception error) + { + atlasFailure = true; + (failures ??= []).Add(error); } } - _globalAtlases.Clear(); + } + if (atlasFailure) + throw new AggregateException( + "One or more texture atlases could not be disposed.", + failures!); + + if (_useModernRendering && GlobalBuffer is not null) + Capture(ref failures, GlobalBuffer.Dispose); + + if (failures is not null) + throw new AggregateException("One or more mesh-manager teardown operations failed.", failures); + + _renderData.Clear(); + _objectReleases.Clear(); + _objectReleaseQueue.Clear(); + _uploadRollbacks.Clear(); + _uploadRollbackQueue.Clear(); + _globalAtlases.Clear(); + _dirtyAtlases.Clear(); + _safeEmptyAtlases.Clear(); + _currentNonArenaGpuMemory = 0; + _cpuMeshCache.Clear(); + lock (_lruList) + _lruList.Clear(); + + if (!_workSignalDisposed) + { + _preparationWorkAvailable.Dispose(); + _workSignalDisposed = true; + } - if (_useModernRendering) { - GlobalBuffer?.Dispose(); - } - }); } } } diff --git a/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs b/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs index 79f8e0c4..fbb65575 100644 --- a/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs +++ b/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs @@ -22,17 +22,43 @@ namespace AcDream.App.Rendering.Wb { public unsafe class OpenGLGraphicsDevice : BaseGraphicsDevice { private readonly ILogger _log; private readonly DebugRenderSettings _renderSettings; + private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue _resourceRetirement; public GL GL { get; } public DebugRenderSettings RenderSettings => _renderSettings; private readonly ConcurrentQueue> _glThreadQueue = new(); + private readonly ConcurrentQueue> _nextGlThreadQueue = new(); + + internal bool HasPendingGLWork => + !_glThreadQueue.IsEmpty || !_nextGlThreadQueue.IsEmpty; public void QueueGLAction(Action action) { _glThreadQueue.Enqueue(action); } + internal void QueueGLActionForNextPass(Action action) { + ArgumentNullException.ThrowIfNull(action); + _nextGlThreadQueue.Enqueue(action); + } + public void ProcessGLQueue() { + // Retry the prior pass before ordinary work (notably sampler + // deletion), but process only the captured generation so a + // persistent driver failure cannot spin this frame forever. + int retryCount = _nextGlThreadQueue.Count; + for (int i = 0; i < retryCount && _nextGlThreadQueue.TryDequeue(out Action? retry); i++) { + try { + retry(GL); + } catch (Exception ex) { + _log.LogError(ex, "Error processing retryable GL queue action"); + } + } + // Normal actions retain drain-to-empty semantics because teardown + // actions intentionally enqueue dependent atlas releases here. + // A persistent retryable error must not starve unrelated uploads + // and releases forever: the retry generation remains bounded to + // one attempt per pass, while ordinary work still makes progress. while (_glThreadQueue.TryDequeue(out var action)) { try { action(GL); @@ -61,6 +87,7 @@ namespace AcDream.App.Rendering.Wb { public uint WrapSampler { get; private set; } /// OpenGL sampler object with TextureWrapMode.ClampToEdge (for meshes without wrapping UVs). public uint ClampSampler { get; private set; } + internal float MaxSupportedAnisotropy { get; private set; } private ManagedGLUniformBuffer? _sceneDataBuffer; /// Shared SceneData UBO. @@ -83,12 +110,23 @@ namespace AcDream.App.Rendering.Wb { protected OpenGLGraphicsDevice() : base() { _log = null!; _renderSettings = null!; + _resourceRetirement = null!; GL = null!; } - public OpenGLGraphicsDevice(GL gl, ILogger log, DebugRenderSettings renderSettings, bool allowBindless = true) : base() { + public OpenGLGraphicsDevice(GL gl, ILogger log, DebugRenderSettings renderSettings, bool allowBindless = true) + : this(gl, log, renderSettings, AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance, allowBindless) { + } + + internal OpenGLGraphicsDevice( + GL gl, + ILogger log, + DebugRenderSettings renderSettings, + AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement, + bool allowBindless = true) : base() { _log = log; _renderSettings = renderSettings; + _resourceRetirement = resourceRetirement ?? throw new ArgumentNullException(nameof(resourceRetirement)); GL = gl; GLHelpers.Init(this, log); @@ -114,26 +152,30 @@ namespace AcDream.App.Rendering.Wb { GL.GenBuffers(1, out uint instanceVbo); InstanceVBO = instanceVbo; + // Query this immutable device limit once. Atlas construction can + // happen hundreds of times during portal streaming; repeating a + // driver GetFloat for every texture serialized the upload burst. + if (renderSettings.EnableAnisotropicFiltering) { + GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out float maxAniso); + MaxSupportedAnisotropy = Math.Max(0f, maxAniso); + } + // Create sampler objects for wrap vs clamp WrapSampler = GL.GenSampler(); GL.SamplerParameter(WrapSampler, SamplerParameterI.WrapS, (int)TextureWrapMode.Repeat); GL.SamplerParameter(WrapSampler, SamplerParameterI.WrapT, (int)TextureWrapMode.Repeat); GL.SamplerParameter(WrapSampler, SamplerParameterI.MinFilter, (int)TextureMinFilter.LinearMipmapLinear); GL.SamplerParameter(WrapSampler, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear); - if (renderSettings.EnableAnisotropicFiltering) { - GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out float maxAniso); - if (maxAniso > 0) GL.SamplerParameter(WrapSampler, GLEnum.TextureMaxAnisotropy, maxAniso); - } + if (MaxSupportedAnisotropy > 0) + GL.SamplerParameter(WrapSampler, GLEnum.TextureMaxAnisotropy, MaxSupportedAnisotropy); ClampSampler = GL.GenSampler(); GL.SamplerParameter(ClampSampler, SamplerParameterI.WrapS, (int)TextureWrapMode.ClampToEdge); GL.SamplerParameter(ClampSampler, SamplerParameterI.WrapT, (int)TextureWrapMode.ClampToEdge); GL.SamplerParameter(ClampSampler, SamplerParameterI.MinFilter, (int)TextureMinFilter.LinearMipmapLinear); GL.SamplerParameter(ClampSampler, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear); - if (renderSettings.EnableAnisotropicFiltering) { - GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out float maxAniso); - if (maxAniso > 0) GL.SamplerParameter(ClampSampler, GLEnum.TextureMaxAnisotropy, maxAniso); - } + if (MaxSupportedAnisotropy > 0) + GL.SamplerParameter(ClampSampler, GLEnum.TextureMaxAnisotropy, MaxSupportedAnisotropy); _sceneDataBuffer = new ManagedGLUniformBuffer(this, BufferUsage.Dynamic, Marshal.SizeOf()); @@ -145,6 +187,15 @@ namespace AcDream.App.Rendering.Wb { ParticleBatcher = null!; } + /// + /// Retires a GL resource only after every submitted draw that could + /// reference it has completed on the GPU. + /// + internal void RetireGpuResource(Action release) => _resourceRetirement.Retire(release); + + internal AcDream.App.Rendering.IGpuResourceRetirementQueue ResourceRetirement => + _resourceRetirement; + private void InitializeSharedDebugResources() { // Unit quad vertices for two triangles (0 to 1 for length, -0.5 to 0.5 for thickness) float[] quadVertices = { @@ -608,14 +659,26 @@ namespace AcDream.App.Rendering.Wb { GpuMemoryTracker.TrackDeallocation(instanceBufferCapacity * instanceBufferStride); } } - if (wrapSampler != 0) { - gl.DeleteSampler(wrapSampler); - } - if (clampSampler != 0) { - gl.DeleteSampler(clampSampler); - } }); + // Bindless texture-array retirements embed these samplers in their + // resident handles. Ordinary GL work must keep flowing when one + // retry is sick, but sampler deletion itself is dependency-ordered + // behind the retry queue. Requeue into the next generation (never + // the drain-to-empty ordinary queue) to remain one attempt/frame. + Action? deleteSamplersWhenSafe = null; + deleteSamplersWhenSafe = gl => { + if (!_nextGlThreadQueue.IsEmpty) { + QueueGLActionForNextPass(deleteSamplersWhenSafe!); + return; + } + if (wrapSampler != 0) + gl.DeleteSampler(wrapSampler); + if (clampSampler != 0) + gl.DeleteSampler(clampSampler); + }; + QueueGLActionForNextPass(deleteSamplersWhenSafe); + InstanceVBO = 0; InstanceVBOPtr = null; WrapSampler = 0; diff --git a/src/AcDream.App/Rendering/Wb/ParticleEmitterRenderer.cs b/src/AcDream.App/Rendering/Wb/ParticleEmitterRenderer.cs index 9476aef2..eb18362b 100644 --- a/src/AcDream.App/Rendering/Wb/ParticleEmitterRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/ParticleEmitterRenderer.cs @@ -58,9 +58,11 @@ namespace AcDream.App.Rendering.Wb { if (emitter.HwGfxObjId.DataId != 0) { _meshManager.IncrementRefCount(emitter.HwGfxObjId.DataId); + _meshManager.PrepareMeshDataAsync(emitter.HwGfxObjId.DataId, isSetup: false); } if (emitter.GfxObjId.DataId != 0 && emitter.GfxObjId.DataId != emitter.HwGfxObjId.DataId) { _meshManager.IncrementRefCount(emitter.GfxObjId.DataId); + _meshManager.PrepareMeshDataAsync(emitter.GfxObjId.DataId, isSetup: false); } } @@ -69,10 +71,12 @@ namespace AcDream.App.Rendering.Wb { if (_gfxRenderData == null) { var gfxId = _emitter.HwGfxObjId.DataId != 0 ? _emitter.HwGfxObjId.DataId : _emitter.GfxObjId.DataId; if (gfxId != 0) { + _meshManager.PrepareMeshDataAsync(gfxId, isSetup: false); _gfxRenderData = _meshManager.TryGetRenderData(gfxId); } } if (_textureRenderData == null && _emitter.GfxObjId.DataId != 0) { + _meshManager.PrepareMeshDataAsync(_emitter.GfxObjId.DataId, isSetup: false); _textureRenderData = _meshManager.TryGetRenderData(_emitter.GfxObjId.DataId); } diff --git a/src/AcDream.App/Rendering/Wb/RetryableResourceReleaseLedger.cs b/src/AcDream.App/Rendering/Wb/RetryableResourceReleaseLedger.cs new file mode 100644 index 00000000..df8d875b --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/RetryableResourceReleaseLedger.cs @@ -0,0 +1,135 @@ +namespace AcDream.App.Rendering.Wb; + +/// +/// Per-resource progress for a teardown which must attempt every independent +/// release even when an earlier release fails. Successful operations are never +/// replayed. A backend which throws after changing ownership reports that fact +/// with so the +/// ledger can preserve the physical outcome while still surfacing the error. +/// +internal sealed class RetryableResourceReleaseLedger +{ + private readonly Entry[] _entries; + private int _remaining; + private bool _advancing; + + public RetryableResourceReleaseLedger(IEnumerable<(string Name, Action Release)> entries) + { + ArgumentNullException.ThrowIfNull(entries); + _entries = entries + .Select(entry => new Entry(entry.Name, entry.Release)) + .ToArray(); + _remaining = _entries.Length; + } + + public bool IsComplete => _remaining == 0; + public int RemainingCount => _remaining; + + /// + /// Attempts every unfinished operation once. Ordinary exceptions retain + /// the operation for a later attempt; committed-outcome exceptions mark it + /// complete. Either kind remains in the returned diagnostics. + /// + public ResourceReleaseAttempt Advance() + { + // Release callbacks may synchronously drain their owner. The active + // top-level pass already owns every unfinished entry; a nested pass + // must not give a failed later entry a second attempt in this frame. + if (_advancing) + return new ResourceReleaseAttempt(0, 0, []); + + _advancing = true; + List? failures = null; + int attempted = 0; + int completed = 0; + try + { + for (int i = 0; i < _entries.Length; i++) + { + Entry entry = _entries[i]; + if (entry.Completed || entry.Running) + continue; + + attempted++; + entry.Running = true; + try + { + entry.Release(); + entry.Completed = true; + _remaining--; + completed++; + } + catch (Exception error) + { + bool committed = error is MeshReferenceMutationException + { + MutationCommitted: true, + }; + if (committed) + { + entry.Completed = true; + _remaining--; + completed++; + } + + (failures ??= []).Add( + new ResourceReleaseFailure(entry.Name, error, committed)); + } + finally + { + entry.Running = false; + } + } + } + finally + { + _advancing = false; + } + + return new ResourceReleaseAttempt( + attempted, + completed, + failures ?? []); + } + + private sealed class Entry + { + public Entry(string name, Action release) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("A resource-release stage needs a name.", nameof(name)); + ArgumentNullException.ThrowIfNull(release); + Name = name; + Release = release; + } + + public string Name { get; } + public Action Release { get; } + public bool Completed { get; set; } + public bool Running { get; set; } + } +} + +internal readonly record struct ResourceReleaseFailure( + string Stage, + Exception Error, + bool MutationCommitted); + +internal readonly record struct ResourceReleaseAttempt( + int AttemptedCount, + int CompletedCount, + IReadOnlyList Failures) +{ + public bool HasFailures => Failures.Count != 0; + + public AggregateException ToException(string message) => + new( + message, + Failures.Select(failure => + new InvalidOperationException( + $"Resource-release stage '{failure.Stage}' failed " + + (failure.MutationCommitted + ? "after committing its mutation." + : "before committing its mutation."), + failure.Error))); +} diff --git a/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs b/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs index 8751c689..22b16666 100644 --- a/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs +++ b/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs @@ -6,8 +6,38 @@ using Silk.NET.OpenGL; using System; using System.Collections.Generic; using PixelFormat = Silk.NET.OpenGL.PixelFormat; +using AcDream.App.Rendering; namespace AcDream.App.Rendering.Wb { + internal sealed class TextureAtlasDisposeTransaction { + private bool _running; + + public bool IsComplete { get; private set; } + public bool IsRunning => _running; + + public void Advance( + Action retryLayerRetirements, + Action disposeTextureArray, + Action commitLogicalDisposal) { + ArgumentNullException.ThrowIfNull(retryLayerRetirements); + ArgumentNullException.ThrowIfNull(disposeTextureArray); + ArgumentNullException.ThrowIfNull(commitLogicalDisposal); + if (IsComplete || _running) + return; + + _running = true; + try { + retryLayerRetirements(); + disposeTextureArray(); + commitLogicalDisposal(); + IsComplete = true; + } + finally { + _running = false; + } + } + } + /// /// Manages texture arrays grouped by (Width, Height, Format). /// Deduplicates textures by a TextureKey and supports reference counting. @@ -20,41 +50,99 @@ namespace AcDream.App.Rendering.Wb { private readonly TextureFormat _format; private readonly Dictionary _textureIndices = new(); private readonly Dictionary _refCounts = new(); - private readonly Stack _freeSlots = new(); - private int _nextIndex = 0; - private const int InitialCapacity = 32; + private readonly TextureAtlasSlotAllocator _slots; + private readonly TextureAtlasLayerRetirement _layerRetirement; + private readonly TextureAtlasDisposeTransaction _disposeTransaction = new(); + private readonly Action? _onGpuSafeEmpty; + private bool _disposed; + internal const long TargetArrayBytes = 8L * 1024 * 1024; + internal const int MaximumArrayLayers = 32; public uint Slot { get; } public ManagedGLTextureArray TextureArray { get; private set; } = null!; public int UsedSlots => _textureIndices.Count; - public int TotalSlots => TextureArray?.Size ?? InitialCapacity; - public int FreeSlots => TotalSlots - UsedSlots; + public int TotalSlots => TextureArray?.Size ?? 0; + public int AvailableSlots => _slots.AvailableCount; + internal bool IsGpuSafeEmpty => UsedSlots == 0 && AvailableSlots == TotalSlots; + internal long AllocatedBytes { + get { + return TextureArray.TotalSizeInBytes; + } + } + internal long LastUseSequence { get; set; } + internal int Width => _textureWidth; + internal int Height => _textureHeight; + internal TextureFormat Format => _format; - public TextureAtlasManager(OpenGLGraphicsDevice graphicsDevice, int width, int height, TextureFormat format = TextureFormat.RGBA8) { + public TextureAtlasManager( + OpenGLGraphicsDevice graphicsDevice, + int width, + int height, + TextureFormat format = TextureFormat.RGBA8, + Action? onGpuSafeEmpty = null) { Slot = _nextSlot++; _graphicsDevice = graphicsDevice; _textureWidth = width; _textureHeight = height; _format = format; - TextureArray = (ManagedGLTextureArray)graphicsDevice.CreateTextureArrayInternal(format, width, height, InitialCapacity, TextureParameters.ClampToEdge); + _onGpuSafeEmpty = onGpuSafeEmpty; + _layerRetirement = new TextureAtlasLayerRetirement(graphicsDevice.ResourceRetirement); + int capacity = CalculateInitialCapacity(width, height, format); + TextureArray = (ManagedGLTextureArray)graphicsDevice.CreateTextureArrayInternal(format, width, height, capacity, TextureParameters.ClampToEdge); + _slots = new TextureAtlasSlotAllocator(TextureArray.Size); } + /// + /// Keeps each physical array near an eight-MiB target instead of + /// reserving 32 layers for every size class. The old fixed capacity + /// made a single 1024x1024 RGBA texture allocate roughly 171 MiB once + /// its mip chain was included, and made glGenerateMipmap process all + /// 32 layers during destination streaming. + /// + internal static int CalculateInitialCapacity(int width, int height, TextureFormat format) { + ArgumentOutOfRangeException.ThrowIfLessThan(width, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(height, 1); + + long bytesPerLayer = CalculateMipChainBytes(width, height, format); + long targetLayers = Math.Max(1L, TargetArrayBytes / bytesPerLayer); + return checked((int)Math.Min(targetLayers, MaximumArrayLayers)); + } + + internal static long CalculateMipChainBytes(int width, int height, TextureFormat format) { + long total = 0; + int w = width; + int h = height; + while (true) { + total = checked(total + CalculateLevelBytes(w, h, format)); + if (w == 1 && h == 1) return total; + w = Math.Max(1, w >> 1); + h = Math.Max(1, h >> 1); + } + } + + internal static long CalculateArrayBytes(int width, int height, TextureFormat format) => + checked(CalculateMipChainBytes(width, height, format) + * CalculateInitialCapacity(width, height, format)); + + internal static long CalculateLevelBytes(int width, int height, TextureFormat format) => format switch { + TextureFormat.RGBA8 => checked((long)width * height * 4L), + TextureFormat.RGB8 => checked((long)width * height * 3L), + TextureFormat.A8 => checked((long)width * height), + TextureFormat.Rgba32f => checked((long)width * height * 16L), + TextureFormat.DXT1 => checked((long)Math.Max(1, (width + 3) / 4) * Math.Max(1, (height + 3) / 4) * 8L), + TextureFormat.DXT3 or TextureFormat.DXT5 => checked((long)Math.Max(1, (width + 3) / 4) * Math.Max(1, (height + 3) / 4) * 16L), + _ => throw new NotSupportedException($"Unsupported texture-atlas format {format}.") + }; + public int AddTexture(TextureKey key, byte[] data, PixelFormat? uploadPixelFormat = null, PixelType? uploadPixelType = null) { + ObjectDisposedException.ThrowIf(_disposed || _disposeTransaction.IsRunning, this); + _layerRetirement.RetryPendingPublications(); if (_textureIndices.TryGetValue(key, out var existingIndex)) { _refCounts[existingIndex]++; return existingIndex; } - int index; - if (_freeSlots.Count > 0) { - index = _freeSlots.Pop(); - } - else { - index = _nextIndex++; - if (index >= TextureArray.Size) { - throw new Exception($"Texture atlas is full! {TextureArray.Size} / {_nextIndex} used."); - } - } + int index = _slots.Rent(); try { TextureArray.UpdateLayer(index, data, uploadPixelFormat, uploadPixelType); @@ -63,14 +151,15 @@ namespace AcDream.App.Rendering.Wb { return index; } catch (Exception) { - if (!_textureIndices.ContainsKey(key)) { - _freeSlots.Push(index); - } + if (!_textureIndices.ContainsKey(key)) + _slots.Return(index); throw; } } public void ReleaseTexture(TextureKey key) { + ObjectDisposedException.ThrowIf(_disposed || _disposeTransaction.IsRunning, this); + _layerRetirement.RetryPendingPublications(); if (!_textureIndices.TryGetValue(key, out var index)) return; if (!_refCounts.ContainsKey(index)) return; @@ -79,21 +168,74 @@ namespace AcDream.App.Rendering.Wb { if (_refCounts[index] <= 0) { _textureIndices.Remove(key); _refCounts.Remove(index); - _freeSlots.Push(index); - TextureArray?.RemoveLayer(index); + // The CPU no longer references this layer, but previously + // submitted draws may still sample it. Recycle the slot only + // after their GPU fence has signaled; no clear or whole-array + // mip regeneration is needed for an unreferenced layer. + _layerRetirement.Retire( + () => { + if (!_disposed) + _slots.Return(index); + }, + () => { + if (!_disposed && IsGpuSafeEmpty) + _onGpuSafeEmpty?.Invoke(this); + }); } } + internal void RetryPendingRetirements() => + _layerRetirement.RetryPendingPublications(); + public bool HasTexture(TextureKey key) => _textureIndices.ContainsKey(key); public int GetTextureIndex(TextureKey key) => _textureIndices.TryGetValue(key, out var index) ? index : -1; public void Dispose() { - TextureArray?.Dispose(); - _textureIndices.Clear(); - _refCounts.Clear(); - _freeSlots.Clear(); + if (_disposed) return; + _disposeTransaction.Advance( + _layerRetirement.RetryPendingPublications, + () => { + TextureArray?.Dispose(); + if (TextureArray is not null && !TextureArray.HasDurableDisposeOwnership) + throw new InvalidOperationException( + "Texture-array disposal returned without retaining or publishing its physical release."); + }, + () => { + _textureIndices.Clear(); + _refCounts.Clear(); + _disposed = true; + }); } } + + /// + /// Owns the publication and callback cursor for returned atlas layers. + /// Removing the logical texture key commits immediately; this owner keeps + /// the physical layer reachable until the frame queue accepts it and keeps + /// the empty-atlas observer separate from the slot return so it cannot make + /// a callback retry return the same slot twice. + /// + internal sealed class TextureAtlasLayerRetirement + { + private readonly GpuRetirementLedger _ledger; + + public TextureAtlasLayerRetirement(IGpuResourceRetirementQueue queue) => + _ledger = new GpuRetirementLedger(queue); + + internal int AwaitingPublicationCount => _ledger.AwaitingPublicationCount; + + public void Retire(Action returnLayer, Action notifyGpuSafeEmpty) + { + ArgumentNullException.ThrowIfNull(returnLayer); + ArgumentNullException.ThrowIfNull(notifyGpuSafeEmpty); + _ledger.Retire(new RetryableGpuResourceRelease( + returnLayer, + notifyGpuSafeEmpty)); + } + + public void RetryPendingPublications() => + _ledger.RetryPendingPublications(); + } } diff --git a/src/AcDream.App/Rendering/Wb/TextureAtlasSlotAllocator.cs b/src/AcDream.App/Rendering/Wb/TextureAtlasSlotAllocator.cs new file mode 100644 index 00000000..7564ed58 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/TextureAtlasSlotAllocator.cs @@ -0,0 +1,55 @@ +namespace AcDream.App.Rendering.Wb; + +/// +/// Owns the physical layer slots in one texture array. A released texture +/// remains rented until the GPU-retirement callback returns its layer, so +/// never advertises a CPU-unreferenced layer +/// that an in-flight draw may still sample. +/// +internal sealed class TextureAtlasSlotAllocator +{ + private readonly bool[] _rented; + private readonly Stack _returned = new(); + private int _next; + + public TextureAtlasSlotAllocator(int capacity) + { + ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1); + _rented = new bool[capacity]; + } + + public int AvailableCount => _returned.Count + (_rented.Length - _next); + + public int Rent() + { + int slot; + if (_returned.Count != 0) + { + slot = _returned.Pop(); + } + else + { + if (_next == _rented.Length) + throw new InvalidOperationException( + $"Texture atlas has no GPU-safe layer available ({_rented.Length} layers)." + ); + slot = _next++; + } + + if (_rented[slot]) + throw new InvalidOperationException($"Texture atlas layer {slot} was rented twice."); + _rented[slot] = true; + return slot; + } + + public void Return(int slot) + { + ArgumentOutOfRangeException.ThrowIfNegative(slot); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(slot, _next); + if (!_rented[slot]) + throw new InvalidOperationException($"Texture atlas layer {slot} was returned twice."); + + _rented[slot] = false; + _returned.Push(slot); + } +} diff --git a/src/AcDream.App/Rendering/Wb/TrackedGlResource.cs b/src/AcDream.App/Rendering/Wb/TrackedGlResource.cs new file mode 100644 index 00000000..f442268a --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/TrackedGlResource.cs @@ -0,0 +1,242 @@ +using Silk.NET.OpenGL; +using AcDream.App.Rendering; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Always-on transaction boundary and accounting for raw dynamic GL objects. +/// Growth keeps the published CPU capacity unchanged until BufferData succeeds; +/// OpenGL leaves the previous data store intact when allocation reports an +/// error, so the caller can continue using the old capacity or unwind. +/// +internal static unsafe class TrackedGlResource +{ + public static RetryableGpuResourceRelease CreateRetryableBufferDeletion( + GL gl, + uint buffer, + long capacityBytes, + string context) + { + if (buffer == 0) + throw new ArgumentOutOfRangeException(nameof(buffer)); + ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes); + return new RetryableGpuResourceRelease( + () => GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"), + () => + { + gl.DeleteBuffer(buffer); + // Per the GL error contract, a command which generates an + // error does not change object state. Keep mutation and its + // validation in one retryable stage so that failed deletion + // is issued again, while later accounting remains untouched. + GLHelpers.ThrowOnResourceError(gl, context); + }, + () => + { + if (capacityBytes != 0) + GpuMemoryTracker.TrackDeallocation(capacityBytes, GpuResourceType.Buffer); + }, + () => GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer)); + } + + public static RetryableGpuResourceRelease CreateRetryableVertexArrayDeletion( + GL gl, + uint vertexArray, + string context, + Action? trackDeallocation = null) + { + if (vertexArray == 0) + throw new ArgumentOutOfRangeException(nameof(vertexArray)); + return new RetryableGpuResourceRelease( + () => GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"), + () => + { + gl.DeleteVertexArray(vertexArray); + GLHelpers.ThrowOnResourceError(gl, context); + }, + trackDeallocation + ?? (() => GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.VAO))); + } + + public static void AllocateBufferStorage( + GL gl, + BufferTargetARB target, + uint buffer, + long previousBytes, + long newBytes, + BufferUsageARB usage, + string context) + => AllocateBufferStorage( + gl, + (GLEnum)target, + buffer, + previousBytes, + newBytes, + (GLEnum)usage, + context); + + public static void AllocateBufferStorage( + GL gl, + BufferTargetARB target, + uint buffer, + long previousBytes, + long newBytes, + BufferUsageARB usage, + void* data, + string context) + => AllocateBufferStorage( + gl, + (GLEnum)target, + buffer, + previousBytes, + newBytes, + (GLEnum)usage, + data, + context); + + public static uint CreateBuffer(GL gl, string context) + { + GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"); + uint name = gl.GenBuffer(); + try + { + if (name == 0) + throw new InvalidOperationException($"OpenGL returned buffer name zero. Context: {context}"); + GLHelpers.ThrowOnResourceError(gl, context); + GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer); + return name; + } + catch + { + if (name != 0) + gl.DeleteBuffer(name); + throw; + } + } + + public static uint CreateVertexArray(GL gl, string context) + { + GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"); + uint name = gl.GenVertexArray(); + try + { + if (name == 0) + throw new InvalidOperationException($"OpenGL returned vertex-array name zero. Context: {context}"); + GLHelpers.ThrowOnResourceError(gl, context); + GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO); + return name; + } + catch + { + if (name != 0) + gl.DeleteVertexArray(name); + throw; + } + } + + public static void AllocateBufferStorage( + GL gl, + GLEnum target, + uint buffer, + long previousBytes, + long newBytes, + GLEnum usage, + string context) + => AllocateBufferStorage( + gl, + target, + buffer, + previousBytes, + newBytes, + usage, + null, + context); + + public static void AllocateBufferStorage( + GL gl, + GLEnum target, + uint buffer, + long previousBytes, + long newBytes, + GLEnum usage, + void* data, + string context) + { + ArgumentOutOfRangeException.ThrowIfNegative(previousBytes); + ArgumentOutOfRangeException.ThrowIfLessThan(newBytes, 1); + if (buffer == 0) + throw new ArgumentOutOfRangeException(nameof(buffer)); + + GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"); + gl.BindBuffer(target, buffer); + gl.BufferData(target, checked((nuint)newBytes), data, usage); + GLHelpers.ThrowOnResourceError(gl, context); + + long delta = checked(newBytes - previousBytes); + if (delta > 0) + GpuMemoryTracker.TrackAllocation(delta, GpuResourceType.Buffer); + else if (delta < 0) + GpuMemoryTracker.TrackDeallocation(-delta, GpuResourceType.Buffer); + } + + public static void UpdateBufferSubData( + GL gl, + BufferTargetARB target, + uint buffer, + nint byteOffset, + long byteCount, + void* data, + string context) + => UpdateBufferSubData( + gl, + (GLEnum)target, + buffer, + byteOffset, + byteCount, + data, + context); + + public static void UpdateBufferSubData( + GL gl, + GLEnum target, + uint buffer, + nint byteOffset, + long byteCount, + void* data, + string context) + { + ArgumentOutOfRangeException.ThrowIfNegative(byteOffset); + ArgumentOutOfRangeException.ThrowIfLessThan(byteCount, 1); + if (buffer == 0) + throw new ArgumentOutOfRangeException(nameof(buffer)); + ArgumentNullException.ThrowIfNull(data); + + GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"); + gl.BindBuffer(target, buffer); + gl.BufferSubData(target, byteOffset, checked((nuint)byteCount), data); + GLHelpers.ThrowOnResourceError(gl, context); + } + + public static void DeleteBuffer(GL gl, uint buffer, long capacityBytes, string context) + { + if (buffer == 0) + return; + ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes); + GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"); + gl.DeleteBuffer(buffer); + GLHelpers.ThrowOnResourceError(gl, context); + if (capacityBytes != 0) + GpuMemoryTracker.TrackDeallocation(capacityBytes, GpuResourceType.Buffer); + GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer); + } + + public static void DeleteVertexArray(GL gl, uint vertexArray, string context) + { + if (vertexArray == 0) + return; + GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"); + gl.DeleteVertexArray(vertexArray); + GLHelpers.ThrowOnResourceError(gl, context); + GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.VAO); + } +} diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 5c2c7a6e..98feef5c 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -22,16 +22,15 @@ namespace AcDream.App.Rendering.Wb; /// /// Atlas-tier entities (ServerGuid == 0): mesh data comes from WB's /// via . -/// Textures resolve through the bindless-suffixed -/// variants, returning 64-bit -/// resident handles stored in the per-group SSBO. +/// Shared textures reuse each batch's WB atlas handle and layer, returning +/// 64-bit resident handles stored in the per-group SSBO. /// /// /// /// Per-instance-tier entities (ServerGuid != 0): mesh data also from -/// WB, but textures resolve through -/// with palette -/// and surface overrides applied. is currently +/// WB. Native surfaces still reuse the WB atlas; only actual indexed-palette +/// and original-texture replacements resolve through owner-scoped +/// composites. is currently /// unused at draw time — GameWindow's spawn path already bakes AnimPartChanges + /// GfxObjDegradeResolver (Issue #47 close-detail mesh) into MeshRefs. /// @@ -107,6 +106,256 @@ public sealed unsafe class WbDrawDispatcher : IDisposable long Triangles); public DrawStats LastDrawStats { get; private set; } + public bool CompositeTexturesReady { get; private set; } = true; + internal int LastCompositeWarmupPendingCount { get; private set; } + internal const int MaximumCompositeWarmupEntitiesPerFrame = 128; + private readonly Queue _compositeWarmupQueue = new(); + private IReadOnlyList? _compositeWarmupSource; + private ulong _compositeWarmupSourceGeneration; + private uint _compositeWarmupDestinationCell; + private int _compositeWarmupRadius; + private int _compositeWarmupScanIndex; + private bool _compositeWarmupScanComplete = true; + + private enum CompositeWarmupResult : byte + { + Complete, + Pending, + UploadBudgetBlocked, + } + + public void InvalidateCompositeWarmupReadiness() + { + CompositeTexturesReady = false; + LastCompositeWarmupPendingCount = 1; + _compositeWarmupQueue.Clear(); + _compositeWarmupSource = null; + _compositeWarmupSourceGeneration = 0; + _compositeWarmupDestinationCell = 0; + _compositeWarmupRadius = 0; + _compositeWarmupScanIndex = 0; + _compositeWarmupScanComplete = true; + } + + /// + /// Resolves live-object palette/original-texture composites before the + /// world viewport becomes visible. The texture cache enforces the upload + /// budget, so repeated calls advance readiness over multiple portal-space + /// frames without one large first-world-frame upload burst. + /// + public void PrepareCompositeTextures( + IReadOnlyList entities, + ulong entityGeneration, + uint destinationCell, + int radius) + { + ArgumentNullException.ThrowIfNull(entities); + ArgumentOutOfRangeException.ThrowIfNegative(radius); + if (RequiresCompositeWarmupRebuild( + _compositeWarmupSource, + _compositeWarmupDestinationCell, + _compositeWarmupRadius, + _compositeWarmupSourceGeneration, + entities, + entityGeneration, + destinationCell, + radius)) + { + RebuildCompositeWarmupQueue( + entities, + entityGeneration, + destinationCell, + radius); + } + if (CompositeTexturesReady) + return; + + int scanEnd = Math.Min( + entities.Count, + _compositeWarmupScanIndex + MaximumCompositeWarmupEntitiesPerFrame); + for (; _compositeWarmupScanIndex < scanEnd; _compositeWarmupScanIndex++) + { + WorldEntity entity = entities[_compositeWarmupScanIndex]; + if (IsCompositeWarmupCandidate(entity, destinationCell, radius)) + _compositeWarmupQueue.Enqueue(entity); + } + _compositeWarmupScanComplete = _compositeWarmupScanIndex == entities.Count; + + int candidatesThisPass = Math.Min( + _compositeWarmupQueue.Count, + MaximumCompositeWarmupEntitiesPerFrame); + for (int i = 0; i < candidatesThisPass; i++) + { + WorldEntity entity = _compositeWarmupQueue.Dequeue(); + CompositeWarmupResult result = PrepareCompositeEntity(entity); + if (result != CompositeWarmupResult.Complete) + _compositeWarmupQueue.Enqueue(entity); + if (result == CompositeWarmupResult.UploadBudgetBlocked) + break; + } + + LastCompositeWarmupPendingCount = _compositeWarmupQueue.Count + + (_compositeWarmupScanComplete ? 0 : entities.Count - _compositeWarmupScanIndex); + CompositeTexturesReady = _compositeWarmupScanComplete + && _compositeWarmupQueue.Count == 0; + } + + internal static bool RequiresCompositeWarmupRebuild( + IReadOnlyList? currentSource, + uint currentDestinationCell, + int currentRadius, + ulong currentGeneration, + IReadOnlyList nextSource, + ulong nextGeneration, + uint nextDestinationCell, + int nextRadius) => + !ReferenceEquals(currentSource, nextSource) + || currentGeneration != nextGeneration + || currentDestinationCell != nextDestinationCell + || currentRadius != nextRadius; + + private void RebuildCompositeWarmupQueue( + IReadOnlyList entities, + ulong entityGeneration, + uint destinationCell, + int radius) + { + _compositeWarmupQueue.Clear(); + _compositeWarmupSource = entities; + _compositeWarmupSourceGeneration = entityGeneration; + _compositeWarmupDestinationCell = destinationCell; + _compositeWarmupRadius = radius; + _compositeWarmupScanIndex = 0; + _compositeWarmupScanComplete = entities.Count == 0; + LastCompositeWarmupPendingCount = entities.Count; + CompositeTexturesReady = _compositeWarmupScanComplete; + } + + internal static bool IsCompositeWarmupCandidate( + WorldEntity entity, + uint destinationCell, + int radius) + { + ArgumentNullException.ThrowIfNull(entity); + ArgumentOutOfRangeException.ThrowIfNegative(radius); + + if (destinationCell != 0) + { + // A cell-less live object is either outside the published + // destination or still transitioning. It must not load meshes + // or hold this destination's portal readiness. + if (!TryGetEntityCell(entity, out uint entityCell) + || !IsWithinLandblockRadius(entityCell, destinationCell, radius)) + { + return false; + } + } + + if (entity.PaletteOverride is not null) + return true; + + for (int meshIndex = 0; meshIndex < entity.MeshRefs.Count; meshIndex++) + { + if (entity.MeshRefs[meshIndex].SurfaceOverrides is { Count: > 0 }) + return true; + } + + return false; + } + + private CompositeWarmupResult PrepareCompositeEntity(WorldEntity entity) + { + bool pending = false; + PaletteCompositeIdentity paletteIdentity = entity.PaletteOverride is not null + ? TextureCache.GetPaletteIdentity(entity.PaletteOverride) + : default; + for (int meshIndex = 0; meshIndex < entity.MeshRefs.Count; meshIndex++) + { + MeshRef meshRef = entity.MeshRefs[meshIndex]; + ObjectRenderData? renderData = _meshAdapter.TryGetRenderData(meshRef.GfxObjId); + if (renderData is null) + { + _meshAdapter.EnsureLoaded(meshRef.GfxObjId); + pending = true; + continue; + } + + if (renderData.IsSetup && renderData.SetupParts.Count > 0) + { + for (int partIndex = 0; partIndex < renderData.SetupParts.Count; partIndex++) + { + ulong partId = renderData.SetupParts[partIndex].GfxObjId; + ObjectRenderData? partData = _meshAdapter.TryGetRenderData(partId); + if (partData is null) + { + _meshAdapter.EnsureLoaded(partId); + pending = true; + continue; + } + if (!PrepareCompositeBatches(entity, meshRef, partData, paletteIdentity)) + pending = true; + if (!_textures.CanStartCompositeUpload && pending) + return CompositeWarmupResult.UploadBudgetBlocked; + } + } + else + { + if (!PrepareCompositeBatches(entity, meshRef, renderData, paletteIdentity)) + pending = true; + if (!_textures.CanStartCompositeUpload && pending) + return CompositeWarmupResult.UploadBudgetBlocked; + } + } + return pending ? CompositeWarmupResult.Pending : CompositeWarmupResult.Complete; + } + + private bool PrepareCompositeBatches( + WorldEntity entity, + MeshRef meshRef, + ObjectRenderData renderData, + PaletteCompositeIdentity paletteIdentity) + { + bool complete = true; + for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++) + { + _ = ResolveTexture( + entity, + meshRef, + renderData.Batches[batchIndex], + paletteIdentity, + out bool compositePending); + if (compositePending) + complete = false; + if (compositePending && !_textures.CanStartCompositeUpload) + break; + } + return complete; + } + + private static bool TryGetEntityCell(WorldEntity entity, out uint cell) + { + if (entity.ParentCellId is uint parent) + { + cell = parent; + return true; + } + if (entity.EffectCellId is uint effect) + { + cell = effect; + return true; + } + cell = 0; + return false; + } + + private static bool IsWithinLandblockRadius(uint cell, uint center, int radius) + { + int x = (int)(cell >> 24); + int y = (int)((cell >> 16) & 0xFFu); + int centerX = (int)(center >> 24); + int centerY = (int)((center >> 16) & 0xFFu); + return Math.Abs(x - centerX) <= radius && Math.Abs(y - centerY) <= radius; + } // Tier 1 cache (#53): per-entity classification results for static // entities (those NOT in GameWindow._animatedEntities). Wired here in @@ -137,12 +386,16 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private uint _instanceSsbo; private uint _batchSsbo; private uint _indirectBuffer; + private int _instanceSsboCapacityBytes; + private int _batchSsboCapacityBytes; + private int _indirectBufferCapacityBytes; // Phase U.3: per-instance clip-slot SSBO (binding=3), parallel to // _instanceSsbo. One uint per instance selecting its CellClip slot. In U.3 // this is ALL ZEROS (every instance → slot 0 → no-clip), so the render is // identical to pre-U.3. U.4 populates real slot indices. private uint _clipSlotSsbo; + private int _clipSlotSsboCapacityBytes; private uint[] _clipSlotData = new uint[256]; // Fix B (A7 #3): per-OBJECT light selection (minimize_object_lighting). Two @@ -153,6 +406,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // instance INTO it (-1 = unused), laid out parallel to _instanceSsbo. private uint _globalLightsSsbo; private uint _instLightSetSsbo; + private int _globalLightsSsboCapacityBytes; + private int _instLightSetSsboCapacityBytes; private int[] _lightSetData = new int[256 * LightManager.MaxLightsPerObject]; private float[] _globalLightData = new float[GlobalLightPacker.FloatsPerLight * 16]; // 16 floats (4 vec4) per GlobalLight @@ -161,6 +416,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // shader's uLightingMode==0 branch); 0 = outdoor object (gets the sun). // Mechanically a clone of _clipSlotData / _clipSlotSsbo. private uint _instIndoorSsbo; + private int _instIndoorSsboCapacityBytes; private uint[] _indoorData = new uint[256]; // #188: per-instance opacity multiplier (binding=7), one float per @@ -169,10 +425,44 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // sampled alpha for an entity mid-TransparentPartHook fade. Mechanically // a clone of _indoorData / _instIndoorSsbo, one binding higher. private uint _instAlphaSsbo; + private int _instAlphaSsboCapacityBytes; private float[] _alphaData = new float[256]; // Retail SmartBox click confirmation: per-instance CMaterial luminosity / // diffuse replacement (binding=8), parallel to the transform buffer. private uint _instSelectionLightingSsbo; + private int _instSelectionLightingSsboCapacityBytes; + + private sealed class DynamicBufferSet + { + public uint InstanceSsbo; + public uint BatchSsbo; + public uint IndirectBuffer; + public uint ClipSlotSsbo; + public uint GlobalLightsSsbo; + public uint InstanceLightSetSsbo; + public uint InstanceIndoorSsbo; + public uint InstanceAlphaSsbo; + public uint InstanceSelectionLightingSsbo; + public int InstanceCapacityBytes; + public int BatchCapacityBytes; + public int IndirectCapacityBytes; + public int ClipSlotCapacityBytes; + public int GlobalLightsCapacityBytes; + public int InstanceLightSetCapacityBytes; + public int InstanceIndoorCapacityBytes; + public int InstanceAlphaCapacityBytes; + public int InstanceSelectionLightingCapacityBytes; + } + + private readonly List[] _dynamicBufferSetsByFrame = + [[], [], []]; + private int _dynamicFrameSlot; + private int _dynamicBufferSetCursor; + private bool _dynamicFrameStarted; + private DynamicBufferSet? _activeDynamicBufferSet; + + internal int DynamicBufferSetCount => + _dynamicBufferSetsByFrame.Sum(frameSets => frameSets.Count); private Vector2[] _selectionLightingData = new Vector2[256]; // This frame's point-light snapshot, handed in by GameWindow before Draw via // SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1). @@ -225,6 +515,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private BatchData[] _batchData = new BatchData[256]; private DrawElementsIndirectCommand[] _indirectCommands = new DrawElementsIndirectCommand[256]; private CullMode[] _drawCullModes = new CullMode[256]; + private BatchDataPublic[] _batchPublicScratch = new BatchDataPublic[256]; + private readonly List _groupInputScratch = new(256); + private readonly Action _cacheHitAppender; private int _opaqueDrawCount; private int _transparentDrawCount; @@ -267,8 +560,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private sealed class AlphaDrawSource(WbDrawDispatcher owner) : IRetailAlphaDrawSource { - public void DrawAlphaBatch(ReadOnlySpan tokens) - => owner.DrawDeferredAlphaBatch(tokens); + public void PrepareAlphaDraws(ReadOnlySpan tokens) + => owner.PrepareDeferredAlphaDraws(tokens); + + public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount) + => owner.DrawPreparedAlphaBatch(firstPreparedDraw, drawCount); public void ResetAlphaSubmissions() => owner._deferredAlpha.Clear(); @@ -299,6 +595,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // outliers (long banners, tall columns) are still landblock-culled. private const float PerEntityCullRadius = 5.0f; + private RetryableResourceReleaseLedger? _disposeResources; + private bool _disposing; private bool _disposed; /// @@ -422,17 +720,25 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _selectionLighting = selectionSink as IRetailSelectionLightingSource; _alphaQueue = alphaQueue; _alphaSource = new AlphaDrawSource(this); + _cacheHitAppender = AppendInstanceToGroup; _bindless = bindless ?? throw new ArgumentNullException(nameof(bindless)); - _instanceSsbo = _gl.GenBuffer(); - _batchSsbo = _gl.GenBuffer(); - _indirectBuffer = _gl.GenBuffer(); - _clipSlotSsbo = _gl.GenBuffer(); // Phase U.3 binding=3 - _globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4 - _instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5 - _instIndoorSsbo = _gl.GenBuffer(); // #142 binding=6 - _instAlphaSsbo = _gl.GenBuffer(); // #188 binding=7 - _instSelectionLightingSsbo = _gl.GenBuffer(); // SmartBox binding=8 + } + + /// + /// Selects the fence-protected frame slot and resets its draw-call cursor. + /// Every Draw/alpha preparation in one frame receives a distinct buffer + /// set, so later per-cell submissions cannot overwrite an earlier draw's + /// still-pending SSBO and indirect-command data. + /// + public void BeginFrame(int frameSlot) + { + if ((uint)frameSlot >= (uint)_dynamicBufferSetsByFrame.Length) + throw new ArgumentOutOfRangeException(nameof(frameSlot)); + _dynamicFrameSlot = frameSlot; + _dynamicBufferSetCursor = 0; + _dynamicFrameStarted = true; + _activeDynamicBufferSet = null; } /// @@ -1242,7 +1548,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // body via the lastHitEntityId == entity.Id check above. if (!isAnimated && !_tier1CacheDisabled && _cache.TryGet(entity.Id, cacheLb, out var cachedEntry)) { - ApplyCacheHit(cachedEntry!, entityWorld, AppendInstanceToGroup); + ApplyCacheHit(cachedEntry!, entityWorld, _cacheHitAppender); // The cache is populated only after every MeshRef rendered // successfully. Publish the same parts for retail picking now; @@ -1284,13 +1590,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable continue; } - // Compute palette-override hash ONCE per entity (perf #4). - // Reused across every (part, batch) lookup so the FNV-1a fold - // over SubPalettes runs once instead of N times. Zero when the - // entity has no palette override (trees, scenery). - ulong palHash = 0; + // Compute structural palette identity once per entity. Its hash + // accelerates lookup; equality still compares every range. + PaletteCompositeIdentity paletteIdentity = default; if (entity.PaletteOverride is not null) - palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride); + paletteIdentity = TextureCache.GetPaletteIdentity(entity.PaletteOverride); // Note: GameWindow's spawn path already applies // AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix — @@ -1437,7 +1741,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable opacityMultiplier = 1f - translucencyValue; // CMaterial::SetTranslucencySimple 0x005396f0 } - ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, opacityMultiplier, collector); + if (!ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, paletteIdentity, metaTable, restPose, opacityMultiplier, collector)) + currentEntityIncomplete = true; _selectionSink?.AddVisiblePart( entity, unchecked((partIdx << 16) | (setupPartIndex & 0xFFFF)), @@ -1462,7 +1767,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (!fullyInvisible) { var model = meshRef.PartTransform * entityWorld; - ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform, opacityMultiplier: opacityMultiplier, collector: collector); + if (!ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, paletteIdentity, metaTable, restPose: meshRef.PartTransform, opacityMultiplier: opacityMultiplier, collector: collector)) + currentEntityIncomplete = true; _selectionSink?.AddVisiblePart( entity, partIdx, @@ -1642,18 +1948,23 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _indirectCommands = new DrawElementsIndirectCommand[totalDraws + 64]; if (_drawCullModes.Length < totalDraws) _drawCullModes = new CullMode[totalDraws + 64]; + if (_batchPublicScratch.Length < totalDraws) + _batchPublicScratch = new BatchDataPublic[totalDraws + 64]; - var groupInputs = new List(totalDraws); - foreach (var g in _opaqueDraws) groupInputs.Add(ToInput(g)); + _groupInputScratch.Clear(); + foreach (var g in _opaqueDraws) _groupInputScratch.Add(ToInput(g)); if (!deferTransparent) - foreach (var g in _translucentDraws) groupInputs.Add(ToInput(g)); + foreach (var g in _translucentDraws) _groupInputScratch.Add(ToInput(g)); // Cast _batchData (private BatchData) to public-mirror BatchDataPublic for BuildIndirectArrays. // Layout is asserted at test time (BatchDataPublic_LayoutMatchesPrivateBatchData test). - var batchPublic = new BatchDataPublic[totalDraws]; - var layout = BuildIndirectArrays(groupInputs, _indirectCommands, batchPublic, _drawCullModes); + var layout = BuildIndirectArrays( + _groupInputScratch, + _indirectCommands, + _batchPublicScratch, + _drawCullModes); long totalTriangles = 0; - foreach (var input in groupInputs) + foreach (var input in _groupInputScratch) totalTriangles += (long)(input.IndexCount / 3) * input.InstanceCount; int cullRuns = CountCullRuns(_drawCullModes, 0, layout.OpaqueCount) + @@ -1664,9 +1975,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable { _batchData[i] = new BatchData { - TextureHandle = batchPublic[i].TextureHandle, - TextureLayer = batchPublic[i].TextureLayer, - Flags = batchPublic[i].Flags, + TextureHandle = _batchPublicScratch[i].TextureHandle, + TextureLayer = _batchPublicScratch[i].TextureLayer, + Flags = _batchPublicScratch[i].Flags, }; } _opaqueDrawCount = layout.OpaqueCount; @@ -1684,11 +1995,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable totalTriangles); // ── Phase 5: upload four buffers ──────────────────────────────────── + ActivateNextDynamicBufferSet(); fixed (float* ip = _instanceData) - UploadSsbo(_instanceSsbo, 0, ip, totalInstances * 16 * sizeof(float)); + UploadSsbo(_instanceSsbo, 0, ref _instanceSsboCapacityBytes, + ip, totalInstances * 16 * sizeof(float)); fixed (BatchData* bp = _batchData) - UploadSsbo(_batchSsbo, 1, bp, totalDraws * sizeof(BatchData)); + UploadSsbo(_batchSsbo, 1, ref _batchSsboCapacityBytes, + bp, totalDraws * sizeof(BatchData)); // Phase U.4: per-instance clip-slot buffer (binding=3), one uint per // instance, laid out parallel to _instanceData in Phase 3's group loop so @@ -1699,25 +2013,29 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // entries; only [0..totalInstances) is uploaded, so any stale tail is // never read by the shader (BaseInstance + gl_InstanceID < totalInstances). fixed (uint* sp = _clipSlotData) - UploadSsbo(_clipSlotSsbo, 3, sp, totalInstances * sizeof(uint)); + UploadSsbo(_clipSlotSsbo, 3, ref _clipSlotSsboCapacityBytes, + sp, totalInstances * sizeof(uint)); // #142: per-instance indoor flag buffer (binding=6), one uint per instance, // laid out parallel to _instanceData in Phase 3. Only [0..totalInstances) // is uploaded — stale tail never read (same guarantee as clip-slot above). fixed (uint* dp = _indoorData) - UploadSsbo(_instIndoorSsbo, 6, dp, totalInstances * sizeof(uint)); + UploadSsbo(_instIndoorSsbo, 6, ref _instIndoorSsboCapacityBytes, + dp, totalInstances * sizeof(uint)); // #188: per-instance opacity buffer (binding=7), one float per instance, // laid out parallel to _instanceData in Phase 3. Only [0..totalInstances) // is uploaded — stale tail never read (same guarantee as clip-slot above). fixed (float* ap = _alphaData) - UploadSsbo(_instAlphaSsbo, 7, ap, totalInstances * sizeof(float)); + UploadSsbo(_instAlphaSsbo, 7, ref _instAlphaSsboCapacityBytes, + ap, totalInstances * sizeof(float)); // SmartBox click lighting: x=luminosity, y=diffuse. mesh_modern.vert // reads this only for the object path (uLightingMode=0), so EnvCell's // independent mode-1 renderer does not consume this binding. fixed (Vector2* hp = _selectionLightingData) - UploadSsbo(_instSelectionLightingSsbo, 8, hp, totalInstances * sizeof(float) * 2); + UploadSsbo(_instSelectionLightingSsbo, 8, ref _instSelectionLightingSsboCapacityBytes, + hp, totalInstances * sizeof(float) * 2); // Fix B: global point-light buffer (binding=4) + per-instance light-set // buffer (binding=5). The global buffer is this frame's PointSnapshot; the @@ -1726,15 +2044,21 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // shader never reads an unbound SSBO on a no-lights frame. UploadGlobalLights(); fixed (int* lp = _lightSetData) - UploadSsbo(_instLightSetSsbo, 5, lp, totalInstances * LightManager.MaxLightsPerObject * sizeof(int)); + UploadSsbo(_instLightSetSsbo, 5, ref _instLightSetSsboCapacityBytes, + lp, totalInstances * LightManager.MaxLightsPerObject * sizeof(int)); fixed (DrawElementsIndirectCommand* cp = _indirectCommands) { - _gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer); - _gl.BufferData(BufferTargetARB.DrawIndirectBuffer, - (nuint)(totalDraws * sizeof(DrawElementsIndirectCommand)), cp, BufferUsageARB.DynamicDraw); + UploadDynamicBuffer( + BufferTargetARB.DrawIndirectBuffer, + _indirectBuffer, + ref _indirectBufferCapacityBytes, + cp, + totalDraws * sizeof(DrawElementsIndirectCommand)); } + PersistActiveDynamicBufferCapacities(); + // Phase U.3: bind the SHARED per-cell clip-region SSBO (binding=2). The // GameWindow-level ClipFrame already uploaded + bound it this frame; we // re-bind defensively in case another consumer touched binding=2 since. @@ -1960,7 +2284,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable CullMode: g.CullMode); private static GroupKey ToKey(InstanceGroup g) => new( - g.Ibo, g.FirstIndex, g.BaseVertex, g.IndexCount, @@ -2010,7 +2333,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } } - private void DrawDeferredAlphaBatch(ReadOnlySpan tokens) + private void PrepareDeferredAlphaDraws(ReadOnlySpan tokens) { if (tokens.Length == 0) return; @@ -2052,26 +2375,54 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _deferredAlphaKinds[i] = key.Translucency; } + // One upload per source per sorted alpha scope. RetailAlphaQueue later + // draws contiguous ranges from this immutable prepared payload; it must + // never overwrite these buffers for every short mesh/particle run. + ActivateNextDynamicBufferSet(); UploadDeferredAlphaBuffers(count); + PersistActiveDynamicBufferCapacities(); + } + + private void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount) + { + if (drawCount <= 0) + return; + if (firstPreparedDraw < 0 + || firstPreparedDraw > _deferredAlpha.Count - drawCount) + throw new ArgumentOutOfRangeException(nameof(firstPreparedDraw)); + + GlobalMeshBuffer? global = _meshAdapter.MeshManager?.GlobalBuffer; + if (global is null || global.VAO == 0) + return; _shader.Use(); _shader.SetMatrix4("uViewProjection", _deferredAlphaViewProjection); _shader.SetInt("uLightingMode", 0); _shader.SetInt("uLightDebug", AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode); _shader.SetInt("uRenderPass", 1); + _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 0, _instanceSsbo); + _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 1, _batchSsbo); + _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 3, _clipSlotSsbo); + _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 4, _globalLightsSsbo); + _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 5, _instLightSetSsbo); + _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 6, _instIndoorSsbo); + _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 7, _instAlphaSsbo); + _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 8, _instSelectionLightingSsbo); BindClipRegionBinding2(); _gl.BindVertexArray(global.VAO); + _gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer); _gl.Enable(EnableCap.DepthTest); _gl.Enable(EnableCap.Blend); _gl.DepthMask(false); _gl.FrontFace(FrontFaceDirection.CW); - int runStart = 0; - while (runStart < count) + int runStart = firstPreparedDraw; + int preparedEnd = firstPreparedDraw + drawCount; + while (runStart < preparedEnd) { TranslucencyKind blend = _deferredAlphaKinds[runStart]; int runEnd = runStart + 1; - while (runEnd < count && _deferredAlphaKinds[runEnd] == blend) + while (runEnd < preparedEnd && _deferredAlphaKinds[runEnd] == blend) runEnd++; ApplyRetailBlend(blend); @@ -2113,29 +2464,36 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private void UploadDeferredAlphaBuffers(int count) { fixed (float* p = _instanceData) - UploadSsbo(_instanceSsbo, 0, p, count * 16 * sizeof(float)); + UploadSsbo(_instanceSsbo, 0, ref _instanceSsboCapacityBytes, + p, count * 16 * sizeof(float)); fixed (BatchData* p = _batchData) - UploadSsbo(_batchSsbo, 1, p, count * sizeof(BatchData)); + UploadSsbo(_batchSsbo, 1, ref _batchSsboCapacityBytes, + p, count * sizeof(BatchData)); fixed (uint* p = _clipSlotData) - UploadSsbo(_clipSlotSsbo, 3, p, count * sizeof(uint)); + UploadSsbo(_clipSlotSsbo, 3, ref _clipSlotSsboCapacityBytes, + p, count * sizeof(uint)); fixed (int* p = _lightSetData) - UploadSsbo(_instLightSetSsbo, 5, p, count * LightManager.MaxLightsPerObject * sizeof(int)); + UploadSsbo(_instLightSetSsbo, 5, ref _instLightSetSsboCapacityBytes, + p, count * LightManager.MaxLightsPerObject * sizeof(int)); fixed (uint* p = _indoorData) - UploadSsbo(_instIndoorSsbo, 6, p, count * sizeof(uint)); + UploadSsbo(_instIndoorSsbo, 6, ref _instIndoorSsboCapacityBytes, + p, count * sizeof(uint)); fixed (float* p = _alphaData) - UploadSsbo(_instAlphaSsbo, 7, p, count * sizeof(float)); + UploadSsbo(_instAlphaSsbo, 7, ref _instAlphaSsboCapacityBytes, + p, count * sizeof(float)); fixed (Vector2* p = _selectionLightingData) - UploadSsbo(_instSelectionLightingSsbo, 8, p, count * sizeof(float) * 2); + UploadSsbo(_instSelectionLightingSsbo, 8, ref _instSelectionLightingSsboCapacityBytes, + p, count * sizeof(float) * 2); UploadGlobalLights(); fixed (DrawElementsIndirectCommand* p = _indirectCommands) { - _gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer); - _gl.BufferData( + UploadDynamicBuffer( BufferTargetARB.DrawIndirectBuffer, - (nuint)(count * sizeof(DrawElementsIndirectCommand)), + _indirectBuffer, + ref _indirectBufferCapacityBytes, p, - BufferUsageARB.DynamicDraw); + count * sizeof(DrawElementsIndirectCommand)); } } @@ -2155,13 +2513,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private static int CompareOpaqueSubmissionOrder(InstanceGroup a, InstanceGroup b) { - int cull = a.CullMode.CompareTo(b.CullMode); + int cull = ((int)a.CullMode).CompareTo((int)b.CullMode); return cull != 0 ? cull : a.SortDistance.CompareTo(b.SortDistance); } private static int CompareTransparentSubmissionOrder(InstanceGroup a, InstanceGroup b) { - int cull = a.CullMode.CompareTo(b.CullMode); + int cull = ((int)a.CullMode).CompareTo((int)b.CullMode); return cull != 0 ? cull : b.SortDistance.CompareTo(a.SortDistance); } @@ -2236,13 +2594,160 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } } - private unsafe void UploadSsbo(uint ssbo, uint binding, void* data, int byteCount) + private void ActivateNextDynamicBufferSet() { - _gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, ssbo); - _gl.BufferData(BufferTargetARB.ShaderStorageBuffer, (nuint)byteCount, data, BufferUsageARB.DynamicDraw); + if (!_dynamicFrameStarted) + throw new InvalidOperationException("BeginFrame must be called before drawing world entities."); + + List slotSets = _dynamicBufferSetsByFrame[_dynamicFrameSlot]; + if (_dynamicBufferSetCursor == slotSets.Count) + slotSets.Add(CreateDynamicBufferSet()); + + DynamicBufferSet set = slotSets[_dynamicBufferSetCursor++]; + _activeDynamicBufferSet = set; + _instanceSsbo = set.InstanceSsbo; + _batchSsbo = set.BatchSsbo; + _indirectBuffer = set.IndirectBuffer; + _clipSlotSsbo = set.ClipSlotSsbo; + _globalLightsSsbo = set.GlobalLightsSsbo; + _instLightSetSsbo = set.InstanceLightSetSsbo; + _instIndoorSsbo = set.InstanceIndoorSsbo; + _instAlphaSsbo = set.InstanceAlphaSsbo; + _instSelectionLightingSsbo = set.InstanceSelectionLightingSsbo; + _instanceSsboCapacityBytes = set.InstanceCapacityBytes; + _batchSsboCapacityBytes = set.BatchCapacityBytes; + _indirectBufferCapacityBytes = set.IndirectCapacityBytes; + _clipSlotSsboCapacityBytes = set.ClipSlotCapacityBytes; + _globalLightsSsboCapacityBytes = set.GlobalLightsCapacityBytes; + _instLightSetSsboCapacityBytes = set.InstanceLightSetCapacityBytes; + _instIndoorSsboCapacityBytes = set.InstanceIndoorCapacityBytes; + _instAlphaSsboCapacityBytes = set.InstanceAlphaCapacityBytes; + _instSelectionLightingSsboCapacityBytes = set.InstanceSelectionLightingCapacityBytes; + } + + private DynamicBufferSet CreateDynamicBufferSet() + { + var set = new DynamicBufferSet(); + try + { + set.InstanceSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity instance SSBO"); + set.BatchSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity batch SSBO"); + set.IndirectBuffer = TrackedGlResource.CreateBuffer(_gl, "creating entity indirect buffer"); + set.ClipSlotSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity clip-slot SSBO"); + set.GlobalLightsSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity global-light SSBO"); + set.InstanceLightSetSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity light-set SSBO"); + set.InstanceIndoorSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity indoor SSBO"); + set.InstanceAlphaSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity alpha SSBO"); + set.InstanceSelectionLightingSsbo = TrackedGlResource.CreateBuffer( + _gl, + "creating entity selection-lighting SSBO"); + return set; + } + catch (Exception creationFailure) + { + try { DeleteDynamicBufferSet(set); } + catch (Exception cleanupFailure) + { + throw new AggregateException( + "Entity dynamic-buffer creation and rollback failed.", + creationFailure, + cleanupFailure); + } + throw; + } + } + + private void DeleteDynamicBufferSet(DynamicBufferSet set) + { + List? failures = null; + void Attempt(uint buffer, int bytes, string name) + { + try { TrackedGlResource.DeleteBuffer(_gl, buffer, bytes, $"deleting {name}"); } + catch (Exception ex) { (failures ??= []).Add(ex); } + } + + Attempt(set.InstanceSsbo, set.InstanceCapacityBytes, "entity instance SSBO"); + Attempt(set.BatchSsbo, set.BatchCapacityBytes, "entity batch SSBO"); + Attempt(set.IndirectBuffer, set.IndirectCapacityBytes, "entity indirect buffer"); + Attempt(set.ClipSlotSsbo, set.ClipSlotCapacityBytes, "entity clip-slot SSBO"); + Attempt(set.GlobalLightsSsbo, set.GlobalLightsCapacityBytes, "entity global-light SSBO"); + Attempt(set.InstanceLightSetSsbo, set.InstanceLightSetCapacityBytes, "entity light-set SSBO"); + Attempt(set.InstanceIndoorSsbo, set.InstanceIndoorCapacityBytes, "entity indoor SSBO"); + Attempt(set.InstanceAlphaSsbo, set.InstanceAlphaCapacityBytes, "entity alpha SSBO"); + Attempt( + set.InstanceSelectionLightingSsbo, + set.InstanceSelectionLightingCapacityBytes, + "entity selection-lighting SSBO"); + + if (failures is not null) + throw new AggregateException("One or more entity dynamic buffers failed to delete.", failures); + } + + private void PersistActiveDynamicBufferCapacities() + { + DynamicBufferSet set = _activeDynamicBufferSet + ?? throw new InvalidOperationException("No dynamic entity buffer set is active."); + set.InstanceCapacityBytes = _instanceSsboCapacityBytes; + set.BatchCapacityBytes = _batchSsboCapacityBytes; + set.IndirectCapacityBytes = _indirectBufferCapacityBytes; + set.ClipSlotCapacityBytes = _clipSlotSsboCapacityBytes; + set.GlobalLightsCapacityBytes = _globalLightsSsboCapacityBytes; + set.InstanceLightSetCapacityBytes = _instLightSetSsboCapacityBytes; + set.InstanceIndoorCapacityBytes = _instIndoorSsboCapacityBytes; + set.InstanceAlphaCapacityBytes = _instAlphaSsboCapacityBytes; + set.InstanceSelectionLightingCapacityBytes = _instSelectionLightingSsboCapacityBytes; + } + + private unsafe void UploadSsbo( + uint ssbo, + uint binding, + ref int capacityBytes, + void* data, + int byteCount) + { + UploadDynamicBuffer( + BufferTargetARB.ShaderStorageBuffer, + ssbo, + ref capacityBytes, + data, + byteCount); _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo); } + private unsafe void UploadDynamicBuffer( + BufferTargetARB target, + uint buffer, + ref int capacityBytes, + void* data, + int byteCount) + { + if (byteCount < 0) + throw new ArgumentOutOfRangeException(nameof(byteCount)); + + _gl.BindBuffer(target, buffer); + // A render bucket can legitimately contain zero batches (for example the outdoor dynamic + // bucket immediately after auto-entry). Keep the buffer bound for the corresponding SSBO + // binding, but there is no active prefix to allocate or upload and no draw can read it. + if (byteCount == 0) + return; + + if (capacityBytes < byteCount) + { + int grownCapacity = DynamicBufferCapacity.Grow(capacityBytes, byteCount); + TrackedGlResource.AllocateBufferStorage( + _gl, + (GLEnum)target, + buffer, + capacityBytes, + grownCapacity, + GLEnum.DynamicDraw, + $"growing entity dynamic buffer {buffer} to {grownCapacity} bytes"); + capacityBytes = grownCapacity; + } + + _gl.BufferSubData(target, 0, (nuint)byteCount, data); + } + /// /// Fix B: pack into the binding=4 global light /// buffer (one GlobalLight = 4 vec4 = 16 floats, std430 stride 64 bytes, @@ -2256,7 +2761,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable int count = n > 0 ? n : 1; // never zero-size // Pack guarantees _globalLightData holds at least max(n,1) * FloatsPerLight floats. fixed (float* gp = _globalLightData) - UploadSsbo(_globalLightsSsbo, 4, gp, + UploadSsbo(_globalLightsSsbo, 4, ref _globalLightsSsboCapacityBytes, gp, count * GlobalLightPacker.FloatsPerLight * sizeof(float)); } @@ -2440,7 +2945,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable { grp = new InstanceGroup { - Ibo = key.Ibo, FirstIndex = key.FirstIndex, BaseVertex = key.BaseVertex, IndexCount = key.IndexCount, @@ -2577,18 +3081,19 @@ public sealed unsafe class WbDrawDispatcher : IDisposable grp.IndoorFlags.Add(_currentEntityIndoor ? 1u : 0u); // #142, parallel to the light block } - private void ClassifyBatches( + private bool ClassifyBatches( ObjectRenderData renderData, ulong gfxObjId, Matrix4x4 model, WorldEntity entity, MeshRef meshRef, - ulong palHash, + PaletteCompositeIdentity paletteIdentity, AcSurfaceMetadataTable metaTable, Matrix4x4 restPose, float opacityMultiplier = 1.0f, List? collector = null) { + bool allTexturesReady = true; for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++) { var batch = renderData.Batches[batchIdx]; @@ -2612,22 +3117,26 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (opacityMultiplier < 1.0f && IsOpaque(translucency)) translucency = TranslucencyKind.AlphaBlend; - ulong texHandle = ResolveTexture(entity, meshRef, batch, palHash); - if (texHandle == 0) continue; - - // TextureLayer is always 0 for per-instance composites; non-zero when - // WB atlas is adopted in N.6+ and batches reference a shared atlas layer. - uint texLayer = 0; + ResolvedTexture texture = ResolveTexture( + entity, + meshRef, + batch, + paletteIdentity, + out bool compositePending); + if (compositePending) + allTexturesReady = false; + if (texture.Handle == 0) continue; + ulong texHandle = texture.Handle; + uint texLayer = texture.Layer; var key = new GroupKey( - batch.IBO, batch.FirstIndex, (int)batch.BaseVertex, + batch.FirstIndex, (int)batch.BaseVertex, batch.IndexCount, texHandle, texLayer, translucency, batch.CullMode); if (!_groups.TryGetValue(key, out var grp)) { grp = new InstanceGroup { - Ibo = batch.IBO, FirstIndex = batch.FirstIndex, BaseVertex = (int)batch.BaseVertex, IndexCount = batch.IndexCount, @@ -2646,30 +3155,69 @@ public sealed unsafe class WbDrawDispatcher : IDisposable grp.SelectionLighting.Add(_currentEntitySelectionLighting); collector?.Add(new CachedBatch(key, texHandle, restPose, renderData.SortCenter)); } + return allTexturesReady; } - private ulong ResolveTexture(WorldEntity entity, MeshRef meshRef, ObjectRenderBatch batch, ulong palHash) + private readonly record struct ResolvedTexture(ulong Handle, uint Layer); + + private ResolvedTexture ResolveTexture( + WorldEntity entity, + MeshRef meshRef, + ObjectRenderBatch batch, + PaletteCompositeIdentity paletteIdentity, + out bool compositePending) { + compositePending = false; uint surfaceId = batch.Key.SurfaceId; - if (surfaceId == 0 || surfaceId == 0xFFFFFFFF) return 0; + if (surfaceId == 0 || surfaceId == 0xFFFFFFFF) + return default; uint overrideOrigTex = 0; bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null - && meshRef.SurfaceOverrides.TryGetValue(surfaceId, out overrideOrigTex); + && meshRef.SurfaceOverrides.TryGetValue(surfaceId, out overrideOrigTex) + && overrideOrigTex != 0; uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null; - if (entity.PaletteOverride is not null) + bool sourceIsPaletteIndexed = entity.PaletteOverride is not null + && _textures.IsPaletteIndexed(surfaceId, origTexOverride); + WbTextureResolutionKind resolution = WbTextureResolutionPolicy.Select( + hasOrigTexOverride, + entity.PaletteOverride is not null, + sourceIsPaletteIndexed); + + switch (resolution) { - return _textures.GetOrUploadWithPaletteOverrideBindless( - surfaceId, origTexOverride, entity.PaletteOverride, palHash); - } - else if (hasOrigTexOverride) - { - return _textures.GetOrUploadWithOrigTextureOverrideBindless(surfaceId, overrideOrigTex); - } - else - { - return _textures.GetOrUploadBindless(surfaceId); + case WbTextureResolutionKind.PaletteComposite: + { + BindlessTextureLocation texture = + _textures.GetOrUploadWithPaletteOverrideBindless( + entity.Id, + surfaceId, + origTexOverride, + entity.PaletteOverride!, + paletteIdentity); + compositePending = texture.Handle == 0; + return new ResolvedTexture(texture.Handle, texture.Layer); + } + + case WbTextureResolutionKind.OriginalTextureOverride: + { + BindlessTextureLocation texture = + _textures.GetOrUploadWithOrigTextureOverrideBindless( + entity.Id, + surfaceId, + overrideOrigTex); + compositePending = texture.Handle == 0; + return new ResolvedTexture(texture.Handle, texture.Layer); + } + + case WbTextureResolutionKind.SharedAtlas: + return new ResolvedTexture( + batch.BindlessTextureHandle, + checked((uint)batch.TextureIndex)); + + default: + throw new ArgumentOutOfRangeException(nameof(resolution)); } } @@ -2716,26 +3264,161 @@ public sealed unsafe class WbDrawDispatcher : IDisposable public void Dispose() { - if (_disposed) return; - _disposed = true; - _gl.DeleteBuffer(_instanceSsbo); - _gl.DeleteBuffer(_batchSsbo); - _gl.DeleteBuffer(_indirectBuffer); - if (_clipSlotSsbo != 0) _gl.DeleteBuffer(_clipSlotSsbo); // Phase U.3 - if (_fallbackClipRegionSsbo != 0) _gl.DeleteBuffer(_fallbackClipRegionSsbo); // Phase U.3 - if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); // Fix B binding=4 - if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5 - if (_instIndoorSsbo != 0) _gl.DeleteBuffer(_instIndoorSsbo); // #142 binding=6 - if (_instAlphaSsbo != 0) _gl.DeleteBuffer(_instAlphaSsbo); // #188 binding=7 - if (_instSelectionLightingSsbo != 0) _gl.DeleteBuffer(_instSelectionLightingSsbo); // SmartBox binding=8 - if (_gpuQueriesInitialized) + if (_disposed || _disposing) return; + _disposing = true; + try { - for (int i = 0; i < GpuQueryRingDepth; i++) + if (_disposeResources is null) { - _gl.DeleteQuery(_gpuQueryOpaque[i]); - _gl.DeleteQuery(_gpuQueryTransparent[i]); + var releases = new List<(string Name, Action Release)>(); + BuildDisposeReleases(releases); + _disposeResources = new RetryableResourceReleaseLedger(releases); + } + + ResourceReleaseAttempt attempt = _disposeResources.Advance(); + if (!_disposeResources.IsComplete) + { + throw attempt.ToException( + "One or more entity renderer resources could not be released."); + } + + CompleteDispose(); + _disposeResources = null; + _disposed = true; + + if (attempt.HasFailures) + { + throw attempt.ToException( + "Entity renderer resources released with exceptional committed outcomes."); } } + finally + { + _disposing = false; + } + } + + private void BuildDisposeReleases(List<(string Name, Action Release)> releases) + { + for (int frame = 0; frame < _dynamicBufferSetsByFrame.Length; frame++) + { + List frameSets = _dynamicBufferSetsByFrame[frame]; + for (int index = 0; index < frameSets.Count; index++) + AddDynamicBufferSetReleases(releases, frameSets[index], frame, index); + } + + AddRawGlRelease( + releases, + _fallbackClipRegionSsbo, + "fallback-clip-region", + "deleting entity fallback clip SSBO", + _gl.DeleteBuffer); + + if (!_gpuQueriesInitialized) + return; + for (int i = 0; i < GpuQueryRingDepth; i++) + { + AddRawGlRelease( + releases, + _gpuQueryOpaque[i], + $"opaque-query-{i}", + "deleting entity opaque timing query", + _gl.DeleteQuery); + AddRawGlRelease( + releases, + _gpuQueryTransparent[i], + $"transparent-query-{i}", + "deleting entity transparent timing query", + _gl.DeleteQuery); + } + } + + private void AddDynamicBufferSetReleases( + List<(string Name, Action Release)> releases, + DynamicBufferSet set, + int frame, + int index) + { + AddTrackedBufferRelease(releases, set.InstanceSsbo, set.InstanceCapacityBytes, + $"dynamic-{frame}-{index}-instances", "deleting entity instance SSBO"); + AddTrackedBufferRelease(releases, set.BatchSsbo, set.BatchCapacityBytes, + $"dynamic-{frame}-{index}-batches", "deleting entity batch SSBO"); + AddTrackedBufferRelease(releases, set.IndirectBuffer, set.IndirectCapacityBytes, + $"dynamic-{frame}-{index}-indirect", "deleting entity indirect buffer"); + AddTrackedBufferRelease(releases, set.ClipSlotSsbo, set.ClipSlotCapacityBytes, + $"dynamic-{frame}-{index}-clip-slots", "deleting entity clip-slot SSBO"); + AddTrackedBufferRelease(releases, set.GlobalLightsSsbo, set.GlobalLightsCapacityBytes, + $"dynamic-{frame}-{index}-global-lights", "deleting entity global-light SSBO"); + AddTrackedBufferRelease(releases, set.InstanceLightSetSsbo, set.InstanceLightSetCapacityBytes, + $"dynamic-{frame}-{index}-light-sets", "deleting entity light-set SSBO"); + AddTrackedBufferRelease(releases, set.InstanceIndoorSsbo, set.InstanceIndoorCapacityBytes, + $"dynamic-{frame}-{index}-indoor", "deleting entity indoor SSBO"); + AddTrackedBufferRelease(releases, set.InstanceAlphaSsbo, set.InstanceAlphaCapacityBytes, + $"dynamic-{frame}-{index}-alpha", "deleting entity alpha SSBO"); + AddTrackedBufferRelease( + releases, + set.InstanceSelectionLightingSsbo, + set.InstanceSelectionLightingCapacityBytes, + $"dynamic-{frame}-{index}-selection-lighting", + "deleting entity selection-lighting SSBO"); + } + + private void AddTrackedBufferRelease( + List<(string Name, Action Release)> releases, + uint buffer, + long capacityBytes, + string name, + string context) + { + if (buffer == 0) + return; + RetryableGpuResourceRelease release = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + buffer, + capacityBytes, + context); + releases.Add((name, release.Run)); + } + + private void AddRawGlRelease( + List<(string Name, Action Release)> releases, + uint resource, + string name, + string context, + Action delete) + { + if (resource == 0) + return; + var release = new RetryableGpuResourceRelease( + () => GLHelpers.ThrowOnResourceError(_gl, $"{context} (precondition)"), + () => + { + delete(resource); + GLHelpers.ThrowOnResourceError(_gl, context); + }); + releases.Add((name, release.Run)); + } + + private void CompleteDispose() + { + foreach (List frameSets in _dynamicBufferSetsByFrame) + frameSets.Clear(); + _activeDynamicBufferSet = null; + _dynamicFrameStarted = false; + _instanceSsbo = 0; + _batchSsbo = 0; + _indirectBuffer = 0; + _clipSlotSsbo = 0; + _globalLightsSsbo = 0; + _instLightSetSsbo = 0; + _instIndoorSsbo = 0; + _instAlphaSsbo = 0; + _instSelectionLightingSsbo = 0; + _fallbackClipRegionSsbo = 0; + Array.Clear(_gpuQueryOpaque); + Array.Clear(_gpuQueryTransparent); + _gpuQueriesInitialized = false; } // ── Public types + helpers for BuildIndirectArrays (Task 9) ───────────── @@ -2894,12 +3577,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable internal sealed class InstanceGroup { - public uint Ibo; public uint FirstIndex; public int BaseVertex; public int IndexCount; public ulong BindlessTextureHandle; // 64-bit (was uint TextureHandle in N.4) - public uint TextureLayer; // 0 for per-instance composites; non-zero when WB atlas is adopted in N.6+ + public uint TextureLayer; // Layer in either the pooled composite array or WB shared atlas. public TranslucencyKind Translucency; public CullMode CullMode; public int FirstInstance; // offset into the shared instance VBO (in instances, not bytes) diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index 3eea0a8e..6769d096 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -17,18 +17,56 @@ namespace AcDream.App.Rendering.Wb; /// so the rest of the renderer doesn't need to know about WB's types directly. /// /// -/// As of Phase O-T7, all DAT I/O routes through -/// (backed by our shared ) — the separate +/// As of Phase O-T7, all DAT I/O routes through the runtime-owned shared +/// facade — the separate /// DefaultDatReaderWriter file-handle set has been removed. /// /// public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter { + internal const int MaximumUploadsPerFrame = 8; + internal const long MaximumUploadBytesPerFrame = 8L * 1024 * 1024; + internal const long MaximumArrayAllocationBytesPerFrame = 8L * 1024 * 1024; + internal const long MaximumMipmapBytesPerFrame = 8L * 1024 * 1024; + internal const int MaximumNewArraysPerFrame = 1; + internal const long MaximumBufferUploadBytesPerFrame = 8L * 1024 * 1024; + // A GL buffer store is indivisible. The active vertex arena's explicit + // supported ceiling is therefore the truthful one-allocation bound; only + // its prefix copy is staged across frames. + internal const long MaximumBufferAllocationBytesPerFrame = + GlobalMeshBuffer.MaximumVertexBufferBytes; + internal const long MaximumBufferCopyBytesPerFrame = 32L * 1024 * 1024; + internal const int MaximumNewBuffersPerFrame = 1; + internal const long MaximumSingleUploadBytes = 128L * 1024 * 1024; + internal const long MaximumSingleArrayAllocationBytes = 128L * 1024 * 1024; + internal const long MaximumSingleMipmapBytes = 128L * 1024 * 1024; + internal const int MaximumSingleNewArrays = 16; + internal const long MaximumSingleBufferUploadBytes = 32L * 1024 * 1024; + internal const int MaximumReclaimedMeshesPerFrame = MaximumUploadsPerFrame; + internal const long MaximumReclaimedMeshBytesPerFrame = 64L * 1024 * 1024; + internal const int MaximumStaleDiscardsPerFrame = 64; private readonly OpenGLGraphicsDevice? _graphicsDevice; private readonly ObjectMeshManager? _meshManager; - private readonly DatCollection? _dats; + private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue? _resourceRetirement; + private readonly IDatReaderWriter? _dats; private readonly AcSurfaceMetadataTable _metadataTable = new(); private readonly HashSet _metadataPopulated = new(); + private readonly MeshUploadFrameBudget _uploadBudget = new(new MeshUploadBudgetLimits( + MaximumUploadsPerFrame, + MaximumUploadBytesPerFrame, + MaximumArrayAllocationBytesPerFrame, + MaximumMipmapBytesPerFrame, + MaximumNewArraysPerFrame, + MaximumBufferUploadBytesPerFrame, + MaximumBufferAllocationBytesPerFrame, + MaximumBufferCopyBytesPerFrame, + MaximumNewBuffersPerFrame, + MaximumSingleUploadBytes, + MaximumSingleArrayAllocationBytes, + MaximumSingleMipmapBytes, + MaximumSingleNewArrays, + MaximumSingleBufferUploadBytes)); + private readonly HashSet _mipmapsBudgeted = new(); /// /// True when this instance was created via ; @@ -37,32 +75,64 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter private readonly bool _isUninitialized; private bool _disposed; + private AcDream.App.Rendering.OrderedResourceTeardown? _teardown; + internal int LastUploadCount { get; private set; } + internal long LastUploadBytes { get; private set; } + internal int LastStaleDiscardCount { get; private set; } + internal long LastArrayAllocationBytes { get; private set; } + internal long LastPlannedMipmapBytes { get; private set; } + internal int LastNewArrayCount { get; private set; } + internal long LastBufferUploadBytes { get; private set; } + internal long LastBufferAllocationBytes { get; private set; } + internal long LastBufferCopyBytes { get; private set; } + internal int LastNewBufferCount { get; private set; } + internal int LastMipmapArrayCount { get; private set; } + internal long LastMipmapBytes { get; private set; } + internal int StagedUploadBacklog => _meshManager?.StagedMeshCount ?? 0; + internal long StagedUploadBytes => _meshManager?.StagedMeshBytes ?? 0; + internal bool StagingAtHighWater => _meshManager?.StagingAtHighWater ?? false; + internal (int Count, long Bytes) CpuMeshCacheDiagnostics => + _meshManager?.CpuCacheDiagnostics ?? default; /// - /// Constructs the full WB pipeline: OpenGLGraphicsDevice → DatCollectionAdapter - /// → ObjectMeshManager. + /// Constructs the full WB pipeline: OpenGLGraphicsDevice → shared bounded + /// DAT facade → ObjectMeshManager. /// /// Active Silk.NET GL context. Must be bound to the current /// thread (construction runs GL queries; call from OnLoad). - /// acdream's DatCollection, used to populate the surface + /// acdream's shared runtime DAT facade, used to populate the surface /// metadata side-table via GfxObjMesh.Build. Shares file handles with /// the rest of the client; read-only access from the render thread. /// Logger for the adapter; ObjectMeshManager uses /// NullLogger internally. - public WbMeshAdapter(GL gl, DatCollection dats, ILogger logger) + public WbMeshAdapter(GL gl, IDatReaderWriter dats, ILogger logger) + : this(gl, dats, logger, AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance) + { + } + + internal WbMeshAdapter( + GL gl, + IDatReaderWriter dats, + ILogger logger, + AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement) { ArgumentNullException.ThrowIfNull(gl); ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(logger); _dats = dats; - _graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings()); + _resourceRetirement = resourceRetirement; + _graphicsDevice = new OpenGLGraphicsDevice( + gl, + logger, + new DebugRenderSettings(), + resourceRetirement); _graphicsDevice.ParticleBatcher = new ParticleBatcher(_graphicsDevice); // ConsoleErrorLogger surfaces WB's silently-caught exceptions // (ObjectMeshManager.PrepareMeshData try/catch at line ~589). _meshManager = new ObjectMeshManager( _graphicsDevice, - new DatCollectionAdapter(dats), + dats, new ConsoleErrorLogger()); } @@ -157,7 +227,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter // An uninitialized adapter owns no render pipeline, so it must not // manufacture a readiness wait that can never complete. The modern // production path is always initialized at startup. - return _isUninitialized || _meshManager?.TryGetRenderData(id) is not null; + return _isUninitialized || (_meshManager?.EnsureRenderDataReady(id) ?? false); } /// @@ -166,9 +236,12 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter if (_isUninitialized || _meshManager is null) return; _meshManager.IncrementRefCount(id); - bool firstEver = _metadataPopulated.Add(id); - if (firstEver) - PopulateMetadata(id); + bool metadataClaimed = false; + try + { + metadataClaimed = _metadataPopulated.Add(id); + if (metadataClaimed) + PopulateMetadata(id); // WB's IncrementRefCount alone only bumps a usage counter; it does // NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync @@ -197,8 +270,37 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter // isSetup: false — acdream's MeshRefs already carry expanded // per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is // unused. - if (firstEver || _meshManager.TryGetRenderData(id) is null) - _meshManager.PrepareMeshDataAsync(id, isSetup: false); + if (metadataClaimed || _meshManager.TryGetRenderData(id) is null) + _meshManager.PrepareMeshDataAsync(id, isSetup: false); + } + catch (Exception acquireFailure) + { + if (metadataClaimed) + { + _metadataPopulated.Remove(id); + _metadataTable.Remove(id); + } + + // IncrementRefCount is a public ownership boundary. Metadata/DAT + // preparation happens after ObjectMeshManager commits its count, + // so compensate before reporting failure. ObjectMeshManager's + // decrement has a strong exception guarantee; if compensation + // itself fails, expose that the increment remains committed so a + // higher-level transactional owner can retain its held marker. + try + { + _meshManager.DecrementRefCount(id); + } + catch (Exception rollbackFailure) + { + throw new MeshReferenceMutationException( + $"Mesh 0x{id:X10} reference acquisition failed and its committed increment could not be rolled back.", + mutationCommitted: true, + new AggregateException(acquireFailure, rollbackFailure)); + } + + throw; + } } /// @@ -263,6 +365,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter if (_isUninitialized) return; if (_disposed) return; + ObjectMeshManager meshManager = _meshManager!; _graphicsDevice!.ProcessGLQueue(); // #125: drain staged uploads; a FAILED upload (UploadMeshData returned // null from its catch) is re-staged for a LATER frame, not dropped. The @@ -270,43 +373,190 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter // inside the while would let a deterministic failure spin the queue in a // single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so // a genuine defect surfaces loudly instead of the old silent sticky drop. - List? requeue = null; - while (_meshManager!.TryDequeueStagedMeshData(out var meshData)) + List? requeue = null; + _uploadBudget.Reset(); + _mipmapsBudgeted.Clear(); + int staleDiscardCount = 0; + bool arenaBackpressured = false; + GlobalMeshBuffer? globalBuffer = meshManager.GlobalBuffer; + + // A backing-store migration is real GL work, not a cost estimate. One + // bounded copy chunk runs per frame while the old VAO remains active. + // Uploads stay queued until the atomic final swap has completed. + if (globalBuffer?.IsMigrationInProgress == true) { - if (meshData is null) - continue; - if (_meshManager.UploadOrRequeue(meshData)) + GlobalMeshMaintenanceStep maintenance = + globalBuffer.AdvanceMigration(MaximumBufferCopyBytesPerFrame); + _uploadBudget.RecordBufferMaintenance( + maintenance.AllocationBytes, + maintenance.CopyBytes, + maintenance.NewBufferCount); + arenaBackpressured = !maintenance.Completed; + } + + while (globalBuffer?.IsMigrationInProgress != true + && _uploadBudget.BufferCopyBytes == 0 + && _uploadBudget.ObjectCount < MaximumUploadsPerFrame) + { + staleDiscardCount += meshManager.DiscardUnownedStagedPrefix( + MaximumStaleDiscardsPerFrame - staleDiscardCount); + if (staleDiscardCount >= MaximumStaleDiscardsPerFrame) + break; + if (!meshManager.TryPeekStagedMeshData(out MeshUploadQueueItem next)) + break; + + GlobalMeshCapacityResult capacity; + GlobalMeshMaintenanceStep maintenance; + try + { + capacity = meshManager.EnsureGlobalBufferCapacity(next, out maintenance); + } + catch (NotSupportedException error) + { + RejectUnsupportedHead(meshManager, next, error); + throw; + } + if (capacity == GlobalMeshCapacityResult.MigrationStarted) + { + _uploadBudget.RecordBufferMaintenance( + maintenance.AllocationBytes, + maintenance.CopyBytes, + maintenance.NewBufferCount); + arenaBackpressured = true; + break; + } + if (capacity == GlobalMeshCapacityResult.MigrationInProgress) + { + arenaBackpressured = true; + break; + } + if (capacity == GlobalMeshCapacityResult.NeedsReclamation) + { + // Do not evict another batch while the frame-fence queue is + // already returning ranges or retiring an old backing store. + // Waiting for real allocator state prevents three frames of + // duplicate eviction before the first release becomes reusable. + if (globalBuffer?.HasPendingReclamation != true) + { + var reclaimed = meshManager.ReclaimUnusedResources( + MaximumReclaimedMeshesPerFrame, + MaximumReclaimedMeshBytesPerFrame, + forceArenaReclamation: true); + if (reclaimed.Count == 0) + { + throw new NotSupportedException( + $"The live global-mesh working set cannot fit the supported " + + $"{GlobalMeshBuffer.MaximumVertexBufferBytes + GlobalMeshBuffer.MaximumIndexBufferBytes:N0}-byte arena " + + $"(blocked staging generation {next.Generation}, object 0x{next.Data.ObjectId:X10})."); + } + } + arenaBackpressured = true; + break; + } + + MeshUploadCost cost; + try + { + cost = meshManager.PlanUploadCost( + next.Data, + _mipmapsBudgeted, + next.Generation); + if (!_uploadBudget.TryAdmit(cost)) + break; + } + catch (NotSupportedException error) + { + RejectUnsupportedHead(meshManager, next, error); + throw; + } + + if (!meshManager.TryDequeueStagedMeshData(out MeshUploadQueueItem meshData)) + break; + if (meshData.Generation != next.Generation) + throw new InvalidOperationException("The staged mesh FIFO head changed during render-thread admission."); + if (meshManager.UploadOrRequeue(meshData)) (requeue ??= new()).Add(meshData); + meshManager.AddDirtyAtlasesTo(_mipmapsBudgeted); } if (requeue is not null) foreach (var m in requeue) - _meshManager.RequeueStagedMeshData(m); + meshManager.RequeueStagedMeshData(m); + meshManager.SetArenaBackpressure(arenaBackpressured); bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled; var pendingBefore = texProbe - ? _meshManager.GetPendingTextureUpdateStats() + ? meshManager.GetPendingTextureUpdateStats() : default; // #105 root cause (2026-06-10): TextureAtlasManager.AddTexture only STAGES - // texture content (PBO write + ManagedGLTextureArray._pendingUpdates) — the + // immutable decoded payloads in ManagedGLTextureArray._pendingUpdates — the // actual TexSubImage3D copies + mipmap regeneration happen in // ProcessDirtyUpdates, which WB drives ONCE PER FRAME from its render loop // (WB GameScene.cs:975 `_meshManager?.GenerateMipmaps()`, just before the // opaque pass). That call site lived in the GameScene file the N.4/O-T4 // extraction replaced with GameWindow, so the driver was silently dropped: - // staged updates only ever reached the GPU as a side effect of PBO growth, - // and every layer staged after an array's LAST growth kept undefined - // TexStorage3D content behind a valid resident bindless handle — the + // staged updates never reached TexSubImage3D, leaving undefined + // TexStorage3D content behind valid resident bindless handles — the // intermittent white indoor walls (#105). Pre-fix evidence: 126 updates // stuck across 34/34 arrays at standstill (texflush-prefix.log). Tick() // runs before all draw passes (GameWindow OnRender), so this is the exact // WB-equivalent position. - _meshManager.GenerateMipmaps(); + (LastMipmapArrayCount, LastMipmapBytes) = meshManager.GenerateMipmaps(); + if (globalBuffer?.HasPendingReclamation != true) + { + meshManager.ReclaimUnusedResources( + MaximumReclaimedMeshesPerFrame, + MaximumReclaimedMeshBytesPerFrame); + } + meshManager.EvictOneEmptyAtlas(); + + // Arena maintenance is capacity-driven and uses genuinely idle upload + // frames. It never competes with destination materialization, and the + // GlobalMeshBuffer policy retains enough hysteresis that portal-route + // revisits do not alternate shrink/grow every frame. + if (_uploadBudget.ObjectCount == 0 + && LastMipmapArrayCount == 0 + && meshManager.StagedMeshCount == 0 + && globalBuffer?.IsMigrationInProgress == false + && globalBuffer.TryTrimUnusedTail(out GlobalMeshMaintenanceStep trim)) + { + _uploadBudget.RecordBufferMaintenance( + trim.AllocationBytes, + trim.CopyBytes, + trim.NewBufferCount); + meshManager.SetArenaBackpressure(true); + } + + LastUploadCount = _uploadBudget.ObjectCount; + LastUploadBytes = _uploadBudget.SourceBytes; + LastStaleDiscardCount = staleDiscardCount; + LastArrayAllocationBytes = _uploadBudget.ArrayAllocationBytes; + LastPlannedMipmapBytes = _uploadBudget.MipmapBytes; + LastNewArrayCount = _uploadBudget.NewArrayCount; + LastBufferUploadBytes = _uploadBudget.BufferUploadBytes; + LastBufferAllocationBytes = _uploadBudget.BufferAllocationBytes; + LastBufferCopyBytes = _uploadBudget.BufferCopyBytes; + LastNewBufferCount = _uploadBudget.NewBufferCount; if (texProbe) EmitTexFlushProbe(pendingBefore); } + private static void RejectUnsupportedHead( + ObjectMeshManager meshManager, + MeshUploadQueueItem expected, + NotSupportedException error) + { + if (!meshManager.TryDequeueStagedMeshData(out MeshUploadQueueItem rejected) + || rejected.Generation != expected.Generation) + { + throw new InvalidOperationException( + "The staged mesh FIFO head changed while rejecting an unsupported generation.", + error); + } + meshManager.RejectUnsupportedStagedUpload(rejected, error); + } + // #105 apparatus state — see RenderingDiagnostics.ProbeTexFlushEnabled. private int _lastTexFlushBefore = -1; private int _texFlushHeartbeat; @@ -351,9 +601,46 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter /// public void Dispose() { - if (_disposed) return; - _disposed = true; - _meshManager?.Dispose(); - _graphicsDevice?.Dispose(); + if (_disposed) + return; + + // These are dependency edges, not merely a best-effort cleanup list. + // In particular, the sampler objects owned by the device must remain + // alive until every resident texture-array handle has been retired. + _teardown ??= new AcDream.App.Rendering.OrderedResourceTeardown( + () => + { + // The current global arena is still directly owned by the mesh + // manager. Fence every submitted draw before manager teardown + // can delete that arena's VAO/VBO/IBO. + if (_resourceRetirement is AcDream.App.Rendering.GpuFrameFlightController frameFlights) + frameFlights.WaitForSubmittedWork(); + }, + () => _meshManager?.Dispose(), + () => DrainGraphicsQueue("publishing mesh resource retirements"), + () => + { + if (_resourceRetirement is AcDream.App.Rendering.GpuFrameFlightController frameFlights) + frameFlights.WaitForSubmittedWork(); + }, + () => DrainGraphicsQueue("releasing retired mesh resources"), + () => _graphicsDevice?.Dispose(), + () => DrainGraphicsQueue("deleting mesh graphics-device resources")); + + _teardown.Advance(); + _disposed = _teardown.IsComplete; + } + + private void DrainGraphicsQueue(string operation) + { + if (_graphicsDevice is null) + return; + + _graphicsDevice.ProcessGLQueue(); + if (_graphicsDevice.HasPendingGLWork) + { + throw new InvalidOperationException( + $"OpenGL work remains pending after {operation}; retry adapter disposal to continue the exact stage."); + } } } diff --git a/src/AcDream.App/Rendering/Wb/WbTextureResolutionPolicy.cs b/src/AcDream.App/Rendering/Wb/WbTextureResolutionPolicy.cs new file mode 100644 index 00000000..f61fa423 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/WbTextureResolutionPolicy.cs @@ -0,0 +1,29 @@ +namespace AcDream.App.Rendering.Wb; + +internal enum WbTextureResolutionKind : byte +{ + SharedAtlas, + OriginalTextureOverride, + PaletteComposite, +} + +/// +/// Selects the retail texture ownership path for one rendered surface. +/// Retail creates a palette-combined ImgTex only for indexed source +/// images; ordinary surfaces continue to reference their shared image. +/// +internal static class WbTextureResolutionPolicy +{ + public static WbTextureResolutionKind Select( + bool hasOriginalTextureOverride, + bool hasPaletteOverride, + bool sourceIsPaletteIndexed) + { + if (hasPaletteOverride && sourceIsPaletteIndexed) + return WbTextureResolutionKind.PaletteComposite; + + return hasOriginalTextureOverride + ? WbTextureResolutionKind.OriginalTextureOverride + : WbTextureResolutionKind.SharedAtlas; + } +} diff --git a/src/AcDream.App/RuntimeDatCollectionFactory.cs b/src/AcDream.App/RuntimeDatCollectionFactory.cs new file mode 100644 index 00000000..e601d55e --- /dev/null +++ b/src/AcDream.App/RuntimeDatCollectionFactory.cs @@ -0,0 +1,101 @@ +using DatReaderWriter; +using DatReaderWriter.Options; +using AcDream.Content; +using DatReaderWriter.Lib.IO; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; + +namespace AcDream.App; + +/// +/// Opens the read-only DAT collection owned for the lifetime of an App process. +/// +/// +/// DatReaderWriter's default retains +/// every unpacked file entry for the lifetime of the collection. The runtime has +/// bounded decoded-content caches of its own, so retaining that second, +/// unbounded copy makes memory depend on every landblock visited. Keep the small +/// B-tree lookup cache, but read file payloads on demand and let the owning +/// runtime caches decide their residency. +/// +internal static class RuntimeDatCollectionFactory +{ + internal static IDatReaderWriter OpenReadOnly(string datDirectory) => + new RuntimeDatCollection(new DatCollection(CreateReadOnlyOptions(datDirectory))); + + internal static DatCollectionOptions CreateReadOnlyOptions(string datDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(datDirectory); + + return new DatCollectionOptions + { + DatDirectory = datDirectory, + AccessType = DatAccessType.Read, + IndexCachingStrategy = IndexCachingStrategy.OnDemand, + FileCachingStrategy = FileCachingStrategy.Never, + }; + } +} + +/// +/// Owns the runtime's one raw DAT handle set and its one shared bounded typed +/// object facade. Disposing this owner closes both layers exactly once. +/// +internal sealed class RuntimeDatCollection : IDatReaderWriter +{ + private readonly DatCollection _raw; + private readonly DatCollectionAdapter _bounded; + private bool _disposed; + + internal RuntimeDatCollection(DatCollection raw) + { + ArgumentNullException.ThrowIfNull(raw); + _raw = raw; + _bounded = new DatCollectionAdapter(raw); + } + + public string SourceDirectory => _bounded.SourceDirectory; + public IDatDatabase Portal => _bounded.Portal; + public IDatDatabase Cell => _bounded.Cell; + public ReadOnlyDictionary CellRegions => _bounded.CellRegions; + public IDatDatabase HighRes => _bounded.HighRes; + public IDatDatabase Language => _bounded.Language; + public IDatDatabase Local => _bounded.Local; + public ReadOnlyDictionary RegionFileMap => _bounded.RegionFileMap; + public int PortalIteration => _bounded.PortalIteration; + public int CellIteration => _bounded.CellIteration; + public int HighResIteration => _bounded.HighResIteration; + public int LanguageIteration => _bounded.LanguageIteration; + + [return: MaybeNull] + public T Get(uint fileId) where T : IDBObj => _bounded.Get(fileId); + + public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) + where T : IDBObj => + _bounded.TryGet(fileId, out value); + + public IEnumerable GetAllIdsOfType() where T : IDBObj => + _bounded.GetAllIdsOfType(); + + public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead) => + _bounded.TryGetFileBytes(regionId, fileId, ref bytes, out bytesRead); + + public IEnumerable ResolveId(uint id) => + _bounded.ResolveId(id); + + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => + _bounded.TrySave(obj, iteration); + + public bool TrySave(uint regionId, T obj, int iteration = 0) where T : IDBObj => + _bounded.TrySave(regionId, obj, iteration); + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _bounded.Dispose(); + _raw.Dispose(); + } +} diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 9f04385e..dc482d8c 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -32,6 +32,7 @@ public sealed record RuntimeOptions( string? LiveUser, string? LivePass, bool DevTools, + bool UncappedRendering, bool DumpMoveTruth, bool NoAudio, bool EnableSkyPesDebug, @@ -73,6 +74,10 @@ public sealed record RuntimeOptions( LiveUser: NullIfEmpty(env("ACDREAM_TEST_USER")), LivePass: NullIfEmpty(env("ACDREAM_TEST_PASS")), DevTools: IsExactlyOne(env("ACDREAM_DEVTOOLS")), + // Normal presentation is always bounded by VSync or a + // refresh-rate software pacer. This explicit diagnostic is the + // sole way to measure truly uncapped renderer throughput. + UncappedRendering: IsExactlyOne(env("ACDREAM_UNCAPPED_RENDER")), DumpMoveTruth: IsExactlyOne(env("ACDREAM_DUMP_MOVE_TRUTH")), NoAudio: IsExactlyOne(env("ACDREAM_NO_AUDIO")), EnableSkyPesDebug: IsExactlyOne(env("ACDREAM_ENABLE_SKY_PES")), diff --git a/src/AcDream.App/Spells/MagicRuntime.cs b/src/AcDream.App/Spells/MagicRuntime.cs index e92dada3..e1aa83b8 100644 --- a/src/AcDream.App/Spells/MagicRuntime.cs +++ b/src/AcDream.App/Spells/MagicRuntime.cs @@ -4,6 +4,7 @@ using System.Linq; using AcDream.Core.Items; using AcDream.Core.Spells; using DatReaderWriter; +using AcDream.Content; using DatReaderWriter.DBObjs; using CoreSpellTable = AcDream.Core.Spells.SpellTable; @@ -69,7 +70,7 @@ public sealed class MagicCatalog _wcidByScid, _magicPackWcidBySchool); - public static MagicCatalog Load(DatCollection dats) + public static MagicCatalog Load(IDatReaderWriter dats) { ArgumentNullException.ThrowIfNull(dats); diff --git a/src/AcDream.App/Streaming/GpuLandblockRetirement.cs b/src/AcDream.App/Streaming/GpuLandblockRetirement.cs new file mode 100644 index 00000000..d97f72c7 --- /dev/null +++ b/src/AcDream.App/Streaming/GpuLandblockRetirement.cs @@ -0,0 +1,20 @@ +using AcDream.Core.World; + +namespace AcDream.App.Streaming; + +/// +/// Immutable context retained after a landblock's spatial/world-state +/// membership has been detached. Presentation owners consume this snapshot +/// asynchronously from the state transition, so a failed renderer cleanup +/// cannot keep the old landblock logically resident. +/// +public sealed record GpuLandblockRetirement( + uint LandblockId, + LandblockRetirementKind Kind, + IReadOnlyList Entities); + +public enum LandblockRetirementKind +{ + Full, + NearLayer, +} diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index b9ac7201..f1e004b6 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -69,6 +69,11 @@ public sealed class GpuWorldState private readonly Dictionary _loaded = new(); private readonly Dictionary _tierByLandblock = new(); private readonly Dictionary _aabbs = new(); + private readonly Dictionary _animatedIndexByLandblock = new(); + + private sealed record AnimatedEntityIndex( + LoadedLandblock Source, + IReadOnlyDictionary EntitiesById); /// /// Per-landblock buffer of live entities awaiting their landblock's @@ -90,15 +95,36 @@ public sealed class GpuWorldState /// private readonly HashSet _persistentGuids = new(); - // Cached flat view over all entities across all loaded landblocks, - // rebuilt on each add/remove. The renderer holds a reference to this - // list, so rebuilding it replaces the reference atomically. - private IReadOnlyList _flatEntities = System.Array.Empty(); - private readonly HashSet _visibleLiveGuids = new(); + // Render-thread-owned flat view over all entities across loaded landblocks. + // The update and render phases are serialized, so mutating this list during + // the update phase is both safe and substantially cheaper than rebuilding a + // full-world array for every CreateObject in an inbound spawn flood. + private readonly List _flatEntities = new(); + private readonly Dictionary _flatEntityIndices = + new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _projectionLocations = + new(ReferenceEqualityComparer.Instance); + // Short-range consumers must never scan each landblock's DAT-static list. + // This index mirrors only loaded live projections and changes at the same + // transaction boundary as _projectionLocations. + private readonly Dictionary> _loadedLiveByLandblock = new(); + // A server GUID normally has exactly one spatial projection. Keep that + // allocation-free common case directly in the primary map; the secondary + // list exists only for the rare overlap during GUID reuse/re-entrant + // lifetime transitions. + private readonly Dictionary _primaryProjectionByGuid = new(); + private readonly Dictionary> _additionalProjectionsByGuid = new(); + private readonly Dictionary _visibleLiveProjectionCounts = new(); + private readonly Dictionary _visibilityBeforeMutation = new(); private readonly Queue<(uint ServerGuid, bool Visible)> _visibilityTransitions = new(); private bool _dispatchingVisibilityTransitions; + private int _mutationDepth; + private long _visibilityCommitCount; + private ulong _flatViewGeneration; + private bool _flatMembershipDirty; public IReadOnlyList Entities => _flatEntities; + public ulong FlatViewGeneration => _flatViewGeneration; public IReadOnlyCollection LoadedLandblockIds => _loaded.Keys; public event Action? LiveProjectionVisibilityChanged; @@ -112,7 +138,7 @@ public sealed class GpuWorldState _loaded.ContainsKey(landblockId) && (_wbSpawnAdapter?.IsLandblockRenderReady(landblockId) ?? true); public bool IsLiveEntityVisible(uint serverGuid) => - serverGuid != 0 && _visibleLiveGuids.Contains(serverGuid); + serverGuid != 0 && _visibleLiveProjectionCounts.ContainsKey(serverGuid); /// /// Try to grab the loaded record for a landblock — useful for callers @@ -145,12 +171,14 @@ public sealed class GpuWorldState /// for both corners, which the culler will conservatively treat as visible. /// /// - /// A.5 T17: also yields an AnimatedById dictionary built on the fly - /// from the landblock's entity list. This lets + /// A.5 T17: also yields an AnimatedById dictionary derived from the + /// landblock's entity list. This lets /// skip the full entity walk when the landblock is frustum-culled but animated - /// entities inside it must still be processed (Change #1). - /// Building the dict per-yield is cheap (~132 entities/LB max). A caching - /// layer is out of A.5 scope. + /// entities inside it must still be processed (Change #1). The lookup is + /// cached against the record identity, so a + /// stable scene does not allocate one dictionary per landblock per frame. + /// Live projection mutations update the mutable resident bucket in place and + /// explicitly invalidate exactly the affected index generation. /// /// public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, @@ -193,12 +221,21 @@ public sealed class GpuWorldState { foreach (var kvp in _loaded) { - Dictionary? byId = null; + IReadOnlyDictionary? byId = null; if (includeAnimatedIndex) { - byId = new Dictionary(kvp.Value.Entities.Count); - foreach (var e in kvp.Value.Entities) - byId[e.Id] = e; + if (!_animatedIndexByLandblock.TryGetValue(kvp.Key, out var cached) + || !ReferenceEquals(cached.Source, kvp.Value)) + { + var rebuilt = new Dictionary(kvp.Value.Entities.Count); + foreach (var entity in kvp.Value.Entities) + rebuilt[entity.Id] = entity; + + cached = new AnimatedEntityIndex(kvp.Value, rebuilt); + _animatedIndexByLandblock[kvp.Key] = cached; + } + + byId = cached.EntitiesById; } if (_aabbs.TryGetValue(kvp.Key, out var aabb)) @@ -218,12 +255,370 @@ public sealed class GpuWorldState public int PendingRescueCount => _persistentRescued.Count; public int PersistentGuidCount => _persistentGuids.Count; public int PendingVisibilityTransitionCount => _visibilityTransitions.Count; + internal long VisibilityCommitCount => _visibilityCommitCount; + + /// + /// Groups spatial mutations into one visibility publication transaction. + /// Bucket and flat-view contents become readable immediately on the render + /// thread; observer edges are computed from the before/after state and drain + /// only when the outermost scope ends. This is what keeps a loaded-to-loaded + /// rebucket from exposing an implementation-only hidden/visible pulse and + /// lets one inbound network drain commit thousands of spawns without global + /// rebuilds between them. + /// + public MutationBatch BeginMutationBatch() + { + _mutationDepth++; + return new MutationBatch(this); + } + + /// + /// Copy live server projections from the bounded landblock neighborhood + /// around . This is the spatial + /// candidate seam for short-range consumers such as retail radar; they + /// still apply their own Hidden/classification/range rules. + /// + public void CopyLiveEntitiesNearLandblock( + uint centerCellOrLandblockId, + int landblockRadius, + List> destination) + { + ArgumentNullException.ThrowIfNull(destination); + ArgumentOutOfRangeException.ThrowIfNegative(landblockRadius); + destination.Clear(); + + int centerX = (int)((centerCellOrLandblockId >> 24) & 0xFFu); + int centerY = (int)((centerCellOrLandblockId >> 16) & 0xFFu); + for (int dx = -landblockRadius; dx <= landblockRadius; dx++) + for (int dy = -landblockRadius; dy <= landblockRadius; dy++) + { + int x = centerX + dx; + int y = centerY + dy; + if ((uint)x > 0xFFu || (uint)y > 0xFFu) + continue; + + uint landblockId = ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu; + if (!_loadedLiveByLandblock.TryGetValue( + landblockId, + out HashSet? liveEntities)) + continue; + + foreach (WorldEntity entity in liveEntities) + destination.Add(new KeyValuePair(entity.ServerGuid, entity)); + } + } + + public readonly ref struct MutationBatch + { + private readonly GpuWorldState _owner; + + public MutationBatch(GpuWorldState owner) => _owner = owner; + + public void Dispose() => _owner.EndMutationBatch(); + } + + private readonly record struct ProjectionLocation( + uint LandblockId, + bool IsLoaded, + int BucketIndex); + + private void EndMutationBatch() + { + if (_mutationDepth <= 0) + throw new InvalidOperationException("GpuWorldState mutation scope underflow."); + if (--_mutationDepth != 0) + return; + + foreach ((uint guid, bool wasVisible) in _visibilityBeforeMutation) + { + bool isVisible = _visibleLiveProjectionCounts.ContainsKey(guid); + if (wasVisible != isVisible) + _visibilityTransitions.Enqueue((guid, isVisible)); + } + _visibilityBeforeMutation.Clear(); + _visibilityCommitCount++; + if (_flatMembershipDirty) + { + _flatViewGeneration++; + _flatMembershipDirty = false; + } + + DrainVisibilityTransitions(); + if (EntityVanishProbe.Enabled) + ProbeFlatViewTransitions(); + } + + private void AdjustVisibleLiveProjection(WorldEntity entity, int delta) + { + uint guid = entity.ServerGuid; + if (guid == 0 || delta == 0) + return; + + _visibleLiveProjectionCounts.TryGetValue(guid, out int priorCount); + if (!_visibilityBeforeMutation.ContainsKey(guid)) + _visibilityBeforeMutation.Add(guid, priorCount > 0); + + int nextCount = checked(priorCount + delta); + if (nextCount < 0) + throw new InvalidOperationException( + $"Live projection visibility count underflow for 0x{guid:X8}."); + + if (nextCount == 0) + { + _visibleLiveProjectionCounts.Remove(guid); + } + else + { + _visibleLiveProjectionCounts[guid] = nextCount; + } + } + + private static LoadedLandblock NormalizeLoadedLandblock( + LoadedLandblock source, + List? entities = null) => + new( + source.LandblockId, + source.Heightmap, + entities ?? new List(source.Entities), + source.PhysicsDats); + + private static List MutableEntities(LoadedLandblock landblock) => + (List)landblock.Entities; + + private void AddFlatEntity(WorldEntity entity) + { + if (!_flatEntityIndices.TryAdd(entity, _flatEntities.Count)) + throw new InvalidOperationException( + $"Entity 0x{entity.Id:X8} already exists in the flat render view."); + _flatEntities.Add(entity); + _flatMembershipDirty = true; + } + + private void RemoveFlatEntity(WorldEntity entity) + { + if (!_flatEntityIndices.Remove(entity, out int index)) + throw new InvalidOperationException( + $"Loaded entity 0x{entity.Id:X8} was absent from the flat render view."); + + int lastIndex = _flatEntities.Count - 1; + if (index != lastIndex) + { + WorldEntity moved = _flatEntities[lastIndex]; + _flatEntities[index] = moved; + _flatEntityIndices[moved] = index; + } + _flatEntities.RemoveAt(lastIndex); + _flatMembershipDirty = true; + } + + private void SetProjectionLocation( + WorldEntity entity, + uint landblockId, + bool isLoaded, + int bucketIndex) + { + if (entity.ServerGuid == 0) + return; + + bool isNew = !_projectionLocations.TryGetValue( + entity, + out ProjectionLocation priorLocation); + if (!isNew + && priorLocation.IsLoaded + && (!isLoaded || priorLocation.LandblockId != landblockId)) + { + RemoveLoadedLiveIndex(priorLocation.LandblockId, entity); + } + _projectionLocations[entity] = new ProjectionLocation( + landblockId, + isLoaded, + bucketIndex); + if (isLoaded + && (isNew || !priorLocation.IsLoaded || priorLocation.LandblockId != landblockId)) + { + AddLoadedLiveIndex(landblockId, entity); + } + if (!isNew) + return; + + uint guid = entity.ServerGuid; + if (_primaryProjectionByGuid.TryAdd(guid, entity)) + return; + + if (!_additionalProjectionsByGuid.TryGetValue(guid, out List? projections)) + { + projections = new List(1); + _additionalProjectionsByGuid.Add(guid, projections); + } + projections.Add(entity); + } + + private void RemoveProjectionLocation(WorldEntity entity) + { + if (!_projectionLocations.Remove(entity, out ProjectionLocation location) + || entity.ServerGuid == 0) + return; + if (location.IsLoaded) + RemoveLoadedLiveIndex(location.LandblockId, entity); + + uint guid = entity.ServerGuid; + if (!_primaryProjectionByGuid.TryGetValue(guid, out WorldEntity? primary)) + throw new InvalidOperationException( + $"Live projection 0x{entity.Id:X8} had no server-guid index entry."); + + if (ReferenceEquals(primary, entity)) + { + if (_additionalProjectionsByGuid.TryGetValue(guid, out List? projections) + && projections.Count > 0) + { + int lastIndex = projections.Count - 1; + _primaryProjectionByGuid[guid] = projections[lastIndex]; + projections.RemoveAt(lastIndex); + if (projections.Count == 0) + _additionalProjectionsByGuid.Remove(guid); + } + else + { + _primaryProjectionByGuid.Remove(guid); + } + return; + } + + if (!_additionalProjectionsByGuid.TryGetValue(guid, out List? additional)) + throw new InvalidOperationException( + $"Live projection 0x{entity.Id:X8} was absent from its server-guid index."); + + int index = -1; + for (int i = 0; i < additional.Count; i++) + { + if (!ReferenceEquals(additional[i], entity)) + continue; + index = i; + break; + } + if (index < 0) + throw new InvalidOperationException( + $"Live projection 0x{entity.Id:X8} was absent from its server-guid index."); + + int lastAdditionalIndex = additional.Count - 1; + if (index != lastAdditionalIndex) + additional[index] = additional[lastAdditionalIndex]; + additional.RemoveAt(lastAdditionalIndex); + if (additional.Count == 0) + _additionalProjectionsByGuid.Remove(guid); + } + + private void AddLoadedLiveIndex(uint landblockId, WorldEntity entity) + { + if (!_loadedLiveByLandblock.TryGetValue( + landblockId, + out HashSet? entities)) + { + entities = new HashSet(ReferenceEqualityComparer.Instance); + _loadedLiveByLandblock.Add(landblockId, entities); + } + if (!entities.Add(entity)) + { + throw new InvalidOperationException( + $"Live entity 0x{entity.Id:X8} already exists in landblock index 0x{landblockId:X8}."); + } + } + + private void RemoveLoadedLiveIndex(uint landblockId, WorldEntity entity) + { + if (!_loadedLiveByLandblock.TryGetValue( + landblockId, + out HashSet? entities) + || !entities.Remove(entity)) + { + throw new InvalidOperationException( + $"Live entity 0x{entity.Id:X8} is absent from landblock index 0x{landblockId:X8}."); + } + if (entities.Count == 0) + _loadedLiveByLandblock.Remove(landblockId); + } + + private void AddLoadedProjection(uint landblockId, WorldEntity entity) + { + LoadedLandblock landblock = _loaded[landblockId]; + List entities = MutableEntities(landblock); + int bucketIndex = entities.Count; + entities.Add(entity); + _animatedIndexByLandblock.Remove(landblockId); + AddFlatEntity(entity); + SetProjectionLocation(entity, landblockId, isLoaded: true, bucketIndex); + AdjustVisibleLiveProjection(entity, +1); + } + + private void RemoveLoadedProjection(WorldEntity entity) + { + if (!_projectionLocations.TryGetValue(entity, out ProjectionLocation location) + || !location.IsLoaded + || !_loaded.TryGetValue(location.LandblockId, out LoadedLandblock? landblock)) + { + throw new InvalidOperationException( + $"Live entity 0x{entity.Id:X8} has no loaded projection location."); + } + + List entities = MutableEntities(landblock); + int lastIndex = entities.Count - 1; + if ((uint)location.BucketIndex >= (uint)entities.Count + || !ReferenceEquals(entities[location.BucketIndex], entity)) + { + throw new InvalidOperationException( + $"Loaded projection index for entity 0x{entity.Id:X8} is stale."); + } + if (location.BucketIndex != lastIndex) + { + WorldEntity moved = entities[lastIndex]; + entities[location.BucketIndex] = moved; + if (moved.ServerGuid != 0) + { + SetProjectionLocation( + moved, + location.LandblockId, + isLoaded: true, + location.BucketIndex); + } + } + entities.RemoveAt(lastIndex); + + _animatedIndexByLandblock.Remove(location.LandblockId); + RemoveFlatEntity(entity); + RemoveProjectionLocation(entity); + AdjustVisibleLiveProjection(entity, -1); + } + + private void RemoveLoadedLandblockFromFlatView(LoadedLandblock landblock) + { + foreach (WorldEntity entity in landblock.Entities) + { + RemoveFlatEntity(entity); + if (entity.ServerGuid != 0) + RemoveProjectionLocation(entity); + AdjustVisibleLiveProjection(entity, -1); + } + } + + private void AddLoadedLandblockToFlatView(LoadedLandblock landblock) + { + for (int i = 0; i < landblock.Entities.Count; i++) + { + WorldEntity entity = landblock.Entities[i]; + AddFlatEntity(entity); + if (entity.ServerGuid != 0) + SetProjectionLocation(entity, landblock.LandblockId, isLoaded: true, i); + AdjustVisibleLiveProjection(entity, +1); + } + } public void AddLandblock( LoadedLandblock landblock, IEnumerable? additionalRenderIds = null, LandblockStreamTier tier = LandblockStreamTier.Near) { + using MutationBatch mutation = BeginMutationBatch(); + // A stale Far completion must never replace a newer Near entity layer. // StreamingController normally filters it before render-side apply; // keep the state boundary independently monotonic as well. @@ -231,20 +626,20 @@ public sealed class GpuWorldState return; // If pending live entities have been waiting for this landblock, - // merge them into the LoadedLandblock record before storing. The - // record's Entities field is IReadOnlyList; we replace the whole - // list rather than try to mutate in place. + // merge them into the render-thread-owned mutable entity bucket before + // storing. Subsequent live spawns append to that bucket in O(1). if (_pendingByLandblock.TryGetValue(landblock.LandblockId, out var pending) && pending.Count > 0) { var merged = new List(landblock.Entities.Count + pending.Count); merged.AddRange(landblock.Entities); merged.AddRange(pending); - landblock = new LoadedLandblock( - landblock.LandblockId, - landblock.Heightmap, - merged); + landblock = NormalizeLoadedLandblock(landblock, merged); _pendingByLandblock.Remove(landblock.LandblockId); } + else + { + landblock = NormalizeLoadedLandblock(landblock); + } HashSet? mergedRenderIds = additionalRenderIds is null ? null @@ -263,7 +658,14 @@ public sealed class GpuWorldState tier = LandblockStreamTier.Near; } + if (_loaded.Remove(landblock.LandblockId, out LoadedLandblock? displaced)) + { + RemoveLoadedLandblockFromFlatView(displaced); + _animatedIndexByLandblock.Remove(landblock.LandblockId); + } + _loaded[landblock.LandblockId] = landblock; + AddLoadedLandblockToFlatView(landblock); _tierByLandblock[landblock.LandblockId] = tier; if (_wbSpawnAdapter is not null) _wbSpawnAdapter.OnLandblockLoaded( @@ -285,7 +687,6 @@ public sealed class GpuWorldState } } - RebuildFlatView(); } /// @@ -308,20 +709,23 @@ public sealed class GpuWorldState { if (entity.ServerGuid == 0) return; + using MutationBatch mutation = BeginMutationBatch(); + uint canonical = (newCanonicalLb & 0xFFFF0000u) | 0xFFFFu; // Fast path: already drawn in the correct loaded bucket → nothing to do // (avoids per-frame list churn for a settled, stationary entity). - if (_loaded.TryGetValue(canonical, out var target)) + if (_projectionLocations.TryGetValue(entity, out ProjectionLocation current) + && current.IsLoaded + && current.LandblockId == canonical) { - foreach (var e in target.Entities) - if (ReferenceEquals(e, entity)) return; + return; } // Remove the entity from wherever it currently lives — a loaded bucket // OR a pending bucket — then re-append to its current landblock. // - // Scanning _pendingByLandblock is the 2026-07-03 fix for the cold-spawn / + // The projection-location index is the 2026-07-03 fix for the cold-spawn / // run-out "invisible player" bug: a persistent (server-spawned) entity // that spawned into a not-yet-loaded landblock sits in _pendingByLandblock, // and the old code scanned ONLY _loaded — so it silently no-op'd and left @@ -329,7 +733,8 @@ public sealed class GpuWorldState // (the AddLandblock pending-drain had already run empty before the churn // re-parked the player, and the player is excluded from the server-object // re-hydrate — so RebucketLiveEntity was the ONLY path that could recover it, - // and it couldn't reach a pending entity). Re-appending routes the entity + // and it couldn't reach a pending entity). The index now reaches either + // residency class in O(1). Re-appending routes the entity // to _loaded (drawn) when its landblock is loaded, or back to pending to // await AddLandblock otherwise. // Remove + place is one spatial transaction. Rebuilding between the @@ -349,35 +754,88 @@ public sealed class GpuWorldState /// private void RemoveEntityFromAllBuckets(WorldEntity entity) { - foreach (var kvp in _loaded) + if (!_projectionLocations.TryGetValue(entity, out ProjectionLocation location)) + return; + + if (location.IsLoaded) { - var entities = kvp.Value.Entities; - for (int i = 0; i < entities.Count; i++) - { - if (ReferenceEquals(entities[i], entity)) - { - var newList = new List(entities.Count - 1); - for (int j = 0; j < entities.Count; j++) - if (j != i) newList.Add(entities[j]); - _loaded[kvp.Key] = new LoadedLandblock( - kvp.Value.LandblockId, kvp.Value.Heightmap, newList); - return; - } - } + RemoveLoadedProjection(entity); + return; } - foreach (var kvp in _pendingByLandblock) + if (!_pendingByLandblock.TryGetValue(location.LandblockId, out List? pending) + || (uint)location.BucketIndex >= (uint)pending.Count + || !ReferenceEquals(pending[location.BucketIndex], entity)) { - if (kvp.Value.Remove(entity)) - return; + throw new InvalidOperationException( + $"Indexed live entity 0x{entity.Id:X8} was absent from pending landblock 0x{location.LandblockId:X8}."); } + + int lastIndex = pending.Count - 1; + if (location.BucketIndex != lastIndex) + { + WorldEntity moved = pending[lastIndex]; + pending[location.BucketIndex] = moved; + SetProjectionLocation( + moved, + location.LandblockId, + isLoaded: false, + location.BucketIndex); + } + pending.RemoveAt(lastIndex); + + RemoveProjectionLocation(entity); + if (pending.Count == 0) + _pendingByLandblock.Remove(location.LandblockId); } - public void RemoveLandblock(uint landblockId) + /// + /// Commits the world-state half of a full landblock retirement and returns + /// the exact entity context required by presentation owners. This method + /// deliberately performs no renderer, script, or cache callbacks: those + /// owners can fail independently and are advanced by + /// after the old state has + /// become unreachable. + /// + public GpuLandblockRetirement? DetachLandblock(uint landblockId) { + using MutationBatch mutation = BeginMutationBatch(); + uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; - if (_wbSpawnAdapter is not null) - _wbSpawnAdapter.OnLandblockUnloaded(canonical); + bool hadState = _loaded.ContainsKey(canonical) + || _pendingByLandblock.ContainsKey(canonical) + || _pendingRenderIdsByLandblock.ContainsKey(canonical) + || _pendingNearTierLandblocks.Contains(canonical) + || _tierByLandblock.ContainsKey(canonical) + || _aabbs.ContainsKey(canonical); + if (!hadState) + return null; + + IReadOnlyList detachedEntities = + _loaded.TryGetValue(canonical, out LoadedLandblock? residentLandblock) + ? residentLandblock.Entities + : Array.Empty(); + if (_pendingByLandblock.TryGetValue(canonical, out List? detachedPending) + && detachedPending.Count > 0) + { + if (detachedEntities.Count == 0) + { + detachedEntities = detachedPending; + } + else + { + var combined = new List( + detachedEntities.Count + detachedPending.Count); + var seen = new HashSet(ReferenceEqualityComparer.Instance); + foreach (WorldEntity entity in detachedEntities) + if (seen.Add(entity)) + combined.Add(entity); + foreach (WorldEntity entity in detachedPending) + if (seen.Add(entity)) + combined.Add(entity); + detachedEntities = combined; + } + } // A logical live object survives streaming residence. Non-persistent // projections move to the pending bucket for this landblock and merge @@ -385,6 +843,7 @@ public sealed class GpuWorldState // (the local player) are rescued because their current bucket may be a // different landblock by the time the caller reinjects them. var retainedLive = new List(); + var retainedLiveSet = new HashSet(ReferenceEqualityComparer.Instance); void RetainOrRescue(WorldEntity entity, string source) { if (entity.ServerGuid == 0) @@ -396,7 +855,7 @@ public sealed class GpuWorldState $"[ent] RESCUE guid=0x{entity.ServerGuid:X8} from={source} lb=0x{canonical:X8}"); return; } - if (!retainedLive.Any(existing => ReferenceEquals(existing, entity))) + if (retainedLiveSet.Add(entity)) retainedLive.Add(entity); } @@ -408,19 +867,6 @@ public sealed class GpuWorldState { foreach (var entity in lb.Entities) RetainOrRescue(entity, "loaded"); - - // C.1.5b: stop DefaultScript for each dat-hydrated entity in - // the landblock. Server-spawned entities are either being - // rescued (script continues at the new LB) or were OnRemove'd - // by LiveEntityRuntime logical teardown; leave them alone here. - if (_entityScriptActivator is not null) - { - foreach (var entity in lb.Entities) - { - if (entity.ServerGuid == 0) - _entityScriptActivator.OnRemove(entity); - } - } } // #138 (secondary): rescue persistent entities sitting in the PENDING @@ -437,7 +883,10 @@ public sealed class GpuWorldState if (_pendingByLandblock.TryGetValue(canonical, out var pendingForLb)) { foreach (var entity in pendingForLb) + { RetainOrRescue(entity, "pending"); + RemoveProjectionLocation(entity); + } } _pendingByLandblock.Remove(canonical); @@ -446,12 +895,51 @@ public sealed class GpuWorldState if (retainedLive.Count > 0) _pendingByLandblock[canonical] = retainedLive; _aabbs.Remove(canonical); + _animatedIndexByLandblock.Remove(canonical); _tierByLandblock.Remove(canonical); - if (_loaded.Remove(canonical)) - RebuildFlatView(); + if (_loaded.Remove(canonical, out LoadedLandblock? removed)) + RemoveLoadedLandblockFromFlatView(removed); + + for (int i = 0; i < retainedLive.Count; i++) + SetProjectionLocation(retainedLive[i], canonical, isLoaded: false, i); + + return new GpuLandblockRetirement( + canonical, + LandblockRetirementKind.Full, + detachedEntities); } + /// + /// Compatibility edge for direct state tests. Production streaming uses + /// so each presentation owner + /// has a retryable committed marker. + /// + public void RemoveLandblock(uint landblockId) + { + GpuLandblockRetirement? retirement = DetachLandblock(landblockId); + if (retirement is null) + return; + + ReleaseLandblockMeshReferences(retirement.LandblockId); + InvalidateLandblockClassification(retirement.LandblockId); + for (int i = 0; i < retirement.Entities.Count; i++) + { + WorldEntity entity = retirement.Entities[i]; + if (entity.ServerGuid == 0) + StopStaticEntityScript(entity); + } + } + + internal void ReleaseLandblockMeshReferences(uint landblockId) => + _wbSpawnAdapter?.OnLandblockUnloaded(landblockId); + + internal void InvalidateLandblockClassification(uint landblockId) => + _onLandblockUnloaded?.Invoke(landblockId); + + internal void StopStaticEntityScript(WorldEntity entity) => + _entityScriptActivator?.OnRemove(entity); + private readonly List _persistentRescued = new(); /// @@ -468,8 +956,8 @@ public sealed class GpuWorldState /// /// Remove every entity with the given from - /// all loaded landblocks AND all pending buckets, then rebuild the flat - /// view. Called by LiveEntityRuntime before rebucketing, temporary + /// all loaded landblocks AND all pending buckets. Called by + /// LiveEntityRuntime before rebucketing, temporary /// world withdrawal, or logical teardown. It owns no renderer/script /// lifecycle and is safe for a still-live incarnation. /// @@ -479,33 +967,13 @@ public sealed class GpuWorldState { if (serverGuid == 0) return; - bool rebuiltLoaded = false; + using MutationBatch mutation = BeginMutationBatch(); - // Scan loaded landblocks. ToArray() so we can mutate _loaded inside. - foreach (var kvp in _loaded.ToArray()) + // Canonical live projections are indexed by server GUID and entity + // identity, so delete/withdraw never scans every resident landblock. + while (_primaryProjectionByGuid.TryGetValue(serverGuid, out WorldEntity? projection)) { - var lb = kvp.Value; - int foundCount = 0; - for (int i = 0; i < lb.Entities.Count; i++) - if (lb.Entities[i].ServerGuid == serverGuid) foundCount++; - - if (foundCount == 0) continue; - - var newList = new List(lb.Entities.Count - foundCount); - foreach (var e in lb.Entities) - if (e.ServerGuid != serverGuid) newList.Add(e); - - _loaded[kvp.Key] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, newList); - rebuiltLoaded = true; - } - - // Scrub pending buckets too so rebucketing cannot leave a second slot. - foreach (uint landblockId in _pendingByLandblock.Keys.ToArray()) - { - List bucket = _pendingByLandblock[landblockId]; - bucket.RemoveAll(e => e.ServerGuid == serverGuid); - if (bucket.Count == 0) - _pendingByLandblock.Remove(landblockId); + RemoveEntityFromAllBuckets(projection); } // A persistent projection may have been rescued by RemoveLandblock @@ -514,7 +982,6 @@ public sealed class GpuWorldState // reference or it can later re-inject a deleted/duplicated object. _persistentRescued.RemoveAll(e => e.ServerGuid == serverGuid); - if (rebuiltLoaded) RebuildFlatView(); } /// @@ -527,32 +994,12 @@ public sealed class GpuWorldState ArgumentNullException.ThrowIfNull(entity); if (entity.ServerGuid == 0) return; - bool rebuiltLoaded = false; - foreach (var kvp in _loaded.ToArray()) - { - IReadOnlyList entities = kvp.Value.Entities; - if (!entities.Any(candidate => ReferenceEquals(candidate, entity))) - continue; + using MutationBatch mutation = BeginMutationBatch(); - _loaded[kvp.Key] = new LoadedLandblock( - kvp.Value.LandblockId, - kvp.Value.Heightmap, - entities.Where(candidate => !ReferenceEquals(candidate, entity)).ToArray()); - rebuiltLoaded = true; - } - - foreach (uint landblockId in _pendingByLandblock.Keys.ToArray()) - { - List bucket = _pendingByLandblock[landblockId]; - bucket.RemoveAll(candidate => ReferenceEquals(candidate, entity)); - if (bucket.Count == 0) - _pendingByLandblock.Remove(landblockId); - } + RemoveEntityFromAllBuckets(entity); _persistentRescued.RemoveAll(candidate => ReferenceEquals(candidate, entity)); - if (rebuiltLoaded) - RebuildFlatView(); } /// @@ -575,7 +1022,12 @@ public sealed class GpuWorldState _persistentRescued.Clear(); _persistentInFlatProbe.Clear(); _visibilityTransitions.Clear(); - _visibleLiveGuids.Clear(); + _visibleLiveProjectionCounts.Clear(); + _visibilityBeforeMutation.Clear(); + _projectionLocations.Clear(); + _loadedLiveByLandblock.Clear(); + _primaryProjectionByGuid.Clear(); + _additionalProjectionsByGuid.Clear(); } /// @@ -594,8 +1046,7 @@ public sealed class GpuWorldState /// Outcome: /// /// If the landblock is already loaded, the entity is appended - /// to its Entities list and the flat view is rebuilt - /// immediately. + /// to its landblock and flat render-thread lists in O(1). /// If the landblock is not yet loaded, the entity is parked /// in and will be merged /// into the next for the same id. @@ -604,26 +1055,27 @@ public sealed class GpuWorldState /// public void PlaceLiveEntityProjection(uint landblockId, WorldEntity entity) { + using MutationBatch mutation = BeginMutationBatch(); + + if (_projectionLocations.ContainsKey(entity)) + { + throw new InvalidOperationException( + $"Live entity 0x{entity.Id:X8} is already spatially projected."); + } + // Spatial placement only. LiveEntityRuntime has already registered // this incarnation's renderer and script resources exactly once. uint canonicalLandblockId = (landblockId & 0xFFFF0000u) | 0xFFFFu; bool probePersistent = EntityVanishProbe.Enabled && entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid); - if (_loaded.TryGetValue(canonicalLandblockId, out var lb)) + if (_loaded.ContainsKey(canonicalLandblockId)) { - // Hot path — landblock is already loaded. Rebuild the record - // with the new entity appended. - var newEntities = new List(lb.Entities.Count + 1); - newEntities.AddRange(lb.Entities); - newEntities.Add(entity); - _loaded[canonicalLandblockId] = new LoadedLandblock( - lb.LandblockId, - lb.Heightmap, - newEntities); + // Hot path — append directly to the render-thread-owned mutable + // resident bucket and flat view. + AddLoadedProjection(canonicalLandblockId, entity); if (probePersistent) EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> LOADED(drawn)"); - RebuildFlatView(); return; } @@ -635,10 +1087,11 @@ public sealed class GpuWorldState bucket = new List(); _pendingByLandblock[canonicalLandblockId] = bucket; } + int bucketIndex = bucket.Count; bucket.Add(entity); + SetProjectionLocation(entity, canonicalLandblockId, isLoaded: false, bucketIndex); if (probePersistent) EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> PENDING(hidden)"); - RebuildFlatView(); } /// @@ -652,8 +1105,10 @@ public sealed class GpuWorldState /// logical or spatial lifetime callback is replayed. /// /// - public void RemoveEntitiesFromLandblock(uint landblockId) + public GpuLandblockRetirement? DetachNearLayer(uint landblockId) { + using MutationBatch mutation = BeginMutationBatch(); + // A.5 T14 follow-up: canonicalize for symmetry with live projection placement. // Streaming callers always pass canonical (0xAAAA0xFFFF) ids; this // protects against future callers that mirror live projection placement's @@ -662,47 +1117,95 @@ public sealed class GpuWorldState // A promotion may have parked its Near layer before the Far base load // exists. Demotion must retire that pending tier just as completely as // an installed tier, while preserving live server projections. - _pendingNearTierLandblocks.Remove(canonical); - _pendingRenderIdsByLandblock.Remove(canonical); + bool hadNearLayer = _pendingNearTierLandblocks.Remove(canonical); + hadNearLayer |= _pendingRenderIdsByLandblock.Remove(canonical); + hadNearLayer |= IsNearTier(canonical); + if (!hadNearLayer) + return null; + + var retiredEntities = new List(); if (_pendingByLandblock.TryGetValue(canonical, out var pending)) { - pending.RemoveAll(entity => entity.ServerGuid == 0); - if (pending.Count == 0) + int pendingWriteIndex = 0; + for (int readIndex = 0; readIndex < pending.Count; readIndex++) + { + WorldEntity entity = pending[readIndex]; + if (entity.ServerGuid == 0) + { + retiredEntities.Add(entity); + continue; + } + if (pendingWriteIndex != readIndex) + pending[pendingWriteIndex] = entity; + SetProjectionLocation( + entity, + canonical, + isLoaded: false, + pendingWriteIndex); + pendingWriteIndex++; + } + if (pendingWriteIndex < pending.Count) + pending.RemoveRange(pendingWriteIndex, pending.Count - pendingWriteIndex); + if (pendingWriteIndex == 0) _pendingByLandblock.Remove(canonical); } - if (!_loaded.TryGetValue(canonical, out var lb)) return; - if (_wbSpawnAdapter is not null) - _wbSpawnAdapter.OnLandblockUnloaded(canonical); + if (_loaded.TryGetValue(canonical, out var lb)) + { // Phase Post-A.5 #53 (Task 12): invalidate the EntityClassificationCache // for this landblock BEFORE we drop the entity list. The cache stores // canonical landblock ids (the dispatcher's _walkScratch carries // entry.LandblockId from GpuWorldState.LandblockEntries, whose keys are // canonicalized). Null when the cache isn't wired (tests). Per spec §5.3 W3b. - _onLandblockUnloaded?.Invoke(canonical); // C.1.5b: stop DefaultScript for each dat-hydrated entity about to // be dropped. Demote-tier entities are always atlas-tier (ServerGuid==0 // per this method's class doc-comment); the filter is belt-and-suspenders. - if (_entityScriptActivator is not null) + var retainedLive = new List(); + for (int readIndex = 0; readIndex < lb.Entities.Count; readIndex++) { - foreach (var entity in lb.Entities) + WorldEntity entity = lb.Entities[readIndex]; + if (entity.ServerGuid != 0) { - if (entity.ServerGuid == 0) - _entityScriptActivator.OnRemove(entity); + int liveWriteIndex = retainedLive.Count; + retainedLive.Add(entity); + SetProjectionLocation( + entity, + canonical, + isLoaded: true, + liveWriteIndex); + continue; } + + retiredEntities.Add(entity); + RemoveFlatEntity(entity); + } + _loaded[canonical] = NormalizeLoadedLandblock(lb, retainedLive); + _animatedIndexByLandblock.Remove(canonical); + _tierByLandblock[canonical] = LandblockStreamTier.Far; } - WorldEntity[] retainedLive = lb.Entities - .Where(entity => entity.ServerGuid != 0) - .ToArray(); - _loaded[canonical] = new LoadedLandblock( - lb.LandblockId, - lb.Heightmap, - retainedLive); - _tierByLandblock[canonical] = LandblockStreamTier.Far; - RebuildFlatView(); + return new GpuLandblockRetirement( + canonical, + LandblockRetirementKind.NearLayer, + retiredEntities); + } + + /// + /// Compatibility edge for direct state tests. Production streaming uses + /// the retryable retirement coordinator. + /// + public void RemoveEntitiesFromLandblock(uint landblockId) + { + GpuLandblockRetirement? retirement = DetachNearLayer(landblockId); + if (retirement is null) + return; + + ReleaseLandblockMeshReferences(retirement.LandblockId); + InvalidateLandblockClassification(retirement.LandblockId); + for (int i = 0; i < retirement.Entities.Count; i++) + StopStaticEntityScript(retirement.Entities[i]); } /// @@ -723,6 +1226,8 @@ public sealed class GpuWorldState IReadOnlyList entities, IEnumerable? additionalRenderIds = null) { + using MutationBatch mutation = BeginMutationBatch(); + // A.5 T14 follow-up: canonicalize for symmetry with live projection placement. uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; if (!_loaded.TryGetValue(canonical, out var lb)) @@ -733,7 +1238,20 @@ public sealed class GpuWorldState bucket = new List(); _pendingByLandblock[canonical] = bucket; } + int firstIndex = bucket.Count; bucket.AddRange(entities); + for (int i = 0; i < entities.Count; i++) + { + WorldEntity entity = entities[i]; + if (entity.ServerGuid != 0) + { + SetProjectionLocation( + entity, + canonical, + isLoaded: false, + firstIndex + i); + } + } _pendingNearTierLandblocks.Add(canonical); if (additionalRenderIds is not null) { @@ -746,10 +1264,18 @@ public sealed class GpuWorldState } return false; } - var merged = new List(lb.Entities.Count + entities.Count); - merged.AddRange(lb.Entities); - merged.AddRange(entities); - _loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged); + List resident = MutableEntities(lb); + for (int i = 0; i < entities.Count; i++) + { + WorldEntity entity = entities[i]; + int bucketIndex = resident.Count; + resident.Add(entity); + AddFlatEntity(entity); + if (entity.ServerGuid != 0) + SetProjectionLocation(entity, canonical, isLoaded: true, bucketIndex); + AdjustVisibleLiveProjection(entity, +1); + } + _animatedIndexByLandblock.Remove(canonical); _tierByLandblock[canonical] = LandblockStreamTier.Near; if (_wbSpawnAdapter is not null) _wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical], additionalRenderIds); @@ -764,38 +1290,9 @@ public sealed class GpuWorldState _entityScriptActivator.OnCreate(entities[i]); } - RebuildFlatView(); return true; } - private void RebuildFlatView() - { - _flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray(); - var nowVisible = new HashSet( - _flatEntities - .Where(entity => entity.ServerGuid != 0) - .Select(entity => entity.ServerGuid)); - uint[] becameHidden = _visibleLiveGuids - .Where(guid => !nowVisible.Contains(guid)) - .ToArray(); - uint[] becameVisible = nowVisible - .Where(guid => !_visibleLiveGuids.Contains(guid)) - .ToArray(); - - // Commit the new spatial truth before notifying observers. A - // visibility callback may synchronously rebucket/withdraw the same - // object (deferred teleport rollback); those nested transitions must - // compare against this committed state rather than the stale prior set. - _visibleLiveGuids.Clear(); - _visibleLiveGuids.UnionWith(nowVisible); - foreach (uint guid in becameHidden) - _visibilityTransitions.Enqueue((guid, false)); - foreach (uint guid in becameVisible) - _visibilityTransitions.Enqueue((guid, true)); - DrainVisibilityTransitions(); - if (EntityVanishProbe.Enabled) ProbeFlatViewTransitions(); - } - private void DrainVisibilityTransitions() { if (_dispatchingVisibilityTransitions) diff --git a/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs b/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs new file mode 100644 index 00000000..c1f98912 --- /dev/null +++ b/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs @@ -0,0 +1,328 @@ +using AcDream.Core.World; + +namespace AcDream.App.Streaming; + +[Flags] +public enum LandblockRetirementStage : ushort +{ + None = 0, + MeshReferences = 1 << 0, + StaticScripts = 1 << 1, + Classification = 1 << 2, + EntityLighting = 1 << 3, + EntityTranslucency = 1 << 4, + PluginProjection = 1 << 5, + Terrain = 1 << 6, + Physics = 1 << 7, + CellVisibility = 1 << 8, + BuildingRegistry = 1 << 9, + EnvironmentCells = 1 << 10, + LegacyPresentation = 1 << 11, +} + +/// +/// One retryable landblock-retirement ledger. Successful owner operations are +/// committed once; a later attempt resumes at the exact failed owner/entity. +/// +public sealed class LandblockRetirementTicket +{ + private readonly Dictionary _entityCursors = new(); + private readonly Dictionary _failures = new(); + private Exception? _callbackFailure; + private Exception? _lastReportedFailure; + + internal LandblockRetirementTicket( + GpuLandblockRetirement state, + LandblockRetirementStage requiredStages) + { + State = state; + RequiredStages = requiredStages; + } + + public GpuLandblockRetirement State { get; } + public uint LandblockId => State.LandblockId; + public LandblockRetirementKind Kind => State.Kind; + public IReadOnlyList Entities => State.Entities; + public LandblockRetirementStage RequiredStages { get; } + public LandblockRetirementStage CompletedStages { get; private set; } + public bool IsComplete => + (CompletedStages & RequiredStages) == RequiredStages + && _callbackFailure is null; + + public bool RunOnce(LandblockRetirementStage stage, Action operation) + { + ValidateSingleStage(stage); + ArgumentNullException.ThrowIfNull(operation); + if ((CompletedStages & stage) != 0) + return true; + + try + { + operation(); + CompletedStages |= stage; + _failures.Remove(stage); + return true; + } + catch (Exception error) + { + _failures[stage] = error; + return false; + } + } + + public bool RunForEachEntity( + LandblockRetirementStage stage, + Func predicate, + Action operation) + { + ValidateSingleStage(stage); + ArgumentNullException.ThrowIfNull(predicate); + ArgumentNullException.ThrowIfNull(operation); + if ((CompletedStages & stage) != 0) + return true; + + _entityCursors.TryGetValue(stage, out int cursor); + while (cursor < Entities.Count) + { + WorldEntity entity = Entities[cursor]; + if (predicate(entity)) + { + try + { + operation(entity); + } + catch (Exception error) + { + _entityCursors[stage] = cursor; + _failures[stage] = error; + return false; + } + } + + cursor++; + _entityCursors[stage] = cursor; + } + + CompletedStages |= stage; + _entityCursors.Remove(stage); + _failures.Remove(stage); + return true; + } + + internal void BeginAttempt() => _callbackFailure = null; + + internal void RecordCallbackFailure(Exception error) => + _callbackFailure = error; + + internal bool TryTakeNewFailure(out Exception? error) + { + error = _callbackFailure ?? _failures.Values.FirstOrDefault(); + if (error is null || ReferenceEquals(error, _lastReportedFailure)) + return false; + + _lastReportedFailure = error; + return true; + } + + private static void ValidateSingleStage(LandblockRetirementStage stage) + { + ushort value = (ushort)stage; + if (value == 0 || (value & (value - 1)) != 0) + throw new ArgumentOutOfRangeException( + nameof(stage), + stage, + "A retirement operation must own exactly one stage bit."); + } +} + +/// +/// Game-window-lifetime owner for landblock retirement. World-state detachment +/// commits first; renderer/cache/script cleanup advances afterward from a +/// per-owner ledger. The coordinator is intentionally shared by replacement +/// instances so a quality-setting rebuild +/// cannot abandon an unfinished retirement. +/// +public sealed class LandblockRetirementCoordinator +{ + private const LandblockRetirementStage CoreStages = + LandblockRetirementStage.MeshReferences + | LandblockRetirementStage.StaticScripts + | LandblockRetirementStage.Classification; + + public const LandblockRetirementStage ProductionPresentationStages = + LandblockRetirementStage.EntityLighting + | LandblockRetirementStage.EntityTranslucency + | LandblockRetirementStage.PluginProjection + | LandblockRetirementStage.Terrain + | LandblockRetirementStage.Physics + | LandblockRetirementStage.CellVisibility + | LandblockRetirementStage.BuildingRegistry + | LandblockRetirementStage.EnvironmentCells; + + private readonly GpuWorldState _state; + private readonly Action _advancePresentation; + private readonly Func + _requiredPresentationStages; + private readonly Dictionary> _pending = new(); + private readonly List _completedIds = new(); + + public LandblockRetirementCoordinator( + GpuWorldState state, + Action advancePresentation, + Func? requiredPresentationStages = null) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(advancePresentation); + _state = state; + _advancePresentation = advancePresentation; + _requiredPresentationStages = requiredPresentationStages + ?? (kind => kind == LandblockRetirementKind.Full + ? ProductionPresentationStages + : ProductionPresentationStages & ~LandblockRetirementStage.Terrain); + } + + public int PendingCount { get; private set; } + + public bool IsPending(uint landblockId) => + _pending.ContainsKey(Canonicalize(landblockId)); + + public void BeginFull(uint landblockId) => + Begin(landblockId, LandblockRetirementKind.Full); + + public void BeginNearLayer(uint landblockId) => + Begin(landblockId, LandblockRetirementKind.NearLayer); + + public void Advance() + { + if (_pending.Count == 0) + return; + + _completedIds.Clear(); + foreach ((uint id, List tickets) in _pending) + { + for (int i = tickets.Count - 1; i >= 0; i--) + { + LandblockRetirementTicket ticket = tickets[i]; + AdvanceTicket(ticket); + if (!ticket.IsComplete) + continue; + + tickets.RemoveAt(i); + PendingCount--; + } + + if (tickets.Count == 0) + _completedIds.Add(id); + } + + for (int i = 0; i < _completedIds.Count; i++) + _pending.Remove(_completedIds[i]); + } + + internal static LandblockRetirementCoordinator CreateLegacy( + GpuWorldState state, + Action? removeTerrain, + Action? demoteNearLayer) + { + LandblockRetirementStage Required(LandblockRetirementKind kind) => + kind switch + { + LandblockRetirementKind.Full when removeTerrain is not null => + LandblockRetirementStage.LegacyPresentation, + LandblockRetirementKind.NearLayer when demoteNearLayer is not null => + LandblockRetirementStage.LegacyPresentation, + _ => LandblockRetirementStage.None, + }; + + void AdvanceLegacy(LandblockRetirementTicket ticket) + { + Action? callback = ticket.Kind == LandblockRetirementKind.Full + ? removeTerrain + : demoteNearLayer; + if (callback is not null) + { + ticket.RunOnce( + LandblockRetirementStage.LegacyPresentation, + () => callback(ticket.LandblockId)); + } + } + + return new LandblockRetirementCoordinator(state, AdvanceLegacy, Required); + } + + private void Begin(uint landblockId, LandblockRetirementKind kind) + { + uint canonical = Canonicalize(landblockId); + if (_pending.TryGetValue(canonical, out List? existing)) + { + for (int i = 0; i < existing.Count; i++) + { + if (existing[i].Kind == kind) + { + AdvanceTicket(existing[i]); + return; + } + } + } + + GpuLandblockRetirement? stateRetirement = kind == LandblockRetirementKind.Full + ? _state.DetachLandblock(canonical) + : _state.DetachNearLayer(canonical); + if (stateRetirement is null) + return; + + LandblockRetirementStage required = + CoreStages | _requiredPresentationStages(kind); + var ticket = new LandblockRetirementTicket(stateRetirement, required); + if (existing is null) + { + existing = new List(1); + _pending.Add(canonical, existing); + } + existing.Add(ticket); + PendingCount++; + + AdvanceTicket(ticket); + if (ticket.IsComplete) + { + existing.Remove(ticket); + PendingCount--; + if (existing.Count == 0) + _pending.Remove(canonical); + } + } + + private void AdvanceTicket(LandblockRetirementTicket ticket) + { + ticket.BeginAttempt(); + ticket.RunOnce( + LandblockRetirementStage.MeshReferences, + () => _state.ReleaseLandblockMeshReferences(ticket.LandblockId)); + ticket.RunForEachEntity( + LandblockRetirementStage.StaticScripts, + static entity => entity.ServerGuid == 0, + _state.StopStaticEntityScript); + ticket.RunOnce( + LandblockRetirementStage.Classification, + () => _state.InvalidateLandblockClassification(ticket.LandblockId)); + + try + { + _advancePresentation(ticket); + } + catch (Exception error) + { + ticket.RecordCallbackFailure(error); + } + + if (ticket.TryTakeNewFailure(out Exception? failure)) + { + Console.WriteLine( + $"streaming: retirement for 0x{ticket.LandblockId:X8} " + + $"({ticket.Kind}) remains pending: {failure}"); + } + } + + private static uint Canonicalize(uint id) => + (id & 0xFFFF0000u) | 0xFFFFu; +} diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs index d8d7587f..f06fac01 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -57,8 +57,12 @@ public sealed class LandblockStreamer : IDisposable private readonly Channel _inbox; private readonly Channel _outbox; private readonly CancellationTokenSource _cancel = new(); + private readonly object _inboxGate = new(); private Thread? _worker; + private Exception? _workerFailure; private int _disposed; + private readonly object _disposeGate = new(); + private bool _disposeCompleted; /// /// Primary ctor — the factory takes the job's @@ -75,7 +79,7 @@ public sealed class LandblockStreamer : IDisposable // Default: no mesh build (returns null → Failed result). Production // wires in LandblockMesh.Build via the T12 construction site. _buildMeshOrNull = buildMeshOrNull ?? ((_, _) => null); - _inbox = Channel.CreateUnbounded( + _inbox = Channel.CreateUnbounded( new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); _outbox = Channel.CreateUnbounded( new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); @@ -117,25 +121,26 @@ public sealed class LandblockStreamer : IDisposable /// /// Activate the dedicated background worker thread. Idempotent and /// thread-safe: concurrent callers will only spawn one worker; subsequent - /// calls are no-ops. Atomic via . + /// calls are no-ops. Serialized with disposal so a worker can never start + /// after the owning DAT lifetime has been released. /// public void Start() { - if (System.Threading.Volatile.Read(ref _disposed) != 0) - throw new ObjectDisposedException(nameof(LandblockStreamer)); - - // A.5 T10-T12 follow-up: atomically install the worker so concurrent - // Start() callers don't both pass the null check and spawn duplicate - // threads. Construct the candidate; CAS it into _worker; if we lost - // the race, the candidate goes unstarted and is GCed. - var candidate = new Thread(WorkerLoop) + lock (_disposeGate) { - IsBackground = true, - Name = "acdream.streaming.worker", - }; - if (Interlocked.CompareExchange(ref _worker, candidate, null) == null) - candidate.Start(); - // else: another caller won the race; their thread is running. + if (System.Threading.Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(LandblockStreamer)); + if (_worker is not null) + return; + + var worker = new Thread(WorkerLoop) + { + IsBackground = true, + Name = "acdream.streaming.worker", + }; + worker.Start(); + _worker = worker; + } } /// @@ -151,7 +156,7 @@ public sealed class LandblockStreamer : IDisposable if (System.Threading.Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(LandblockStreamer)); AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("ENQ", landblockId, $"kind={kind}"); - _inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind, generation)); + WriteJob(new LandblockStreamJob.Load(landblockId, kind, generation)); } /// @@ -160,9 +165,7 @@ public sealed class LandblockStreamer : IDisposable /// public void EnqueueUnload(uint landblockId, ulong generation = 0) { - if (System.Threading.Volatile.Read(ref _disposed) != 0) - throw new ObjectDisposedException(nameof(LandblockStreamer)); - _inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId, generation)); + WriteJob(new LandblockStreamJob.Unload(landblockId, generation)); } /// @@ -176,9 +179,25 @@ public sealed class LandblockStreamer : IDisposable /// public void ClearPendingLoads() { - if (System.Threading.Volatile.Read(ref _disposed) != 0) - throw new ObjectDisposedException(nameof(LandblockStreamer)); - _inbox.Writer.TryWrite(new LandblockStreamJob.ClearLoads()); + WriteJob(new LandblockStreamJob.ClearLoads()); + } + + private void WriteJob(LandblockStreamJob job) + { + // Serialize the writer's terminal transition with enqueue. Without + // this small lifecycle gate a worker crash could complete the channel + // between the caller's state check and TryWrite, silently dropping a + // landblock request. Enqueues are destination-boundary events, not a + // per-frame hot path. + lock (_inboxGate) + { + if (System.Threading.Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(LandblockStreamer)); + if (_workerFailure is { } failure) + throw new InvalidOperationException("The landblock streaming worker has terminated.", failure); + if (!_inbox.Writer.TryWrite(job)) + throw new InvalidOperationException("The landblock streaming inbox is no longer accepting work."); + } } /// @@ -248,6 +267,11 @@ public sealed class LandblockStreamer : IDisposable { // Last-ditch: surface via outbox so the caller at least sees // something. We never retry a crashed worker. + lock (_inboxGate) + { + _workerFailure = ex; + _inbox.Writer.TryComplete(ex); + } _outbox.Writer.TryWrite(new LandblockStreamResult.WorkerCrashed(ex.ToString())); } finally @@ -397,18 +421,21 @@ public sealed class LandblockStreamer : IDisposable public void Dispose() { - if (System.Threading.Interlocked.Exchange(ref _disposed, 1) != 0) return; - _cancel.Cancel(); - _inbox.Writer.TryComplete(); - // Generous join: the owner disposes the DatCollection after this, which - // unmaps the dats' memory-mapped views — an abandoned worker mid-dat-read - // would take the process down with an AccessViolation in - // MemoryMappedBlockAllocator.ReadBlock (dat-race investigation 2026-06-09). - // Cancellation is honored between jobs, so the wait is bounded by one - // landblock load; 15s only ever elapses if the worker is genuinely hung. - if (_worker is not null && !_worker.Join(TimeSpan.FromSeconds(15))) - Console.Error.WriteLine( - "[streamer] worker did not stop within 15s — dat teardown may race an in-flight load"); - _cancel.Dispose(); + lock (_disposeGate) + { + if (_disposeCompleted) + return; + + System.Threading.Interlocked.Exchange(ref _disposed, 1); + _cancel.Cancel(); + lock (_inboxGate) + _inbox.Writer.TryComplete(); + // The owner releases the memory-mapped DAT immediately after this + // object. Join the actual worker without a grace-period timeout so + // no native read can survive into that teardown. + _worker?.Join(); + _cancel.Dispose(); + _disposeCompleted = true; + } } } diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 1b826a09..69bb8760 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -22,9 +22,8 @@ public sealed class StreamingController private readonly Action _enqueueUnload; private readonly Func> _drainCompletions; private readonly Action _applyTerrain; - private readonly Action? _removeTerrain; - private readonly Action? _demoteNearLayer; private readonly Action? _clearPendingLoads; + private readonly LandblockRetirementCoordinator _retirements; /// /// #138: fired after a landblock's entity layer (re)loads — both the full @@ -42,6 +41,9 @@ public sealed class StreamingController private readonly Action? _ensureEnvCellMeshes; private readonly GpuWorldState _state; private StreamingRegion? _region; + private RadiiReconfiguration? _pendingRadiiReconfiguration; + private bool _advancingRadiiReconfiguration; + private (int NearRadius, int FarRadius)? _deferredRadiiRequest; // Hard streaming boundaries advance this token. Worker completions carry // the generation captured at enqueue time, so an old overlapping load or // unload cannot mutate the replacement window after a portal recenter. @@ -63,23 +65,15 @@ public sealed class StreamingController /// /// Near-tier radius (LBs from observer that load full detail: terrain + - /// scenery + entities). Set at construction; readable thereafter. + /// scenery + entities). Runtime changes go through + /// . /// - /// - /// Mutating after the first has no effect — the - /// internal snapshots both radii on its - /// constructor. Treat as init-only post-Tick. - /// - public int NearRadius { get; } + public int NearRadius { get; private set; } /// - /// Far-tier radius (LBs from observer that load terrain only). Set at - /// construction; readable thereafter. + /// Far-tier radius (LBs from observer that load terrain only). /// - /// - /// Mutating after the first has no effect — see . - /// - public int FarRadius { get; } + public int FarRadius { get; private set; } /// /// Cap on completions drained per call. The cap is @@ -194,22 +188,122 @@ public sealed class StreamingController Action? demoteNearLayer = null, Action? clearPendingLoads = null, Action? onLandblockLoaded = null, - Action? ensureEnvCellMeshes = null) + Action? ensureEnvCellMeshes = null, + LandblockRetirementCoordinator? retirementCoordinator = null) { _enqueueLoad = enqueueLoad; _enqueueUnload = enqueueUnload; _drainCompletions = drainCompletions; _applyTerrain = applyTerrain; - _removeTerrain = removeTerrain; - _demoteNearLayer = demoteNearLayer; _clearPendingLoads = clearPendingLoads; _onLandblockLoaded = onLandblockLoaded; _ensureEnvCellMeshes = ensureEnvCellMeshes; _state = state; + _retirements = retirementCoordinator + ?? LandblockRetirementCoordinator.CreateLegacy( + state, + removeTerrain, + demoteNearLayer); NearRadius = nearRadius; FarRadius = farRadius; } + /// + /// Reconciles an active outdoor streaming window to new quality radii + /// without replacing the worker/controller or blindly bootstrapping data + /// already published in . Pending jobs from the + /// old window are invalidated by generation; resident blocks are retained, + /// promoted, demoted, loaded, or unloaded from their actual current tier. + /// A sealed dungeon remains radius-zero until its normal exit edge while + /// remembering the new outdoor radii for that exit. + /// + public void ReconfigureRadii(int nearRadius, int farRadius) + { + if (nearRadius < 0) + throw new ArgumentOutOfRangeException(nameof(nearRadius)); + if (farRadius < nearRadius) + throw new ArgumentOutOfRangeException( + nameof(farRadius), + "Far radius must be greater than or equal to near radius."); + + // Queue callbacks are injected seams and can synchronously reenter the + // controller. Last-request-wins deferral keeps the active mutation + // cursor stable; the request is applied after the current transaction + // converges instead of recursively replaying its active step. + if (_advancingRadiiReconfiguration) + { + _deferredRadiiRequest = (nearRadius, farRadius); + return; + } + + // A prior callback may have failed after this controller accepted the + // request. Resume that exact transaction before considering another + // target; replacing it would orphan already-admitted generation work. + if (_pendingRadiiReconfiguration is not null) + AdvanceRadiiReconfiguration(); + + // This explicit call is newer than any request deferred by a prior + // failed attempt and therefore supersedes it. + _deferredRadiiRequest = null; + + if (nearRadius == NearRadius && farRadius == FarRadius) + return; + + if (_collapsed || _region is null) + { + NearRadius = nearRadius; + FarRadius = farRadius; + return; + } + + var rebuilt = new StreamingRegion( + _region.CenterX, + _region.CenterY, + nearRadius, + farRadius); + TwoTierDiff bootstrap = rebuilt.ComputeFirstTickDiff(); + var desiredNear = new HashSet(bootstrap.ToLoadNear); + var desiredFar = new HashSet(bootstrap.ToLoadFar); + var mutations = new List(); + + uint[] loaded = [.. _state.LoadedLandblockIds]; + for (int i = 0; i < loaded.Length; i++) + { + uint id = loaded[i]; + if (desiredNear.Contains(id)) + { + if (!_state.IsNearTier(id)) + mutations.Add(new RadiiMutation( + () => EnqueueLoad(id, LandblockStreamJobKind.PromoteToNear))); + } + else if (desiredFar.Contains(id)) + { + if (_state.IsNearTier(id)) + mutations.Add(new RadiiMutation(() => DemoteLandblock(id))); + } + else + { + mutations.Add(new RadiiMutation(() => EnqueueUnload(id))); + } + } + + foreach (uint id in desiredNear) + if (!_state.IsLoaded(id)) + mutations.Add(new RadiiMutation( + () => EnqueueLoad(id, LandblockStreamJobKind.LoadNear))); + foreach (uint id in desiredFar) + if (!_state.IsLoaded(id)) + mutations.Add(new RadiiMutation( + () => EnqueueLoad(id, LandblockStreamJobKind.LoadFar))); + + _pendingRadiiReconfiguration = new RadiiReconfiguration( + nearRadius, + farRadius, + rebuilt, + mutations); + AdvanceRadiiReconfiguration(); + } + /// /// Compatibility constructor for deterministic tests and callers that do /// not own an asynchronous worker. Production must use the generation-aware @@ -227,7 +321,8 @@ public sealed class StreamingController Action? demoteNearLayer = null, Action? clearPendingLoads = null, Action? onLandblockLoaded = null, - Action? ensureEnvCellMeshes = null) + Action? ensureEnvCellMeshes = null, + LandblockRetirementCoordinator? retirementCoordinator = null) : this( (id, kind, _) => enqueueLoad(id, kind), (id, _) => enqueueUnload(id), @@ -240,7 +335,8 @@ public sealed class StreamingController demoteNearLayer, clearPendingLoads, onLandblockLoaded, - ensureEnvCellMeshes) + ensureEnvCellMeshes, + retirementCoordinator) { } @@ -252,11 +348,7 @@ public sealed class StreamingController private void DemoteLandblock(uint id) { uint canonical = (id & 0xFFFF0000u) | 0xFFFFu; - // App-owned Near resources need the still-present static entity list - // for exact light/translucency owner cleanup. Retire them before - // GpuWorldState drops the static layer and its render pins. - _demoteNearLayer?.Invoke(canonical); - _state.RemoveEntitiesFromLandblock(canonical); + _retirements.BeginNearLayer(canonical); } private void AdvanceGeneration() @@ -280,6 +372,18 @@ public sealed class StreamingController /// public void Tick(int observerCx, int observerCy, bool insideDungeon = false) { + if (_pendingRadiiReconfiguration is not null) + AdvanceRadiiReconfiguration(); + else if (_deferredRadiiRequest is { } deferred) + { + _deferredRadiiRequest = null; + ReconfigureRadii(deferred.NearRadius, deferred.FarRadius); + } + + // Presentation-owner failures never roll back spatial state. Resume + // unfinished owner ledgers before accepting replacement publications. + _retirements.Advance(); + uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy); if (_collapsed) @@ -313,6 +417,125 @@ public sealed class StreamingController DrainAndApply(); } + private void AdvanceRadiiReconfiguration() + { + if (_advancingRadiiReconfiguration) + return; + + RadiiReconfiguration pending = _pendingRadiiReconfiguration + ?? throw new InvalidOperationException("No radii reconfiguration is pending."); + var failures = new List(); + _advancingRadiiReconfiguration = true; + try + { + + if (!pending.GenerationAdvanced) + { + AdvanceGeneration(); + pending.GenerationAdvanced = true; + } + + if (!pending.PendingLoadsCleared) + { + try + { + _clearPendingLoads?.Invoke(); + pending.PendingLoadsCleared = true; + } + catch (Exception error) + { + if (error is StreamingMutationException { MutationCommitted: true }) + pending.PendingLoadsCleared = true; + failures.Add(error); + } + } + + // Do not admit new-generation work until cancellation of the old + // inbox is durable. Otherwise a successful retry of ClearPendingLoads + // would also erase mutations already marked complete by this ledger. + for (int i = 0; pending.PendingLoadsCleared && i < pending.Mutations.Count; i++) + { + RadiiMutation mutation = pending.Mutations[i]; + if (mutation.Completed) + continue; + try + { + mutation.Apply(); + mutation.Completed = true; + } + catch (Exception error) + { + if (error is StreamingMutationException { MutationCommitted: true }) + mutation.Completed = true; + failures.Add(error); + } + } + + bool converged = pending.PendingLoadsCleared + && pending.Mutations.All(static mutation => mutation.Completed); + if (converged) + { + // Publish the new logical window only after every queue/retirement + // admission is durable. A later Tick can now trust Resident as the + // complete desired set and will never strand a hole. + pending.Region.MarkResidentFromBootstrap(); + _region = pending.Region; + NearRadius = pending.NearRadius; + FarRadius = pending.FarRadius; + _deferredApply.Clear(); + _pendingRadiiReconfiguration = null; + } + } + finally + { + _advancingRadiiReconfiguration = false; + } + + if (failures.Count != 0) + { + throw new AggregateException( + "Streaming quality reconfiguration did not complete cleanly.", + failures); + } + + if (_pendingRadiiReconfiguration is null + && _deferredRadiiRequest is { } deferred) + { + _deferredRadiiRequest = null; + ReconfigureRadii(deferred.NearRadius, deferred.FarRadius); + } + } + + private sealed class RadiiReconfiguration + { + public RadiiReconfiguration( + int nearRadius, + int farRadius, + StreamingRegion region, + List mutations) + { + NearRadius = nearRadius; + FarRadius = farRadius; + Region = region; + Mutations = mutations; + } + + public int NearRadius { get; } + public int FarRadius { get; } + public StreamingRegion Region { get; } + public List Mutations { get; } + public bool GenerationAdvanced { get; set; } + public bool PendingLoadsCleared { get; set; } + } + + private sealed class RadiiMutation + { + public RadiiMutation(Action apply) => Apply = apply; + + public Action Apply { get; } + public bool Completed { get; set; } + } + /// /// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first /// has a chance to bootstrap the full 25×25 window. Called @@ -400,6 +623,7 @@ public sealed class StreamingController AdvanceGeneration(); _clearPendingLoads?.Invoke(); _deferredApply.Clear(); + _region = null; foreach (var id in _state.LoadedLandblockIds) if (id != centerId) EnqueueUnload(id); @@ -495,16 +719,15 @@ public sealed class StreamingController AdvanceGeneration(); _clearPendingLoads?.Invoke(); _deferredApply.Clear(); + // Commit the old region boundary before any presentation owner is + // advanced. New same-id publications are fenced by _retirements. + _region = null; // Snapshot — RemoveLandblock mutates the loaded set we're iterating. var ids = new List(_state.LoadedLandblockIds); ForceReloadCount++; // [FRAME-DIAG] churn counter LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume foreach (var id in ids) - { - _state.RemoveLandblock(id); - _removeTerrain?.Invoke(id); // frees the render slot + physics + cell registries - } - _region = null; // NormalTick re-creates + bootstraps the full window next frame + _retirements.BeginFull(id); } /// @@ -541,7 +764,9 @@ public sealed class StreamingController // for direct callers and future drain paths. if (IsStaleGeneration(result)) continue; - if (result is LandblockStreamResult.Unloaded + if (IsPublicationBlockedByRetirement(result)) + _deferredApply.Add(result); + else if (result is LandblockStreamResult.Unloaded || IsWithinPriorityRing(ResultLandblockId(result))) ApplyResult(result); // free (unload) or behind-the-fade near ring else @@ -553,9 +778,39 @@ public sealed class StreamingController // earlier-queued landblocks win). Caps GPU upload per frame so a big diff doesn't // spike. _deferredApply now only ever holds loads — unloads were applied in step 1. int budget = MaxCompletionsPerFrame; - int i = 0; - while (i < budget && i < _deferredApply.Count) { ApplyResult(_deferredApply[i]); i++; } - if (i > 0) _deferredApply.RemoveRange(0, i); + int applied = 0; + int read = 0; + int write = 0; + try + { + while (applied < budget && read < _deferredApply.Count) + { + LandblockStreamResult result = _deferredApply[read]; + if (IsPublicationBlockedByRetirement(result)) + { + if (write != read) + _deferredApply[write] = result; + write++; + read++; + continue; + } + + // Advance read only after the apply commits. If it throws, the + // finally block removes prior committed entries but retains this + // exact result and the untouched tail for retry. + ApplyResult(result); + read++; + applied++; + } + } + finally + { + // One linear tail shift replaces budget-many RemoveAt shifts. The + // retained blocked prefix and the untouched FIFO tail keep their + // original relative order. + if (read > write) + _deferredApply.RemoveRange(write, read - write); + } } /// @@ -683,8 +938,7 @@ public sealed class StreamingController _onLandblockLoaded?.Invoke(promoted.LandblockId); break; case LandblockStreamResult.Unloaded unloaded: - _state.RemoveLandblock(unloaded.LandblockId); - _removeTerrain?.Invoke(unloaded.LandblockId); + _retirements.BeginFull(unloaded.LandblockId); break; case LandblockStreamResult.Failed failed: Console.WriteLine( @@ -722,6 +976,10 @@ public sealed class StreamingController result is not LandblockStreamResult.WorkerCrashed && result.Generation != _generation; + private bool IsPublicationBlockedByRetirement(LandblockStreamResult result) => + result is LandblockStreamResult.Loaded or LandblockStreamResult.Promoted + && _retirements.IsPending(result.LandblockId); + /// /// Returns the landblock id associated with . /// For this is 0 by diff --git a/src/AcDream.App/Streaming/StreamingMutationException.cs b/src/AcDream.App/Streaming/StreamingMutationException.cs new file mode 100644 index 00000000..5c74dc88 --- /dev/null +++ b/src/AcDream.App/Streaming/StreamingMutationException.cs @@ -0,0 +1,20 @@ +namespace AcDream.App.Streaming; + +/// +/// Reports whether a streaming queue/retirement mutation crossed its commit +/// point before a callback failed. Retry ledgers use this to avoid submitting +/// the same generation operation twice after a post-commit diagnostic error. +/// +public sealed class StreamingMutationException : Exception +{ + public StreamingMutationException( + string message, + bool mutationCommitted, + Exception? innerException = null) + : base(message, innerException) + { + MutationCommitted = mutationCommitted; + } + + public bool MutationCommitted { get; } +} diff --git a/src/AcDream.App/Studio/FixtureProvider.cs b/src/AcDream.App/Studio/FixtureProvider.cs index 211dba77..7f14e387 100644 --- a/src/AcDream.App/Studio/FixtureProvider.cs +++ b/src/AcDream.App/Studio/FixtureProvider.cs @@ -6,6 +6,7 @@ using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.UI.Abstractions.Panels.Settings; using DatReaderWriter; +using AcDream.Content; namespace AcDream.App.Studio; @@ -51,7 +52,7 @@ public static class FixtureProvider ImportedLayout layout, RenderStack stack, ClientObjectTable objects, - DatCollection dats) + IDatReaderWriter dats) { switch (layoutId) { diff --git a/src/AcDream.App/Studio/LayoutSource.cs b/src/AcDream.App/Studio/LayoutSource.cs index 9aa2517d..78e53383 100644 --- a/src/AcDream.App/Studio/LayoutSource.cs +++ b/src/AcDream.App/Studio/LayoutSource.cs @@ -1,6 +1,7 @@ using AcDream.App.UI; using AcDream.App.UI.Layout; using DatReaderWriter; +using AcDream.Content; namespace AcDream.App.Studio; @@ -18,7 +19,7 @@ public enum LayoutSourceKind { DatLayout, Markup } /// public sealed class LayoutSource { - private readonly DatCollection _dats; + private readonly IDatReaderWriter _dats; private readonly Func _resolve; private readonly UiDatFont? _datFont; private readonly Func? _fontResolve; @@ -41,7 +42,7 @@ public sealed class LayoutSource /// Pass null (default) for the original single-font behavior — the live /// path passes null so it is provably unchanged. public LayoutSource( - DatCollection dats, + IDatReaderWriter dats, Func resolve, UiDatFont? datFont, Func? fontResolve = null) diff --git a/src/AcDream.App/Studio/MockupDesktop.cs b/src/AcDream.App/Studio/MockupDesktop.cs index 59e4c72f..eaa379f8 100644 --- a/src/AcDream.App/Studio/MockupDesktop.cs +++ b/src/AcDream.App/Studio/MockupDesktop.cs @@ -5,6 +5,7 @@ using AcDream.Core.Chat; using AcDream.UI.Abstractions; using AcDream.UI.Abstractions.Panels.Chat; using DatReaderWriter; +using AcDream.Content; using System.Numerics; namespace AcDream.App.Studio; @@ -17,7 +18,7 @@ namespace AcDream.App.Studio; /// internal static class MockupDesktop { - public static void Load(DatCollection dats, RenderStack stack) + public static void Load(IDatReaderWriter dats, RenderStack stack) { var objects = SampleData.BuildObjectTable(); var windows = new List(); @@ -36,11 +37,11 @@ internal static class MockupDesktop MountControls(stack, windows); } - private static ImportedLayout? Import(DatCollection dats, uint layoutId, RenderStack stack) + private static ImportedLayout? Import(IDatReaderWriter dats, uint layoutId, RenderStack stack) => LayoutImporter.Import(dats, layoutId, stack.ResolveChrome, stack.VitalsDatFont, fontResolve: stack.ResolveDatFont); - private static RetailWindowHandle? MountVitals(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) + private static RetailWindowHandle? MountVitals(IDatReaderWriter dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) { var layout = Import(dats, 0x2100006Cu, stack); if (layout is null) return null; @@ -66,7 +67,7 @@ internal static class MockupDesktop }); } - private static RetailWindowHandle? MountToolbar(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) + private static RetailWindowHandle? MountToolbar(IDatReaderWriter dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) { var layout = Import(dats, 0x21000016u, stack); if (layout is null) return null; @@ -136,7 +137,7 @@ internal static class MockupDesktop frame.MaxHeight = expandedH; } - private static RetailWindowHandle? MountCharacter(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) + private static RetailWindowHandle? MountCharacter(IDatReaderWriter dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) { var layout = Import(dats, 0x2100002Eu, stack); if (layout is null) return null; @@ -164,7 +165,7 @@ internal static class MockupDesktop }); } - private static RetailWindowHandle? MountInventory(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) + private static RetailWindowHandle? MountInventory(IDatReaderWriter dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) { var layout = Import(dats, 0x21000023u, stack); if (layout is null) return null; @@ -219,7 +220,7 @@ internal static class MockupDesktop element.Anchors = AnchorEdges.Left | AnchorEdges.Top; } - private static RetailWindowHandle? MountChat(DatCollection dats, RenderStack stack) + private static RetailWindowHandle? MountChat(IDatReaderWriter dats, RenderStack stack) { var rootInfo = LayoutImporter.ImportInfos(dats, ChatWindowController.LayoutId); if (rootInfo is null) return null; diff --git a/src/AcDream.App/Studio/StudioWindow.cs b/src/AcDream.App/Studio/StudioWindow.cs index a88d463a..4df81786 100644 --- a/src/AcDream.App/Studio/StudioWindow.cs +++ b/src/AcDream.App/Studio/StudioWindow.cs @@ -1,8 +1,8 @@ using System.Numerics; +using AcDream.Content; using AcDream.App.Rendering; using AcDream.App.UI; using DatReaderWriter; -using DatReaderWriter.Options; using Silk.NET.Input; using Silk.NET.Maths; using Silk.NET.OpenGL; @@ -39,7 +39,7 @@ public sealed class StudioWindow : IDisposable // Created in OnLoad, released in OnClosing. private IWindow? _window; - private DatCollection? _dats; + private IDatReaderWriter? _dats; private RenderStack? _stack; private LayoutSource? _source; @@ -113,7 +113,7 @@ public sealed class StudioWindow : IDisposable { var gl = GL.GetApi(_window!); - _dats = new DatCollection(_opts.DatDir, DatAccessType.Read); + _dats = RuntimeDatCollectionFactory.OpenReadOnly(_opts.DatDir); // Build QualitySettings for RenderBootstrap (same as Run() above — re-read // after the GL context is confirmed, mirroring GameWindow.OnLoad). @@ -218,6 +218,10 @@ public sealed class StudioWindow : IDisposable private void OnRender(double dt) { if (_stack is null || _panelFbo is null) return; + _stack.BeginFrame(); + Exception? renderFailure = null; + try + { // ── HEADLESS SCREENSHOT PATH ────────────────────────────────────────────── if (_opts.ScreenshotPath is not null) @@ -415,6 +419,26 @@ public sealed class StudioWindow : IDisposable // 9. Finalise ImGui and flush draw data to the window. _imgui.Render(); + } + catch (Exception ex) + { + renderFailure = ex; + throw; + } + finally + { + try + { + _stack.EndFrame(); + } + catch (Exception closeFailure) when (renderFailure is not null) + { + throw new AggregateException( + "UI Studio rendering failed and its GPU frame could not be closed.", + renderFailure, + closeFailure); + } + } } /// diff --git a/src/AcDream.App/UI/IconComposer.cs b/src/AcDream.App/UI/IconComposer.cs index 583b853a..77dacf2b 100644 --- a/src/AcDream.App/UI/IconComposer.cs +++ b/src/AcDream.App/UI/IconComposer.cs @@ -6,6 +6,7 @@ using AcDream.Core.Items; using AcDream.Core.Textures; using AcDream.Core.Spells; using DatReaderWriter; +using AcDream.Content; using DatReaderWriter.DBObjs; namespace AcDream.App.UI; @@ -32,7 +33,7 @@ namespace AcDream.App.UI; /// public sealed class IconComposer { - private readonly DatCollection _dats; + private readonly IDatReaderWriter _dats; private readonly TextureCache _cache; private readonly Dictionary<(uint, uint, uint, uint, uint), uint> _byTuple = new(); private readonly Dictionary<(uint, uint, uint), ComposedIcon> _dragByTuple = new(); @@ -59,7 +60,7 @@ public sealed class IconComposer private readonly Dictionary _effectDidByIndex = new(); private readonly Dictionary _effectTileByDid = new(); - public IconComposer(DatCollection dats, TextureCache cache) + public IconComposer(IDatReaderWriter dats, TextureCache cache) { _dats = dats; _cache = cache; @@ -93,7 +94,7 @@ public sealed class IconComposer { if (_underlayResolveTried) return; _underlayResolveTried = true; - uint masterDid = (uint)_dats.Portal.Header.MasterMapId; // = 0x25000000 + uint masterDid = (uint)_dats.Portal.Db.Header.MasterMapId; // = 0x25000000 if (masterDid == 0) return; if (!_dats.Portal.TryGet(masterDid, out var master)) return; if (!master.ClientEnumToID.TryGetValue(0x10000004u, out var subDid)) return; // → 0x25000008 @@ -125,7 +126,7 @@ public sealed class IconComposer { if (_effectResolveTried) return; _effectResolveTried = true; - uint masterDid = (uint)_dats.Portal.Header.MasterMapId; // = 0x25000000 + uint masterDid = (uint)_dats.Portal.Db.Header.MasterMapId; // = 0x25000000 if (masterDid == 0) return; if (!_dats.Portal.TryGet(masterDid, out var master)) return; if (!master.ClientEnumToID.TryGetValue(0x10000005u, out var subDid)) return; // → 0x25000009 diff --git a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs index 2c986023..7b5c10d7 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using AcDream.Core.Items; using AcDream.Core.Player; using DatReaderWriter; +using AcDream.Content; namespace AcDream.App.UI.Layout; @@ -186,7 +187,7 @@ public sealed class CharacterSheetProvider /// never silently swallowed — and leave raise costs unavailable (0). /// public static DatReaderWriter.DBObjs.ExperienceTable? LoadExperienceTable( - DatCollection dats, Action? log = null) + IDatReaderWriter dats, Action? log = null) { if (dats is null) return null; diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index d38f29dd..c41561dd 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -75,6 +75,17 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta private ChatChannelKind _activeChannel = ChatChannelKind.Say; + // UiText polls LinesProvider while drawing and hit-testing. Keep the fully + // formatted + wrapped transcript until either its source revision or the + // metrics that determine wrapping change. This makes an idle chat window + // allocation-free instead of snapshotting/formatting/wrapping every frame. + private IReadOnlyList _cachedTranscriptLines = Array.Empty(); + private long _cachedTranscriptRevision = -1; + private float _cachedTranscriptWrapWidth = float.NaN; + private UiDatFont? _cachedTranscriptDatFont; + private BitmapFont? _cachedTranscriptDebugFont; + internal int TranscriptLayoutBuildCount { get; private set; } + // ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ── private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems = @@ -215,7 +226,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta c.Transcript.OneLine = false; c.Transcript.Selectable = true; c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript - c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont); + c.Transcript.LinesProvider = () => c.GetTranscriptLines(vm); // ── Input ──────────────────────────────────────────────────────── // Editable/selectable/one-line semantics and state sprites came from the @@ -415,20 +426,35 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta /// record format, applying retail-faithful /// per- colors. /// - private static IReadOnlyList BuildLines( - ChatVM vm, UiText view, UiDatFont? datFont, BitmapFont? debugFont) + private IReadOnlyList GetTranscriptLines(ChatVM vm) { + float maxW = Transcript.Width - 2f * Transcript.Padding; + UiDatFont? datFont = Transcript.DatFont; + BitmapFont? debugFont = Transcript.Font; + long revision = vm.Revision; + + if (_cachedTranscriptRevision == revision + && _cachedTranscriptWrapWidth.Equals(maxW) + && ReferenceEquals(_cachedTranscriptDatFont, datFont) + && ReferenceEquals(_cachedTranscriptDebugFont, debugFont)) + { + return _cachedTranscriptLines; + } + var detailed = vm.RecentLinesDetailed(); - if (detailed.Count == 0) return Array.Empty(); + if (detailed.Count == 0) + { + return StoreTranscriptLayout( + Array.Empty(), revision, maxW, datFont, debugFont); + } // Word-wrap each message to the transcript's current pixel width (ports retail // GlyphList::Recalculate @0x473800 — break at word boundaries when the line would - // exceed wrapWidth). Re-evaluated each frame so wrapping follows window resize. - float maxW = view.Width - 2f * view.Padding; + // exceed wrapWidth). The cache key re-evaluates it after window resize. Func measure = datFont is { } df ? s => df.MeasureWidth(s) : debugFont is { } bf ? s => bf.MeasureWidth(s) - : s => s.Length * 7f; + : static s => s.Length * 7f; var result = new List(detailed.Count); foreach (var d in detailed) @@ -437,7 +463,23 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta foreach (var frag in WrapText(d.Text, maxW, measure)) result.Add(new UiText.Line(frag, color)); } - return result; + return StoreTranscriptLayout(result, revision, maxW, datFont, debugFont); + } + + private IReadOnlyList StoreTranscriptLayout( + IReadOnlyList lines, + long revision, + float wrapWidth, + UiDatFont? datFont, + BitmapFont? debugFont) + { + _cachedTranscriptRevision = revision; + _cachedTranscriptWrapWidth = wrapWidth; + _cachedTranscriptDatFont = datFont; + _cachedTranscriptDebugFont = debugFont; + _cachedTranscriptLines = lines; + TranscriptLayoutBuildCount++; + return lines; } /// diff --git a/src/AcDream.App/UI/Layout/ComponentBookTemplateFactory.cs b/src/AcDream.App/UI/Layout/ComponentBookTemplateFactory.cs index 41ce9139..b37dd408 100644 --- a/src/AcDream.App/UI/Layout/ComponentBookTemplateFactory.cs +++ b/src/AcDream.App/UI/Layout/ComponentBookTemplateFactory.cs @@ -1,4 +1,5 @@ using DatReaderWriter; +using AcDream.Content; namespace AcDream.App.UI.Layout; @@ -62,7 +63,7 @@ public sealed class ComponentBookTemplateFactory /// so later inventory refreshes instantiate rows without reading DAT files. /// public static ComponentBookTemplateFactory? TryLoad( - DatCollection dats, + IDatReaderWriter dats, Func resolveSprite, UiDatFont? defaultFont, Func? resolveFont) diff --git a/src/AcDream.App/UI/Layout/DatStringResolver.cs b/src/AcDream.App/UI/Layout/DatStringResolver.cs index 8a12d321..999d4d17 100644 --- a/src/AcDream.App/UI/Layout/DatStringResolver.cs +++ b/src/AcDream.App/UI/Layout/DatStringResolver.cs @@ -1,4 +1,5 @@ using DatReaderWriter; +using AcDream.Content; using DatReaderWriter.DBObjs; namespace AcDream.App.UI.Layout; @@ -14,10 +15,10 @@ namespace AcDream.App.UI.Layout; /// public sealed class DatStringResolver { - private readonly DatCollection _dats; + private readonly IDatReaderWriter _dats; private readonly Dictionary _tables = new(); - public DatStringResolver(DatCollection dats) + public DatStringResolver(IDatReaderWriter dats) => _dats = dats ?? throw new ArgumentNullException(nameof(dats)); public string? Resolve(UiStringInfoValue info) diff --git a/src/AcDream.App/UI/Layout/EffectRowTemplateFactory.cs b/src/AcDream.App/UI/Layout/EffectRowTemplateFactory.cs index d888cfd8..80aeebde 100644 --- a/src/AcDream.App/UI/Layout/EffectRowTemplateFactory.cs +++ b/src/AcDream.App/UI/Layout/EffectRowTemplateFactory.cs @@ -1,4 +1,5 @@ using DatReaderWriter; +using AcDream.Content; namespace AcDream.App.UI.Layout; @@ -31,7 +32,7 @@ public sealed class EffectRowTemplateFactory public float Height => _template.Height; public static EffectRowTemplateFactory? TryLoad( - DatCollection dats, + IDatReaderWriter dats, Func resolveSprite, UiDatFont? defaultFont, Func? resolveFont) diff --git a/src/AcDream.App/UI/Layout/ItemListCellTemplate.cs b/src/AcDream.App/UI/Layout/ItemListCellTemplate.cs index 7f85228a..ea6a53e5 100644 --- a/src/AcDream.App/UI/Layout/ItemListCellTemplate.cs +++ b/src/AcDream.App/UI/Layout/ItemListCellTemplate.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using DatReaderWriter; +using AcDream.Content; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; @@ -30,7 +31,7 @@ public static class ItemListCellTemplate /// Returns 0 when the layout/element/attribute/prototype/media is absent (the caller keeps /// the default). /// - public static uint ResolveEmptySprite(DatCollection dats, uint listLayoutId, uint listElementId) + public static uint ResolveEmptySprite(IDatReaderWriter dats, uint listLayoutId, uint listElementId) { var listLd = dats.Get(listLayoutId); if (listLd is null) return 0; @@ -52,7 +53,7 @@ public static class ItemListCellTemplate /// attribute 0x1000000E. /// public static uint ResolveEmptySprite( - DatCollection dats, + IDatReaderWriter dats, ElementInfo resolvedRoot, uint listElementId) { @@ -71,7 +72,7 @@ public static class ItemListCellTemplate /// . Paperdoll lists select distinct prototypes for their /// jewelry, weapon, clothing, and armor locations even though the lists share one base. /// - public static uint ResolvePrototypeEmptySprite(DatCollection dats, uint prototypeElementId) + public static uint ResolvePrototypeEmptySprite(IDatReaderWriter dats, uint prototypeElementId) { if (prototypeElementId == 0) return 0; diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index f942f59f..43418270 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using AcDream.Content; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; @@ -179,7 +180,7 @@ public static class LayoutImporter /// /// The dat collection to read the LayoutDesc from. /// The LayoutDesc dat id to read. - public static ElementInfo? ImportInfos(DatCollection dats, uint layoutId) + public static ElementInfo? ImportInfos(IDatReaderWriter dats, uint layoutId) { var ld = dats.Get(layoutId); if (ld is null) return null; @@ -234,7 +235,7 @@ public static class LayoutImporter /// top-level template. DialogFactory uses this path for the shared dialog catalog. /// public static ElementInfo? ImportInfos( - DatCollection dats, + IDatReaderWriter dats, uint layoutId, uint rootElementId) { @@ -273,7 +274,7 @@ public static class LayoutImporter /// Optional per-element font resolver (see /// for details). Null = original single-font behavior. public static ImportedLayout? Import( - DatCollection dats, + IDatReaderWriter dats, uint layoutId, Func resolve, UiDatFont? datFont, @@ -287,7 +288,7 @@ public static class LayoutImporter /// Import one selected root from a catalog-style LayoutDesc. public static ImportedLayout? Import( - DatCollection dats, + IDatReaderWriter dats, uint layoutId, uint rootElementId, Func resolve, @@ -314,7 +315,7 @@ public static class LayoutImporter /// (cycle-guarded by ), then resolves + attaches children. /// private static ElementInfo Resolve( - DatCollection dats, + IDatReaderWriter dats, ElementDesc d, HashSet<(uint layoutId, uint elementId)> baseChain) { @@ -369,7 +370,7 @@ public static class LayoutImporter } private static void IncorporateChildren( - DatCollection dats, + IDatReaderWriter dats, ElementInfo result, IReadOnlyList? baseChildren, ElementDesc derived) @@ -406,7 +407,7 @@ public static class LayoutImporter } private static ElementInfo IncorporateResolvedChild( - DatCollection dats, + IDatReaderWriter dats, ElementInfo baseChild, ElementDesc derivedChild) { diff --git a/src/AcDream.App/UI/Layout/PaperdollClickMap.cs b/src/AcDream.App/UI/Layout/PaperdollClickMap.cs index 87d9cbca..aed5a3f5 100644 --- a/src/AcDream.App/UI/Layout/PaperdollClickMap.cs +++ b/src/AcDream.App/UI/Layout/PaperdollClickMap.cs @@ -1,5 +1,6 @@ using AcDream.Core.Items; using AcDream.Core.Textures; +using AcDream.Content; using DatReaderWriter; using DatReaderWriter.DBObjs; @@ -38,11 +39,11 @@ public sealed class PaperdollClickMap /// and decodes the resulting RenderSurface for exact per-pixel sampling. /// The caller owns synchronization around . /// - public static PaperdollClickMap? Load(DatCollection dats) + public static PaperdollClickMap? Load(IDatReaderWriter dats) { ArgumentNullException.ThrowIfNull(dats); - uint masterDid = (uint)dats.Portal.Header.MasterMapId; + uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId; if (masterDid == 0 || !dats.Portal.TryGet(masterDid, out var master) || master is null diff --git a/src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs b/src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs index ced31d90..6fef9fdf 100644 --- a/src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs +++ b/src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using AcDream.Core.Items; +using AcDream.Content; using DatReaderWriter; namespace AcDream.App.UI.Layout; @@ -56,7 +57,7 @@ public static class PaperdollSlotBackgrounds }; /// Resolves every supported slot's exact empty RenderSurface from the live DAT. - public static IReadOnlyDictionary ResolveEmptySprites(DatCollection dats) + public static IReadOnlyDictionary ResolveEmptySprites(IDatReaderWriter dats) { var result = new Dictionary(Definitions.Length); foreach (Definition definition in Definitions) diff --git a/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs b/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs index 9ad215ee..a4d714d1 100644 --- a/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs +++ b/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs @@ -29,6 +29,8 @@ public sealed class RadarSnapshotProvider private readonly Func _coordinatesOnRadar; private readonly Func _uiLocked; private readonly Func? _relationshipFor; + private readonly Action>>? _copySpatialCandidates; + private readonly List> _candidateScratch = new(); public RadarSnapshotProvider( ClientObjectTable objects, @@ -41,7 +43,8 @@ public sealed class RadarSnapshotProvider Func coordinatesOnRadar, Func uiLocked, Func? relationshipFor = null, - Func>? playerEntities = null) + Func>? playerEntities = null, + Action>>? copySpatialCandidates = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _worldEntities = worldEntities ?? throw new ArgumentNullException(nameof(worldEntities)); @@ -54,6 +57,7 @@ public sealed class RadarSnapshotProvider _coordinatesOnRadar = coordinatesOnRadar ?? throw new ArgumentNullException(nameof(coordinatesOnRadar)); _uiLocked = uiLocked ?? throw new ArgumentNullException(nameof(uiLocked)); _relationshipFor = relationshipFor; + _copySpatialCandidates = copySpatialCandidates; } public UiRadarSnapshot BuildSnapshot() @@ -85,14 +89,33 @@ public sealed class RadarSnapshotProvider (uint)(_objects.Get(playerGuid)?.Type ?? ItemType.None), playerPwd); float range = RetailRadar.GetRangeMeters(isOutside); - var blips = new List(worldEntities.Count); - foreach (var pair in worldEntities) + _candidateScratch.Clear(); + if (_copySpatialCandidates is not null) + { + // Retail radar is at most 75 metres while a landblock is 192 + // metres wide. The current block plus its eight neighbours is a + // complete broadphase (the cheap spatial pre-filter). + _copySpatialCandidates(playerCellId, 1, _candidateScratch); + } + else + { + foreach (var pair in worldEntities) + _candidateScratch.Add(pair); + } + + var blips = new List(Math.Min(_candidateScratch.Count, 64)); + foreach (var pair in _candidateScratch) { uint guid = pair.Key; if (guid == playerGuid) continue; var entity = pair.Value; + if (!worldEntities.TryGetValue(guid, out WorldEntity? visibleEntity) + || !ReferenceEquals(entity, visibleEntity)) + { + continue; + } var clientObject = _objects.Get(guid); spawns.TryGetValue(guid, out var spawn); diff --git a/src/AcDream.App/UI/Layout/SpellbookRowStyle.cs b/src/AcDream.App/UI/Layout/SpellbookRowStyle.cs index 7f81c516..668709fa 100644 --- a/src/AcDream.App/UI/Layout/SpellbookRowStyle.cs +++ b/src/AcDream.App/UI/Layout/SpellbookRowStyle.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.Content; using DatReaderWriter; namespace AcDream.App.UI.Layout; @@ -33,7 +34,7 @@ public readonly record struct SpellbookRowStyle( /// Returns null if the authored prototype is incomplete; callers then leave the /// panel unbound rather than inventing replacement art. /// - public static SpellbookRowStyle? TryLoad(DatCollection dats) + public static SpellbookRowStyle? TryLoad(IDatReaderWriter dats) { ArgumentNullException.ThrowIfNull(dats); ElementInfo? prototype = LayoutImporter.ImportInfos(dats, CatalogLayoutId, PrototypeId); diff --git a/src/AcDream.App/UI/RetailDataIdResolver.cs b/src/AcDream.App/UI/RetailDataIdResolver.cs index 5c1be6d7..267ae8a0 100644 --- a/src/AcDream.App/UI/RetailDataIdResolver.cs +++ b/src/AcDream.App/UI/RetailDataIdResolver.cs @@ -1,4 +1,5 @@ using DatReaderWriter; +using AcDream.Content; using DatReaderWriter.DBObjs; namespace AcDream.App.UI; @@ -9,11 +10,11 @@ namespace AcDream.App.UI; /// public static class RetailDataIdResolver { - public static uint Resolve(DatCollection dats, uint enumValue, uint enumCategory) + public static uint Resolve(IDatReaderWriter dats, uint enumValue, uint enumCategory) { ArgumentNullException.ThrowIfNull(dats); - uint masterDid = (uint)dats.Portal.Header.MasterMapId; + uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId; if (masterDid == 0 || !dats.Portal.TryGet(masterDid, out var master) || master is null diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 8d3eb60c..b2ab101e 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -12,6 +12,7 @@ using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Selection; using AcDream.Core.Spells; +using AcDream.Content; using AcDream.UI.Abstractions; using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Settings; @@ -22,7 +23,7 @@ using Silk.NET.Input; namespace AcDream.App.UI; public sealed record RetailUiAssets( - DatCollection Dats, + IDatReaderWriter Dats, object DatLock, Func ResolveSprite, Func ResolveFont, @@ -186,14 +187,15 @@ public sealed class RetailUiRuntime : IDisposable { private readonly RetailUiRuntimeBindings _bindings; private StackSplitQuantityState StackSplitQuantity => _bindings.StackSplitQuantity; - private readonly RetailWindowLayoutPersistence? _persistence; - private readonly RetailUiAutomationScriptRunner? _automation; + private RetailWindowLayoutPersistence? _persistence; + private RetailUiAutomationScriptRunner? _automation; private readonly RetailPanelUiController _panelUi; private GameplayConfirmationController? _gameplayConfirmationController; private RetailItemConfirmationController? _itemConfirmationController; private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController; private UiShortcutDigitGraphics? _shortcutDigitGraphics; private VividTargetIndicatorController? _vividTargetIndicator; + private ResourceShutdownTransaction? _shutdown; private bool _disposed; private RetailUiRuntime(RetailUiRuntimeBindings bindings) @@ -203,6 +205,11 @@ public sealed class RetailUiRuntime : IDisposable bindings.Host.IsWindowVisible, bindings.Host.ShowWindow, bindings.Host.HideWindow); + } + + private void Initialize() + { + RetailUiRuntimeBindings bindings = _bindings; MountFpsDisplay(); MountVividTargetIndicator(); MountVitals(); @@ -284,13 +291,26 @@ public sealed class RetailUiRuntime : IDisposable public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings) { ArgumentNullException.ThrowIfNull(bindings); + var runtime = new RetailUiRuntime(bindings); try { - return new RetailUiRuntime(bindings); + runtime.Initialize(); + return runtime; } - catch + catch (Exception initializationFailure) { - bindings.Host.Dispose(); + try + { + runtime.Dispose(); + } + catch (Exception cleanupFailure) + { + throw new AggregateException( + "Retail UI initialization failed and its partially constructed ownership could not be fully released.", + initializationFailure, + cleanupFailure); + } + throw; } } @@ -1699,14 +1719,57 @@ public sealed class RetailUiRuntime : IDisposable public void Dispose() { - if (_disposed) return; - _disposed = true; - _persistence?.Dispose(); - Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged; - _itemConfirmationController?.Dispose(); - _gameplayConfirmationController?.Dispose(); - DialogFactory?.Dispose(); - _panelUi.Dispose(); - Host.Dispose(); + if (_disposed) + return; + + _shutdown ??= CreateShutdownTransaction( + () => _persistence?.Dispose(), + () => Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged, + () => _itemConfirmationController?.Dispose(), + () => _gameplayConfirmationController?.Dispose(), + () => DialogFactory?.Dispose(), + _panelUi.Dispose, + Host.Dispose); + _shutdown.CompleteOrThrow(); + _disposed = _shutdown.IsComplete; + } + + internal static ResourceShutdownTransaction CreateShutdownTransaction( + Action disposePersistence, + Action unsubscribeWindowVisibility, + Action disposeItemConfirmation, + Action disposeGameplayConfirmation, + Action disposeDialogFactory, + Action disposePanelController, + Action disposeHost) + { + ArgumentNullException.ThrowIfNull(disposePersistence); + ArgumentNullException.ThrowIfNull(unsubscribeWindowVisibility); + ArgumentNullException.ThrowIfNull(disposeItemConfirmation); + ArgumentNullException.ThrowIfNull(disposeGameplayConfirmation); + ArgumentNullException.ThrowIfNull(disposeDialogFactory); + ArgumentNullException.ThrowIfNull(disposePanelController); + ArgumentNullException.ThrowIfNull(disposeHost); + + return new ResourceShutdownTransaction( + new ResourceShutdownStage("retail UI observers", + [ + new("window persistence", disposePersistence), + new("window visibility", unsubscribeWindowVisibility), + ]), + new ResourceShutdownStage("retail UI semantic controllers", + [ + new("item confirmation", disposeItemConfirmation), + new("gameplay confirmation", disposeGameplayConfirmation), + ]), + new ResourceShutdownStage("retail UI panel composition", + [ + new("dialog factory", disposeDialogFactory), + new("panel controller", disposePanelController), + ]), + new ResourceShutdownStage("retained UI host", + [ + new("host", disposeHost), + ])); } } diff --git a/src/AcDream.App/UI/UiDatFont.cs b/src/AcDream.App/UI/UiDatFont.cs index 400ccf0f..1e04c70e 100644 --- a/src/AcDream.App/UI/UiDatFont.cs +++ b/src/AcDream.App/UI/UiDatFont.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering; +using AcDream.Content; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; @@ -89,7 +90,7 @@ public sealed class UiDatFont /// path the D.2b chrome sprites use). Returns null if the Font DBObj is /// missing — callers fall back to the debug bitmap font. /// - public static UiDatFont? Load(DatCollection dats, TextureCache cache, uint fontId = DefaultFontId) + public static UiDatFont? Load(IDatReaderWriter dats, TextureCache cache, uint fontId = DefaultFontId) { ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(cache); diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index b4b5e214..b59b083d 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -171,8 +171,19 @@ public abstract class UiElement /// public bool IsEditControl { get; set; } + private int _zOrder; + /// Painter's-algorithm z-order within siblings. Higher = on top. - public int ZOrder { get; set; } + public int ZOrder + { + get => _zOrder; + set + { + if (_zOrder == value) return; + _zOrder = value; + Parent?.InvalidateChildOrder(); + } + } /// Window opacity (0..1) multiplied into this element's and its /// descendants' background + sprite draws (text stays opaque). 1 = fully opaque. @@ -269,6 +280,8 @@ public abstract class UiElement public UiElement? Parent { get; private set; } private readonly List _children = new(); + private UiElement[]? _childrenBackToFront; + private UiElement[]? _childrenFrontToBack; public IReadOnlyList Children => _children; public virtual void AddChild(UiElement child) @@ -276,6 +289,7 @@ public abstract class UiElement if (child.Parent is not null) child.Parent.RemoveChild(child); child.Parent = this; _children.Add(child); + InvalidateChildOrder(); } public virtual bool RemoveChild(UiElement child) @@ -284,9 +298,52 @@ public abstract class UiElement FindRoot()?.OnSubtreeRemoving(child); _children.Remove(child); child.Parent = null; + InvalidateChildOrder(); return true; } + /// + /// Stable snapshots of the current sibling order. Retained UI mutation is + /// render-thread-owned, so the arrays can be reused until membership or a + /// child's Z-order changes. A traversal keeps its local array if a callback + /// mutates the tree, preserving the prior snapshot semantics without a + /// per-element allocation on every draw and overlay pass. + /// + internal UiElement[] ChildrenBackToFrontSnapshot() + { + if (_childrenBackToFront is not null) + return _childrenBackToFront; + + _childrenBackToFront = _children.ToArray(); + Array.Sort( + _childrenBackToFront, + static (a, b) => a.ZOrder.CompareTo(b.ZOrder)); + return _childrenBackToFront; + } + + internal UiElement[] ChildrenFrontToBackSnapshot() + { + if (_childrenFrontToBack is not null) + return _childrenFrontToBack; + + UiElement[] backToFront = ChildrenBackToFrontSnapshot(); + _childrenFrontToBack = new UiElement[backToFront.Length]; + for (int source = backToFront.Length - 1, destination = 0; + source >= 0; + source--, destination++) + { + _childrenFrontToBack[destination] = backToFront[source]; + } + + return _childrenFrontToBack; + } + + private void InvalidateChildOrder() + { + _childrenBackToFront = null; + _childrenFrontToBack = null; + } + /// /// True if this widget draws its full appearance itself and REPRODUCES its dat /// sub-elements procedurally (3-slice caps, button labels, scroll arrows, popup @@ -405,9 +462,7 @@ public abstract class UiElement ctx.PushClip(0f, 0f, Width, Height); try { - // Avoid LINQ allocation by copying to a temp array and sorting. - var ordered = _children.ToArray(); - Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder)); + UiElement[] ordered = ChildrenBackToFrontSnapshot(); for (int i = 0; i < ordered.Length; i++) ordered[i].DrawSelfAndChildren(ctx); } @@ -449,8 +504,7 @@ public abstract class UiElement ctx.PushClip(0f, 0f, Width, Height); try { - var ordered = _children.ToArray(); - Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder)); + UiElement[] ordered = ChildrenBackToFrontSnapshot(); for (int i = 0; i < ordered.Length; i++) ordered[i].DrawOverlays(ctx); } @@ -496,8 +550,7 @@ public abstract class UiElement // only whether THIS element claims the hit. if (_children.Count > 0) { - var ordered = _children.ToArray(); - Array.Sort(ordered, static (a, b) => b.ZOrder.CompareTo(a.ZOrder)); + UiElement[] ordered = ChildrenFrontToBackSnapshot(); for (int i = 0; i < ordered.Length; i++) { var c = ordered[i]; diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs index 04cc9be6..dcd233c4 100644 --- a/src/AcDream.App/UI/UiHost.cs +++ b/src/AcDream.App/UI/UiHost.cs @@ -50,6 +50,8 @@ public sealed class UiHost : System.IDisposable private long _startTicks = System.Environment.TickCount64; private readonly List _inputUnsubscribers = new(); + private ResourceShutdownTransaction? _shutdown; + private bool _disposeRequested; private bool _disposed; public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null) @@ -81,7 +83,7 @@ public sealed class UiHost : System.IDisposable public void WireMouse(IMouse mouse) { - System.ObjectDisposedException.ThrowIf(_disposed, this); + System.ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this); System.ArgumentNullException.ThrowIfNull(mouse); void OnMouseDown(IMouse sender, MouseButton button) => @@ -107,7 +109,7 @@ public sealed class UiHost : System.IDisposable public void WireKeyboard(IKeyboard kb) { - System.ObjectDisposedException.ThrowIf(_disposed, this); + System.ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this); System.ArgumentNullException.ThrowIfNull(kb); Keyboard = kb; // last wired keyboard wins (one-keyboard desktop) void OnKeyDown(IKeyboard sender, Key key, int scanCode) => Root.OnKeyDown((int)key); @@ -162,13 +164,55 @@ public sealed class UiHost : System.IDisposable public void Dispose() { - if (_disposed) return; - _disposed = true; - for (int i = _inputUnsubscribers.Count - 1; i >= 0; i--) - _inputUnsubscribers[i](); - _inputUnsubscribers.Clear(); - Keyboard = null; - WindowManager.Dispose(); - TextRenderer.Dispose(); + if (_disposed) + return; + + _disposeRequested = true; + _shutdown ??= CreateShutdownTransaction( + _inputUnsubscribers.AsEnumerable().Reverse().ToArray(), + () => + { + _inputUnsubscribers.Clear(); + Keyboard = null; + }, + WindowManager.Dispose, + TextRenderer.Dispose); + _shutdown.CompleteOrThrow(); + _disposed = _shutdown.IsComplete; + } + + internal static ResourceShutdownTransaction CreateShutdownTransaction( + IReadOnlyList inputUnsubscribers, + Action releaseInputState, + Action disposeWindowManager, + Action disposeTextRenderer) + { + ArgumentNullException.ThrowIfNull(inputUnsubscribers); + ArgumentNullException.ThrowIfNull(releaseInputState); + ArgumentNullException.ThrowIfNull(disposeWindowManager); + ArgumentNullException.ThrowIfNull(disposeTextRenderer); + + return new ResourceShutdownTransaction( + new ResourceShutdownStage( + "retained UI input subscriptions", + inputUnsubscribers + .Select((unsubscribe, index) => new ResourceShutdownOperation( + $"input subscription {index}", + unsubscribe ?? throw new ArgumentException( + "Input unsubscriber entries cannot be null.", + nameof(inputUnsubscribers)))) + .ToArray()), + new ResourceShutdownStage("retained UI input state", + [ + new("input state", releaseInputState), + ]), + new ResourceShutdownStage("retained UI windows", + [ + new("window manager", disposeWindowManager), + ]), + new ResourceShutdownStage("retained UI renderer", + [ + new("text renderer", disposeTextRenderer), + ])); } } diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index c1d67887..03b0b726 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -197,7 +197,7 @@ public sealed class UiRoot : UiElement // A listener may synchronously close/remove a window. Snapshot the walk, // then skip children no longer owned by this parent so a deleted subtree // cannot receive a stale pulse and collection mutation cannot invalidate it. - foreach (var child in element.Children.ToArray()) + foreach (var child in element.ChildrenBackToFrontSnapshot()) if (ReferenceEquals(child.Parent, element)) BroadcastGlobalUiTime(child, nowSeconds); } @@ -898,10 +898,7 @@ public sealed class UiRoot : UiElement } // Walk top-level children in reverse Z-order (topmost first). - var kids = new UiElement[Children.Count]; - for (int i = 0; i < Children.Count; i++) kids[i] = Children[i]; - Array.Sort(kids, static (a, b) => b.ZOrder.CompareTo(a.ZOrder)); - foreach (var c in kids) + foreach (var c in ChildrenFrontToBackSnapshot()) { var cp = c.ScreenPosition; var hit = c.HitTest(x - cp.X, y - cp.Y); diff --git a/src/AcDream.App/UI/UiScrollablePanel.cs b/src/AcDream.App/UI/UiScrollablePanel.cs index efc01e30..ea993c13 100644 --- a/src/AcDream.App/UI/UiScrollablePanel.cs +++ b/src/AcDream.App/UI/UiScrollablePanel.cs @@ -45,7 +45,7 @@ public sealed class UiScrollablePanel : UiPanel public void ClearContent() { - foreach (var child in Children.ToArray()) + foreach (var child in ChildrenBackToFrontSnapshot()) RemoveChild(child); _baseTops.Clear(); ContentHeight = 0; diff --git a/src/AcDream.App/World/CompositeLiveEntityResourceLifecycle.cs b/src/AcDream.App/World/CompositeLiveEntityResourceLifecycle.cs new file mode 100644 index 00000000..9f2fe8c8 --- /dev/null +++ b/src/AcDream.App/World/CompositeLiveEntityResourceLifecycle.cs @@ -0,0 +1,163 @@ +using AcDream.Core.World; + +namespace AcDream.App.World; + +/// +/// Symmetric multi-owner live-resource transaction. Each owner's acquisition +/// and release is tracked independently so a late failure cannot replay an +/// earlier script/effect teardown on the next tombstone retry. +/// +internal sealed class CompositeLiveEntityResourceLifecycle : ILiveEntityResourceLifecycle +{ + internal readonly record struct Owner( + Action Register, + Action Unregister); + + private sealed class OwnerState(int count) + { + public bool[] Held { get; } = new bool[count]; + public bool DesiredRegistered { get; set; } + public bool Reconciling { get; set; } + } + + private sealed record ReconcileFailures( + List Registration, + List Release); + + private readonly Owner[] _owners; + private readonly Dictionary _states = + new(ReferenceEqualityComparer.Instance); + + public CompositeLiveEntityResourceLifecycle(params Owner[] owners) + { + ArgumentNullException.ThrowIfNull(owners); + if (owners.Length == 0) + throw new ArgumentException("At least one resource owner is required.", nameof(owners)); + _owners = [.. owners]; + } + + public void Register(WorldEntity entity) + { + ArgumentNullException.ThrowIfNull(entity); + if (!_states.TryGetValue(entity, out OwnerState? state)) + { + state = new OwnerState(_owners.Length); + _states.Add(entity, state); + } + + state.DesiredRegistered = true; + ReconcileFailures? failures = Reconcile(entity, state); + if (failures is null) + return; + + if (failures.Registration.Count == 1 && failures.Release.Count == 0) + System.Runtime.ExceptionServices.ExceptionDispatchInfo + .Capture(failures.Registration[0]) + .Throw(); + + throw new AggregateException( + "Live entity resource registration and owner rollback failed.", + failures.Registration.Concat(failures.Release)); + } + + public void Unregister(WorldEntity entity) + { + ArgumentNullException.ThrowIfNull(entity); + if (!_states.TryGetValue(entity, out OwnerState? state)) + return; + + state.DesiredRegistered = false; + ReconcileFailures? failures = Reconcile(entity, state); + if (failures is not null) + throw new AggregateException( + "One or more live entity resource owners failed to release.", + failures.Registration.Concat(failures.Release)); + } + + private ReconcileFailures? Reconcile(WorldEntity entity, OwnerState state) + { + if (state.Reconciling) + return null; + + state.Reconciling = true; + List? registrationFailures = null; + List? releaseFailures = null; + try + { + while (true) + { + bool directionChanged = false; + if (state.DesiredRegistered) + { + for (int i = 0; i < _owners.Length; i++) + { + if (!state.DesiredRegistered) + { + directionChanged = true; + break; + } + if (state.Held[i]) + continue; + + // Entering Register creates the symmetric rollback + // obligation even when the callback partially commits + // and then throws. + state.Held[i] = true; + try + { + _owners[i].Register(entity); + } + catch (Exception error) + { + (registrationFailures ??= []).Add(error); + state.DesiredRegistered = false; + directionChanged = true; + break; + } + } + } + + if (!state.DesiredRegistered) + { + for (int i = _owners.Length - 1; i >= 0; i--) + { + if (state.DesiredRegistered) + { + directionChanged = true; + break; + } + if (!state.Held[i]) + continue; + try + { + _owners[i].Unregister(entity); + state.Held[i] = false; + } + catch (Exception error) + { + (releaseFailures ??= []).Add(error); + } + } + + if (state.DesiredRegistered) + directionChanged = true; + } + + if (!directionChanged) + break; + } + } + finally + { + state.Reconciling = false; + if (!state.DesiredRegistered && !state.Held.Contains(true)) + _states.Remove(entity); + } + + return registrationFailures is null && releaseFailures is null + ? null + : new ReconcileFailures( + registrationFailures ?? [], + releaseFailures ?? []); + } +} diff --git a/src/AcDream.App/World/LiveEntityIncarnationCleanup.cs b/src/AcDream.App/World/LiveEntityIncarnationCleanup.cs new file mode 100644 index 00000000..6e87b47d --- /dev/null +++ b/src/AcDream.App/World/LiveEntityIncarnationCleanup.cs @@ -0,0 +1,55 @@ +namespace AcDream.App.World; + +/// +/// Protects GUID-keyed runtime state while an older incarnation is draining. +/// A teardown tombstone may outlive the active record and a newer generation +/// may already own the same server GUID; only state captured by reference may +/// be removed unconditionally in that case. +/// +internal sealed class LiveEntityIncarnationCleanup +{ + private readonly LiveEntityRecord _owner; + private readonly Func _resolveCurrent; + + public LiveEntityIncarnationCleanup( + LiveEntityRecord owner, + Func resolveCurrent) + { + _owner = owner ?? throw new ArgumentNullException(nameof(owner)); + _resolveCurrent = resolveCurrent + ?? throw new ArgumentNullException(nameof(resolveCurrent)); + } + + /// + /// Runs a GUID-scoped mutation only while no replacement incarnation owns + /// that GUID. The original record may still be current (projection-only + /// withdrawal) or may already have moved into the teardown ledger. + /// + public void RunIfNoReplacement(Action mutation) + { + ArgumentNullException.ThrowIfNull(mutation); + LiveEntityRecord? current = _resolveCurrent(_owner.ServerGuid); + if (current is null || ReferenceEquals(current, _owner)) + mutation(); + } + + /// + /// Removes a reference-owned dictionary entry only when the entry still + /// points at this incarnation's captured owner. This remains safe even if + /// a replacement has overwritten the same GUID between plan creation and + /// execution. + /// + public void RemoveCaptured( + IDictionary owners, + T captured) + where T : class + { + ArgumentNullException.ThrowIfNull(owners); + ArgumentNullException.ThrowIfNull(captured); + if (owners.TryGetValue(_owner.ServerGuid, out T? current) + && ReferenceEquals(current, captured)) + { + owners.Remove(_owner.ServerGuid); + } + } +} diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index bba60382..53a2c0f2 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -136,6 +136,11 @@ public sealed class LiveEntityRecord internal ulong ProjectionMutationVersion { get; set; } public bool WorldSpawnPublished { get; internal set; } public LiveEntityProjectionKind ProjectionKind { get; internal set; } + internal bool RuntimeComponentsTeardownCompleted { get; set; } + internal LiveEntityTeardownPlan? RuntimeComponentTeardownPlan { get; set; } + internal bool SpatialProjectionTeardownCompleted { get; set; } + internal bool TeardownInProgress { get; set; } + internal bool DeleteAcceptedForTeardown { get; set; } internal bool TryDequeueStateTransition(out RetailPhysicsStateTransition transition) => _pendingStateTransitions.TryDequeue(out transition); @@ -217,12 +222,27 @@ public sealed class LiveEntityRuntime private readonly Action? _tearDownRuntimeComponents; private readonly InboundPhysicsStateController _inbound = new(); private readonly Dictionary _recordsByGuid = new(); + // Records leave the active gameplay map before arbitrary teardown callbacks + // so a newer GUID generation can be accepted re-entrantly. They remain + // canonical teardown tombstones until every resource owner confirms + // unregister; failures are therefore retryable by Delete or session Clear. + private readonly Dictionary<(uint Guid, ushort Generation), LiveEntityRecord> + _teardownRecords = new(); private readonly Dictionary _materializedWorldEntitiesByGuid = new(); private readonly Dictionary _visibleWorldEntitiesByGuid = new(); private readonly Dictionary _guidByLocalId = new(); private readonly Dictionary _animationsByLocalId = new(); private readonly Dictionary _remoteMotionByGuid = new(); + // Logical components survive retail's 25-second leave-visibility lifetime. + // Frame loops must not scan those retained owners. These component-specific + // indexes contain only records with a loaded, non-cellless spatial + // projection. Hidden and Frozen remain members: their state controls what + // each retail update path advances after the O(active) lookup. + private readonly Dictionary _spatialAnimationsByLocalId = new(); + private readonly Dictionary _spatialRemoteMotionByGuid = new(); + private readonly Dictionary _spatialProjectilesByGuid = new(); private bool _isClearing; + private bool _sessionClearPendingFinalization; private bool _isRegisteringResources; private int _logicalTeardownDepth; private ulong _sessionLifetimeVersion; @@ -248,6 +268,7 @@ public sealed class LiveEntityRuntime } public int Count => _recordsByGuid.Count; + public int PendingTeardownCount => _teardownRecords.Count; public int MaterializedCount => _guidByLocalId.Count; public IReadOnlyCollection Records => _recordsByGuid.Values; /// @@ -270,6 +291,12 @@ public sealed class LiveEntityRuntime public IReadOnlyDictionary Snapshots => _inbound.Snapshots; public IReadOnlyDictionary AnimationRuntimes => _animationsByLocalId; public IReadOnlyDictionary RemoteMotionRuntimes => _remoteMotionByGuid; + public IReadOnlyDictionary SpatialAnimationRuntimes => + _spatialAnimationsByLocalId; + public IReadOnlyDictionary SpatialRemoteMotionRuntimes => + _spatialRemoteMotionByGuid; + internal IReadOnlyDictionary SpatialProjectileRuntimes => + _spatialProjectilesByGuid; public ParentAttachmentState ParentAttachments { get; } = new(); /// @@ -280,10 +307,10 @@ public sealed class LiveEntityRuntime public LiveEntityRegistrationResult RegisterLiveEntity(WorldSession.EntitySpawn incoming) { - if (_isClearing || _isRegisteringResources) + if (_isClearing || _sessionClearPendingFinalization || _isRegisteringResources) { throw new InvalidOperationException( - _isClearing + _isClearing || _sessionClearPendingFinalization ? "A live entity cannot register while the session lifetime is clearing." : "A live entity cannot register from inside atomic resource registration."); } @@ -300,9 +327,24 @@ public sealed class LiveEntityRuntime { retained.Snapshot = result.Snapshot; retained.RefreshDerivedState(); + RefreshSpatialRuntimeIndexes(retained); return new LiveEntityRegistrationResult(result, retained, false, false); } + // A failed materialization rollback retains this exact incarnation + // as a teardown tombstone. Do not create a second active record + // with the same (GUID, INSTANCE_TS) while its partial resources are + // still converging; a later retransmit can recover after teardown. + if (_teardownRecords.ContainsKey( + (incoming.Guid, result.Snapshot.InstanceSequence))) + { + return new LiveEntityRegistrationResult( + result, + null, + false, + false); + } + // Defensive repair for a caller that cleared only the logical map. // Normal session teardown clears both owners together. var recovered = new LiveEntityRecord(result.Snapshot); @@ -317,12 +359,14 @@ public sealed class LiveEntityRuntime Exception? tearDownFailure = null; if (old is not null) { + RetainTeardownRecord(old); _logicalTeardownDepth++; try { try { TearDownRecord(old); + ReleaseTeardownRecord(old); } catch (Exception error) { @@ -379,7 +423,10 @@ public sealed class LiveEntityRuntime LiveEntityProjectionKind projectionKind = LiveEntityProjectionKind.World) { ArgumentNullException.ThrowIfNull(factory); - if (_isClearing || _logicalTeardownDepth != 0 || _isRegisteringResources) + if (_isClearing + || _sessionClearPendingFinalization + || _logicalTeardownDepth != 0 + || _isRegisteringResources) { throw new InvalidOperationException( "A live entity cannot materialize inside an active logical-lifetime transition."); @@ -404,34 +451,47 @@ public sealed class LiveEntityRuntime _isRegisteringResources = true; try { + // Registration may span several App owners. Until the whole + // edge returns, Unregister remains a required rollback + // obligation; a failed rollback retains this identity as a + // teardown tombstone instead of making partial owners + // unreachable. + record.ResourcesRegistered = true; try { _resources.Register(entity); - record.ResourcesRegistered = true; } catch (Exception registrationError) { - // Registration is an atomic logical-lifetime boundary. - // Give composite owners a symmetric rollback opportunity, - // then remove identity even if cleanup itself fails. + Exception? rollbackError = null; try { _resources.Unregister(entity); + record.ResourcesRegistered = false; } - catch (Exception rollbackError) + catch (Exception error) { - throw new AggregateException( - "Live entity resource registration and rollback both failed.", - registrationError, - rollbackError); + rollbackError = error; } - finally + + if (rollbackError is null) { _guidByLocalId.Remove(localId); record.WorldEntity = null; - record.ResourcesRegistered = false; + throw; } - throw; + + if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? active) + && ReferenceEquals(active, record)) + { + _recordsByGuid.Remove(serverGuid); + } + RetainTeardownRecord(record); + throw new AggregateException( + "Live entity resource registration and rollback both failed; " + + "the partial owner was retained for teardown retry.", + registrationError, + rollbackError); } } finally @@ -510,6 +570,7 @@ public sealed class LiveEntityRuntime record.CanonicalLandblockId = spatialCellOrLandblockId == 0 ? 0u : (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu; + RefreshSpatialRuntimeIndexes(record); Exception? runtimeNotificationFailure = null; if (!wasProjected || wasVisible != visible) { @@ -570,6 +631,7 @@ public sealed class LiveEntityRuntime _visibleWorldEntitiesByGuid.Remove(serverGuid); record.IsSpatiallyProjected = false; record.IsSpatiallyVisible = false; + RefreshSpatialRuntimeIndexes(record); Exception? runtimeNotificationFailure = null; if (spatialEdgeWasDeferred) { @@ -616,30 +678,53 @@ public sealed class LiveEntityRuntime throw new InvalidOperationException( "A live entity cannot unregister from inside atomic resource registration."); } - if (!_inbound.TryDelete(delete, isLocalPlayer)) - return false; - AdvanceLifetimeMutation(delete.Guid); + var teardownKey = (delete.Guid, delete.InstanceSequence); + _teardownRecords.TryGetValue(teardownKey, out LiveEntityRecord? record); + bool retryingAcceptedDelete = record is not null + && (record.DeleteAcceptedForTeardown + || _isClearing + || _sessionClearPendingFinalization); + if (!retryingAcceptedDelete) + { + if (!_inbound.TryDelete(delete, isLocalPlayer)) + return false; + AdvanceLifetimeMutation(delete.Guid); + ParentAttachments.DeleteGeneration(delete.Guid, delete.InstanceSequence); - ParentAttachments.DeleteGeneration(delete.Guid, delete.InstanceSequence); - - // Remove the accepted incarnation from canonical identity before - // invoking arbitrary callbacks. A callback may synchronously create a - // newer generation with the same server GUID; materialization waits - // until this teardown completes, and cleanup remains scoped to this - // captured record. - _recordsByGuid.Remove(delete.Guid, out LiveEntityRecord? record); + // End active gameplay identity before arbitrary callbacks so a + // newer generation can be accepted re-entrantly, but retain the + // exact incarnation as a teardown tombstone until unregister has + // succeeded. A throwing resource callback can then be retried by + // the same accepted Delete instead of orphaning its owner. + LiveEntityRecord? retainedRegistrationFailure = record; + _recordsByGuid.Remove(delete.Guid, out LiveEntityRecord? activeRecord); + if (activeRecord is not null) + { + record = activeRecord; + RetainTeardownRecord(record); + } + else + { + record = retainedRegistrationFailure; + } + if (record is not null) + record.DeleteAcceptedForTeardown = true; + } List? failures = null; _logicalTeardownDepth++; try { - try + if (!retryingAcceptedDelete) { - beforeTeardown?.Invoke(); - } - catch (Exception error) - { - (failures ??= new List()).Add(error); + try + { + beforeTeardown?.Invoke(); + } + catch (Exception error) + { + (failures ??= new List()).Add(error); + } } if (record is not null) @@ -647,6 +732,7 @@ public sealed class LiveEntityRuntime try { TearDownRecord(record); + ReleaseTeardownRecord(record); } catch (Exception error) { @@ -662,8 +748,10 @@ public sealed class LiveEntityRuntime try { // Persistence is GUID-scoped. End it only when the accepted - // delete left no replacement incarnation behind. - if (!_recordsByGuid.ContainsKey(delete.Guid)) + // delete left no replacement incarnation or unfinished teardown + // behind. + if (!_recordsByGuid.ContainsKey(delete.Guid) + && !HasPendingTeardown(delete.Guid)) { _spatial.ForgetLiveEntity(delete.Guid); } @@ -739,6 +827,7 @@ public sealed class LiveEntityRuntime _animationsByLocalId.Remove(entity.Id); record.AnimationRuntime = runtime; _animationsByLocalId[entity.Id] = runtime; + RefreshSpatialRuntimeIndexes(record); } public bool TryGetAnimationRuntime(uint localEntityId, out ILiveEntityAnimationRuntime runtime) => @@ -752,6 +841,7 @@ public sealed class LiveEntityRuntime if (record.LocalEntityId is { } localId) _animationsByLocalId.Remove(localId); record.AnimationRuntime = null; + RefreshSpatialRuntimeIndexes(record); return true; } @@ -803,6 +893,7 @@ public sealed class LiveEntityRuntime }); } _remoteMotionByGuid[serverGuid] = runtime; + RefreshSpatialRuntimeIndexes(record); } public bool TryGetRemoteMotionRuntime(uint serverGuid, out ILiveEntityRemoteMotionRuntime runtime) => @@ -815,6 +906,7 @@ public sealed class LiveEntityRuntime return false; _remoteMotionByGuid.Remove(serverGuid); record.RemoteMotionRuntime = null; + RefreshSpatialRuntimeIndexes(record); return true; } @@ -838,6 +930,7 @@ public sealed class LiveEntityRuntime record.ProjectileRuntime = runtime; record.PhysicsBody = candidateBody; candidateBody.State = record.FinalPhysicsState; + RefreshSpatialRuntimeIndexes(record); } public bool TryGetProjectileRuntime( @@ -862,6 +955,7 @@ public sealed class LiveEntityRuntime return false; record.ProjectileRuntime = null; + RefreshSpatialRuntimeIndexes(record); return true; } @@ -1049,6 +1143,67 @@ public sealed class LiveEntityRuntime && record.FullCellId != 0 && (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0; + /// + /// Copies the currently spatial remote-motion owners into caller-owned + /// reusable storage. The record reference is the incarnation token: a + /// delete and GUID reuse cannot make an older snapshot address the newer + /// object. + /// + internal void CopySpatialRemoteMotionRecordsTo(List destination) + { + ArgumentNullException.ThrowIfNull(destination); + destination.Clear(); + + foreach ((uint serverGuid, ILiveEntityRemoteMotionRuntime runtime) in _spatialRemoteMotionByGuid) + { + if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + && ReferenceEquals(record.RemoteMotionRuntime, runtime) + && HasSpatialRuntimeProjection(record)) + { + destination.Add(record); + } + } + } + + internal bool IsCurrentSpatialRemoteMotion( + LiveEntityRecord record, + ILiveEntityRemoteMotionRuntime runtime) => + IsCurrentRecord(record) + && ReferenceEquals(record.RemoteMotionRuntime, runtime) + && _spatialRemoteMotionByGuid.TryGetValue(record.ServerGuid, out var indexed) + && ReferenceEquals(indexed, runtime) + && HasSpatialRuntimeProjection(record); + + /// + /// Copies only projectile owners that currently have a loaded world-cell + /// projection. Pending projectiles retain their logical runtime but impose + /// no per-frame scan cost. + /// + internal void CopySpatialProjectileRecordsTo(List destination) + { + ArgumentNullException.ThrowIfNull(destination); + destination.Clear(); + + foreach ((uint serverGuid, ILiveEntityProjectileRuntime runtime) in _spatialProjectilesByGuid) + { + if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + && ReferenceEquals(record.ProjectileRuntime, runtime) + && HasSpatialRuntimeProjection(record)) + { + destination.Add(record); + } + } + } + + internal bool IsCurrentSpatialProjectile( + LiveEntityRecord record, + ILiveEntityProjectileRuntime runtime) => + IsCurrentRecord(record) + && ReferenceEquals(record.ProjectileRuntime, runtime) + && _spatialProjectilesByGuid.TryGetValue(record.ServerGuid, out var indexed) + && ReferenceEquals(indexed, runtime) + && HasSpatialRuntimeProjection(record); + public bool IsHidden(uint serverGuid) => _recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) && (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0; @@ -1081,6 +1236,7 @@ public sealed class LiveEntityRuntime } _isClearing = true; + _sessionClearPendingFinalization = true; _sessionLifetimeVersion++; _lifetimeMutationVersionByGuid.Clear(); List? failures = null; @@ -1088,17 +1244,28 @@ public sealed class LiveEntityRuntime { foreach (LiveEntityRecord record in _recordsByGuid.Values.ToArray()) { - // Remove canonical identity first. Registration is rejected - // during this terminal clear, so callbacks cannot leak an - // owner that is absent from the teardown snapshot. if (!_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) || !ReferenceEquals(current, record)) continue; _recordsByGuid.Remove(record.ServerGuid); + RetainTeardownRecord(record); + } + ParentAttachments.Clear(); + _inbound.Clear(); + + foreach (LiveEntityRecord record in _teardownRecords.Values.ToArray()) + { + // A re-entrant Clear can observe the tombstone currently being + // unregistered by its caller. Leave it queued; the outer + // teardown's ReleaseTeardownRecord finalizes the session once + // it unwinds successfully. + if (record.TeardownInProgress) + continue; try { TearDownRecord(record); + ReleaseTeardownRecord(record); } catch (Exception error) { @@ -1106,15 +1273,7 @@ public sealed class LiveEntityRuntime } } - _recordsByGuid.Clear(); - _materializedWorldEntitiesByGuid.Clear(); - _visibleWorldEntitiesByGuid.Clear(); - _guidByLocalId.Clear(); - _animationsByLocalId.Clear(); - _remoteMotionByGuid.Clear(); - ParentAttachments.Clear(); - _inbound.Clear(); - _spatial.ClearLiveEntityLifetimeState(); + CompleteSessionClearIfConverged(); } finally { @@ -1125,6 +1284,57 @@ public sealed class LiveEntityRuntime throw new AggregateException("One or more live entities failed session teardown.", failures); } + /// + /// Drains teardown tombstones whose first unregister attempt failed. This + /// runs on the update thread after the callback stack that deferred the + /// removal has unwound; successful steps are not replayed. + /// + public int RetryPendingTeardowns() + { + if (_teardownRecords.Count == 0 + || _isClearing + || _isRegisteringResources + || _logicalTeardownDepth != 0) + return 0; + + int completed = 0; + List? failures = null; + foreach (LiveEntityRecord record in _teardownRecords.Values.ToArray()) + { + if (record.TeardownInProgress) + continue; + + _logicalTeardownDepth++; + try + { + try + { + TearDownRecord(record); + ReleaseTeardownRecord(record); + completed++; + if (!_recordsByGuid.ContainsKey(record.ServerGuid) + && !HasPendingTeardown(record.ServerGuid) + && !_inbound.TryGetSnapshot(record.ServerGuid, out _)) + { + _spatial.ForgetLiveEntity(record.ServerGuid); + } + } + catch (Exception error) + { + (failures ??= new List()).Add(error); + } + } + finally + { + _logicalTeardownDepth--; + } + } + + if (failures is not null) + throw new AggregateException("One or more pending live-entity teardowns failed again.", failures); + return completed; + } + private void RefreshRecord( uint guid, WorldSession.EntitySpawn accepted, @@ -1185,6 +1395,84 @@ public sealed class LiveEntityRuntime return; record.FullCellId = 0; record.CanonicalLandblockId = 0; + RefreshSpatialRuntimeIndexes(record); + } + + private bool IsCurrentRecord(LiveEntityRecord record) => + _recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) + && ReferenceEquals(current, record); + + private static bool HasSpatialRuntimeProjection(LiveEntityRecord record) => + record.WorldEntity is not null + && record.IsSpatiallyProjected + && record.IsSpatiallyVisible + && record.FullCellId != 0; + + private void RefreshSpatialRuntimeIndexes(LiveEntityRecord record) + { + bool current = IsCurrentRecord(record); + bool spatial = current && HasSpatialRuntimeProjection(record); + + if (record.WorldEntity is { } entity) + { + if (spatial && record.AnimationRuntime is { } animation) + { + _spatialAnimationsByLocalId[entity.Id] = animation; + } + else if (current + || (record.AnimationRuntime is { } retainedAnimation + && _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexedAnimation) + && ReferenceEquals(indexedAnimation, retainedAnimation))) + { + _spatialAnimationsByLocalId.Remove(entity.Id); + } + } + + if (spatial && record.RemoteMotionRuntime is { } remote) + { + _spatialRemoteMotionByGuid[record.ServerGuid] = remote; + } + else if (current + || (record.RemoteMotionRuntime is { } retainedRemote + && _spatialRemoteMotionByGuid.TryGetValue(record.ServerGuid, out var indexedRemote) + && ReferenceEquals(indexedRemote, retainedRemote))) + { + _spatialRemoteMotionByGuid.Remove(record.ServerGuid); + } + + if (spatial && record.ProjectileRuntime is { } projectile) + { + _spatialProjectilesByGuid[record.ServerGuid] = projectile; + } + else if (current + || (record.ProjectileRuntime is { } retainedProjectile + && _spatialProjectilesByGuid.TryGetValue(record.ServerGuid, out var indexedProjectile) + && ReferenceEquals(indexedProjectile, retainedProjectile))) + { + _spatialProjectilesByGuid.Remove(record.ServerGuid); + } + } + + private void RemoveSpatialRuntimeIndexes(LiveEntityRecord record) + { + if (record.WorldEntity is { } entity + && _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexedAnimation) + && ReferenceEquals(indexedAnimation, record.AnimationRuntime)) + { + _spatialAnimationsByLocalId.Remove(entity.Id); + } + + if (_spatialRemoteMotionByGuid.TryGetValue(record.ServerGuid, out var indexedRemote) + && ReferenceEquals(indexedRemote, record.RemoteMotionRuntime)) + { + _spatialRemoteMotionByGuid.Remove(record.ServerGuid); + } + + if (_spatialProjectilesByGuid.TryGetValue(record.ServerGuid, out var indexedProjectile) + && ReferenceEquals(indexedProjectile, record.ProjectileRuntime)) + { + _spatialProjectilesByGuid.Remove(record.ServerGuid); + } } private uint ReserveLocalEntityId() @@ -1219,6 +1507,7 @@ public sealed class LiveEntityRuntime bool wasVisible = record.IsSpatiallyVisible; record.IsSpatiallyVisible = visible; + RefreshSpatialRuntimeIndexes(record); RefreshPresentation(record); if (_rebucketingGuid != serverGuid && wasVisible != visible) PublishProjectionVisibilityChanged(record, visible); @@ -1267,67 +1556,170 @@ public sealed class LiveEntityRuntime _visibleWorldEntitiesByGuid.Remove(record.ServerGuid); } + private static (uint Guid, ushort Generation) TeardownKey(LiveEntityRecord record) => + (record.ServerGuid, record.Generation); + + private void RetainTeardownRecord(LiveEntityRecord record) + { + var key = TeardownKey(record); + if (_teardownRecords.TryGetValue(key, out LiveEntityRecord? retained) + && !ReferenceEquals(retained, record)) + { + throw new InvalidOperationException( + $"Live entity teardown tombstone collision for 0x{record.ServerGuid:X8} generation {record.Generation}."); + } + _teardownRecords[key] = record; + if (record.WorldEntity is { } entity + && _visibleWorldEntitiesByGuid.TryGetValue( + record.ServerGuid, + out WorldEntity? visible) + && ReferenceEquals(visible, entity)) + { + _visibleWorldEntitiesByGuid.Remove(record.ServerGuid); + } + } + + private void ReleaseTeardownRecord(LiveEntityRecord record) + { + var key = TeardownKey(record); + if (_teardownRecords.TryGetValue(key, out LiveEntityRecord? retained) + && ReferenceEquals(retained, record)) + { + _teardownRecords.Remove(key); + } + CompleteSessionClearIfConverged(); + } + + private bool HasPendingTeardown(uint serverGuid) + { + foreach ((uint Guid, ushort Generation) key in _teardownRecords.Keys) + { + if (key.Guid == serverGuid) + return true; + } + return false; + } + + private void CompleteSessionClearIfConverged() + { + if (!_sessionClearPendingFinalization + || _recordsByGuid.Count != 0 + || _teardownRecords.Count != 0) + { + return; + } + + _materializedWorldEntitiesByGuid.Clear(); + _visibleWorldEntitiesByGuid.Clear(); + _guidByLocalId.Clear(); + _animationsByLocalId.Clear(); + _remoteMotionByGuid.Clear(); + _spatialAnimationsByLocalId.Clear(); + _spatialRemoteMotionByGuid.Clear(); + _spatialProjectilesByGuid.Clear(); + ParentAttachments.Clear(); + _inbound.Clear(); + _spatial.ClearLiveEntityLifetimeState(); + _sessionClearPendingFinalization = false; + } + private void TearDownRecord(LiveEntityRecord record) { + if (record.TeardownInProgress) + throw new InvalidOperationException( + $"Live entity 0x{record.ServerGuid:X8} teardown is already in progress."); + + record.TeardownInProgress = true; List? failures = null; - void RunCleanup(Action cleanup) + bool TryCleanup(Action cleanup) { try { cleanup(); + return true; } catch (Exception error) { (failures ??= new List()).Add(error); + return false; } } - if (_tearDownRuntimeComponents is not null) - RunCleanup(() => _tearDownRuntimeComponents(record)); - - if (record.WorldEntity is { } entity) + try { - RunCleanup(() => _spatial.RemoveLiveEntityProjection(entity)); - if (record.ResourcesRegistered) - RunCleanup(() => _resources.Unregister(entity)); - _guidByLocalId.Remove(entity.Id); - if (_materializedWorldEntitiesByGuid.TryGetValue( - record.ServerGuid, - out WorldEntity? materialized) - && ReferenceEquals(materialized, entity)) - { - _materializedWorldEntitiesByGuid.Remove(record.ServerGuid); - } - if (_visibleWorldEntitiesByGuid.TryGetValue( - record.ServerGuid, - out WorldEntity? visible) - && ReferenceEquals(visible, entity)) - { - _visibleWorldEntitiesByGuid.Remove(record.ServerGuid); - } - _animationsByLocalId.Remove(entity.Id); - } + // Remove frame-workset membership before arbitrary callbacks. The + // logical component dictionaries and WorldEntity remain intact as + // a retryable tombstone until every external cleanup acknowledges + // success. + RemoveSpatialRuntimeIndexes(record); - if (_remoteMotionByGuid.TryGetValue(record.ServerGuid, out var remote) - && ReferenceEquals(remote, record.RemoteMotionRuntime)) + if (!record.RuntimeComponentsTeardownCompleted) + { + record.RuntimeComponentsTeardownCompleted = + _tearDownRuntimeComponents is null + || TryCleanup(() => _tearDownRuntimeComponents(record)); + } + + if (record.WorldEntity is { } entity) + { + if (!record.SpatialProjectionTeardownCompleted) + { + record.SpatialProjectionTeardownCompleted = + TryCleanup(() => _spatial.RemoveLiveEntityProjection(entity)); + } + if (record.ResourcesRegistered + && TryCleanup(() => _resources.Unregister(entity))) + { + record.ResourcesRegistered = false; + } + } + + if (failures is not null) + { + throw new AggregateException( + $"Live entity 0x{record.ServerGuid:X8} teardown failed.", + failures); + } + + if (record.WorldEntity is { } finalizedEntity) + { + _guidByLocalId.Remove(finalizedEntity.Id); + if (_materializedWorldEntitiesByGuid.TryGetValue( + record.ServerGuid, + out WorldEntity? materialized) + && ReferenceEquals(materialized, finalizedEntity)) + { + _materializedWorldEntitiesByGuid.Remove(record.ServerGuid); + } + if (_visibleWorldEntitiesByGuid.TryGetValue( + record.ServerGuid, + out WorldEntity? visible) + && ReferenceEquals(visible, finalizedEntity)) + { + _visibleWorldEntitiesByGuid.Remove(record.ServerGuid); + } + _animationsByLocalId.Remove(finalizedEntity.Id); + } + + if (_remoteMotionByGuid.TryGetValue(record.ServerGuid, out var remote) + && ReferenceEquals(remote, record.RemoteMotionRuntime)) + { + _remoteMotionByGuid.Remove(record.ServerGuid); + } + record.AnimationRuntime = null; + record.RemoteMotionRuntime = null; + record.RequiresRemotePlacementRuntime = false; + record.PhysicsBody = null; + record.ProjectileRuntime = null; + record.EffectProfile = null; + record.IsSpatiallyProjected = false; + record.IsSpatiallyVisible = false; + record.WorldSpawnPublished = false; + record.WorldEntity = null; + } + finally { - _remoteMotionByGuid.Remove(record.ServerGuid); + record.TeardownInProgress = false; } - record.AnimationRuntime = null; - record.RemoteMotionRuntime = null; - record.RequiresRemotePlacementRuntime = false; - record.PhysicsBody = null; - record.ProjectileRuntime = null; - record.EffectProfile = null; - record.ResourcesRegistered = false; - record.IsSpatiallyProjected = false; - record.IsSpatiallyVisible = false; - record.WorldSpawnPublished = false; - record.WorldEntity = null; - - if (failures is not null) - throw new AggregateException( - $"Live entity 0x{record.ServerGuid:X8} teardown failed.", - failures); } } diff --git a/src/AcDream.App/World/LiveEntityRuntimeViews.cs b/src/AcDream.App/World/LiveEntityRuntimeViews.cs index 325b11f3..2aa5f40b 100644 --- a/src/AcDream.App/World/LiveEntityRuntimeViews.cs +++ b/src/AcDream.App/World/LiveEntityRuntimeViews.cs @@ -1,22 +1,23 @@ namespace AcDream.App.World; /// -/// Strongly typed, storage-free view over animation components owned by a -/// . This keeps feature loops type-safe without -/// introducing a second component dictionary. +/// Strongly typed view over animation components owned by a +/// . It owns only reusable traversal scratch; +/// canonical component identity remains in the runtime. /// public sealed class LiveEntityAnimationRuntimeView : IEnumerable> where TAnimation : class, ILiveEntityAnimationRuntime { private readonly Func _runtime; + private readonly List> _iterationSnapshot = new(); public LiveEntityAnimationRuntimeView(Func runtime) => _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); - public int Count => _runtime()?.AnimationRuntimes.Count ?? 0; + public int Count => _runtime()?.SpatialAnimationRuntimes.Count ?? 0; public IEnumerable Keys => - _runtime()?.AnimationRuntimes.Keys ?? Array.Empty(); + _runtime()?.SpatialAnimationRuntimes.Keys ?? Array.Empty(); public TAnimation this[uint localEntityId] { @@ -51,16 +52,79 @@ public sealed class LiveEntityAnimationRuntimeView && runtime.ClearAnimationRuntime(guid); } - public IEnumerator> GetEnumerator() + public Enumerator GetEnumerator() { - if (_runtime() is not { } runtime) - yield break; - foreach (var pair in runtime.AnimationRuntimes) - if (pair.Value is TAnimation typed) - yield return new KeyValuePair(pair.Key, typed); + _iterationSnapshot.Clear(); + LiveEntityRuntime? runtime = _runtime(); + if (runtime is not null) + { + foreach (var pair in runtime.SpatialAnimationRuntimes) + { + if (pair.Value is TAnimation typed) + { + _iterationSnapshot.Add( + new KeyValuePair(pair.Key, typed)); + } + } + } + + return new Enumerator(runtime, _iterationSnapshot); } + IEnumerator> IEnumerable>.GetEnumerator() => + GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + /// Struct enumerator over a reusable frame snapshot. A runtime can rebucket + /// or delete another entity from inside animation/physics callbacks without + /// invalidating this traversal. Each entry is identity-checked immediately + /// before it is yielded, so GUID reuse cannot advance the displaced owner. + /// + public struct Enumerator : IEnumerator> + { + private readonly LiveEntityRuntime? _runtime; + private readonly List> _snapshot; + private int _index; + + internal Enumerator( + LiveEntityRuntime? runtime, + List> snapshot) + { + _runtime = runtime; + _snapshot = snapshot; + _index = -1; + Current = default; + } + + public KeyValuePair Current { get; private set; } + object System.Collections.IEnumerator.Current => Current; + + public bool MoveNext() + { + while (++_index < _snapshot.Count) + { + KeyValuePair pair = _snapshot[_index]; + if (_runtime?.SpatialAnimationRuntimes.TryGetValue( + pair.Key, + out ILiveEntityAnimationRuntime? indexed) == true + && ReferenceEquals(indexed, pair.Value)) + { + Current = pair; + return true; + } + } + + Current = default; + return false; + } + + public void Dispose() { } + + void System.Collections.IEnumerator.Reset() => + throw new NotSupportedException(); + } } /// Storage-free typed view over remote-motion components. diff --git a/src/AcDream.App/World/LiveEntityTeardown.cs b/src/AcDream.App/World/LiveEntityTeardown.cs index 3652a25f..0a9ee95e 100644 --- a/src/AcDream.App/World/LiveEntityTeardown.cs +++ b/src/AcDream.App/World/LiveEntityTeardown.cs @@ -31,3 +31,48 @@ internal static class LiveEntityTeardown failures); } } + +/// +/// A stable teardown plan whose successful steps are committed individually. +/// The plan itself stays on the live-record tombstone, so retrying a later +/// failed owner never replays an earlier ExitWorld notification or release. +/// +internal sealed class LiveEntityTeardownPlan +{ + private readonly Action[] _steps; + private readonly bool[] _completed; + + public LiveEntityTeardownPlan(IEnumerable steps) + { + ArgumentNullException.ThrowIfNull(steps); + _steps = [.. steps]; + _completed = new bool[_steps.Length]; + } + + internal int CompletedCount => _completed.Count(static completed => completed); + internal bool IsComplete => CompletedCount == _steps.Length; + + public void Advance() + { + List? failures = null; + for (int i = 0; i < _steps.Length; i++) + { + if (_completed[i]) + continue; + try + { + _steps[i](); + _completed[i] = true; + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } + + if (failures is not null) + throw new AggregateException( + "One or more live-entity component teardown steps failed.", + failures); + } +} diff --git a/src/AcDream.Content/AcDream.Content.csproj b/src/AcDream.Content/AcDream.Content.csproj index 93c7dbd2..be0e8d9e 100644 --- a/src/AcDream.Content/AcDream.Content.csproj +++ b/src/AcDream.Content/AcDream.Content.csproj @@ -9,6 +9,10 @@ true + + + + diff --git a/src/AcDream.Content/BoundedDatObjectCache.cs b/src/AcDream.Content/BoundedDatObjectCache.cs new file mode 100644 index 00000000..82ad47c7 --- /dev/null +++ b/src/AcDream.Content/BoundedDatObjectCache.cs @@ -0,0 +1,161 @@ +using DatReaderWriter.DBObjs; +using DatReaderWriter.Lib.IO; +using System.Diagnostics.CodeAnalysis; + +namespace AcDream.Content; + +/// +/// Thread-safe LRU for unpacked DAT objects retained by +/// . +/// +/// +/// The underlying remains the sole +/// reader and owner of the DAT databases. Entries here are managed object +/// references only; eviction therefore releases the adapter's reference and +/// never disposes or mutates an object. +/// +/// DatReaderWriter objects do not expose an exact retained-size contract. The +/// byte budget is consequently conservative accounting rather than a hard +/// managed-heap limit: known raw texture payloads are charged exactly, while +/// other object graphs receive a fixed floor. The independent entry ceiling +/// provides the hard bound even when a generated DAT type grows an unmeasured +/// child graph. +/// +internal sealed class BoundedDatObjectCache +{ + internal const int DefaultEntryLimit = 256; + internal const long DefaultEstimatedByteLimit = 64L * 1024L * 1024L; + private const long UnknownObjectEstimate = 128L * 1024L; + + private readonly record struct CacheKey(Type ObjectType, uint FileId); + + private sealed record CacheEntry( + CacheKey Key, + IDBObj Value, + long EstimatedBytes); + + private readonly int _entryLimit; + private readonly long _estimatedByteLimit; + private readonly Func _estimateRetainedBytes; + private readonly Dictionary> _byKey = new(); + private readonly LinkedList _leastRecentlyUsed = new(); + private readonly object _gate = new(); + private long _estimatedBytes; + + internal BoundedDatObjectCache( + int entryLimit = DefaultEntryLimit, + long estimatedByteLimit = DefaultEstimatedByteLimit, + Func? estimateRetainedBytes = null) + { + ArgumentOutOfRangeException.ThrowIfLessThan(entryLimit, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(estimatedByteLimit, 1); + + _entryLimit = entryLimit; + _estimatedByteLimit = estimatedByteLimit; + _estimateRetainedBytes = estimateRetainedBytes ?? EstimateRetainedBytes; + } + + internal int Count + { + get + { + lock (_gate) + return _byKey.Count; + } + } + + internal long EstimatedBytes + { + get + { + lock (_gate) + return _estimatedBytes; + } + } + + internal bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) + where T : IDBObj + { + lock (_gate) + { + if (!_byKey.TryGetValue(new CacheKey(typeof(T), fileId), out var node)) + { + value = default; + return false; + } + + MarkMostRecentlyUsed(node); + value = (T)node.Value.Value; + return true; + } + } + + /// + /// Returns the existing canonical object for a key, or admits + /// and returns it. An object larger than the + /// complete byte budget is served to the caller but is not retained. + /// + internal T GetOrAdd(uint fileId, T candidate) + where T : IDBObj + { + ArgumentNullException.ThrowIfNull(candidate); + + var key = new CacheKey(typeof(T), fileId); + long estimatedBytes = Math.Max(1L, _estimateRetainedBytes(candidate)); + + lock (_gate) + { + if (_byKey.TryGetValue(key, out var existing)) + { + MarkMostRecentlyUsed(existing); + return (T)existing.Value.Value; + } + + if (estimatedBytes > _estimatedByteLimit) + return candidate; + + while (_byKey.Count >= _entryLimit + || _estimatedBytes > _estimatedByteLimit - estimatedBytes) + { + EvictLeastRecentlyUsed(); + } + + var entry = new CacheEntry(key, candidate, estimatedBytes); + var node = _leastRecentlyUsed.AddLast(entry); + _byKey.Add(key, node); + _estimatedBytes += estimatedBytes; + return candidate; + } + } + + private void MarkMostRecentlyUsed(LinkedListNode node) + { + if (!ReferenceEquals(node, _leastRecentlyUsed.Last)) + { + _leastRecentlyUsed.Remove(node); + _leastRecentlyUsed.AddLast(node); + } + } + + private void EvictLeastRecentlyUsed() + { + LinkedListNode? node = _leastRecentlyUsed.First; + if (node is null) + throw new InvalidOperationException("DAT object cache accounting is inconsistent."); + + _leastRecentlyUsed.RemoveFirst(); + _byKey.Remove(node.Value.Key); + _estimatedBytes -= node.Value.EstimatedBytes; + } + + private static long EstimateRetainedBytes(IDBObj value) + { + // RenderSurface is the dominant byte-bearing object on the mesh path. + // Charge its unpacked source payload exactly, plus the conservative + // floor for the generated object and array overhead. + if (value is RenderSurface renderSurface) + return Math.Max(UnknownObjectEstimate, renderSurface.SourceData.LongLength + 256L); + + return UnknownObjectEstimate; + } +} diff --git a/src/AcDream.Content/DatCollectionAdapter.cs b/src/AcDream.Content/DatCollectionAdapter.cs index 6c8b3aa5..c907138c 100644 --- a/src/AcDream.Content/DatCollectionAdapter.cs +++ b/src/AcDream.Content/DatCollectionAdapter.cs @@ -2,7 +2,6 @@ using DatReaderWriter; using DatReaderWriter.Enums; using DatReaderWriter.Lib.IO; using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; @@ -58,9 +57,44 @@ public sealed class DatCollectionAdapter : IDatReaderWriter { public string SourceDirectory => _dats.Options.DatDirectory ?? string.Empty; public IDatDatabase Portal => _portal; + public IDatDatabase Cell => _cell; public ReadOnlyDictionary CellRegions => _cellRegions; public IDatDatabase HighRes => _highRes; public IDatDatabase Language => _language; + public IDatDatabase Local => _language; + + [return: MaybeNull] + public T Get(uint fileId) where T : IDBObj => + TryGet(fileId, out var value) ? value : default; + + public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj { + if (typeof(T) == typeof(DatReaderWriter.DBObjs.Iteration)) { + throw new Exception( + "Iteration is not a valid type to get from a dat file collection since it is used in all dat files. Use a specific dat like datCollection.Portal.Get()"); + } + + switch (_dats.TypeToDatFileType()) { + case DatFileType.Cell: + return _cell.TryGet(fileId, out value); + case DatFileType.Portal: + return _portal.TryGet(fileId, out value) + || _highRes.TryGet(fileId, out value); + case DatFileType.Local: + return _language.TryGet(fileId, out value); + default: + value = default; + return false; + } + } + + public IEnumerable GetAllIdsOfType() where T : IDBObj => + _dats.TypeToDatFileType() switch { + DatFileType.Cell => _cell.GetAllIdsOfType(), + DatFileType.Portal => _portal.GetAllIdsOfType() + .Concat(_highRes.GetAllIdsOfType()), + DatFileType.Local => _language.GetAllIdsOfType(), + _ => Array.Empty(), + }; // RegionFileMap is used by some WB internals but not by any acdream consumer. public ReadOnlyDictionary RegionFileMap => @@ -122,8 +156,11 @@ public sealed class DatCollectionAdapter : IDatReaderWriter { /// public sealed class DatDatabaseWrapper : IDatDatabase { private readonly DatDatabase _db; - private readonly ConcurrentDictionary<(Type, uint), IDBObj> _cache = new(); - private readonly object _lock = new(); + // One cache per database: a DatCollectionAdapter therefore retains at + // most 4 * 256 decoded entries and 4 * 64 MiB of estimated payload. The + // cache itself documents why estimated bytes are not a hard heap bound. + private readonly BoundedDatObjectCache _cache = new(); + private readonly object _databaseLock = new(); public DatDatabaseWrapper(DatDatabase db) { ArgumentNullException.ThrowIfNull(db); @@ -140,14 +177,19 @@ public sealed class DatDatabaseWrapper : IDatDatabase { _db.GetAllIdsOfType(); public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj { - if (_cache.TryGetValue((typeof(T), fileId), out var cached)) { - value = (T)cached; + if (_cache.TryGet(fileId, out value)) { return true; } - lock (_lock) { + lock (_databaseLock) { + // A different reader may have populated the cache while this + // caller waited for the serialized DatDatabase read path. + if (_cache.TryGet(fileId, out value)) { + return true; + } + if (_db.TryGet(fileId, out value)) { - _cache.TryAdd((typeof(T), fileId), value); + value = _cache.GetOrAdd(fileId, value); return true; } @@ -168,13 +210,13 @@ public sealed class DatDatabaseWrapper : IDatDatabase { } public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) { - lock (_lock) { + lock (_databaseLock) { return _db.TryGetFileBytes(fileId, out value); } } public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead) { - lock (_lock) { + lock (_databaseLock) { return _db.TryGetFileBytes(fileId, ref bytes, out bytesRead); } } diff --git a/src/AcDream.Content/DecodedTextureCache.cs b/src/AcDream.Content/DecodedTextureCache.cs new file mode 100644 index 00000000..ba4bf279 --- /dev/null +++ b/src/AcDream.Content/DecodedTextureCache.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Collections.Concurrent; +using System.Threading; + +namespace AcDream.Content; + +/// +/// Thread-safe, byte-bounded residency for canonical decoded texture pixels. +/// Mesh extraction may run on several workers, so cached arrays are immutable +/// after admission and callers clone them before applying surface-local alpha. +/// +internal sealed class DecodedTextureCache { + private sealed record Entry(DecodedTextureKey Key, byte[] Pixels); + + private readonly object _gate = new(); + private readonly Dictionary> _entries = new(); + private readonly LinkedList _lru = new(); + private readonly ConcurrentDictionary> _inflight = new(); + private readonly long _maximumBytes; + private readonly int _maximumEntries; + private long _residentBytes; + + public DecodedTextureCache(long maximumBytes, int maximumEntries) { + ArgumentOutOfRangeException.ThrowIfNegative(maximumBytes); + ArgumentOutOfRangeException.ThrowIfNegative(maximumEntries); + _maximumBytes = maximumBytes; + _maximumEntries = maximumEntries; + } + + public int Count { + get { + lock (_gate) { + return _entries.Count; + } + } + } + + public long ResidentBytes { + get { + lock (_gate) { + return _residentBytes; + } + } + } + + public bool TryGet(DecodedTextureKey key, out byte[] pixels) { + lock (_gate) { + if (!_entries.TryGetValue(key, out var node)) { + pixels = null!; + return false; + } + + Touch(node); + pixels = node.Value.Pixels; + return true; + } + } + + /// + /// Returns the canonical pixels for . If another + /// worker admitted the same key first, its array wins. + /// reports whether the returned array is retained and therefore immutable. + /// + public byte[] RetainOrUse(DecodedTextureKey key, byte[] pixels, out bool isCached) { + ArgumentNullException.ThrowIfNull(pixels); + + lock (_gate) { + if (_entries.TryGetValue(key, out var existing)) { + Touch(existing); + isCached = true; + return existing.Value.Pixels; + } + + if (_maximumEntries == 0 || pixels.LongLength > _maximumBytes) { + isCached = false; + return pixels; + } + + while (_lru.First is { } oldest + && (_entries.Count >= _maximumEntries + || _residentBytes + pixels.LongLength > _maximumBytes)) { + Remove(oldest); + } + + var node = _lru.AddLast(new Entry(key, pixels)); + _entries.Add(key, node); + _residentBytes += pixels.LongLength; + isCached = true; + return pixels; + } + } + + /// + /// Decode a missing key once across concurrent mesh workers. The expensive + /// factory runs outside the LRU lock and unrelated keys proceed in parallel. + /// A failed or oversized decode is not stranded as permanent in-flight state. + /// + public byte[] GetOrCreate( + DecodedTextureKey key, + Func factory, + out bool isCached) { + ArgumentNullException.ThrowIfNull(factory); + if (TryGet(key, out byte[] cached)) { + isCached = true; + return cached; + } + + var candidate = new Lazy( + factory, + LazyThreadSafetyMode.ExecutionAndPublication); + Lazy shared = _inflight.GetOrAdd(key, candidate); + try { + byte[] decoded = shared.Value; + return RetainOrUse(key, decoded, out isCached); + } + finally { + _inflight.TryRemove( + new KeyValuePair>(key, shared)); + } + } + + private void Touch(LinkedListNode node) { + if (node != _lru.Last) { + _lru.Remove(node); + _lru.AddLast(node); + } + } + + private void Remove(LinkedListNode node) { + _lru.Remove(node); + _entries.Remove(node.Value.Key); + _residentBytes -= node.Value.Pixels.LongLength; + } +} + +/// +/// Every input that can change decoded RGBA belongs in the cache identity. +/// INDEX16/P8 clip-map conversion and A8 additive conversion are surface +/// dependent even when they reference the same RenderSurface DID. +/// +internal readonly record struct DecodedTextureKey( + uint RenderSurfaceId, + bool IsClipMap, + bool IsAdditive); diff --git a/src/AcDream.Content/IDatReaderWriter.cs b/src/AcDream.Content/IDatReaderWriter.cs index 2ab4afda..a18efe0f 100644 --- a/src/AcDream.Content/IDatReaderWriter.cs +++ b/src/AcDream.Content/IDatReaderWriter.cs @@ -1,6 +1,7 @@ using DatReaderWriter; using DatReaderWriter.Enums; using DatReaderWriter.Lib.IO; +using AcDream.Core.Content; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; @@ -20,7 +21,7 @@ namespace AcDream.Content; /// /// Interface for the dat reader/writer /// -public interface IDatReaderWriter : IDisposable { +public interface IDatReaderWriter : IDisposable, IDatObjectSource { /// /// Gets the source directory of the DAT files. /// @@ -36,6 +37,9 @@ public interface IDatReaderWriter : IDisposable { /// IDatDatabase Portal { get; } + /// The single cell database exposed by DatCollection. + IDatDatabase Cell { get; } + /// /// The cell region databases. Each key is a cell region ID /// @@ -51,6 +55,12 @@ public interface IDatReaderWriter : IDisposable { /// IDatDatabase Language { get; } + /// Alias matching DatCollection's Local database name. + IDatDatabase Local { get; } + + /// Enumerates typed ids across the database selected for the type. + IEnumerable GetAllIdsOfType() where T : IDBObj; + /// /// A mapping of region ids to region dat file entry ids. key: region id, value: region dat file entry /// diff --git a/src/AcDream.Content/MeshExtractor.cs b/src/AcDream.Content/MeshExtractor.cs index df17f97c..594e815d 100644 --- a/src/AcDream.Content/MeshExtractor.cs +++ b/src/AcDream.Content/MeshExtractor.cs @@ -10,7 +10,6 @@ using DatReaderWriter.Enums; using DatReaderWriter.Types; using Microsoft.Extensions.Logging; using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Numerics; @@ -34,10 +33,11 @@ public sealed class MeshExtractor { private readonly ILogger _logger; private readonly RetailPhysicsScriptLoader _physicsScripts; - // Cache for decoded textures to avoid redundant BCn decoding - private readonly ConcurrentQueue _decodedTextureLru = new(); - private readonly ConcurrentDictionary _decodedTextureCache = new(); - private const int MaxDecodedTextures = 128; + // Canonical decoded pixels are immutable and bounded by bytes as well as + // count. A count-only cache can retain hundreds of MiB of large RGBA data. + private readonly DecodedTextureCache _decodedTextureCache = new( + maximumBytes: 64L * 1024 * 1024, + maximumEntries: 128); private readonly ThreadLocal _bcDecoder = new(() => new BcDecoder()); /// @@ -370,6 +370,7 @@ public sealed class MeshExtractor { bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); uint paletteId = 0; bool isDxt3or5 = false; + bool textureDataIsCached = false; DatReaderWriter.Enums.PixelFormat? sourceFormat = null; var isAdditive = false; var isTransparent = false; @@ -395,37 +396,33 @@ public sealed class MeshExtractor { texHeight = renderSurface.Height; paletteId = renderSurface.DefaultPaletteId; sourceFormat = renderSurface.Format; + isDxt3or5 = renderSurface.Format is + DatReaderWriter.Enums.PixelFormat.PFID_DXT3 or + DatReaderWriter.Enums.PixelFormat.PFID_DXT5; + var decodedTextureKey = CreateDecodedTextureKey( + renderSurfaceId, + renderSurface.Format, + isClipMap, + surface.Type.HasFlag(SurfaceType.Additive)); if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) { isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5; textureFormat = TextureFormat.RGBA8; uploadPixelFormat = UploadPixelFormat.Rgba; - if (_decodedTextureCache.TryGetValue(renderSurfaceId, out textureData!)) { - // use cached data - } - else { - textureData = new byte[texWidth * texHeight * 4]; - - CompressionFormat compressionFormat = renderSurface.Format switch { - DatReaderWriter.Enums.PixelFormat.PFID_DXT1 => CompressionFormat.Bc1, - DatReaderWriter.Enums.PixelFormat.PFID_DXT3 => CompressionFormat.Bc2, - DatReaderWriter.Enums.PixelFormat.PFID_DXT5 => CompressionFormat.Bc3, - _ => throw new NotSupportedException($"Unsupported compressed format: {renderSurface.Format}") - }; - - using (var image = _bcDecoder.Value!.DecodeRawToImageRgba32(renderSurface.SourceData, texWidth, texHeight, compressionFormat)) { - image.CopyPixelDataTo(textureData); - } - _decodedTextureCache.TryAdd(renderSurfaceId, textureData); - } + textureData = _decodedTextureCache.GetOrCreate( + decodedTextureKey, + () => DecodeCompressedTexture(renderSurface, texWidth, texHeight), + out textureDataIsCached); if (isClipMap && textureData != null) { - // If we got this from the cache, we need to clone it so we don't scale the cached raw data - if (_decodedTextureCache.ContainsKey(renderSurfaceId)) { + // Cached pixels are canonical and immutable. Surface-local + // alpha processing must never modify the shared array. + if (textureDataIsCached) { var clonedData = new byte[textureData.Length]; System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length); textureData = clonedData; + textureDataIsCached = false; } for (int i = 0; i < textureData.Length; i += 4) { @@ -437,8 +434,16 @@ public sealed class MeshExtractor { } else { textureFormat = TextureFormat.RGBA8; - textureData = renderSurface.SourceData; - switch (renderSurface.Format) { + uploadPixelFormat = UploadPixelFormat.Rgba; + if (_decodedTextureCache.TryGet( + decodedTextureKey, + out byte[] cachedPixels)) { + textureData = cachedPixels; + textureDataIsCached = true; + } + else { + textureData = renderSurface.SourceData; + switch (renderSurface.Format) { case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8: textureData = new byte[texWidth * texHeight * 4]; TextureHelpers.FillA8R8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); @@ -486,12 +491,18 @@ public sealed class MeshExtractor { break; default: throw new NotSupportedException($"Unsupported surface format: {renderSurface.Format}"); + } + + textureData = _decodedTextureCache.RetainOrUse( + decodedTextureKey, + textureData, + out textureDataIsCached); } } if (surface.Translucency > 0.0f && textureData != null) { // If we got this from the cache, we need to clone it so we don't scale the cached raw data - if (sourceFormat.HasValue && TextureHelpers.IsCompressedFormat(sourceFormat.Value) && _decodedTextureCache.ContainsKey(renderSurfaceId)) { + if (textureDataIsCached) { var clonedData = new byte[textureData.Length]; System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length); textureData = clonedData; @@ -749,8 +760,16 @@ public sealed class MeshExtractor { texHeight = renderSurface.Height; paletteId = renderSurface.DefaultPaletteId; sourceFormat = renderSurface.Format; + isDxt3or5 = renderSurface.Format is + DatReaderWriter.Enums.PixelFormat.PFID_DXT3 or + DatReaderWriter.Enums.PixelFormat.PFID_DXT5; + var decodedTextureKey = CreateDecodedTextureKey( + renderSurfaceId, + renderSurface.Format, + isClipMap, + surface.Type.HasFlag(SurfaceType.Additive)); - if (_decodedTextureCache.TryGetValue(renderSurfaceId, out var cachedData)) { + if (_decodedTextureCache.TryGet(decodedTextureKey, out var cachedData)) { textureData = cachedData; textureFormat = TextureFormat.RGBA8; uploadPixelFormat = UploadPixelFormat.Rgba; @@ -761,17 +780,10 @@ public sealed class MeshExtractor { textureFormat = TextureFormat.RGBA8; uploadPixelFormat = UploadPixelFormat.Rgba; - textureData = new byte[texWidth * texHeight * 4]; - CompressionFormat compressionFormat = renderSurface.Format switch { - DatReaderWriter.Enums.PixelFormat.PFID_DXT1 => CompressionFormat.Bc1, - DatReaderWriter.Enums.PixelFormat.PFID_DXT3 => CompressionFormat.Bc2, - DatReaderWriter.Enums.PixelFormat.PFID_DXT5 => CompressionFormat.Bc3, - _ => throw new NotSupportedException($"Unsupported compressed format: {renderSurface.Format}") - }; - - using (var image = _bcDecoder.Value!.DecodeRawToImageRgba32(renderSurface.SourceData, texWidth, texHeight, compressionFormat)) { - image.CopyPixelDataTo(textureData); - } + textureData = _decodedTextureCache.GetOrCreate( + decodedTextureKey, + () => DecodeCompressedTexture(renderSurface, texWidth, texHeight), + out _); } else { textureFormat = TextureFormat.RGBA8; @@ -824,14 +836,12 @@ public sealed class MeshExtractor { } } - // Add to cache with LRU logic - if (textureData != null && _decodedTextureCache.TryAdd(renderSurfaceId, textureData)) { - _decodedTextureLru.Enqueue(renderSurfaceId); - if (_decodedTextureCache.Count > MaxDecodedTextures) { - if (_decodedTextureLru.TryDequeue(out var evictedId)) { - _decodedTextureCache.TryRemove(evictedId, out _); - } - } + if (!TextureHelpers.IsCompressedFormat(renderSurface.Format)) + { + textureData = _decodedTextureCache.RetainOrUse( + decodedTextureKey, + textureData, + out _); } } @@ -996,6 +1006,48 @@ public sealed class MeshExtractor { } } + private static DecodedTextureKey CreateDecodedTextureKey( + uint renderSurfaceId, + DatReaderWriter.Enums.PixelFormat format, + bool isClipMap, + bool isAdditive) + { + bool clipAffectsDecode = format is + DatReaderWriter.Enums.PixelFormat.PFID_INDEX16 or + DatReaderWriter.Enums.PixelFormat.PFID_P8; + bool additiveAffectsDecode = format is + DatReaderWriter.Enums.PixelFormat.PFID_A8 or + DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA; + return new DecodedTextureKey( + renderSurfaceId, + clipAffectsDecode && isClipMap, + additiveAffectsDecode && isAdditive); + } + + private byte[] DecodeCompressedTexture( + RenderSurface renderSurface, + int width, + int height) + { + var textureData = new byte[width * height * 4]; + CompressionFormat compressionFormat = renderSurface.Format switch + { + DatReaderWriter.Enums.PixelFormat.PFID_DXT1 => CompressionFormat.Bc1, + DatReaderWriter.Enums.PixelFormat.PFID_DXT3 => CompressionFormat.Bc2, + DatReaderWriter.Enums.PixelFormat.PFID_DXT5 => CompressionFormat.Bc3, + _ => throw new NotSupportedException( + $"Unsupported compressed format: {renderSurface.Format}"), + }; + + using var image = _bcDecoder.Value!.DecodeRawToImageRgba32( + renderSurface.SourceData, + width, + height, + compressionFormat); + image.CopyPixelDataTo(textureData); + return textureData; + } + private void BuildPolygonIndices(Polygon poly, GfxObj gfxObj, Vector3 scale, Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort> UVLookup, List vertices, List indices, bool useNegSurface, ref bool hasWrappingUVs) { diff --git a/src/AcDream.Content/ObjectMeshData.cs b/src/AcDream.Content/ObjectMeshData.cs index b5f7d5b0..8622108f 100644 --- a/src/AcDream.Content/ObjectMeshData.cs +++ b/src/AcDream.Content/ObjectMeshData.cs @@ -42,6 +42,7 @@ public struct StagedEmitter { /// Contains vertex data and per-batch index/texture info, but NO GPU resources. /// public class ObjectMeshData { + private long _estimatedUploadBytes = -1; public ulong ObjectId { get; set; } public bool IsSetup { get; set; } public VertexPositionNormalTexture[] Vertices { get; set; } = Array.Empty(); @@ -92,6 +93,36 @@ public class ObjectMeshData { /// Edge line vertices for Environment wireframe rendering. public Vector3[] EdgeLines { get; set; } = Array.Empty(); + + /// + /// Returns the immutable prepared payload's CPU-to-GPU work estimate. + /// Mesh extraction finishes populating the object before publishing it to + /// App, so this value can be cached once and reused by the CPU cache, + /// staging queue, retry path, and frame-budget planner without repeatedly + /// walking every material batch under their locks. + /// + public long GetEstimatedUploadBytes() + { + long cached = System.Threading.Volatile.Read(ref _estimatedUploadBytes); + if (cached >= 0) + return cached; + + long bytes = IsSetup ? 1024L : 0L; + bytes = checked(bytes + (long)Vertices.Length * VertexPositionNormalTexture.Size); + foreach (List batches in TextureBatches.Values) + { + foreach (TextureBatchData batch in batches) + { + bytes = checked(bytes + batch.TextureData.LongLength); + bytes = checked(bytes + (long)batch.Indices.Count * sizeof(ushort)); + } + } + if (EnvCellGeometry is not null) + bytes = checked(bytes + EnvCellGeometry.GetEstimatedUploadBytes()); + + System.Threading.Interlocked.CompareExchange(ref _estimatedUploadBytes, bytes, -1); + return System.Threading.Volatile.Read(ref _estimatedUploadBytes); + } } /// diff --git a/src/AcDream.Content/Vfx/RetailAnimationLoader.cs b/src/AcDream.Content/Vfx/RetailAnimationLoader.cs index 138b6cee..f214ef64 100644 --- a/src/AcDream.Content/Vfx/RetailAnimationLoader.cs +++ b/src/AcDream.Content/Vfx/RetailAnimationLoader.cs @@ -22,10 +22,10 @@ public sealed class RetailAnimationLoader : IAnimationLoader private readonly DatDatabase? _database; private readonly ConcurrentDictionary> _cache = new(); - public RetailAnimationLoader(DatCollection dats) + public RetailAnimationLoader(IDatReaderWriter dats) { ArgumentNullException.ThrowIfNull(dats); - _database = dats.Portal; + _database = dats.Portal.Db; _readRaw = id => dats.Portal.TryGetFileBytes(id, out byte[]? bytes) ? bytes : null; diff --git a/src/AcDream.Content/Vfx/RetailPhysicsScriptLoader.cs b/src/AcDream.Content/Vfx/RetailPhysicsScriptLoader.cs index 0adc32dd..37b65f48 100644 --- a/src/AcDream.Content/Vfx/RetailPhysicsScriptLoader.cs +++ b/src/AcDream.Content/Vfx/RetailPhysicsScriptLoader.cs @@ -21,10 +21,10 @@ public sealed class RetailPhysicsScriptLoader private readonly DatDatabase? _database; private readonly ConcurrentDictionary> _cache = new(); - public RetailPhysicsScriptLoader(DatCollection dats) + public RetailPhysicsScriptLoader(IDatReaderWriter dats) { ArgumentNullException.ThrowIfNull(dats); - _database = dats.Portal; + _database = dats.Portal.Db; _readRaw = id => dats.Portal.TryGetFileBytes(id, out byte[]? bytes) ? bytes : null; diff --git a/src/AcDream.Core/Audio/DatSoundCache.cs b/src/AcDream.Core/Audio/DatSoundCache.cs index 8e25ed24..92015f6e 100644 --- a/src/AcDream.Core/Audio/DatSoundCache.cs +++ b/src/AcDream.Core/Audio/DatSoundCache.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using DatReaderWriter; using DatReaderWriter.DBObjs; +using AcDream.Core.Content; namespace AcDream.Core.Audio; @@ -22,11 +23,11 @@ namespace AcDream.Core.Audio; /// public sealed class DatSoundCache { - private readonly DatCollection _dats; + private readonly IDatObjectSource _dats; private readonly ConcurrentDictionary _waves = new(); private readonly ConcurrentDictionary _tables = new(); - public DatSoundCache(DatCollection dats) + public DatSoundCache(IDatObjectSource dats) { _dats = dats; } diff --git a/src/AcDream.Core/Chat/ChatLog.cs b/src/AcDream.Core/Chat/ChatLog.cs index e6294895..aeedcc31 100644 --- a/src/AcDream.Core/Chat/ChatLog.cs +++ b/src/AcDream.Core/Chat/ChatLog.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.Concurrent; +using System.Threading; namespace AcDream.Core.Chat; @@ -25,6 +26,7 @@ public sealed class ChatLog private readonly ConcurrentQueue _buffer = new(); private readonly int _maxEntries; private uint _localPlayerGuid; + private long _revision; // Phase J follow-up: ACE often sends the same system text via two // wire paths (GameMessageSystemChat 0xF7E0 + GameEventCommunication- @@ -51,6 +53,13 @@ public sealed class ChatLog public int Count => _buffer.Count; + /// + /// Monotonic content revision. It advances after every successful append and + /// every explicit clear, allowing retained UI consumers to cache formatted + /// transcript layout without snapshotting the concurrent queue every frame. + /// + public long Revision => Interlocked.Read(ref _revision); + /// /// Push the authoritative local-player GUID from WorldSession. /// One-way setter — only GameWindow should call it, exactly @@ -301,12 +310,14 @@ public sealed class ChatLog _buffer.Enqueue(entry); while (_buffer.Count > _maxEntries) _buffer.TryDequeue(out _); + Interlocked.Increment(ref _revision); EntryAppended?.Invoke(entry); } public void Clear() { while (_buffer.TryDequeue(out _)) { /* drain */ } + Interlocked.Increment(ref _revision); } } diff --git a/src/AcDream.Core/Content/IDatObjectSource.cs b/src/AcDream.Core/Content/IDatObjectSource.cs new file mode 100644 index 00000000..de290233 --- /dev/null +++ b/src/AcDream.Core/Content/IDatObjectSource.cs @@ -0,0 +1,21 @@ +using DatReaderWriter.Lib.IO; +using System.Diagnostics.CodeAnalysis; + +namespace AcDream.Core.Content; + +/// +/// Minimal typed DAT-object lookup seam used by Core algorithms. +/// +/// +/// Core deliberately does not own DAT file handles or caching policy. The App +/// runtime supplies the bounded Content-layer implementation; tests and +/// short-lived tools may adapt another source explicitly. +/// +public interface IDatObjectSource +{ + [return: MaybeNull] + T Get(uint fileId) where T : IDBObj; + + bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) + where T : IDBObj; +} diff --git a/src/AcDream.Core/Meshing/CellMesh.cs b/src/AcDream.Core/Meshing/CellMesh.cs index 02f48995..810863a9 100644 --- a/src/AcDream.Core/Meshing/CellMesh.cs +++ b/src/AcDream.Core/Meshing/CellMesh.cs @@ -2,6 +2,7 @@ using System.Numerics; using AcDream.Core.Terrain; using DatReaderWriter; using DatReaderWriter.DBObjs; +using AcDream.Core.Content; using DatReaderWriter.Types; namespace AcDream.Core.Meshing; @@ -28,7 +29,7 @@ public static class CellMesh /// . When null (e.g. offline tests) /// all sub-meshes default to . /// - public static IReadOnlyList Build(EnvCell envCell, CellStruct cellStruct, DatCollection? dats = null) + public static IReadOnlyList Build(EnvCell envCell, CellStruct cellStruct, IDatObjectSource? dats = null) { // Group output vertices and indices per surface dat id. var perSurface = new Dictionary Vertices, List Indices, Dictionary<(int pos, int uv), uint> Dedupe)>(); diff --git a/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs b/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs index c9c2bedd..f41edc57 100644 --- a/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs +++ b/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs @@ -1,5 +1,6 @@ using DatReaderWriter; using DatReaderWriter.DBObjs; +using AcDream.Core.Content; using DatReaderWriter.Enums; namespace AcDream.Core.Meshing; @@ -66,7 +67,7 @@ public static class GfxObjDegradeResolver /// this; tests use the callback overload below for easy fakes. /// public static bool TryResolveCloseGfxObj( - DatCollection dats, + IDatObjectSource dats, uint gfxObjId, out uint resolvedId, out GfxObj? resolvedGfxObj) @@ -156,7 +157,7 @@ public static class GfxObjDegradeResolver /// slot1 Id=0 MaxDist=FLT_MAX}). Callers that hydrate static geometry (always viewed at /// distance > 0) skip such GfxObjs. /// - public static bool IsRuntimeHiddenMarker(DatCollection dats, uint gfxObjId) + public static bool IsRuntimeHiddenMarker(IDatObjectSource dats, uint gfxObjId) => IsRuntimeHiddenMarker( id => dats.Get(id), id => dats.Get(id), diff --git a/src/AcDream.Core/Meshing/GfxObjMesh.cs b/src/AcDream.Core/Meshing/GfxObjMesh.cs index 24ed7a56..3b5d2f0d 100644 --- a/src/AcDream.Core/Meshing/GfxObjMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjMesh.cs @@ -2,6 +2,7 @@ using System.Numerics; using AcDream.Core.Terrain; using DatReaderWriter; using DatReaderWriter.DBObjs; +using AcDream.Core.Content; using DatReaderWriter.Enums; namespace AcDream.Core.Meshing; @@ -50,7 +51,7 @@ public static class GfxObjMesh /// normals (and potentially different UVs via NegUVIndices). /// /// - public static IReadOnlyList Build(GfxObj gfxObj, DatCollection? dats = null) + public static IReadOnlyList Build(GfxObj gfxObj, IDatObjectSource? dats = null) { // One bucket per (surface-index, isNeg) pair. Negative-side triangles // always land in a different bucket than their positive counterparts diff --git a/src/AcDream.Core/Meshing/MotionResolver.cs b/src/AcDream.Core/Meshing/MotionResolver.cs index c1150974..2fccf86c 100644 --- a/src/AcDream.Core/Meshing/MotionResolver.cs +++ b/src/AcDream.Core/Meshing/MotionResolver.cs @@ -2,6 +2,7 @@ using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; using AcDream.Core.Physics; +using AcDream.Core.Content; namespace AcDream.Core.Meshing; @@ -75,7 +76,7 @@ public static class MotionResolver /// public static IdleCycle? GetIdleCycle( Setup setup, - DatCollection dats, + IDatObjectSource dats, IAnimationLoader animationLoader, uint? motionTableIdOverride = null, ushort? stanceOverride = null, @@ -123,7 +124,7 @@ public static class MotionResolver public static AnimationFrame? GetIdleFrame( Setup setup, - DatCollection dats, + IDatObjectSource dats, IAnimationLoader animationLoader, uint? motionTableIdOverride = null, ushort? stanceOverride = null, @@ -148,7 +149,7 @@ public static class MotionResolver /// private static (Animation, AnimData)? ResolveIdleCycleInternal( Setup setup, - DatCollection dats, + IDatObjectSource dats, IAnimationLoader animationLoader, uint? motionTableIdOverride, ushort? stanceOverride, diff --git a/src/AcDream.Core/Plugins/WorldEvents.cs b/src/AcDream.Core/Plugins/WorldEvents.cs index 086810fa..a4cc02f3 100644 --- a/src/AcDream.Core/Plugins/WorldEvents.cs +++ b/src/AcDream.Core/Plugins/WorldEvents.cs @@ -6,8 +6,20 @@ namespace AcDream.Core.Plugins; public sealed class WorldEvents : IEvents { private readonly object _lock = new(); - private readonly List _alreadySpawned = new(); - private Action? _subscribers; + // Late subscribers replay the current projected world, not every entity + // encountered since process start. Spawn notifications themselves remain + // ephemeral; the public plugin API does not expose a despawn event yet. + private readonly Dictionary _current = new(); + private readonly List _subscriptions = new(); + private Subscription[] _liveSnapshot = Array.Empty(); + + private sealed class Subscription(Action handler) + { + public Action Handler { get; } = handler; + public Queue Pending { get; } = new(); + public bool Replaying { get; set; } = true; + public bool Active { get; set; } = true; + } /// /// Called by the host as each entity is hydrated into the world. Records the @@ -15,42 +27,155 @@ public sealed class WorldEvents : IEvents /// public void FireEntitySpawned(WorldEntitySnapshot snapshot) { - Action? toNotify; + Subscription[] toNotify; lock (_lock) { - _alreadySpawned.Add(snapshot); - toNotify = _subscribers; + _current[snapshot.Id] = snapshot; + for (int i = 0; i < _subscriptions.Count; i++) + { + Subscription subscription = _subscriptions[i]; + if (subscription.Active && subscription.Replaying) + subscription.Pending.Enqueue(snapshot); + } + toNotify = _liveSnapshot; } - if (toNotify is null) return; - foreach (Action handler in toNotify.GetInvocationList()) + for (int i = 0; i < toNotify.Length; i++) { - try { handler(snapshot); } + try { toNotify[i].Handler(snapshot); } catch { /* plugin errors don't propagate out of event dispatch */ } } } + /// + /// Restore current replay membership without emitting another logical + /// spawn notification. Used when a still-live object re-enters the world. + /// + public void UpsertCurrent(WorldEntitySnapshot snapshot) + { + lock (_lock) + _current[snapshot.Id] = snapshot; + } + + public bool ForgetEntity(uint id) + { + lock (_lock) + return _current.Remove(id); + } + + public void ClearCurrent() + { + lock (_lock) + { + _current.Clear(); + for (int i = 0; i < _subscriptions.Count; i++) + { + Subscription subscription = _subscriptions[i]; + if (subscription.Replaying) + subscription.Pending.Clear(); + } + } + } + public event Action EntitySpawned { add { + ArgumentNullException.ThrowIfNull(value); + var subscription = new Subscription(value); WorldEntitySnapshot[] replay; lock (_lock) { - _subscribers += value; - replay = _alreadySpawned.ToArray(); + _subscriptions.Add(subscription); + replay = _current.Values.ToArray(); } - // Replay outside the lock to avoid deadlock if a handler re-enters. + + // Replay outside the lock. Live events that arrive meanwhile queue + // behind this snapshot and drain before the subscription joins the + // direct multicast, preserving one monotonic delivery order. foreach (var s in replay) { - try { value(s); } + lock (_lock) + { + if (!subscription.Active) + return; + if (!_current.TryGetValue(s.Id, out WorldEntitySnapshot current) + || current != s) + { + continue; + } + } + + try { subscription.Handler(s); } + catch { /* plugin errors don't propagate out of += */ } + } + + while (true) + { + WorldEntitySnapshot pending; + lock (_lock) + { + if (!subscription.Active) + return; + if (!subscription.Pending.TryDequeue(out pending)) + { + subscription.Replaying = false; + RebuildLiveSnapshotLocked(); + return; + } + } + + try { subscription.Handler(pending); } catch { /* plugin errors don't propagate out of += */ } } } remove { + if (value is null) + return; lock (_lock) - _subscribers -= value; + { + for (int i = _subscriptions.Count - 1; i >= 0; i--) + { + Subscription subscription = _subscriptions[i]; + if (subscription.Handler != value) + continue; + + subscription.Active = false; + subscription.Pending.Clear(); + _subscriptions.RemoveAt(i); + if (!subscription.Replaying) + RebuildLiveSnapshotLocked(); + break; + } + } } } + + private void RebuildLiveSnapshotLocked() + { + int count = 0; + for (int i = 0; i < _subscriptions.Count; i++) + { + Subscription subscription = _subscriptions[i]; + if (subscription.Active && !subscription.Replaying) + count++; + } + + if (count == 0) + { + _liveSnapshot = Array.Empty(); + return; + } + + var rebuilt = new Subscription[count]; + int write = 0; + for (int i = 0; i < _subscriptions.Count; i++) + { + Subscription subscription = _subscriptions[i]; + if (subscription.Active && !subscription.Replaying) + rebuilt[write++] = subscription; + } + _liveSnapshot = rebuilt; + } } diff --git a/src/AcDream.Core/Plugins/WorldGameState.cs b/src/AcDream.Core/Plugins/WorldGameState.cs index fdcd828b..03fcb13d 100644 --- a/src/AcDream.Core/Plugins/WorldGameState.cs +++ b/src/AcDream.Core/Plugins/WorldGameState.cs @@ -6,11 +6,25 @@ namespace AcDream.Core.Plugins; public sealed class WorldGameState : IGameState { private readonly List _entities = new(); + private readonly Dictionary _indexById = new(); public IReadOnlyList Entities => _entities; - /// Called by the host as each entity is hydrated. - public void Add(WorldEntitySnapshot snapshot) => _entities.Add(snapshot); + /// + /// Publish the current projection for an entity. Re-hydration replaces the + /// prior snapshot instead of turning the current-state API into history. + /// + public void Add(WorldEntitySnapshot snapshot) + { + if (_indexById.TryGetValue(snapshot.Id, out int index)) + { + _entities[index] = snapshot; + return; + } + + _indexById.Add(snapshot.Id, _entities.Count); + _entities.Add(snapshot); + } /// /// Remove any snapshot with the given local Id. Used when the @@ -18,5 +32,25 @@ public sealed class WorldGameState : IGameState /// acdream — the host deletes the old snapshot before adding the new /// one so plugins don't see stale duplicates. /// - public void RemoveById(uint id) => _entities.RemoveAll(e => e.Id == id); + public bool RemoveById(uint id) + { + if (!_indexById.Remove(id, out int index)) + return false; + + int lastIndex = _entities.Count - 1; + if (index != lastIndex) + { + WorldEntitySnapshot moved = _entities[lastIndex]; + _entities[index] = moved; + _indexById[moved.Id] = index; + } + _entities.RemoveAt(lastIndex); + return true; + } + + public void Clear() + { + _entities.Clear(); + _indexById.Clear(); + } } diff --git a/src/AcDream.Core/Vfx/EmitterDescLoader.cs b/src/AcDream.Core/Vfx/EmitterDescLoader.cs index 0d076efa..484f730a 100644 --- a/src/AcDream.Core/Vfx/EmitterDescLoader.cs +++ b/src/AcDream.Core/Vfx/EmitterDescLoader.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Numerics; using DatReaderWriter; using DatReaderWriter.Lib.IO; +using AcDream.Core.Content; using DatParticleEmitter = DatReaderWriter.DBObjs.ParticleEmitter; using DatGfxObj = DatReaderWriter.DBObjs.GfxObj; using DatGfxObjDegradeInfo = DatReaderWriter.DBObjs.GfxObjDegradeInfo; @@ -27,7 +28,7 @@ public sealed class EmitterDescRegistry { } - public EmitterDescRegistry(DatCollection dats) + public EmitterDescRegistry(IDatObjectSource dats) { ArgumentNullException.ThrowIfNull(dats); _resolver = id => SafeGet(dats, id); @@ -155,7 +156,7 @@ public sealed class EmitterDescRegistry } private static float? ResolveMaxDegradeDistance( - DatCollection dats, + IDatObjectSource dats, DatParticleEmitter emitter) { uint gfxObjId = GetRetailHardwareGfxObjId(emitter); @@ -178,7 +179,7 @@ public sealed class EmitterDescRegistry internal static uint GetRetailHardwareGfxObjId(DatParticleEmitter emitter) => emitter.HwGfxObjId.DataId; - private static T? SafeGet(DatCollection dats, uint id) where T : class, IDBObj + private static T? SafeGet(IDatObjectSource dats, uint id) where T : class, IDBObj { if (dats is null) return null; diff --git a/src/AcDream.Core/Vfx/IEntityEffectPoseSource.cs b/src/AcDream.Core/Vfx/IEntityEffectPoseSource.cs index 0e7b8ff3..0dde930e 100644 --- a/src/AcDream.Core/Vfx/IEntityEffectPoseSource.cs +++ b/src/AcDream.Core/Vfx/IEntityEffectPoseSource.cs @@ -14,6 +14,22 @@ public interface IEntityEffectPoseSource bool TryGetPartPose(uint localEntityId, int partIndex, out Matrix4x4 partLocal); } +/// +/// Optional update-thread notification surface for pose registries. Effect +/// consumers use this to refresh only owners whose final root, part, or cell +/// pose actually changed instead of polling every retained owner each frame. +/// +/// +/// Notifications are synchronous and occur after the new pose is published. +/// Implementations must suppress notifications when a publish is byte-for-byte +/// equivalent to the current pose. Consumers must not mutate the pose source +/// from inside the callback. +/// +public interface IEntityEffectPoseChangeSource +{ + event Action? EffectPoseChanged; +} + /// /// Optional spatial companion for consumers, such as object lights, whose /// render registration is scoped to the owner's current AC cell. diff --git a/src/AcDream.Core/Vfx/ParticleHookSink.cs b/src/AcDream.Core/Vfx/ParticleHookSink.cs index 12840df6..ad2a5998 100644 --- a/src/AcDream.Core/Vfx/ParticleHookSink.cs +++ b/src/AcDream.Core/Vfx/ParticleHookSink.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Numerics; using AcDream.Core.Physics; using DatReaderWriter.Types; @@ -29,17 +28,35 @@ public sealed class ParticleHookSink : IAnimationHookSink private readonly ParticleSystem _system; private readonly IEntityEffectPoseSource _poses; private readonly IEntityEffectCellSource? _cells; - private readonly ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new(); - private readonly ConcurrentDictionary> _handlesByEntity = new(); - private readonly ConcurrentDictionary _bindingsByHandle = new(); - private readonly ConcurrentDictionary _renderPassByEntity = new(); - private readonly ConcurrentDictionary _examinationOwners = new(); - private readonly ConcurrentDictionary _hiddenPresentationOwners = new(); + private readonly IEntityEffectPoseChangeSource? _poseChanges; + + // Game-state, pose, script, and particle mutation is update-thread owned. + // Ordinary collections avoid ConcurrentDictionary's per-binding node and + // enumeration overhead while preserving that single-writer contract. + private readonly Dictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new(); + private readonly Dictionary> _handlesByEntity = new(); + private readonly Dictionary _bindingsByHandle = new(); + private readonly Dictionary _renderPassByEntity = new(); + private readonly HashSet _examinationOwners = []; + private readonly HashSet _hiddenPresentationOwners = []; + private readonly HashSet _stoppingOwners = []; + + // Production pose registries publish actual root/part/cell changes. Only + // those owners are recomposed; static world emitters do no refresh work. + // Sources without the optional notification interface retain the safe + // owner-scoped polling fallback used by tests and plugins. + private readonly HashSet _spatialOwners = []; + private readonly HashSet _dirtyOwnerSet = []; + private readonly List _dirtyOwnerOrder = []; + private readonly List _refreshOwnerSnapshot = []; public ParticleHookSink(ParticleSystem system, IEntityEffectPoseSource poses) { _system = system ?? throw new ArgumentNullException(nameof(system)); _poses = poses ?? throw new ArgumentNullException(nameof(poses)); _cells = poses as IEntityEffectCellSource; + _poseChanges = poses as IEntityEffectPoseChangeSource; + if (_poseChanges is not null) + _poseChanges.EffectPoseChanged += OnEffectPoseChanged; _system.EmitterDied += OnEmitterDied; } @@ -57,23 +74,28 @@ public sealed class ParticleHookSink : IAnimationHookSink public int RenderPassOwnerCount => _renderPassByEntity.Count; public int ExaminationOwnerCount => _examinationOwners.Count; public int HiddenPresentationOwnerCount => _hiddenPresentationOwners.Count; + internal int LastRefreshOwnerVisitCount { get; private set; } + internal int LastRefreshBindingVisitCount { get; private set; } private void OnEmitterDied(int handle) { - if (!_bindingsByHandle.TryRemove(handle, out EmitterBinding binding)) + if (!_bindingsByHandle.Remove(handle, out EmitterBinding binding)) return; if (binding.LogicalId != 0 && _handlesByKey.TryGetValue((binding.OwnerLocalId, binding.LogicalId), out int current) && current == handle) { - _handlesByKey.TryRemove((binding.OwnerLocalId, binding.LogicalId), out _); + _handlesByKey.Remove((binding.OwnerLocalId, binding.LogicalId)); } if (_handlesByEntity.TryGetValue(binding.OwnerLocalId, out var handles)) { - handles.TryRemove(handle, out _); - if (handles.IsEmpty) - _handlesByEntity.TryRemove(binding.OwnerLocalId, out _); + handles.Remove(handle); + if (handles.Count == 0) + { + _handlesByEntity.Remove(binding.OwnerLocalId); + _spatialOwners.Remove(binding.OwnerLocalId); + } } } @@ -127,7 +149,7 @@ public sealed class ParticleHookSink : IAnimationHookSink public void ClearEntityRenderPass(uint entityId) { - _renderPassByEntity.TryRemove(entityId, out _); + _renderPassByEntity.Remove(entityId); RefreshOwnerVisibilityPolicy(entityId); } @@ -139,9 +161,9 @@ public sealed class ParticleHookSink : IAnimationHookSink public void SetEntityExaminationObject(uint entityId, bool isExaminationObject) { if (isExaminationObject) - _examinationOwners[entityId] = 0; + _examinationOwners.Add(entityId); else - _examinationOwners.TryRemove(entityId, out _); + _examinationOwners.Remove(entityId); RefreshOwnerVisibilityPolicy(entityId); } @@ -154,16 +176,27 @@ public sealed class ParticleHookSink : IAnimationHookSink public void SetEntityPresentationVisible(uint entityId, bool visible) { if (visible) - _hiddenPresentationOwners.TryRemove(entityId, out _); + _hiddenPresentationOwners.Remove(entityId); else - _hiddenPresentationOwners[entityId] = 0; + _hiddenPresentationOwners.Add(entityId); + + if (visible) + { + if (_handlesByEntity.ContainsKey(entityId)) + _spatialOwners.Add(entityId); + MarkOwnerDirty(entityId); + } + else + { + _spatialOwners.Remove(entityId); + } // Withdrawal is immediate. Re-entry is enabled by the next pose // refresh so an attached owner cannot flash for one frame at its old // anchor before the composed child pose is published. if (!visible && _handlesByEntity.TryGetValue(entityId, out var handles)) { - foreach (int handle in handles.Keys) + foreach (int handle in handles) { _system.SetEmitterPresentationVisible(handle, false); _system.SetEmitterSimulationEnabled(handle, false); @@ -178,57 +211,141 @@ public sealed class ParticleHookSink : IAnimationHookSink /// public void RefreshAttachedEmitters() { - foreach ((int handle, EmitterBinding binding) in _bindingsByHandle) + LastRefreshOwnerVisitCount = 0; + LastRefreshBindingVisitCount = 0; + _refreshOwnerSnapshot.Clear(); + if (_poseChanges is null) { - bool presentationVisible = - !_hiddenPresentationOwners.ContainsKey(binding.OwnerLocalId); - if (TryResolveAnchor(binding.OwnerLocalId, binding.PartIndex, - binding.HookOffsetOrigin, - out Vector3 anchor, - out Quaternion rotation, - out Vector3 ownerPosition)) + foreach (uint ownerLocalId in _handlesByEntity.Keys) + _refreshOwnerSnapshot.Add(ownerLocalId); + } + else + { + _refreshOwnerSnapshot.AddRange(_dirtyOwnerOrder); + _dirtyOwnerOrder.Clear(); + _dirtyOwnerSet.Clear(); + } + + for (int ownerIndex = 0; ownerIndex < _refreshOwnerSnapshot.Count; ownerIndex++) + { + uint ownerLocalId = _refreshOwnerSnapshot[ownerIndex]; + if (!_handlesByEntity.TryGetValue(ownerLocalId, out HashSet? handles)) { - _system.UpdateEmitterAnchor(handle, anchor, rotation); - _system.UpdateEmitterOwnerPosition(handle, ownerPosition); - _system.UpdateEmitterOwnerCell( - handle, - _cells is not null && _cells.TryGetCellId(binding.OwnerLocalId, out uint cellId) - ? cellId - : 0u); - // Keep the anchor current while spatially paused; re-entry - // resumes at the authoritative pose without generating the - // absent interval's time- or distance-driven emissions. - _system.SetEmitterPresentationVisible(handle, presentationVisible); - _system.SetEmitterSimulationEnabled(handle, presentationVisible); + continue; + } + + LastRefreshOwnerVisitCount++; + bool presentationVisible = + !_hiddenPresentationOwners.Contains(ownerLocalId); + foreach (int handle in handles) + { + if (!_bindingsByHandle.TryGetValue(handle, out EmitterBinding binding)) + continue; + + LastRefreshBindingVisitCount++; + if (TryResolveAnchor(ownerLocalId, binding.PartIndex, + binding.HookOffsetOrigin, + out Vector3 anchor, + out Quaternion rotation, + out Vector3 ownerPosition)) + { + _system.UpdateEmitterAnchor(handle, anchor, rotation); + _system.UpdateEmitterOwnerPosition(handle, ownerPosition); + _system.UpdateEmitterOwnerCell( + handle, + _cells is not null && _cells.TryGetCellId(ownerLocalId, out uint cellId) + ? cellId + : 0u); + // Keep the anchor current while spatially paused; re-entry + // resumes at the authoritative pose without generating the + // absent interval's time- or distance-driven emissions. + _system.SetEmitterPresentationVisible(handle, presentationVisible); + _system.SetEmitterSimulationEnabled(handle, presentationVisible); + } + else + { + _system.SetEmitterPresentationVisible(handle, false); + _system.SetEmitterSimulationEnabled(handle, false); + } } - else - _system.SetEmitterPresentationVisible(handle, false); } } public void StopAllForEntity(uint entityId, bool fadeOut) { - if (_handlesByEntity.TryRemove(entityId, out var handles)) + // EmitterDied callbacks may execute arbitrary hook routing. Keep one + // owner-scoped teardown gate active until the initial bag is drained; + // a callback cannot resurrect an emitter after its logical owner has + // already been accepted for destruction. + if (!_stoppingOwners.Add(entityId)) + return; + + try { - foreach (int handle in handles.Keys) + _spatialOwners.Remove(entityId); + List? failures = null; + if (_handlesByEntity.TryGetValue(entityId, out HashSet? handles)) { - // A fading emitter needs its clock to retire naturally. A - // hard destroy is removed synchronously by ParticleSystem. - if (fadeOut) - _system.SetEmitterSimulationEnabled(handle, true); - _system.StopEmitter(handle, fadeOut); - _bindingsByHandle.TryRemove(handle, out _); + int[] snapshot = [.. handles]; + for (int i = 0; i < snapshot.Length; i++) + { + int handle = snapshot[i]; + if (_bindingsByHandle.TryGetValue(handle, out EmitterBinding binding) + && binding.LogicalId != 0) + { + _handlesByKey.Remove((entityId, binding.LogicalId)); + } + + // A fading emitter needs its clock to retire naturally. A + // hard destroy is removed synchronously by ParticleSystem. + bool stopCommitted = false; + try + { + if (fadeOut) + _system.SetEmitterSimulationEnabled(handle, true); + _system.StopEmitter(handle, fadeOut); + stopCommitted = true; + } + catch (Exception error) + { + // DestroyParticleEmitter removes the table entry before + // broadcasting EmitterDied. A subscriber failure therefore + // reports after the stop mutation committed; never replay + // that dead handle, but retain genuinely live failures for + // the next owner-teardown attempt. + stopCommitted = !_system.IsEmitterAlive(handle); + (failures ??= []).Add(error); + } + + if (!stopCommitted) + continue; + + handles.Remove(handle); + _bindingsByHandle.Remove(handle); + } + + if (handles.Count == 0 + && _handlesByEntity.TryGetValue(entityId, out HashSet? current) + && ReferenceEquals(current, handles)) + { + _handlesByEntity.Remove(entityId); + } + } + ClearEntityRenderPass(entityId); + _examinationOwners.Remove(entityId); + _hiddenPresentationOwners.Remove(entityId); + + if (failures is not null) + { + throw new AggregateException( + $"One or more particle emitters for owner 0x{entityId:X8} failed to stop cleanly.", + failures); } } - - foreach (var key in _handlesByKey.Keys) + finally { - if (key.EntityId == entityId) - _handlesByKey.TryRemove(key, out _); + _stoppingOwners.Remove(entityId); } - ClearEntityRenderPass(entityId); - _examinationOwners.TryRemove(entityId, out _); - _hiddenPresentationOwners.TryRemove(entityId, out _); } private void DestroyLogical(uint ownerLocalId, uint logicalId, bool fadeOut) @@ -244,7 +361,7 @@ public sealed class ParticleHookSink : IAnimationHookSink // A blocking create with the same logical ID must therefore continue // to see it. DestroyParticleEmitter (0x0051B770) removes it at once. if (!fadeOut) - _handlesByKey.TryRemove((ownerLocalId, logicalId), out _); + _handlesByKey.Remove((ownerLocalId, logicalId)); if (fadeOut) _system.SetEmitterSimulationEnabled(handle, true); _system.StopEmitter(handle, fadeOut); @@ -258,13 +375,20 @@ public sealed class ParticleHookSink : IAnimationHookSink uint logicalId, bool isBlocking) { + if (_stoppingOwners.Contains(ownerLocalId)) + { + DiagnosticSink?.Invoke( + $"Particle creation for owner 0x{ownerLocalId:X8} was ignored while its emitters were stopping."); + return; + } + if (logicalId != 0 && _handlesByKey.TryGetValue((ownerLocalId, logicalId), out int existing)) { if (isBlocking && _system.IsEmitterAlive(existing)) return; - _handlesByKey.TryRemove((ownerLocalId, logicalId), out _); + _handlesByKey.Remove((ownerLocalId, logicalId)); _system.StopEmitter(existing, fadeOut: false); } @@ -306,7 +430,7 @@ public sealed class ParticleHookSink : IAnimationHookSink _cells is not null && _cells.TryGetCellId(ownerLocalId, out uint cellId) ? cellId : 0u); - if (_hiddenPresentationOwners.ContainsKey(ownerLocalId)) + if (_hiddenPresentationOwners.Contains(ownerLocalId)) { _system.SetEmitterPresentationVisible(handle, false); _system.SetEmitterSimulationEnabled(handle, false); @@ -320,16 +444,21 @@ public sealed class ParticleHookSink : IAnimationHookSink logicalId, renderPass); _bindingsByHandle[handle] = binding; - _handlesByEntity - .GetOrAdd(ownerLocalId, _ => new ConcurrentDictionary()) - .TryAdd(handle, 0); + if (!_handlesByEntity.TryGetValue(ownerLocalId, out HashSet? handles)) + { + handles = []; + _handlesByEntity.Add(ownerLocalId, handles); + } + handles.Add(handle); + if (!_hiddenPresentationOwners.Contains(ownerLocalId)) + _spatialOwners.Add(ownerLocalId); if (logicalId != 0) _handlesByKey[(ownerLocalId, logicalId)] = handle; } private ParticleVisibilityPolicy ResolveVisibilityPolicy(uint ownerLocalId) { - if (_examinationOwners.ContainsKey(ownerLocalId)) + if (_examinationOwners.Contains(ownerLocalId)) return ParticleVisibilityPolicy.Examination; return _renderPassByEntity.TryGetValue(ownerLocalId, out ParticleRenderPass pass) @@ -344,10 +473,24 @@ public sealed class ParticleHookSink : IAnimationHookSink return; ParticleVisibilityPolicy policy = ResolveVisibilityPolicy(ownerLocalId); - foreach (int handle in handles.Keys) + foreach (int handle in handles) _system.SetEmitterVisibilityPolicy(handle, policy); } + private void OnEffectPoseChanged(uint ownerLocalId) + => MarkOwnerDirty(ownerLocalId); + + private void MarkOwnerDirty(uint ownerLocalId) + { + if (!_handlesByEntity.ContainsKey(ownerLocalId) + || !_dirtyOwnerSet.Add(ownerLocalId)) + { + return; + } + + _dirtyOwnerOrder.Add(ownerLocalId); + } + private bool TryResolveAnchor( uint ownerLocalId, int partIndex, diff --git a/src/AcDream.Core/Vfx/ParticleSystem.cs b/src/AcDream.Core/Vfx/ParticleSystem.cs index e2de7ad9..4e067102 100644 --- a/src/AcDream.Core/Vfx/ParticleSystem.cs +++ b/src/AcDream.Core/Vfx/ParticleSystem.cs @@ -14,7 +14,79 @@ public sealed class ParticleSystem : IParticleSystem private readonly EmitterDescRegistry _registry; private readonly Random _rng; private readonly Dictionary _byHandle = new(); - private readonly List _handleOrder = new(); + // Handles are monotonic, so sorted indexes preserve retail emitter-spawn + // order while making every lifecycle edge O(log E). The old List.Remove + // hard-stop path was O(E) and became visible when portal routes retained + // thousands of finite/fading emitters. + private readonly SortedSet _allHandles = []; + private readonly SortedSet _simulationHandles = []; + private readonly SortedSet _worldSimulationHandles = []; + private readonly SortedSet[] _renderableHandlesByPass = + [[], [], []]; + private readonly SortedSet[] _renderableUnattachedHandlesByPass = + [[], [], []]; + private readonly Dictionary[] _ownerHandlesByPass = + [new(), new(), new()]; + private readonly List _tickSnapshot = []; + private readonly List _scopeHandleScratch = []; + + private sealed class OwnerEmitterBucket + { + public int LogicalCount; + public int FirstRenderableHandle; + public List? AdditionalRenderableHandles; + + public void AddRenderable(int handle) + { + if (FirstRenderableHandle == 0) + { + FirstRenderableHandle = handle; + return; + } + + if (handle < FirstRenderableHandle) + { + (FirstRenderableHandle, handle) = (handle, FirstRenderableHandle); + } + List additional = AdditionalRenderableHandles ??= []; + int index = additional.BinarySearch(handle); + if (index < 0) + additional.Insert(~index, handle); + } + + public void RemoveRenderable(int handle) + { + if (FirstRenderableHandle == handle) + { + if (AdditionalRenderableHandles is { Count: > 0 } additional) + { + FirstRenderableHandle = additional[0]; + additional.RemoveAt(0); + } + else + { + FirstRenderableHandle = 0; + } + return; + } + + if (AdditionalRenderableHandles is { } others) + { + int index = others.BinarySearch(handle); + if (index >= 0) + others.RemoveAt(index); + } + } + + public void CopyRenderableHandlesTo(List destination) + { + if (FirstRenderableHandle == 0) + return; + destination.Add(FirstRenderableHandle); + if (AdditionalRenderableHandles is { Count: > 0 } additional) + destination.AddRange(additional); + } + } private int _nextHandle = 1; private float _time; @@ -29,6 +101,10 @@ public sealed class ParticleSystem : IParticleSystem public int ActiveEmitterCount => _byHandle.Count; public int ActiveParticleCount => _activeParticleCount; + internal int LastTickEmitterVisitCount { get; private set; } + internal int LastRetailViewEmitterVisitCount { get; private set; } + internal int LastRenderScopeEmitterVisitCount { get; private set; } + public int SpawnEmitter( EmitterDesc desc, Vector3 anchor, @@ -59,7 +135,11 @@ public sealed class ParticleSystem : IParticleSystem }; _byHandle[handle] = emitter; - _handleOrder.Add(handle); + _allHandles.Add(handle); + _simulationHandles.Add(handle); + if (visibilityPolicy == ParticleVisibilityPolicy.World) + _worldSimulationHandles.Add(handle); + AddEmitterToRenderIndexes(emitter); for (int i = 0; i < desc.InitialParticles; i++) SpawnOne(emitter, allowWhenFull: false); @@ -133,9 +213,7 @@ public sealed class ParticleSystem : IParticleSystem // Retail DestroyParticleEmitter removes the table entry now; it // does not wait for the next update. This is also required for a // hard-stopped emitter whose cell-less simulation is paused. - _byHandle.Remove(handle); - _handleOrder.Remove(handle); - EmitterDied?.Invoke(handle); + RemoveEmitter(handle, em); } } @@ -180,8 +258,28 @@ public sealed class ParticleSystem : IParticleSystem int handle, ParticleVisibilityPolicy visibilityPolicy) { - if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)) - emitter.VisibilityPolicy = visibilityPolicy; + if (!_byHandle.TryGetValue(handle, out ParticleEmitter? emitter) + || emitter.VisibilityPolicy == visibilityPolicy) + { + return; + } + + bool wasRenderable = IsRenderable(emitter); + if (emitter.VisibilityPolicy == ParticleVisibilityPolicy.World) + _worldSimulationHandles.Remove(handle); + + emitter.VisibilityPolicy = visibilityPolicy; + if (visibilityPolicy == ParticleVisibilityPolicy.World) + { + if (emitter.SimulationEnabled) + _worldSimulationHandles.Add(handle); + } + else + { + // Examination and dedicated-pass objects bypass world PView. + emitter.ViewEligible = true; + } + RefreshRenderableIndex(emitter, wasRenderable); } /// @@ -200,21 +298,19 @@ public sealed class ParticleSystem : IParticleSystem if (!float.IsFinite(rangeMultiplier) || rangeMultiplier <= 0f) rangeMultiplier = 1f; - foreach (int handle in _handleOrder) + LastRetailViewEmitterVisitCount = 0; + foreach (int handle in _worldSimulationHandles) { if (!_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)) continue; - - if (emitter.VisibilityPolicy != ParticleVisibilityPolicy.World) - { - emitter.ViewEligible = true; - continue; - } + LastRetailViewEmitterVisitCount++; + bool wasRenderable = IsRenderable(emitter); if (!hasCompletedView) { // With no completed world PView (portal space, login, or a // reset frame), a world cell cannot report IsInView. emitter.ViewEligible = false; + RefreshRenderableIndex(emitter, wasRenderable); continue; } @@ -227,6 +323,7 @@ public sealed class ParticleSystem : IParticleSystem && (float.IsNaN(distance) || float.IsNaN(maxDistance) || distance <= maxDistance); + RefreshRenderableIndex(emitter, wasRenderable); } } @@ -235,8 +332,15 @@ public sealed class ParticleSystem : IParticleSystem /// public void SetEmitterPresentationVisible(int handle, bool visible) { - if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)) - emitter.PresentationVisible = visible; + if (!_byHandle.TryGetValue(handle, out ParticleEmitter? emitter) + || emitter.PresentationVisible == visible) + { + return; + } + + bool wasRenderable = IsRenderable(emitter); + emitter.PresentationVisible = visible; + RefreshRenderableIndex(emitter, wasRenderable); } /// @@ -256,7 +360,19 @@ public sealed class ParticleSystem : IParticleSystem if (!enabled) { + bool wasRenderable = IsRenderable(emitter); emitter.SimulationEnabled = false; + _simulationHandles.Remove(handle); + _worldSimulationHandles.Remove(handle); + if (emitter.VisibilityPolicy == ParticleVisibilityPolicy.World) + { + // A spatially withdrawn owner has no current CObjCell::IsInView + // result. Invalidate the previous PView decision so re-entry + // cannot become renderable until ApplyRetailView evaluates the + // owner against a freshly completed view. + emitter.ViewEligible = false; + RefreshRenderableIndex(emitter, wasRenderable); + } return; } @@ -266,6 +382,11 @@ public sealed class ParticleSystem : IParticleSystem emitter.EmittedAccumulator = 0f; } emitter.SimulationEnabled = true; + _simulationHandles.Add(handle); + if (emitter.VisibilityPolicy == ParticleVisibilityPolicy.World) + _worldSimulationHandles.Add(handle); + else + emitter.ViewEligible = true; } /// True when the given handle still maps to a live emitter. @@ -287,14 +408,24 @@ public sealed class ParticleSystem : IParticleSystem _time += dt; _activeParticleCount = 0; + LastTickEmitterVisitCount = 0; - for (int i = 0; i < _handleOrder.Count; i++) + // EmitterDied callbacks may synchronously stop another emitter or + // create a replacement. Snapshot the current workset into retained + // storage so those mutations cannot invalidate traversal; newly + // created emitters begin on the following retail object update. + _tickSnapshot.Clear(); + foreach (int handle in _simulationHandles) + _tickSnapshot.Add(handle); + + for (int i = 0; i < _tickSnapshot.Count; i++) { - int handle = _handleOrder[i]; + int handle = _tickSnapshot[i]; if (!_byHandle.TryGetValue(handle, out var em)) continue; if (!em.SimulationEnabled) continue; + LastTickEmitterVisitCount++; bool wasFinishedBeforeUpdate = em.Finished; if (em.ViewEligible) @@ -321,10 +452,7 @@ public sealed class ParticleSystem : IParticleSystem // branch may return num_particles == 0 and retire the emitter. if (em.Finished && live == 0 && wasFinishedBeforeUpdate) { - _byHandle.Remove(handle); - _handleOrder.RemoveAt(i); - i--; - EmitterDied?.Invoke(handle); + RemoveEmitter(handle, em); } } } @@ -357,6 +485,65 @@ public sealed class ParticleSystem : IParticleSystem /// public LiveEmitterEnumerable EnumerateEmitters() => new(this); + /// + /// Enumerates only presentation-visible, view-eligible emitters in the + /// requested pass, retaining global spawn order. Renderers should use this + /// workset instead of rejecting every logical emitter on every pass. + /// + public RenderableEmitterEnumerable EnumerateRenderableEmitters( + ParticleRenderPass renderPass) => new(this, renderPass); + + /// + /// Copies the renderable emitters owned by a visibility scope without + /// walking unrelated emitters in the same render pass. Results retain + /// global emitter-spawn order so equal-distance retail alpha ties remain + /// identical to the unscoped path. + /// + public void CopyRenderableEmittersForOwners( + ParticleRenderPass renderPass, + IReadOnlySet attachedOwnerIds, + bool includeUnattached, + List destination, + IReadOnlySet? excludedAttachedOwnerIds = null) + { + ArgumentNullException.ThrowIfNull(attachedOwnerIds); + ArgumentNullException.ThrowIfNull(destination); + destination.Clear(); + LastRenderScopeEmitterVisitCount = 0; + int passIndex = RenderPassIndex(renderPass); + + if (includeUnattached) + { + foreach (int handle in _renderableUnattachedHandlesByPass[passIndex]) + { + LastRenderScopeEmitterVisitCount++; + if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)) + destination.Add(emitter); + } + } + + Dictionary owners = _ownerHandlesByPass[passIndex]; + foreach (uint ownerId in attachedOwnerIds) + { + if (excludedAttachedOwnerIds?.Contains(ownerId) == true + || !owners.TryGetValue(ownerId, out OwnerEmitterBucket? bucket)) + { + continue; + } + + _scopeHandleScratch.Clear(); + bucket.CopyRenderableHandlesTo(_scopeHandleScratch); + LastRenderScopeEmitterVisitCount += _scopeHandleScratch.Count; + foreach (int handle in _scopeHandleScratch) + { + if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)) + destination.Add(emitter); + } + } + + destination.Sort(static (left, right) => left.Handle.CompareTo(right.Handle)); + } + public readonly struct LiveEmitterEnumerable : IEnumerable { private readonly ParticleSystem _owner; @@ -372,7 +559,7 @@ public sealed class ParticleSystem : IParticleSystem private static IEnumerable EnumerateBoxed(ParticleSystem owner) { - foreach (int handle in owner._handleOrder) + foreach (int handle in owner._allHandles) { if (owner._byHandle.TryGetValue(handle, out ParticleEmitter? emitter)) yield return emitter; @@ -382,12 +569,12 @@ public sealed class ParticleSystem : IParticleSystem public struct Enumerator { private readonly ParticleSystem _owner; - private int _index; + private SortedSet.Enumerator _handles; internal Enumerator(ParticleSystem owner) { _owner = owner; - _index = -1; + _handles = owner._allHandles.GetEnumerator(); Current = null!; } @@ -395,11 +582,68 @@ public sealed class ParticleSystem : IParticleSystem public bool MoveNext() { - while (++_index < _owner._handleOrder.Count) + while (_handles.MoveNext()) { - if (_owner._byHandle.TryGetValue( - _owner._handleOrder[_index], - out ParticleEmitter? emitter)) + if (_owner._byHandle.TryGetValue(_handles.Current, out ParticleEmitter? emitter)) + { + Current = emitter; + return true; + } + } + return false; + } + } + } + + public readonly struct RenderableEmitterEnumerable : IEnumerable + { + private readonly ParticleSystem _owner; + private readonly int _passIndex; + + internal RenderableEmitterEnumerable(ParticleSystem owner, ParticleRenderPass renderPass) + { + _owner = owner; + _passIndex = RenderPassIndex(renderPass); + } + + public Enumerator GetEnumerator() => new(_owner, _passIndex); + + IEnumerator IEnumerable.GetEnumerator() + => EnumerateBoxed(_owner, _passIndex).GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() + => EnumerateBoxed(_owner, _passIndex).GetEnumerator(); + + private static IEnumerable EnumerateBoxed( + ParticleSystem owner, + int passIndex) + { + foreach (int handle in owner._renderableHandlesByPass[passIndex]) + { + if (owner._byHandle.TryGetValue(handle, out ParticleEmitter? emitter)) + yield return emitter; + } + } + + public struct Enumerator + { + private readonly ParticleSystem _owner; + private SortedSet.Enumerator _handles; + + internal Enumerator(ParticleSystem owner, int passIndex) + { + _owner = owner; + _handles = owner._renderableHandlesByPass[passIndex].GetEnumerator(); + Current = null!; + } + + public ParticleEmitter Current { get; private set; } + + public bool MoveNext() + { + while (_handles.MoveNext()) + { + if (_owner._byHandle.TryGetValue(_handles.Current, out ParticleEmitter? emitter)) { Current = emitter; return true; @@ -432,7 +676,7 @@ public sealed class ParticleSystem : IParticleSystem private static IEnumerable<(ParticleEmitter Emitter, int Index)> EnumerateLiveBoxed(ParticleSystem owner) { - foreach (var handle in owner._handleOrder) + foreach (var handle in owner._allHandles) { if (!owner._byHandle.TryGetValue(handle, out var em)) continue; @@ -449,14 +693,14 @@ public sealed class ParticleSystem : IParticleSystem public struct Enumerator { private readonly ParticleSystem _owner; - private int _handleIdx; + private SortedSet.Enumerator _handles; private ParticleEmitter? _currentEmitter; private int _particleIdx; internal Enumerator(ParticleSystem owner) { _owner = owner; - _handleIdx = -1; + _handles = owner._allHandles.GetEnumerator(); _currentEmitter = null; _particleIdx = -1; } @@ -477,11 +721,10 @@ public sealed class ParticleSystem : IParticleSystem _currentEmitter = null; } - _handleIdx++; - if (_handleIdx >= _owner._handleOrder.Count) + if (!_handles.MoveNext()) return false; - if (!_owner._byHandle.TryGetValue(_owner._handleOrder[_handleIdx], out var em)) + if (!_owner._byHandle.TryGetValue(_handles.Current, out var em)) continue; _currentEmitter = em; @@ -491,6 +734,126 @@ public sealed class ParticleSystem : IParticleSystem } } + private void RemoveEmitter(int handle, ParticleEmitter emitter) + { + if (!_byHandle.Remove(handle)) + return; + + _allHandles.Remove(handle); + _simulationHandles.Remove(handle); + _worldSimulationHandles.Remove(handle); + int passIndex = RenderPassIndex(emitter.RenderPass); + _renderableHandlesByPass[passIndex].Remove(handle); + if (emitter.AttachedObjectId == 0) + { + _renderableUnattachedHandlesByPass[passIndex].Remove(handle); + } + else if (_ownerHandlesByPass[passIndex].TryGetValue( + emitter.AttachedObjectId, + out OwnerEmitterBucket? bucket)) + { + bucket.RemoveRenderable(handle); + bucket.LogicalCount--; + if (bucket.LogicalCount == 0) + _ownerHandlesByPass[passIndex].Remove(emitter.AttachedObjectId); + } + NotifyEmitterDied(handle); + } + + private void NotifyEmitterDied(int handle) + { + Action? callbacks = EmitterDied; + if (callbacks is null) + return; + + List? failures = null; + foreach (Action callback in callbacks.GetInvocationList().Cast>()) + { + try + { + callback(handle); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } + + if (failures is not null) + throw new AggregateException( + $"One or more emitter-death callbacks failed for handle {handle}.", + failures); + } + + private void AddEmitterToRenderIndexes(ParticleEmitter emitter) + { + int passIndex = RenderPassIndex(emitter.RenderPass); + OwnerEmitterBucket? ownerBucket = null; + if (emitter.AttachedObjectId != 0) + { + if (!_ownerHandlesByPass[passIndex].TryGetValue( + emitter.AttachedObjectId, + out ownerBucket)) + { + ownerBucket = new OwnerEmitterBucket(); + _ownerHandlesByPass[passIndex].Add(emitter.AttachedObjectId, ownerBucket); + } + ownerBucket.LogicalCount++; + } + + if (IsRenderable(emitter)) + { + _renderableHandlesByPass[passIndex].Add(emitter.Handle); + if (emitter.AttachedObjectId == 0) + _renderableUnattachedHandlesByPass[passIndex].Add(emitter.Handle); + else + ownerBucket!.AddRenderable(emitter.Handle); + } + } + + private void RefreshRenderableIndex(ParticleEmitter emitter, bool wasRenderable) + { + bool isRenderable = IsRenderable(emitter); + if (wasRenderable == isRenderable) + return; + + SortedSet index = + _renderableHandlesByPass[RenderPassIndex(emitter.RenderPass)]; + if (isRenderable) + index.Add(emitter.Handle); + else + index.Remove(emitter.Handle); + + int passIndex = RenderPassIndex(emitter.RenderPass); + if (emitter.AttachedObjectId == 0) + { + if (isRenderable) + _renderableUnattachedHandlesByPass[passIndex].Add(emitter.Handle); + else + _renderableUnattachedHandlesByPass[passIndex].Remove(emitter.Handle); + } + else if (_ownerHandlesByPass[passIndex].TryGetValue( + emitter.AttachedObjectId, + out OwnerEmitterBucket? bucket)) + { + if (isRenderable) + bucket.AddRenderable(emitter.Handle); + else + bucket.RemoveRenderable(emitter.Handle); + } + } + + private static bool IsRenderable(ParticleEmitter emitter) + => emitter.PresentationVisible && emitter.ViewEligible; + + private static int RenderPassIndex(ParticleRenderPass renderPass) + { + int index = (int)renderPass; + if ((uint)index >= 3u) + throw new ArgumentOutOfRangeException(nameof(renderPass)); + return index; + } + private void AdvanceEmitter(ParticleEmitter em) { for (int i = 0; i < em.Particles.Length; i++) diff --git a/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs b/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs index 353e51d4..679a61f9 100644 --- a/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs +++ b/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs @@ -30,6 +30,7 @@ public sealed class PhysicsScriptRunner private readonly Dictionary _owners = new(); private readonly Dictionary _ownerAnchors = new(); private readonly List _delayedCalls = new(); + private readonly List _ownerIterationSnapshot = new(); private double _gameTime; private long _tickGeneration; private bool _frameTimePublished; @@ -196,10 +197,12 @@ public sealed class PhysicsScriptRunner // Snapshot owner IDs. Hooks may append to their current owner, create a // different owner, or delete an owner without invalidating iteration. - uint[] ownerIds = _owners.Keys.ToArray(); - for (int ownerIndex = 0; ownerIndex < ownerIds.Length; ownerIndex++) + _ownerIterationSnapshot.Clear(); + foreach (uint ownerId in _owners.Keys) + _ownerIterationSnapshot.Add(ownerId); + for (int ownerIndex = 0; ownerIndex < _ownerIterationSnapshot.Count; ownerIndex++) { - uint ownerId = ownerIds[ownerIndex]; + uint ownerId = _ownerIterationSnapshot[ownerIndex]; if (!_owners.TryGetValue(ownerId, out OwnerQueue? owner)) continue; if (!_canAdvanceOwner(ownerId)) diff --git a/src/AcDream.Core/World/LandblockLoader.cs b/src/AcDream.Core/World/LandblockLoader.cs index eec383f7..d79198e9 100644 --- a/src/AcDream.Core/World/LandblockLoader.cs +++ b/src/AcDream.Core/World/LandblockLoader.cs @@ -1,5 +1,6 @@ using DatReaderWriter; using DatReaderWriter.DBObjs; +using AcDream.Core.Content; using DatReaderWriter.Types; using AcDream.Core.Physics; @@ -15,7 +16,7 @@ public static class LandblockLoader /// Load a single landblock (heightmap + static objects) from the dats. /// /// Null if the landblock is missing from the cell dat. - public static LoadedLandblock? Load(DatCollection dats, uint landblockId) + public static LoadedLandblock? Load(IDatObjectSource dats, uint landblockId) { var block = dats.Get(landblockId); if (block is null) @@ -40,7 +41,7 @@ public static class LandblockLoader var result = new List(info.Objects.Count + info.Buildings.Count); // When landblockId is non-zero, namespace stab Ids globally: - // 0xC0XXYY00 + n, where XX = lbX byte, YY = lbY byte + // 0xCXXYYIII, where XX = lbX, YY = lbY, III = 12-bit counter // distinct from the 0x8XXYYIII scenery and 0x4XXYYIII interior // namespaces in GameWindow.cs. The 0xC0 top byte distinguishes stabs. // @@ -48,20 +49,21 @@ public static class LandblockLoader // legacy starting-from-1 monotonic Ids — compatible with their assertions // which check uniqueness within a single landblock. // - // The low byte reserves 0 for the namespace base and 1..255 for - // entities. Never wrap into the Y byte: a collision here would corrupt - // every renderer/physics/effect table keyed by WorldEntity.Id. - uint stabIdBase = landblockId == 0 - ? 0u - : 0xC0000000u | ((landblockId >> 24) & 0xFFu) << 16 | ((landblockId >> 16) & 0xFFu) << 8; - uint nextId = stabIdBase == 0 ? 1u : stabIdBase + 1u; + // The 12-bit allocator fails before crossing into the adjacent + // landblock's Y range; no wrapped id can corrupt renderer, physics, or + // effect tables keyed by WorldEntity.Id. + uint landblockX = (landblockId >> 24) & 0xFFu; + uint landblockY = (landblockId >> 16) & 0xFFu; + uint nextId = landblockId == 0 ? 1u : 0u; uint AllocateId() { - if (stabIdBase != 0 && nextId > stabIdBase + 0xFFu) - throw new InvalidDataException( - $"Landblock 0x{landblockId:X8} exceeds the 255-entry static stab id namespace."); - return nextId++; + if (landblockId == 0) + return nextId++; + return LandblockStaticEntityIdAllocator.Allocate( + landblockX, + landblockY, + ref nextId); } foreach (var stab in info.Objects) diff --git a/src/AcDream.Core/World/LandblockStaticEntityIdAllocator.cs b/src/AcDream.Core/World/LandblockStaticEntityIdAllocator.cs new file mode 100644 index 00000000..18bcf08a --- /dev/null +++ b/src/AcDream.Core/World/LandblockStaticEntityIdAllocator.cs @@ -0,0 +1,36 @@ +namespace AcDream.Core.World; + +/// +/// Allocates stable, collision-free local ids for dat-authored +/// LandBlockInfo.Objects and building shells. +/// +/// +/// The top nibble is fixed at 0xC. The remaining 28 bits encode the +/// complete landblock X byte, Y byte, and a 12-bit per-landblock counter: +/// 0xCXXYYIII. The former 0xC0XXYYII packing had only eight +/// counter bits, so a legitimate dense retail landblock aborted after 255 +/// entries and was retried every time it streamed into view. +/// +public static class LandblockStaticEntityIdAllocator +{ + public const uint MaxCounter = 0xFFFu; + + public static uint Base(uint landblockX, uint landblockY) => + 0xC0000000u + | ((landblockX & 0xFFu) << 20) + | ((landblockY & 0xFFu) << 12); + + public static uint Allocate(uint landblockX, uint landblockY, ref uint counter) + { + if (counter > MaxCounter) + { + throw new InvalidDataException( + $"Landblock ({landblockX & 0xFFu:X2},{landblockY & 0xFFu:X2}) exceeds the 4096-entry static entity id namespace."); + } + + return Base(landblockX, landblockY) + counter++; + } + + public static bool IsInNamespace(uint entityId) => + (entityId & 0xF0000000u) == 0xC0000000u; +} diff --git a/src/AcDream.Core/World/MeshRef.cs b/src/AcDream.Core/World/MeshRef.cs index 841d812e..9d0852d8 100644 --- a/src/AcDream.Core/World/MeshRef.cs +++ b/src/AcDream.Core/World/MeshRef.cs @@ -21,9 +21,8 @@ public readonly record struct MeshRef(uint GfxObjId, Matrix4x4 PartTransform) /// bind a sub-mesh's texture, it consults this map. A hit means /// "use the base Surface's colors/flags/palette but swap its /// OrigTextureId for the value here." The renderer calls - /// TextureCache.GetOrUploadWithOrigTextureOverride which caches - /// the decoded result under a composite key so multiple entities can - /// share the same override without redecoding. + /// the owning renderer resolves an owner-scoped composite. Equivalent + /// live entities may share it, and the final owner releases it. /// /// /// Null means "no overrides, use each sub-mesh's native surface as-is." diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs index 953c7de6..5d5299bd 100644 --- a/src/AcDream.Core/World/SceneryGenerator.cs +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.Core.Rendering.Wb; +using AcDream.Core.Content; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; @@ -50,7 +51,7 @@ public static class SceneryGenerator /// see docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md. /// public static IReadOnlyList Generate( - DatCollection dats, + IDatObjectSource dats, Region region, LandBlock block, uint landblockId, @@ -71,7 +72,7 @@ public static class SceneryGenerator public static bool IsRoadVertex(ushort raw) => (raw & 0x3u) != 0; private static IReadOnlyList GenerateInternal( - DatCollection dats, + IDatObjectSource dats, Region region, LandBlock block, uint landblockId, diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs index b8204ba5..b3380f3b 100644 --- a/src/AcDream.Core/World/SkyDescLoader.cs +++ b/src/AcDream.Core/World/SkyDescLoader.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Numerics; using DatReaderWriter; +using AcDream.Core.Content; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; @@ -357,7 +358,7 @@ public static class SkyDescLoader /// Load + parse. Returns null if the Region doesn't have /// or the dat is absent. /// - public static LoadedSkyDesc? LoadFromDat(DatCollection dats) + public static LoadedSkyDesc? LoadFromDat(IDatObjectSource dats) { ArgumentNullException.ThrowIfNull(dats); var region = dats.Get(RegionDatId); diff --git a/src/AcDream.Core/World/WorldView.cs b/src/AcDream.Core/World/WorldView.cs index 50968dca..71a18b5a 100644 --- a/src/AcDream.Core/World/WorldView.cs +++ b/src/AcDream.Core/World/WorldView.cs @@ -1,5 +1,5 @@ // src/AcDream.Core/World/WorldView.cs -using DatReaderWriter; +using AcDream.Core.Content; namespace AcDream.Core.World; @@ -19,7 +19,7 @@ public sealed class WorldView /// Load the 3x3 grid of landblocks around . /// Missing neighbors (edges of the world or absent from the cell dat) are silently skipped. /// - public static WorldView Load(DatCollection dats, uint centerLandblockId) + public static WorldView Load(IDatObjectSource dats, uint centerLandblockId) { var loaded = new List(); foreach (var id in NeighborLandblockIds(centerLandblockId)) diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 41b91580..4fa8593e 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -16,10 +16,9 @@ namespace AcDream.UI.Abstractions.Panels.Chat; /// /// /// -/// D.2a snapshots the log every frame. Cheap: the default 500-entry cap -/// keeps it < 1 ms. A future iteration can subscribe to -/// for incremental updates once we -/// add virtualized scrolling in . +/// Retained UI consumers can key formatted-layout caches from +/// . The revision advances on append and clear, so an +/// unchanged transcript does not require a queue snapshot each frame. /// /// public sealed class ChatVM @@ -69,6 +68,9 @@ public sealed class ChatVM /// public Func? PositionProvider { get; init; } + /// Monotonic revision of the underlying transcript content. + public long Revision => _log.Revision; + /// /// Build a ChatVM bound to a instance. /// diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs index ca13519e..9a747f0d 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs @@ -37,14 +37,15 @@ public sealed record DisplaySettings( ParticleRange ParticleRange) { /// Values used on first launch / when settings.json is absent. - /// Geometry/render defaults preserve the pre-L.0 runtime state — Resolution - /// matches the WindowOptions startup size (1280×720), FieldOfView - /// matches camera FovY (60°), VSync matches WindowOptions (false), + /// Geometry defaults preserve the pre-L.0 runtime state: Resolution + /// matches the WindowOptions startup size (1280×720) and FieldOfView + /// matches camera FovY (60°). VSync defaults on so normal rendering is + /// synchronized to the active monitor, while /// ShowFps matches retail's initially-hidden SmartBox FPS readout. public static DisplaySettings Default { get; } = new( Resolution: "1280x720", Fullscreen: false, - VSync: false, + VSync: true, FieldOfView: 60f, Gamma: 1.0f, ShowFps: false, diff --git a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj index 272953e3..260829c6 100644 --- a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj +++ b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/tests/AcDream.App.Tests/BoundedTestDatCollection.cs b/tests/AcDream.App.Tests/BoundedTestDatCollection.cs new file mode 100644 index 00000000..931faad3 --- /dev/null +++ b/tests/AcDream.App.Tests/BoundedTestDatCollection.cs @@ -0,0 +1,76 @@ +using AcDream.Content; +using AcDream.Core.Content; +using DatReaderWriter.Lib.IO; +using DatReaderWriter.Options; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; + +namespace AcDream.App.Tests; + +/// +/// Test-only raw DatCollection which can be passed through the same bounded +/// abstraction as production without changing DAT fixture setup code. +/// +public sealed class BoundedTestDatCollection : DatReaderWriter.DatCollection, IDatReaderWriter +{ + private readonly DatCollectionAdapter _bounded; + + public BoundedTestDatCollection( + string datDirectory, + DatAccessType datAccessType = DatAccessType.Read) + : base(new DatCollectionOptions + { + DatDirectory = datDirectory, + AccessType = datAccessType, + IndexCachingStrategy = IndexCachingStrategy.OnDemand, + FileCachingStrategy = FileCachingStrategy.Never, + }) + { + _bounded = new DatCollectionAdapter(this); + } + + public BoundedTestDatCollection(DatCollectionOptions options) + : base(options) + { + _bounded = new DatCollectionAdapter(this); + } + + string IDatReaderWriter.SourceDirectory => _bounded.SourceDirectory; + IDatDatabase IDatReaderWriter.Portal => _bounded.Portal; + IDatDatabase IDatReaderWriter.Cell => _bounded.Cell; + ReadOnlyDictionary IDatReaderWriter.CellRegions => _bounded.CellRegions; + IDatDatabase IDatReaderWriter.HighRes => _bounded.HighRes; + IDatDatabase IDatReaderWriter.Language => _bounded.Language; + IDatDatabase IDatReaderWriter.Local => _bounded.Local; + ReadOnlyDictionary IDatReaderWriter.RegionFileMap => _bounded.RegionFileMap; + int IDatReaderWriter.PortalIteration => _bounded.PortalIteration; + int IDatReaderWriter.CellIteration => _bounded.CellIteration; + int IDatReaderWriter.HighResIteration => _bounded.HighResIteration; + int IDatReaderWriter.LanguageIteration => _bounded.LanguageIteration; + + [return: MaybeNull] + T IDatObjectSource.Get(uint fileId) => + _bounded.Get(fileId); + + bool IDatObjectSource.TryGet(uint fileId, [MaybeNullWhen(false)] out T value) + => _bounded.TryGet(fileId, out value); + + IEnumerable IDatReaderWriter.GetAllIdsOfType() => + _bounded.GetAllIdsOfType(); + + bool IDatReaderWriter.TryGetFileBytes( + uint regionId, + uint fileId, + ref byte[] bytes, + out int bytesRead) => + _bounded.TryGetFileBytes(regionId, fileId, ref bytes, out bytesRead); + + IEnumerable IDatReaderWriter.ResolveId(uint id) => + _bounded.ResolveId(id); + + bool IDatReaderWriter.TrySave(T obj, int iteration) => + _bounded.TrySave(obj, iteration); + + bool IDatReaderWriter.TrySave(uint regionId, T obj, int iteration) => + _bounded.TrySave(regionId, obj, iteration); +} diff --git a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs index 0be47c48..abd03fb0 100644 --- a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs +++ b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs @@ -197,6 +197,45 @@ public sealed class ProjectileControllerTests Assert.True(entity.Position.X > pendingPosition.X); } + [Fact] + public void LandblockUnload_SuspendsProjectileAtVisibilityEdgeWithoutFrameScan() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + WorldEntity entity = record.WorldEntity!; + fixture.Engine.ShadowObjects.Register( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation, + radius: 0.5f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: 0x01010000u, + state: (uint)MissileState, + seedCellId: CellA); + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0, 1, 1)); + Assert.True(record.PhysicsBody!.InWorld); + Assert.True(record.PhysicsBody.IsActive); + Assert.Single(fixture.Live.SpatialProjectileRuntimes); + + fixture.Spatial.RemoveLandblock(0x0101FFFFu); + + Assert.False(record.IsSpatiallyVisible); + Assert.Empty(fixture.Live.SpatialProjectileRuntimes); + Assert.False(record.PhysicsBody.InWorld); + Assert.False(record.PhysicsBody.IsActive); + Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered); + + fixture.Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + Assert.Single(fixture.Live.SpatialProjectileRuntimes); + fixture.Controller.Tick(1.1, 1, 1, playerWorldPosition: null); + + Assert.True(record.PhysicsBody.InWorld); + Assert.True(record.PhysicsBody.IsActive); + Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); + } + [Fact] public void LoadedLandblockCrossing_RebucketsSameProjection() { @@ -909,7 +948,7 @@ public sealed class ProjectileControllerTests body.Position = new Vector3(191f, 10f, 50f); entity.SetPosition(body.Position); remote.CellId = startCell; - Assert.True(fixture.Controller.LeaveWorld(Guid)); + Assert.True(fixture.Controller.LeaveWorld(record)); fixture.Controller.Tick(1.6, 0xA9, 0xB4, playerWorldPosition: null); Assert.True(body.InWorld); Assert.Contains( @@ -1033,7 +1072,7 @@ public sealed class ProjectileControllerTests new PickupEvent.Parsed(Guid, InstanceSequence: 4, PositionSequence: 2), out _)); Assert.True(fixture.Live.WithdrawLiveEntityProjection(Guid)); - Assert.True(fixture.Controller.LeaveWorld(Guid)); + Assert.True(fixture.Controller.LeaveWorld(record)); Assert.DoesNotContain( fixture.Engine.ShadowObjects.GetObjectsInCell(CellA), entry => entry.EntityId == entity.Id); diff --git a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs index 40c8dfd5..d199ff70 100644 --- a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs @@ -2,8 +2,13 @@ using System.Numerics; using AcDream.App.Physics; using AcDream.App.Rendering; using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; +using DatReaderWriter.DBObjs; namespace AcDream.App.Tests.Physics; @@ -136,6 +141,45 @@ public sealed class RemotePhysicsUpdaterTests Assert.False(motion.Airborne); } + [Fact] + public void TickHiddenEntities_DeletionCallbackCannotAdvanceStaleSnapshotOwner() + { + const uint firstGuid = 0x70000011u; + const uint secondGuid = 0x70000012u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + 0x0101FFFFu, + new LandBlock(), + Array.Empty())); + var live = new LiveEntityRuntime( + spatial, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + BindHiddenRemote(live, firstGuid); + BindHiddenRemote(live, secondGuid); + Assert.Equal(2, live.SpatialRemoteMotionRuntimes.Count); + + var updater = new RemotePhysicsUpdater( + new PhysicsEngine(), + (_, _) => (0.48f, 1.835f), + (_, _, _, _) => { }); + var published = new List(); + updater.TickHiddenEntities( + live, + localPlayerServerGuid: 0x50000001u, + dt: 0.1f, + entity => + { + published.Add(entity.ServerGuid); + uint other = entity.ServerGuid == firstGuid ? secondGuid : firstGuid; + Assert.True(live.UnregisterLiveEntity( + new DeleteObject.Parsed(other, InstanceSequence: 1), + isLocalPlayer: false)); + }); + + Assert.Single(published); + Assert.Single(live.SpatialRemoteMotionRuntimes); + } + private static PhysicsEngine BuildBoundaryEngine() { static TerrainSurface FlatTerrain() @@ -163,4 +207,77 @@ public sealed class RemotePhysicsUpdaterTests 0f); return engine; } + + private static void BindHiddenRemote(LiveEntityRuntime live, uint guid) + { + const uint cellId = 0x01010001u; + PhysicsStateFlags state = PhysicsStateFlags.Hidden + | PhysicsStateFlags.IgnoreCollisions; + var position = new CreateObject.ServerPosition( + cellId, 10f, 10f, 5f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1); + var physics = new PhysicsSpawnData( + RawState: (uint)state, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + live.RegisterLiveEntity(new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "hidden fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)state, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics)); + WorldEntity entity = live.MaterializeLiveEntity( + guid, + cellId, + id => new WorldEntity + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(10f, 10f, 5f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = cellId, + })!; + var remote = new GameWindow.RemoteMotion(); + remote.Body.Position = entity.Position; + remote.Body.Orientation = entity.Rotation; + remote.CellId = cellId; + remote.Interp.Enqueue( + entity.Position + Vector3.UnitX, + heading: 0f, + isMovingTo: false, + currentBodyPosition: entity.Position); + live.SetRemoteMotionRuntime(guid, remote); + } } diff --git a/tests/AcDream.App.Tests/Rendering/BoundedUnownedResourceCacheTests.cs b/tests/AcDream.App.Tests/Rendering/BoundedUnownedResourceCacheTests.cs new file mode 100644 index 00000000..e93d201a --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/BoundedUnownedResourceCacheTests.cs @@ -0,0 +1,100 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class BoundedUnownedResourceCacheTests +{ + [Fact] + public void ReacquireRemovesResourceFromBudgetAndEvictionOrder() + { + var cache = new BoundedUnownedResourceCache(budgetBytes: 10); + cache.MarkUnowned("first", 6); + cache.MarkUnowned("second", 6); + + Assert.True(cache.MarkOwned("first")); + Assert.Equal(1, cache.Count); + Assert.Equal(6, cache.ResidentBytes); + Assert.False(cache.TryTakeOldestOverBudget(out _)); + } + + [Fact] + public void TakesOnlyOldestResourceWhileOverBudget() + { + var cache = new BoundedUnownedResourceCache(budgetBytes: 10); + cache.MarkUnowned("first", 6); + cache.MarkUnowned("second", 6); + cache.MarkUnowned("third", 6); + + Assert.True(cache.TryTakeOldestOverBudget(out string first)); + Assert.Equal("first", first); + Assert.Equal(12, cache.ResidentBytes); + + Assert.True(cache.TryTakeOldestOverBudget(out string second)); + Assert.Equal("second", second); + Assert.Equal(6, cache.ResidentBytes); + + Assert.False(cache.TryTakeOldestOverBudget(out _)); + } + + [Fact] + public void RemarkingUnownedRefreshesLruPositionWithoutDoubleCounting() + { + var cache = new BoundedUnownedResourceCache(budgetBytes: 5); + cache.MarkUnowned("first", 4); + cache.MarkUnowned("second", 4); + cache.MarkUnowned("first", 4); + + Assert.Equal(2, cache.Count); + Assert.Equal(8, cache.ResidentBytes); + Assert.True(cache.TryTakeOldestOverBudget(out string key)); + Assert.Equal("second", key); + } + + [Fact] + public void SizeMutationForSameKeyIsRejected() + { + var cache = new BoundedUnownedResourceCache(budgetBytes: 100); + cache.MarkUnowned("key", 4); + + Assert.Throws(() => cache.MarkUnowned("key", 8)); + } + + [Fact] + public void PhysicalBudgetCanTakeOldestEvenBelowLogicalBudget() + { + var cache = new BoundedUnownedResourceCache(budgetBytes: 100); + cache.MarkUnowned("old", 4); + cache.MarkUnowned("new", 4); + + Assert.False(cache.TryTakeOldestOverBudget(out _)); + Assert.True(cache.TryTakeOldest(out string key)); + Assert.Equal("old", key); + Assert.Equal(4, cache.ResidentBytes); + } + + [Fact] + public void ExactByteAndCountLimitsRemainRetained() + { + var cache = new BoundedUnownedResourceCache(budgetBytes: 64, maximumCount: 2); + cache.MarkUnowned(1, 32); + cache.MarkUnowned(2, 32); + + Assert.False(cache.TryTakeOldestOverBudget(out _)); + Assert.Equal(2, cache.Count); + Assert.Equal(64, cache.ResidentBytes); + } + + [Fact] + public void CountOverflowTakesOnlyOneOldestResource() + { + var cache = new BoundedUnownedResourceCache(budgetBytes: 1_000, maximumCount: 2); + cache.MarkUnowned("oldest", 1); + cache.MarkUnowned("middle", 1); + cache.MarkUnowned("newest", 1); + + Assert.True(cache.TryTakeOldestOverBudget(out string victim)); + Assert.Equal("oldest", victim); + Assert.Equal(2, cache.Count); + Assert.False(cache.TryTakeOldestOverBudget(out _)); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/BuildingGroupScratchTests.cs b/tests/AcDream.App.Tests/Rendering/BuildingGroupScratchTests.cs new file mode 100644 index 00000000..c256ca62 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/BuildingGroupScratchTests.cs @@ -0,0 +1,252 @@ +using System.Collections.Generic; +using System.Linq; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public sealed class BuildingGroupScratchTests +{ + [Fact] + public void Rebuild_DropsHistoricalKeys_BoundsRetention_AndPreservesEncounterOrder() + { + var scratch = new BuildingGroupScratch(); + int pathologicalCount = BuildingGroupScratch.MaxRetainedGroups * 4; + var pathological = new List(pathologicalCount); + for (int i = 0; i < pathologicalCount; i++) + { + pathological.Add(new LoadedCell + { + CellId = (uint)i + 1u, + BuildingId = 0x1000_0000u + (uint)i, + }); + } + + scratch.Rebuild(pathological); + Assert.Equal(pathologicalCount, scratch.ActiveGroupCount); + Assert.True(scratch.MapCapacity > BuildingGroupScratch.MaxRetainedGroups); + + var firstA = new LoadedCell { CellId = 0xAA01u, BuildingId = 0x20u }; + var second = new LoadedCell { CellId = 0xBB01u, BuildingId = 0x10u }; + var firstB = new LoadedCell { CellId = 0xAA02u, BuildingId = 0x20u }; + var unstamped = new LoadedCell { CellId = 0xCC01u }; + scratch.Rebuild([firstA, second, firstB, unstamped]); + + Assert.Equal(3, scratch.ActiveGroupCount); + Assert.Equal( + new uint[] { 0x20u, 0x10u, unstamped.CellId }, + scratch.Groups.Keys.ToArray()); + Assert.Equal(new[] { firstA, firstB }, scratch.Groups[0x20u]); + Assert.Equal(new[] { second }, scratch.Groups[0x10u]); + Assert.Equal(new[] { unstamped }, scratch.Groups[unstamped.CellId]); + Assert.DoesNotContain( + scratch.Groups.Keys, + key => key >= 0x1000_0000u); + Assert.InRange( + scratch.RetainedListCount, + 0, + BuildingGroupScratch.MaxRetainedGroups); + Assert.InRange( + scratch.MapCapacity, + 0, + BuildingGroupScratch.MaxRetainedGroups); + + scratch.Reset(); + + Assert.Equal(0, scratch.ActiveGroupCount); + Assert.Empty(scratch.Groups); + Assert.InRange( + scratch.RetainedListCount, + 0, + BuildingGroupScratch.MaxRetainedGroups); + Assert.InRange( + scratch.MapCapacity, + 0, + BuildingGroupScratch.MaxRetainedGroups); + } + + [Fact] + public void Reset_DoesNotRetainPathologicalGroupBackingArray() + { + var scratch = new BuildingGroupScratch(); + var oversizedGroup = new List( + BuildingGroupScratch.MaxRetainedCellsPerGroup * 2); + for (int i = 0; + i < BuildingGroupScratch.MaxRetainedCellsPerGroup * 2; + i++) + { + oversizedGroup.Add(new LoadedCell + { + CellId = (uint)i + 1u, + BuildingId = 0x42u, + }); + } + + scratch.Rebuild(oversizedGroup); + Assert.Single(scratch.Groups); + Assert.True( + scratch.Groups[0x42u].Capacity + > BuildingGroupScratch.MaxRetainedCellsPerGroup); + + scratch.Reset(); + + Assert.Equal(0, scratch.RetainedListCount); + Assert.Empty(scratch.Groups); + } +} + +public sealed class RetailPViewScratchRetentionTests +{ + [Fact] + public void ClearFrameBuffers_DropsPathologicalHighWater_AfterIdleHysteresis() + { + var retention = new RetailPViewScratchRetention(); + int pathologicalCellCount = + RetailPViewScratchRetention.MaxRetainedCellItems * 4; + int pathologicalFrameCount = + RetailPViewScratchRetention.MaxRetainedLookInFrames * 4; + var lookInFrames = new List(pathologicalFrameCount); + var lookInPrepare = new HashSet(); + var drawableCells = new HashSet(); + var shellBatch = new HashSet(); + var orderedTransparent = new List(pathologicalCellCount); + + for (int i = 0; i < pathologicalFrameCount; i++) + lookInFrames.Add(new PortalVisibilityFrame()); + for (int i = 0; i < pathologicalCellCount; i++) + { + uint id = (uint)i; + lookInPrepare.Add(id); + drawableCells.Add(id); + shellBatch.Add(id); + orderedTransparent.Add(id); + } + + Assert.True( + lookInFrames.Capacity + > RetailPViewScratchRetention.MaxRetainedLookInFrames); + Assert.True( + drawableCells.EnsureCapacity(0) + > RetailPViewScratchRetention.MaxRetainedCellItems); + + retention.ClearFrameBuffers( + lookInFrames, + lookInPrepare, + drawableCells, + shellBatch, + orderedTransparent); + + Assert.Empty(lookInFrames); + Assert.Empty(lookInPrepare); + Assert.Empty(drawableCells); + Assert.Empty(shellBatch); + Assert.Empty(orderedTransparent); + Assert.True( + lookInFrames.Capacity + > RetailPViewScratchRetention.MaxRetainedLookInFrames); + + for (int i = 0; i < RetailPViewScratchRetention.CapacityTrimIdleFrames; i++) + { + retention.ClearFrameBuffers( + lookInFrames, + lookInPrepare, + drawableCells, + shellBatch, + orderedTransparent); + } + + Assert.InRange( + lookInFrames.Capacity, + 0, + RetailPViewScratchRetention.MaxRetainedLookInFrames); + Assert.InRange( + lookInPrepare.EnsureCapacity(0), + 0, + RetailPViewScratchRetention.MaxRetainedCellItems); + Assert.InRange( + drawableCells.EnsureCapacity(0), + 0, + RetailPViewScratchRetention.MaxRetainedCellItems); + Assert.InRange( + shellBatch.EnsureCapacity(0), + 0, + RetailPViewScratchRetention.MaxRetainedCellItems); + Assert.InRange( + orderedTransparent.Capacity, + 0, + RetailPViewScratchRetention.MaxRetainedCellItems); + } + + [Fact] + public void ClearFrameBuffers_PreservesNormalWarmCapacity() + { + var retention = new RetailPViewScratchRetention(); + var lookInFrames = new List(8); + var lookInPrepare = new HashSet(); + var drawableCells = new HashSet(); + var shellBatch = new HashSet(); + var orderedTransparent = new List(64); + for (uint id = 0; id < 64; id++) + { + lookInPrepare.Add(id); + drawableCells.Add(id); + shellBatch.Add(id); + orderedTransparent.Add(id); + } + int lookInCapacity = lookInFrames.Capacity; + int prepareCapacity = lookInPrepare.EnsureCapacity(0); + int drawableCapacity = drawableCells.EnsureCapacity(0); + int shellCapacity = shellBatch.EnsureCapacity(0); + int transparentCapacity = orderedTransparent.Capacity; + + retention.ClearFrameBuffers( + lookInFrames, + lookInPrepare, + drawableCells, + shellBatch, + orderedTransparent); + + Assert.Equal(lookInCapacity, lookInFrames.Capacity); + Assert.Equal(prepareCapacity, lookInPrepare.EnsureCapacity(0)); + Assert.Equal(drawableCapacity, drawableCells.EnsureCapacity(0)); + Assert.Equal(shellCapacity, shellBatch.EnsureCapacity(0)); + Assert.Equal(transparentCapacity, orderedTransparent.Capacity); + } + + [Fact] + public void ClearFrameBuffers_RecurringLargeWorkingSet_PreservesWarmCapacity() + { + const int recurringCount = + RetailPViewScratchRetention.MaxRetainedCellItems + 163; + var retention = new RetailPViewScratchRetention(); + var lookInFrames = new List(); + var lookInPrepare = new HashSet(); + var drawableCells = new HashSet(); + var shellBatch = new HashSet(); + var orderedTransparent = new List(); + + for (int iteration = 0; + iteration < RetailPViewScratchRetention.CapacityTrimIdleFrames + 5; + iteration++) + { + for (int i = 0; i < recurringCount; i++) + { + uint id = (uint)i; + drawableCells.Add(id); + orderedTransparent.Add(id); + } + + int drawableCapacity = drawableCells.EnsureCapacity(0); + int transparentCapacity = orderedTransparent.Capacity; + retention.ClearFrameBuffers( + lookInFrames, + lookInPrepare, + drawableCells, + shellBatch, + orderedTransparent); + + Assert.Equal(drawableCapacity, drawableCells.EnsureCapacity(0)); + Assert.Equal(transparentCapacity, orderedTransparent.Capacity); + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs b/tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs index 1e545007..2b7763dd 100644 --- a/tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs @@ -61,4 +61,44 @@ public class CellViewDedupTests Assert.True(v.Add(Quad(0.4f, 0f))); Assert.Equal(2, v.Polygons.Count); } + + [Fact] + public void Add_RepeatedDuplicate_DoesNotAllocatePerProbe() + { + var view = new CellView(); + ViewPolygon polygon = Quad(0f, 0f); + Assert.True(view.Add(polygon)); + + // Warm the method/JIT before measuring. Duplicate portal emissions are the hot path. + Assert.False(view.Add(polygon)); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 1_000; i++) + Assert.False(view.Add(polygon)); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(allocated <= 256, $"duplicate probes allocated {allocated:N0} bytes"); + } + + [Fact] + public void ResetAndRebuild_ReusesCanonicalKeyStorage() + { + var view = new CellView(); + ViewPolygon polygon = Quad(0f, 0f); + + // Warm the retained polygon list, hash table, collision link, and + // coordinate payload before measuring frame-to-frame rebuilds. + Assert.True(view.Add(polygon)); + view.Reset(); + Assert.True(view.Add(polygon)); + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 1_000; i++) + { + view.Reset(); + Assert.True(view.Add(polygon)); + } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(allocated <= 256, $"steady rebuilds allocated {allocated:N0} bytes"); + } } diff --git a/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs b/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs index 8962529a..679d7a1f 100644 --- a/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs @@ -206,4 +206,97 @@ public class ClipFrameAssemblerTests Assert.False(asm2.OutdoorVisible); Assert.Equal(TerrainClipMode.Skip, asm2.TerrainMode); } + + [Fact] + public void Assemble_ReusedAssembly_ClearsStateAndReusesExactLengthArrays() + { + const uint firstCell = 0xA9B40100; + const uint secondCell = 0xA9B40200; + var frame = ClipFrame.NoClip(); + var reuse = new ClipFrameAssembly(); + + var first = new PortalVisibilityFrame(); + first.CellViews[firstCell] = ViewOf( + Square(-0.4f, 0f, 0.15f), + Square(0.4f, 0f, 0.15f)); + first.OrderedVisibleCells.Add(firstCell); + first.OutsideView.Add(Square(0f, 0.5f, 0.2f)); + ClipFrameAssembly firstResult = ClipFrameAssembler.Assemble(frame, first, reuse); + Assert.Same(reuse, firstResult); + int sliceAllocations = reuse.SliceArrayAllocationCount; + int slotAllocations = reuse.SlotArrayAllocationCount; + + var second = new PortalVisibilityFrame(); + second.CellViews[secondCell] = ViewOf( + Square(-0.4f, 0f, 0.15f), + Square(0.4f, 0f, 0.15f)); + second.OrderedVisibleCells.Add(secondCell); + second.OutsideView.Add(Square(0f, 0.5f, 0.2f)); + + for (int i = 0; i < 12; i++) + { + ClipFrameAssembly actual = ClipFrameAssembler.Assemble(frame, second, reuse); + Assert.Same(reuse, actual); + Assert.DoesNotContain(firstCell, actual.CellIdToViewSlices.Keys); + Assert.Contains(secondCell, actual.CellIdToViewSlices.Keys); + Assert.Equal(2, actual.CellIdToViewSlices[secondCell].Length); + Assert.Equal(2, actual.CellIdToViewSlots[secondCell].Length); + Assert.Single(actual.OutsideViewSlices); + Assert.Equal(sliceAllocations, reuse.SliceArrayAllocationCount); + Assert.Equal(slotAllocations, reuse.SlotArrayAllocationCount); + } + + var empty = new PortalVisibilityFrame(); + ClipFrameAssembly cleared = ClipFrameAssembler.Assemble(frame, empty, reuse); + Assert.Empty(cleared.CellIdToSlot); + Assert.Empty(cleared.CellIdToViewSlots); + Assert.Empty(cleared.CellIdToViewSlices); + Assert.Empty(cleared.PerCellPlaneCounts); + Assert.Empty(cleared.OutsideViewSlices); + Assert.Equal(TerrainClipMode.Skip, cleared.TerrainMode); + } + + [Fact] + public void Assemble_ReusedAssembly_ChangingCardinalityStaysBoundedThenReachesSteadyState() + { + const uint cellId = 0xA9B40100; + var frame = ClipFrame.NoClip(); + var reuse = new ClipFrameAssembly(); + + static PortalVisibilityFrame FrameWithSlices(uint id, int count) + { + var portalFrame = new PortalVisibilityFrame(); + var view = new CellView(); + for (int i = 0; i < count; i++) + { + // The assembler consumes the retail view_poly list directly. Distinct placement is + // irrelevant to this cache-cardinality stress; avoid CellView dedup collapsing it. + view.Polygons.Add(Square(i * 0.001f, 0f, 0.0004f)); + } + portalFrame.CellViews[id] = view; + portalFrame.OrderedVisibleCells.Add(id); + return portalFrame; + } + + for (int count = 1; count <= 180; count++) + ClipFrameAssembler.Assemble(frame, FrameWithSlices(cellId, count), reuse); + + // Flush the final live arrays into the cache too. + ClipFrameAssembler.Assemble(frame, new PortalVisibilityFrame(), reuse); + Assert.InRange(reuse.RetainedSliceItems, 0, ClipFrameAssembly.MaxRetainedSliceItems); + Assert.InRange(reuse.RetainedSlotItems, 0, ClipFrameAssembly.MaxRetainedSlotItems); + Assert.InRange(reuse.RetainedSliceArrays, 0, ClipFrameAssembly.MaxRetainedArraysPerPool); + Assert.InRange(reuse.RetainedSlotArrays, 0, ClipFrameAssembly.MaxRetainedArraysPerPool); + + PortalVisibilityFrame steady = FrameWithSlices(cellId, 73); + ClipFrameAssembler.Assemble(frame, steady, reuse); + int sliceAllocations = reuse.SliceArrayAllocationCount; + int slotAllocations = reuse.SlotArrayAllocationCount; + for (int i = 0; i < 16; i++) + { + ClipFrameAssembler.Assemble(frame, steady, reuse); + Assert.Equal(sliceAllocations, reuse.SliceArrayAllocationCount); + Assert.Equal(slotAllocations, reuse.SlotArrayAllocationCount); + } + } } diff --git a/tests/AcDream.App.Tests/Rendering/ClipFrameUploadTests.cs b/tests/AcDream.App.Tests/Rendering/ClipFrameUploadTests.cs new file mode 100644 index 00000000..24704a28 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ClipFrameUploadTests.cs @@ -0,0 +1,130 @@ +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public sealed class ClipFrameUploadTests +{ + [Theory] + [InlineData(1, 144)] + [InlineData(16, 144)] + [InlineData(64, 192)] + [InlineData(256, 256)] + [InlineData(512, 512)] + public void TerrainArena_RecordStride_RespectsDriverAlignment( + int alignment, + int expectedStride) + { + Assert.Equal(expectedStride, ClipFrameArenaLayout.RecordStride(alignment)); + } + + [Fact] + public void TerrainArena_AssignsEverySliceAUniqueOrderedRange() + { + const int stride = 256; + Assert.Equal(0, ClipFrameArenaLayout.RecordOffset(0, stride)); + Assert.Equal(256, ClipFrameArenaLayout.RecordOffset(1, stride)); + Assert.Equal(512, ClipFrameArenaLayout.RecordOffset(2, stride)); + Assert.Equal(1280, ClipFrameArenaLayout.RequiredBytes(5, stride)); + } + + [Fact] + public void UploadState_RegionsOnce_AndTerrainRangesInSubmissionOrder() + { + var state = new ClipFrameUploadState(); + state.BeginFrame(); + state.ValidateRegionsNotUploaded(); + state.MarkRegionsUploaded(); + Assert.True(state.RegionsUploaded); + Assert.Throws(state.ValidateRegionsNotUploaded); + + state.ValidateTerrainReservation(4); + state.CommitTerrainReservation(4); + Assert.Equal(new[] { 0, 1, 2, 3 }, new[] + { + state.NextTerrainRecord(), + state.NextTerrainRecord(), + state.NextTerrainRecord(), + state.NextTerrainRecord(), + }); + Assert.Equal(4, state.TerrainUploaded); + Assert.Throws(() => state.NextTerrainRecord()); + + state.BeginFrame(); + Assert.False(state.RegionsUploaded); + Assert.Equal(0, state.TerrainReserved); + Assert.Equal(0, state.TerrainUploaded); + } + + [Fact] + public void ResourceRing_RetainsAtMostOneResourcePerFencedFrameSlot() + { + var ring = new ClipFrameResourceRing(ClipFrame.FrameSlotCount); + for (int slot = 0; slot < ClipFrame.FrameSlotCount; slot++) + ring.Set(slot, new object()); + + // Pathological slice counts reuse ranges in the frame slot's one arena; + // they cannot add another GL object to the ring. + for (int slice = 0; slice < 10_000; slice++) + Assert.True(ring.TryGet(slice % ClipFrame.FrameSlotCount, out _)); + + Assert.Equal(ClipFrame.FrameSlotCount, ring.Count); + Assert.Throws(() => ring.Set(0, new object())); + } + + [Fact] + public void CapacityPolicy_ShrinksOneOffPathologicalPeakAfterHysteresis() + { + var policy = new ClipBufferCapacityPolicy(); + int peak = policy.SelectCapacity(0, 1_000_000); + Assert.True(peak >= 1_000_000); + + int first = policy.SelectCapacity(peak, 4_096); + int second = policy.SelectCapacity(first, 4_096); + int third = policy.SelectCapacity(second, 4_096); + + Assert.Equal(peak, first); + Assert.Equal(peak, second); + Assert.Equal(4_096, third); + } + + [Fact] + public void CapacityPolicy_OrdinaryDemandJitterCancelsPendingShrink() + { + var policy = new ClipBufferCapacityPolicy(); + int capacity = policy.SelectCapacity(0, 65_536); + capacity = policy.SelectCapacity(capacity, 4_096); + capacity = policy.SelectCapacity(capacity, 20_000); // above 25% utilization + capacity = policy.SelectCapacity(capacity, 4_096); + capacity = policy.SelectCapacity(capacity, 4_096); + + Assert.Equal(65_536, capacity); + } + + [Fact] + public void CapacityTransaction_PublishesOnlySuccessfulResize() + { + int capacity = 4_096; + Assert.Throws(() => + ClipBufferCapacityTransaction.Resize( + ref capacity, + 8_192, + (_, _) => throw new InvalidOperationException("BufferData failed"))); + Assert.Equal(4_096, capacity); + + ClipBufferCapacityTransaction.Resize( + ref capacity, + 8_192, + (previous, next) => + { + Assert.Equal(4_096, previous); + Assert.Equal(8_192, next); + }); + Assert.Equal(8_192, capacity); + + // A later stage failure must not roll accounting back to the old store. + Action laterFailure = () => throw new InvalidOperationException("later bind failed"); + Assert.Throws(laterFailure); + Assert.Equal(8_192, capacity); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs b/tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs index 52bca483..cc1a57d7 100644 --- a/tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs @@ -32,6 +32,27 @@ public class ClipPlaneSetTests return cv; } + [Fact] + public void From_ViewPolygon_MatchesSinglePolygonCellView() + { + var polygon = new ViewPolygon(new[] + { + new Vector2(-0.7f, -0.4f), new Vector2(0.2f, -0.4f), + new Vector2(0.7f, 0.3f), new Vector2(-0.5f, 0.6f), + }); + var view = new CellView(); + Assert.True(view.Add(polygon)); + + var fromView = ClipPlaneSet.From(view); + var fromPolygon = ClipPlaneSet.From(polygon); + + Assert.Equal(fromView.Count, fromPolygon.Count); + Assert.Equal(fromView.UseScissorFallback, fromPolygon.UseScissorFallback); + Assert.Equal(fromView.IsNothingVisible, fromPolygon.IsNothingVisible); + Assert.Equal(fromView.ScissorNdcAabb, fromPolygon.ScissorNdcAabb); + Assert.Equal(fromView.Planes, fromPolygon.Planes); + } + // --- The three required tests (verbatim intent from the plan) ------------- [Fact] diff --git a/tests/AcDream.App.Tests/Rendering/CompositeTextureArrayCachePolicyTests.cs b/tests/AcDream.App.Tests/Rendering/CompositeTextureArrayCachePolicyTests.cs new file mode 100644 index 00000000..fe69eb5a --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/CompositeTextureArrayCachePolicyTests.cs @@ -0,0 +1,609 @@ +using AcDream.App.Rendering; +using AcDream.Core.Textures; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Rendering; + +public sealed class CompositeTextureArrayCachePolicyTests +{ + [Theory] + [InlineData(16, 16, 64)] + [InlineData(128, 128, 64)] + [InlineData(256, 256, 16)] + [InlineData(512, 512, 4)] + [InlineData(1024, 1024, 1)] + public void CapacityTargetsFourMiBWithoutOversizingLargeLayers( + int width, + int height, + int expected) + { + Assert.Equal( + expected, + CompositeTextureArrayCache.CalculateLayerCapacity(width, height, driverMaximumLayers: 2048)); + } + + [Fact] + public void CapacityHonorsDriverLayerLimit() + { + Assert.Equal( + 8, + CompositeTextureArrayCache.CalculateLayerCapacity(16, 16, driverMaximumLayers: 8)); + } + + [Fact] + public void CompatibleTexturesShareArrayAndOwnersShareExactSlice() + { + var backend = new FakeBackend(maximumLayers: 2); + var retirements = new DeferredRetirementQueue(); + using var cache = CreateCache(backend, retirements); + cache.BeginFrame(); + + Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out BindlessTextureLocation first)); + Assert.True(cache.TryAcquire(2, Key(1), out BindlessTextureLocation shared)); + Assert.True(cache.TryAddAndAcquire(1, Key(2), Texture(4, 4), out BindlessTextureLocation second)); + + Assert.Equal(first, shared); + Assert.Equal(first.Handle, second.Handle); + Assert.NotEqual(first.Layer, second.Layer); + Assert.Single(backend.Created); + Assert.Equal(2, backend.Uploads.Count); + Assert.Equal(2, cache.ActiveResourceCount); + } + + [Fact] + public void FinalReleaseCachesAndReacquireAvoidsUpload() + { + var backend = new FakeBackend(maximumLayers: 2); + using var cache = CreateCache(backend, new DeferredRetirementQueue()); + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out BindlessTextureLocation original)); + Assert.True(cache.TryAcquire(2, Key(1), out _)); + + cache.ReleaseOwner(1); + Assert.Equal(0, cache.UnownedEntryCount); + cache.ReleaseOwner(2); + Assert.Equal(1, cache.UnownedEntryCount); + Assert.Empty(backend.NonResident); + Assert.Empty(backend.Deleted); + + Assert.True(cache.TryAcquire(3, Key(1), out BindlessTextureLocation reacquired)); + Assert.Equal(original, reacquired); + Assert.Single(backend.Uploads); + Assert.Equal(0, cache.UnownedEntryCount); + } + + [Fact] + public void EvictedLayerCannotBeReusedBeforeFenceAndOldCallbackCannotTouchReplacement() + { + var backend = new FakeBackend(maximumLayers: 1); + var retirements = new DeferredRetirementQueue(); + using var cache = CreateCache( + backend, + retirements, + unownedBudgetBytes: 0, + physicalBudgetBytes: long.MaxValue); + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out BindlessTextureLocation old)); + cache.ReleaseOwner(1); + cache.Tick(); + + Assert.Equal(1, retirements.Count); + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(2, Key(1), Texture(4, 4), out BindlessTextureLocation replacement)); + Assert.NotEqual(old.Handle, replacement.Handle); + Assert.Equal(2, backend.Created.Count); + + retirements.DrainAll(); + Assert.True(cache.TryAcquire(3, Key(1), out BindlessTextureLocation stillReplacement)); + Assert.Equal(replacement, stillReplacement); + } + + [Fact] + public void MaintenanceBatchesLogicalEvictionButDeletesOnlyOneArrayPerTick() + { + var backend = new FakeBackend(maximumLayers: 1); + var retirements = new DeferredRetirementQueue(); + using var cache = CreateCache( + backend, + retirements, + unownedBudgetBytes: 0, + physicalBudgetBytes: 0); + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _)); + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(2), Texture(4, 4), out _)); + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(3), Texture(4, 4), out _)); + cache.ReleaseOwner(1); + + cache.Tick(); + Assert.Equal(0, cache.CachedEntryCount); + Assert.Equal(3, retirements.Count); + Assert.Empty(backend.Deleted); + + retirements.DrainAll(); + cache.Tick(); + Assert.Equal(0, cache.CachedEntryCount); + Assert.Single(backend.Deleted); + Assert.Equal( + ["nonresident:1", "delete:1"], + backend.Events.TakeLast(2)); + + cache.Tick(); + Assert.Equal(2, backend.Deleted.Count); + } + + [Fact] + public void UploadFailureRollsBackAndDeletesNewEmptyArray() + { + var backend = new FakeBackend(maximumLayers: 2) { FailNextUpload = true }; + using var cache = CreateCache(backend, new DeferredRetirementQueue()); + cache.BeginFrame(); + + Assert.Throws( + () => cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _)); + Assert.Equal(0, cache.CachedEntryCount); + Assert.Equal(0, cache.AtlasCount); + Assert.Equal(0, cache.AllocatedBytes); + Assert.Equal(["nonresident:1", "delete:1"], backend.Events); + } + + [Fact] + public void UploadAndRollbackFailureRetainsEmptyAtlasForMaintenanceRetry() + { + var backend = new FakeBackend(maximumLayers: 2) + { + FailNextUpload = true, + FailNextNonResident = true, + }; + using var cache = CreateCache(backend, new DeferredRetirementQueue()); + cache.BeginFrame(); + + Assert.Throws( + () => cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _)); + Assert.Equal(128, cache.AllocatedBytes); + Assert.Equal(1, cache.AtlasCount); + + cache.Tick(); + Assert.Equal(0, cache.AllocatedBytes); + Assert.Equal(0, cache.AtlasCount); + Assert.Equal(["nonresident:1", "delete:1"], backend.Events); + } + + [Fact] + public void DisposeMakesEveryHandleNonResidentBeforeDeletingAnyArray() + { + var backend = new FakeBackend(maximumLayers: 1); + var cache = CreateCache(backend, new DeferredRetirementQueue()); + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _)); + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(2), Texture(8, 8), out _)); + + cache.Dispose(); + + int firstDelete = backend.Events.FindIndex(e => e.StartsWith("delete:")); + Assert.Equal(2, backend.Events.Take(firstDelete).Count(e => e.StartsWith("nonresident:"))); + Assert.Equal(2, backend.Deleted.Count); + cache.Dispose(); + Assert.Equal(4, backend.Events.Count); + } + + [Fact] + public void StructuralPaletteEqualityRejectsSameHashWithDifferentRanges() + { + const ulong collision = 1234; + var left = new PaletteCompositeIdentity( + new PaletteOverride(1, [new PaletteOverride.SubPaletteRange(2, 3, 4)]), + collision); + var right = new PaletteCompositeIdentity( + new PaletteOverride(1, [new PaletteOverride.SubPaletteRange(9, 3, 4)]), + collision); + + Assert.NotEqual(left, right); + Assert.NotEqual( + new CompositeTextureKey(CompositeTextureKind.PaletteComposite, 1, 0, left), + new CompositeTextureKey(CompositeTextureKind.PaletteComposite, 1, 0, right)); + } + + [Fact] + public void UploadBudgetIncludesIncomingLayerAndResetsEachFrame() + { + var backend = new FakeBackend(maximumLayers: 8); + using var cache = new CompositeTextureArrayCache( + backend, + new DeferredRetirementQueue(), + unownedBudgetBytes: long.MaxValue, + physicalBudgetBytes: long.MaxValue, + maximumUploadsPerFrame: 8, + maximumUploadBytesPerFrame: 100); + + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _)); // 64 bytes + Assert.False(cache.TryAddAndAcquire(1, Key(2), Texture(4, 4), out _)); + Assert.False(cache.CanStartUpload); + Assert.Single(backend.Uploads); + + cache.BeginFrame(); + Assert.True(cache.CanStartUpload); + Assert.True(cache.TryAddAndAcquire(1, Key(2), Texture(4, 4), out _)); + Assert.Equal(2, backend.Uploads.Count); + } + + [Fact] + public void OversizedTextureIsAllowedAsOnlyUploadToGuaranteeProgress() + { + var backend = new FakeBackend(maximumLayers: 8); + using var cache = new CompositeTextureArrayCache( + backend, + new DeferredRetirementQueue(), + unownedBudgetBytes: long.MaxValue, + physicalBudgetBytes: long.MaxValue, + maximumUploadsPerFrame: 8, + maximumUploadBytesPerFrame: 32); + + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _)); + Assert.False(cache.TryAddAndAcquire(1, Key(2), Texture(1, 1), out _)); + Assert.Single(backend.Uploads); + } + + [Fact] + public void DimensionPreflightRejectsBeforeAllocatingDecodedPixelsForBlockedUpload() + { + var backend = new FakeBackend(maximumLayers: 8); + using var cache = new CompositeTextureArrayCache( + backend, + new DeferredRetirementQueue(), + unownedBudgetBytes: long.MaxValue, + physicalBudgetBytes: long.MaxValue, + maximumUploadsPerFrame: 8, + maximumUploadBytesPerFrame: 100); + + cache.BeginFrame(); + Assert.True(cache.CanPrepareUpload(4, 4)); + Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _)); // 64 bytes + Assert.False(cache.CanPrepareUpload(4, 4)); + Assert.False(cache.CanStartUpload); + Assert.Single(backend.Uploads); + } + + [Fact] + public void DimensionPreflightHonorsOneNewArrayPerFrame() + { + var backend = new FakeBackend(maximumLayers: 8); + using var cache = CreateCache(backend, new DeferredRetirementQueue()); + + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _)); + Assert.False(cache.CanPrepareUpload(8, 8)); + Assert.False(cache.CanStartUpload); + Assert.Single(backend.Created); + + cache.BeginFrame(); + cache.Tick(); + Assert.True(cache.CanPrepareUpload(8, 8)); + } + + [Fact] + public void PhysicalAdmissionReclaimsCompatibleLayerBeforeAllocatingAgain() + { + var backend = new FakeBackend(maximumLayers: 1); + var retirements = new DeferredRetirementQueue(); + using var cache = CreateCache( + backend, + retirements, + unownedBudgetBytes: long.MaxValue, + physicalBudgetBytes: 64); + + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out BindlessTextureLocation first)); + cache.ReleaseOwner(1); + + cache.BeginFrame(); + Assert.False(cache.TryAddAndAcquire(2, Key(2), Texture(4, 4), out _)); + Assert.Single(backend.Created); + Assert.Equal(64, cache.AllocatedBytes); + + cache.Tick(); + Assert.Single(retirements.Actions); + retirements.DrainAll(); + + cache.BeginFrame(); + cache.Tick(); + Assert.True(cache.TryAddAndAcquire(2, Key(2), Texture(4, 4), out BindlessTextureLocation reused)); + Assert.Equal(first.Handle, reused.Handle); + Assert.Single(backend.Created); + Assert.Equal(64, cache.AllocatedBytes); + } + + [Fact] + public void AtlasRelease_NonResidentFailureRetainsAtlasAndAccountingForRetry() + { + var backend = new FakeBackend(maximumLayers: 1); + var retirements = new DeferredRetirementQueue(); + using var cache = CreateCache( + backend, + retirements, + unownedBudgetBytes: 0, + physicalBudgetBytes: 0); + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _)); + cache.ReleaseOwner(1); + cache.Tick(); + retirements.DrainAll(); + + backend.FailNextNonResident = true; + Assert.Throws(cache.Tick); + Assert.Equal(64, cache.AllocatedBytes); + Assert.Equal(1, cache.AtlasCount); + Assert.Empty(backend.Deleted); + + cache.Tick(); + Assert.Equal(0, cache.AllocatedBytes); + Assert.Equal(0, cache.AtlasCount); + Assert.Single(backend.NonResident); + Assert.Single(backend.Deleted); + } + + [Fact] + public void AtlasRelease_DeleteFailureRevokesReuseButPreservesPhysicalAccounting() + { + var backend = new FakeBackend(maximumLayers: 1); + var retirements = new DeferredRetirementQueue(); + using var cache = CreateCache( + backend, + retirements, + unownedBudgetBytes: 0, + physicalBudgetBytes: 0); + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _)); + cache.ReleaseOwner(1); + cache.Tick(); + retirements.DrainAll(); + + backend.FailNextDelete = true; + Assert.Throws(cache.Tick); + Assert.Equal(64, cache.AllocatedBytes); + Assert.Single(backend.NonResident); + Assert.Empty(backend.Deleted); + + cache.BeginFrame(); + Assert.False(cache.TryAddAndAcquire(2, Key(2), Texture(4, 4), out _)); + Assert.Single(backend.Created); + + cache.Tick(); + Assert.Equal(0, cache.AllocatedBytes); + Assert.Single(backend.NonResident); + Assert.Single(backend.Deleted); + + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(2, Key(2), Texture(4, 4), out _)); + Assert.Equal(2, backend.Created.Count); + } + + [Fact] + public void AtlasRelease_PostCommitNonResidentFailureDoesNotReplayMutation() + { + var backend = new FakeBackend(maximumLayers: 1); + var retirements = new DeferredRetirementQueue(); + using var cache = CreateCache( + backend, + retirements, + unownedBudgetBytes: 0, + physicalBudgetBytes: 0); + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _)); + cache.ReleaseOwner(1); + cache.Tick(); + retirements.DrainAll(); + + backend.FailNextNonResidentAfterCommit = true; + Assert.Throws(cache.Tick); + Assert.Single(backend.NonResident); + Assert.Empty(backend.Deleted); + + cache.Tick(); + Assert.Single(backend.NonResident); + Assert.Single(backend.Deleted); + Assert.Equal(0, cache.AllocatedBytes); + } + + [Fact] + public void AtlasRelease_PostCommitDeleteFailureDoesNotDeleteOrAccountTwice() + { + var backend = new FakeBackend(maximumLayers: 1); + var retirements = new DeferredRetirementQueue(); + using var cache = CreateCache( + backend, + retirements, + unownedBudgetBytes: 0, + physicalBudgetBytes: 0); + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _)); + cache.ReleaseOwner(1); + cache.Tick(); + retirements.DrainAll(); + + backend.FailNextDeleteAfterCommit = true; + Assert.Throws(cache.Tick); + Assert.Single(backend.NonResident); + Assert.Single(backend.Deleted); + Assert.Equal(64, cache.AllocatedBytes); + + cache.Tick(); + Assert.Single(backend.Deleted); + Assert.Equal(0, cache.AllocatedBytes); + } + + [Fact] + public void LayerRetirement_PublicationFailureKeepsSlotUnavailableUntilRetry() + { + var backend = new FakeBackend(maximumLayers: 1); + var retirements = new DeferredRetirementQueue { FailNextRetire = true }; + using var cache = CreateCache( + backend, + retirements, + unownedBudgetBytes: 0, + physicalBudgetBytes: 64); + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _)); + cache.ReleaseOwner(1); + + Assert.Throws(cache.Tick); + Assert.Equal(0, cache.CachedEntryCount); + Assert.Empty(retirements.Actions); + + cache.BeginFrame(); + Assert.Single(retirements.Actions); + cache.BeginFrame(); + Assert.False(cache.TryAddAndAcquire(2, Key(2), Texture(4, 4), out _)); + + retirements.DrainAll(); + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(2, Key(2), Texture(4, 4), out _)); + Assert.Single(backend.Created); + } + + [Fact] + public void Dispose_ResidencyFailureDeletesNothingAndRetryResumesExactAtlas() + { + var backend = new FakeBackend(maximumLayers: 1); + var cache = CreateCache(backend, new DeferredRetirementQueue()); + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _)); + cache.BeginFrame(); + Assert.True(cache.TryAddAndAcquire(1, Key(2), Texture(8, 8), out _)); + + backend.FailNextNonResident = true; + Assert.Throws(cache.Dispose); + Assert.Empty(backend.Deleted); + Assert.Single(backend.NonResident); + + cache.Dispose(); + Assert.Equal(2, backend.NonResident.Count); + Assert.Equal(2, backend.Deleted.Count); + Assert.Equal(0, cache.AllocatedBytes); + } + + private static CompositeTextureArrayCache CreateCache( + FakeBackend backend, + DeferredRetirementQueue retirements, + long unownedBudgetBytes = long.MaxValue, + long physicalBudgetBytes = long.MaxValue) => + new( + backend, + retirements, + unownedBudgetBytes, + physicalBudgetBytes, + maximumUploadsPerFrame: 100, + maximumUploadBytesPerFrame: long.MaxValue); + + private static CompositeTextureKey Key(uint id) => + new(CompositeTextureKind.OriginalTextureOverride, id, id + 100, default); + + private static DecodedTexture Texture(int width, int height) => + new(new byte[width * height * 4], width, height); + + private sealed class DeferredRetirementQueue : IGpuResourceRetirementQueue + { + private readonly Queue _actions = new(); + public int Count => _actions.Count; + public IReadOnlyCollection Actions => _actions; + public bool FailNextRetire { get; set; } + public void Retire(Action release) + { + if (FailNextRetire) + { + FailNextRetire = false; + throw new InvalidOperationException("synthetic retirement publication failure"); + } + _actions.Enqueue(release); + } + public void DrainAll() + { + while (_actions.TryDequeue(out Action? action)) + action(); + } + } + + private sealed class FakeBackend(int maximumLayers) : ICompositeTextureArrayBackend + { + private uint _nextName = 1; + public int MaximumArrayLayers { get; } = maximumLayers; + public List Created { get; } = []; + public List<(uint Name, int Layer)> Uploads { get; } = []; + public List NonResident { get; } = []; + public List Deleted { get; } = []; + public List Events { get; } = []; + public bool FailNextUpload { get; set; } + public bool FailNextNonResident { get; set; } + public bool FailNextDelete { get; set; } + public bool FailNextNonResidentAfterCommit { get; set; } + public bool FailNextDeleteAfterCommit { get; set; } + + public CompositeTextureArrayResource Create(int width, int height, int capacity) + { + uint name = _nextName++; + var resource = new CompositeTextureArrayResource + { + Name = name, + Handle = 1000UL + name, + Width = width, + Height = height, + Capacity = capacity, + Bytes = (long)width * height * 4 * capacity, + }; + Created.Add(resource); + return resource; + } + + public void Upload(CompositeTextureArrayResource resource, int layer, byte[] rgba) + { + if (FailNextUpload) + { + FailNextUpload = false; + throw new InvalidOperationException("synthetic upload failure"); + } + Uploads.Add((resource.Name, layer)); + } + + public void MakeNonResident(CompositeTextureArrayResource resource) + { + if (FailNextNonResident) + { + FailNextNonResident = false; + throw new InvalidOperationException("synthetic non-resident failure"); + } + NonResident.Add(resource.Name); + Events.Add($"nonresident:{resource.Name}"); + if (FailNextNonResidentAfterCommit) + { + FailNextNonResidentAfterCommit = false; + throw new GpuResourceMutationException( + "synthetic post-commit nonresident failure", + mutationCommitted: true, + new InvalidOperationException("observer")); + } + } + + public void Delete(CompositeTextureArrayResource resource) + { + if (FailNextDelete) + { + FailNextDelete = false; + throw new InvalidOperationException("synthetic delete failure"); + } + Deleted.Add(resource.Name); + Events.Add($"delete:{resource.Name}"); + if (FailNextDeleteAfterCommit) + { + FailNextDeleteAfterCommit = false; + throw new GpuResourceMutationException( + "synthetic post-commit delete failure", + mutationCommitted: true, + new InvalidOperationException("observer")); + } + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/DynamicBufferCapacityTests.cs b/tests/AcDream.App.Tests/Rendering/DynamicBufferCapacityTests.cs new file mode 100644 index 00000000..e9fabef8 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/DynamicBufferCapacityTests.cs @@ -0,0 +1,23 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class DynamicBufferCapacityTests +{ + [Theory] + [InlineData(0, 1, 4096)] + [InlineData(4096, 4096, 4096)] + [InlineData(4096, 4097, 8192)] + [InlineData(8192, 9000, 16384)] + [InlineData(0, 8193, 12288)] + public void Grow_UsesAlignedGeometricCapacity(int current, int required, int expected) + => Assert.Equal(expected, DynamicBufferCapacity.Grow(current, required)); + + [Fact] + public void Grow_RejectsInvalidInputs() + { + Assert.Throws(() => DynamicBufferCapacity.Grow(-1, 1)); + Assert.Throws(() => DynamicBufferCapacity.Grow(0, -1)); + Assert.Throws(() => DynamicBufferCapacity.Grow(0, 1, 0)); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/FixedEntityTextureOwnerLeaseTests.cs b/tests/AcDream.App.Tests/Rendering/FixedEntityTextureOwnerLeaseTests.cs new file mode 100644 index 00000000..c7e1093e --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/FixedEntityTextureOwnerLeaseTests.cs @@ -0,0 +1,46 @@ +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Tests.Rendering; + +public sealed class FixedEntityTextureOwnerLeaseTests +{ + [Fact] + public void RepeatedReplacementReleasesHistoricalKeysForTheFixedOwner() + { + const uint owner = 42; + var lifetime = new RegistryLifetime(); + using var lease = new FixedEntityTextureOwnerLease(lifetime, owner); + + lease.Replace(hasReplacement: true); + lifetime.Acquire(owner, "first"); + Assert.Equal(1, lifetime.ResourceCount); + + lease.Replace(hasReplacement: true); + lifetime.Acquire(owner, "second"); + Assert.Equal(1, lifetime.ResourceCount); + + lease.Replace(hasReplacement: true); + lifetime.Acquire(owner, "third"); + Assert.Equal(1, lifetime.ResourceCount); + + lease.Replace(hasReplacement: false); + Assert.Equal(0, lifetime.ResourceCount); + Assert.Equal(3, lifetime.ReleaseCount); + } + + private sealed class RegistryLifetime : IEntityTextureLifetime + { + private readonly OwnerScopedResourceRegistry _registry = new(); + public int ResourceCount => _registry.ResourceCount; + public int ReleaseCount { get; private set; } + + public void Acquire(uint owner, string key) => _registry.Acquire(owner, key); + + public void ReleaseOwner(uint localEntityId) + { + ReleaseCount++; + _registry.ReleaseOwner(localEntityId); + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/FramePacingControllerTests.cs b/tests/AcDream.App.Tests/Rendering/FramePacingControllerTests.cs new file mode 100644 index 00000000..4b45d652 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/FramePacingControllerTests.cs @@ -0,0 +1,116 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class FramePacingControllerTests +{ + [Fact] + public void Software_pacing_waits_only_for_remaining_deadline_time() + { + var clock = new FakeClock(frequency: 1_000); + var waiter = new AdvancingWaiter(clock); + var controller = new FramePacingController(clock, waiter); + controller.Apply(new FramePacingPolicy(UseVSync: false, SoftwareLimitHz: 10d)); + + clock.Timestamp = 40; + controller.CompleteFrame(); + Assert.Equal([60L], waiter.Durations); + + clock.Timestamp = 150; + controller.CompleteFrame(); + Assert.Equal([60L, 50L], waiter.Durations); + } + + [Fact] + public void Missed_deadline_rebases_instead_of_running_a_catch_up_frame() + { + var clock = new FakeClock(frequency: 1_000); + var waiter = new AdvancingWaiter(clock); + var controller = new FramePacingController(clock, waiter); + controller.Apply(new FramePacingPolicy(UseVSync: false, SoftwareLimitHz: 10d)); + + clock.Timestamp = 250; + controller.CompleteFrame(); + Assert.Empty(waiter.Durations); + + // Rebased deadline is 350, not one of the elapsed 100/200/300 + // deadlines. The next frame therefore waits instead of bursting. + clock.Timestamp = 300; + controller.CompleteFrame(); + Assert.Equal([50L], waiter.Durations); + } + + [Fact] + public void Wait_overshoot_rebases_future_deadline() + { + var clock = new FakeClock(frequency: 1_000); + var waiter = new AdvancingWaiter(clock) { OvershootTicks = 150 }; + var controller = new FramePacingController(clock, waiter); + controller.Apply(new FramePacingPolicy(UseVSync: false, SoftwareLimitHz: 10d)); + + controller.CompleteFrame(); + Assert.Equal(250, clock.Timestamp); + + waiter.OvershootTicks = 0; + clock.Timestamp = 300; + controller.CompleteFrame(); + Assert.Equal([100L, 50L], waiter.Durations); + } + + [Fact] + public void VSync_and_explicit_uncapped_policies_do_not_software_wait() + { + var clock = new FakeClock(frequency: 1_000); + var waiter = new AdvancingWaiter(clock); + var controller = new FramePacingController(clock, waiter); + + controller.Apply(new FramePacingPolicy(UseVSync: true, SoftwareLimitHz: null)); + clock.Timestamp = 1_000; + controller.CompleteFrame(); + + controller.Apply(new FramePacingPolicy(UseVSync: false, SoftwareLimitHz: null)); + clock.Timestamp = 2_000; + controller.CompleteFrame(); + + Assert.Empty(waiter.Durations); + } + + [Fact] + public void Rate_change_resets_deadline_from_current_monotonic_time() + { + var clock = new FakeClock(frequency: 1_000); + var waiter = new AdvancingWaiter(clock); + var controller = new FramePacingController(clock, waiter); + controller.Apply(new FramePacingPolicy(UseVSync: false, SoftwareLimitHz: 10d)); + + clock.Timestamp = 25; + controller.Apply(new FramePacingPolicy(UseVSync: false, SoftwareLimitHz: 20d)); + clock.Timestamp = 50; + controller.CompleteFrame(); + + Assert.Equal([25L], waiter.Durations); + } + + private sealed class FakeClock(long frequency) : IFramePacingClock + { + public long Frequency { get; } = frequency; + + public long Timestamp { get; set; } + + public long GetTimestamp() => Timestamp; + } + + private sealed class AdvancingWaiter(FakeClock clock) : IFramePacingWaiter + { + public List Durations { get; } = []; + + public long OvershootTicks { get; set; } + + public void Wait(long durationTicks, long clockFrequency) + { + Assert.Equal(clock.Frequency, clockFrequency); + Durations.Add(durationTicks); + clock.Timestamp += durationTicks + OvershootTicks; + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/FramePacingPolicyTests.cs b/tests/AcDream.App.Tests/Rendering/FramePacingPolicyTests.cs new file mode 100644 index 00000000..3786e6b5 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/FramePacingPolicyTests.cs @@ -0,0 +1,63 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class FramePacingPolicyTests +{ + [Fact] + public void VSync_request_uses_driver_swap_without_software_limit() + { + var policy = FramePacingPolicy.Resolve( + requestedVSync: true, + uncappedRendering: false, + monitorRefreshHz: 144); + + Assert.True(policy.UseVSync); + Assert.Null(policy.SoftwareLimitHz); + } + + [Theory] + [InlineData(60)] + [InlineData(120)] + [InlineData(144)] + public void Normal_VSync_off_uses_active_monitor_refresh_limit(int refreshHz) + { + var policy = FramePacingPolicy.Resolve( + requestedVSync: false, + uncappedRendering: false, + monitorRefreshHz: refreshHz); + + Assert.False(policy.UseVSync); + Assert.Equal((double)refreshHz, policy.SoftwareLimitHz); + } + + [Theory] + [InlineData(null)] + [InlineData(0)] + [InlineData(-1)] + public void Missing_or_invalid_monitor_refresh_uses_safe_fallback(int? refreshHz) + { + var policy = FramePacingPolicy.Resolve( + requestedVSync: false, + uncappedRendering: false, + monitorRefreshHz: refreshHz); + + Assert.False(policy.UseVSync); + Assert.Equal(FramePacingPolicy.FallbackRefreshHz, policy.SoftwareLimitHz); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Explicit_uncapped_diagnostic_disables_both_pacing_mechanisms( + bool requestedVSync) + { + var policy = FramePacingPolicy.Resolve( + requestedVSync, + uncappedRendering: true, + monitorRefreshHz: 144); + + Assert.False(policy.UseVSync); + Assert.Null(policy.SoftwareLimitHz); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/GpuFrameFlightControllerTests.cs b/tests/AcDream.App.Tests/Rendering/GpuFrameFlightControllerTests.cs new file mode 100644 index 00000000..26f30da0 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/GpuFrameFlightControllerTests.cs @@ -0,0 +1,212 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class GpuFrameFlightControllerTests +{ + [Fact] + public void FourthFrameRetiresOldestFenceBeforeReusingItsSlot() + { + var api = new FakeFenceApi(); + using var controller = new GpuFrameFlightController(api, maximumFramesInFlight: 3); + + SubmitFrame(controller); + SubmitFrame(controller); + SubmitFrame(controller); + + Assert.Empty(api.Waited); + + controller.BeginFrame(); + + Assert.Equal([1], api.Waited); + Assert.Equal([1], api.Deleted); + + controller.EndFrame(); + Assert.Equal([1, 2, 3, 4], api.Inserted); + } + + [Fact] + public void TimeoutIsRetriedAndOnlyFirstWaitFlushesCommands() + { + var api = new FakeFenceApi(); + api.Results.Enqueue(GpuFenceWaitResult.Timeout); + api.Results.Enqueue(GpuFenceWaitResult.Signaled); + using var controller = new GpuFrameFlightController(api, maximumFramesInFlight: 1); + + SubmitFrame(controller); + controller.BeginFrame(); + + Assert.Equal([true, false], api.FlushRequests); + Assert.Equal([1, 1], api.Waited); + Assert.Equal([1], api.Deleted); + } + + [Fact] + public void WaitFailureDoesNotDeleteOrReuseUnretiredFence() + { + var api = new FakeFenceApi(); + api.Results.Enqueue(GpuFenceWaitResult.Failed); + using var controller = new GpuFrameFlightController(api, maximumFramesInFlight: 1); + + SubmitFrame(controller); + + Assert.Throws(() => controller.BeginFrame()); + Assert.Empty(api.Deleted); + Assert.Throws(() => controller.EndFrame()); + } + + [Fact] + public void DisposeDeletesEveryOutstandingFenceExactlyOnce() + { + var api = new FakeFenceApi(); + var controller = new GpuFrameFlightController(api, maximumFramesInFlight: 3); + SubmitFrame(controller); + SubmitFrame(controller); + SubmitFrame(controller); + + controller.Dispose(); + controller.Dispose(); + + Assert.Equal([1, 2, 3], api.Deleted); + } + + [Fact] + public void RetirementAfterSubmittedFrameWaitsForItsFence() + { + var api = new FakeFenceApi(); + using var controller = new GpuFrameFlightController(api, maximumFramesInFlight: 2); + int releases = 0; + + SubmitFrame(controller); + controller.Retire(() => releases++); + + Assert.Equal(0, releases); + SubmitFrame(controller); + Assert.Equal(0, releases); + + controller.BeginFrame(); + Assert.Equal(1, releases); + controller.EndFrame(); + } + + [Fact] + public void RetirementDuringFrameIncludesCurrentFrameFence() + { + var api = new FakeFenceApi(); + using var controller = new GpuFrameFlightController(api, maximumFramesInFlight: 1); + int releases = 0; + + controller.BeginFrame(); + controller.Retire(() => releases++); + Assert.Equal(0, releases); + controller.EndFrame(); + + controller.BeginFrame(); + Assert.Equal(1, releases); + controller.EndFrame(); + } + + [Fact] + public void RetirementBeforeFirstFrameRunsImmediately() + { + var api = new FakeFenceApi(); + using var controller = new GpuFrameFlightController(api); + int releases = 0; + + controller.Retire(() => releases++); + + Assert.Equal(1, releases); + Assert.Equal(0, controller.PendingRetirementCount); + } + + [Fact] + public void ThrowingRetirementDoesNotDropLaterCallbacksInCompletedBucket() + { + var api = new FakeFenceApi(); + using var controller = new GpuFrameFlightController(api, maximumFramesInFlight: 1); + int releases = 0; + int attempts = 0; + + SubmitFrame(controller); + controller.Retire(() => + { + if (++attempts == 1) + throw new InvalidOperationException("first"); + }); + controller.Retire(() => releases++); + + AggregateException error = Assert.Throws(() => controller.BeginFrame()); + + Assert.Single(error.InnerExceptions); + Assert.Equal(1, releases); + Assert.Equal(1, controller.PendingRetirementCount); + + controller.WaitForSubmittedWork(); + Assert.Equal(2, attempts); + Assert.Equal(0, controller.PendingRetirementCount); + } + + [Fact] + public void WaitForSubmittedWorkDrainsLaterFencesAfterEarlierRetirementThrows() + { + var api = new FakeFenceApi(); + using var controller = new GpuFrameFlightController(api, maximumFramesInFlight: 2); + int laterReleases = 0; + bool failOnce = true; + + SubmitFrame(controller); + controller.Retire(() => + { + if (failOnce) + { + failOnce = false; + throw new InvalidOperationException("first frame"); + } + }); + SubmitFrame(controller); + controller.Retire(() => laterReleases++); + + AggregateException error = Assert.Throws(controller.WaitForSubmittedWork); + + Assert.Single(error.InnerExceptions); + Assert.Equal(1, laterReleases); + Assert.Equal([1, 2], api.Deleted); + Assert.Equal(0, controller.PendingRetirementCount); + } + + private static void SubmitFrame(GpuFrameFlightController controller) + { + controller.BeginFrame(); + controller.EndFrame(); + } + + private sealed class FakeFenceApi : IGpuFenceApi + { + private nint _nextFence = 1; + + public List Inserted { get; } = []; + public List Waited { get; } = []; + public List FlushRequests { get; } = []; + public List Deleted { get; } = []; + public Queue Results { get; } = new(); + + public nint Insert() + { + nint fence = _nextFence++; + Inserted.Add(fence); + return fence; + } + + public GpuFenceWaitResult Wait(nint fence, bool flushCommands, ulong timeoutNanoseconds) + { + Assert.Equal(1_000_000ul, timeoutNanoseconds); + Waited.Add(fence); + FlushRequests.Add(flushCommands); + return Results.TryDequeue(out GpuFenceWaitResult result) + ? result + : GpuFenceWaitResult.Signaled; + } + + public void Delete(nint fence) => Deleted.Add(fence); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/GpuResourceRetirementTransactionTests.cs b/tests/AcDream.App.Tests/Rendering/GpuResourceRetirementTransactionTests.cs new file mode 100644 index 00000000..8f20d3d3 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/GpuResourceRetirementTransactionTests.cs @@ -0,0 +1,372 @@ +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using Silk.NET.OpenGL; + +namespace AcDream.App.Tests.Rendering; + +public sealed class GpuResourceRetirementTransactionTests +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void Release_RetryResumesAtFirstUncommittedStage(int failingStage) + { + int[] calls = new int[4]; + bool failed = false; + Action[] stages = Enumerable.Range(0, calls.Length) + .Select(stage => () => + { + calls[stage]++; + if (stage == failingStage && !failed) + { + failed = true; + throw new InvalidOperationException($"stage {stage}"); + } + }) + .ToArray(); + var release = new RetryableGpuResourceRelease(stages); + + Assert.Throws(release.Run); + Assert.Equal(failingStage, release.CompletedStageCount); + + release.Run(); + + Assert.True(release.IsComplete); + for (int stage = 0; stage < calls.Length; stage++) + Assert.Equal(stage == failingStage ? 2 : 1, calls[stage]); + } + + [Fact] + public void Ledger_QueueInsertionFailureRetainsReleaseForPublicationRetry() + { + var queue = new FailBeforeAcceptQueue(); + var ledger = new GpuRetirementLedger(queue); + int releases = 0; + + Assert.Throws(() => + ledger.Retire(new RetryableGpuResourceRelease(() => releases++))); + Assert.Equal(1, ledger.AwaitingPublicationCount); + Assert.Equal(0, releases); + + ledger.RetryPendingPublications(); + Assert.Equal(0, ledger.AwaitingPublicationCount); + Assert.Single(queue.Actions); + + queue.Actions.Single()(); + Assert.Equal(1, releases); + } + + [Fact] + public void Ledger_ImmediateCallbackFailureRetainsCommittedStageCursor() + { + var ledger = new GpuRetirementLedger(ImmediateGpuResourceRetirementQueue.Instance); + int first = 0; + int second = 0; + bool failSecond = true; + var release = new RetryableGpuResourceRelease( + () => first++, + () => + { + second++; + if (failSecond) + { + failSecond = false; + throw new InvalidOperationException("second stage"); + } + }); + + Assert.Throws(() => ledger.Retire(release)); + Assert.Equal(1, ledger.AwaitingPublicationCount); + Assert.Equal(1, release.CompletedStageCount); + + ledger.RetryPendingPublications(); + Assert.Equal(0, ledger.AwaitingPublicationCount); + Assert.Equal(1, first); + Assert.Equal(2, second); + } + + [Fact] + public void Release_ReentrantDrainDoesNotReplayActiveStage() + { + RetryableGpuResourceRelease? release = null; + int active = 0; + int tail = 0; + release = new RetryableGpuResourceRelease( + () => + { + active++; + release!.Run(); + }, + () => tail++); + + release.Run(); + + Assert.True(release.IsComplete); + Assert.Equal(1, active); + Assert.Equal(1, tail); + } + + [Fact] + public void Release_PostMutationValidationFailureDoesNotReplayMutationStage() + { + int mutations = 0; + int validations = 0; + int accounting = 0; + var release = new RetryableGpuResourceRelease( + () => mutations++, + () => + { + validations++; + if (validations == 1) + throw new InvalidOperationException("post-mutation validation"); + }, + () => accounting++); + + Assert.Throws(release.Run); + release.Run(); + + Assert.Equal(1, mutations); + Assert.Equal(2, validations); + Assert.Equal(1, accounting); + Assert.True(release.IsComplete); + } + + [Fact] + public void Ledger_RetryAttemptsEveryPendingPublicationDespiteOneFailure() + { + var queue = new FailFirstNQueue(3); + var ledger = new GpuRetirementLedger(queue); + Assert.Throws(() => + ledger.Retire(new RetryableGpuResourceRelease(() => { }))); + Assert.Throws(() => + ledger.Retire(new RetryableGpuResourceRelease(() => { }))); + + AggregateException error = Assert.Throws( + ledger.RetryPendingPublications); + + Assert.Single(error.InnerExceptions); + Assert.Equal(1, ledger.AwaitingPublicationCount); + Assert.Single(queue.Actions); + + ledger.RetryPendingPublications(); + Assert.Equal(0, ledger.AwaitingPublicationCount); + Assert.Equal(2, queue.Actions.Count); + } + + [Fact] + public void Ledger_RetrySpecificPublicationDoesNotRepublishOtherRelease() + { + var queue = new FailFirstNQueue(2); + var ledger = new GpuRetirementLedger(queue); + var first = new RetryableGpuResourceRelease(() => { }); + var second = new RetryableGpuResourceRelease(() => { }); + Assert.Throws(() => ledger.Retire(first)); + Assert.Throws(() => ledger.Retire(second)); + + ledger.RetryPendingPublication(first); + + Assert.Equal(1, ledger.AwaitingPublicationCount); + Assert.Single(queue.Actions); + ledger.RetryPendingPublication(second); + Assert.Equal(0, ledger.AwaitingPublicationCount); + Assert.Equal(2, queue.Actions.Count); + } + + [Fact] + public void Ledger_BatchOwnsEveryReleaseBeforePublishingFirst() + { + var queue = new FailFirstNQueue(1); + var ledger = new GpuRetirementLedger(queue); + var first = new RetryableGpuResourceRelease(() => { }); + var second = new RetryableGpuResourceRelease(() => { }); + + Assert.Throws(() => ledger.RetireMany([first, second])); + + Assert.Equal(1, ledger.AwaitingPublicationCount); + Assert.Single(queue.Actions); + ledger.RetryPendingPublications(); + Assert.Equal(2, queue.Actions.Count); + } + + [Fact] + public void GlQueue_PersistentNextPassRetryDoesNotStarveOrdinaryWork() + { + var device = new QueueOnlyGraphicsDevice(); + int retryCalls = 0; + int ordinaryCalls = 0; + Action? retry = null; + retry = _ => + { + retryCalls++; + device.QueueGLActionForNextPass(retry!); + }; + device.QueueGLActionForNextPass(retry); + device.QueueGLAction(_ => ordinaryCalls++); + + device.ProcessGLQueue(); + + Assert.Equal(1, retryCalls); + Assert.Equal(1, ordinaryCalls); + Assert.True(device.HasPendingGLWork); + } + + [Fact] + public void GlQueue_ReportsPendingWorkUntilBothGenerationsDrain() + { + var device = new QueueOnlyGraphicsDevice(); + device.QueueGLAction(_ => { }); + + Assert.True(device.HasPendingGLWork); + device.ProcessGLQueue(); + + Assert.False(device.HasPendingGLWork); + } + + [Fact] + public void MigrationAbortTicket_RetainsBufferUntilEveryReleaseStageConverges() + { + int deleteCalls = 0; + int accountingCalls = 0; + bool failAccounting = true; + var ticket = new GlobalMeshMigrationAbortTicket( + buffer: 37, + capacityBytes: 4096, + new RetryableGpuResourceRelease( + () => deleteCalls++, + () => + { + if (failAccounting) + { + failAccounting = false; + throw new InvalidOperationException("injected accounting failure"); + } + accountingCalls++; + })); + + Assert.Throws(ticket.Advance); + Assert.False(ticket.IsComplete); + Assert.Equal((uint)37, ticket.Buffer); + Assert.Equal(4096, ticket.CapacityBytes); + Assert.Equal(1, deleteCalls); + Assert.Equal(0, accountingCalls); + + ticket.Advance(); + ticket.Advance(); + + Assert.True(ticket.IsComplete); + Assert.Equal(1, deleteCalls); + Assert.Equal(1, accountingCalls); + } + + [Fact] + public void MigrationAbortTicket_DeleteValidationFailureRetriesBeforeAccounting() + { + int deleteCalls = 0; + int accountingCalls = 0; + bool failDeleteValidation = true; + var ticket = new GlobalMeshMigrationAbortTicket( + buffer: 41, + capacityBytes: 8192, + new RetryableGpuResourceRelease( + () => + { + deleteCalls++; + if (failDeleteValidation) + { + failDeleteValidation = false; + throw new InvalidOperationException("injected GL delete validation failure"); + } + }, + () => accountingCalls++)); + + Assert.Throws(ticket.Advance); + Assert.False(ticket.IsComplete); + Assert.Equal(1, deleteCalls); + Assert.Equal(0, accountingCalls); + + ticket.Advance(); + + Assert.True(ticket.IsComplete); + Assert.Equal(2, deleteCalls); + Assert.Equal(1, accountingCalls); + } + + [Fact] + public void GlobalMeshVaoAccounting_CreateAndRetryableDeleteBalanceExactlyOnce() + { + int baseline = GpuMemoryTracker.VaoCount; + bool allocationOutstanding = true; + GlobalMeshVaoAccounting.TrackAllocation(); + try + { + Assert.Equal(baseline + 1, GpuMemoryTracker.VaoCount); + var release = new RetryableGpuResourceRelease( + () => { }, + () => + { + GlobalMeshVaoAccounting.TrackDeallocation(); + allocationOutstanding = false; + }); + + release.Run(); + release.Run(); + + Assert.Equal(baseline, GpuMemoryTracker.VaoCount); + } + finally + { + if (allocationOutstanding) + GlobalMeshVaoAccounting.TrackDeallocation(); + } + } + + [Fact] + public void GlobalMeshVaoAccounting_InitializationRollbackReturnsToBaseline() + { + int baseline = GpuMemoryTracker.VaoCount; + GlobalMeshVaoAccounting.TrackAllocation(); + + GlobalMeshVaoAccounting.TrackDeallocation(); + + Assert.Equal(baseline, GpuMemoryTracker.VaoCount); + } + + private sealed class FailBeforeAcceptQueue : IGpuResourceRetirementQueue + { + private bool _fail = true; + public List Actions { get; } = []; + + public void Retire(Action release) + { + if (_fail) + { + _fail = false; + throw new InvalidOperationException("queue insertion"); + } + Actions.Add(release); + } + } + + private sealed class FailFirstNQueue(int failures) : IGpuResourceRetirementQueue + { + private int _remaining = failures; + public List Actions { get; } = []; + + public void Retire(Action release) + { + if (_remaining-- > 0) + throw new InvalidOperationException("synthetic queue publication failure"); + Actions.Add(release); + } + } + + private sealed class QueueOnlyGraphicsDevice : OpenGLGraphicsDevice + { + public QueueOnlyGraphicsDevice() + : base() + { + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/GpuRetiredTerrainSlotAllocatorTests.cs b/tests/AcDream.App.Tests/Rendering/GpuRetiredTerrainSlotAllocatorTests.cs new file mode 100644 index 00000000..3bad46d0 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/GpuRetiredTerrainSlotAllocatorTests.cs @@ -0,0 +1,158 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class GpuRetiredTerrainSlotAllocatorTests +{ + [Fact] + public void RemovedTerrainSlotIsNotReusedBeforeGpuRetirement() + { + var retirement = new DeferredRetirementQueue(); + var allocator = new GpuRetiredTerrainSlotAllocator(1, retirement); + int first = allocator.Allocate(out bool firstNeedsGrow); + Assert.False(firstNeedsGrow); + + allocator.FreeAfterGpuUse(first); + int whilePending = allocator.Allocate(out bool pendingNeedsGrow); + + Assert.NotEqual(first, whilePending); + Assert.True(pendingNeedsGrow); + + retirement.RunAll(); + int reused = allocator.Allocate(out _); + Assert.Equal(first, reused); + } + + [Fact] + public void UnsubmittedTerrainSlotIsReusableImmediately() + { + var retirement = new DeferredRetirementQueue(); + var allocator = new GpuRetiredTerrainSlotAllocator(1, retirement); + int first = allocator.Allocate(out bool firstNeedsGrow); + Assert.False(firstNeedsGrow); + + allocator.ReleaseUnsubmitted(first); + int reused = allocator.Allocate(out bool reusedNeedsGrow); + + Assert.Equal(first, reused); + Assert.False(reusedNeedsGrow); + } + + [Fact] + public void PublicationFailure_RetainsSlotUntilRetryIsAccepted() + { + var retirement = new DeferredRetirementQueue { FailNextPublication = true }; + var allocator = new GpuRetiredTerrainSlotAllocator(1, retirement); + int slot = allocator.Allocate(out _); + + Assert.Throws(() => allocator.FreeAfterGpuUse(slot)); + Assert.Equal(1, allocator.PendingReleaseCount); + Assert.Equal(0, retirement.Count); + int whileUnpublished = allocator.Allocate(out bool needsGrow); + Assert.NotEqual(slot, whileUnpublished); + Assert.True(needsGrow); + + allocator.FreeAfterGpuUse(slot); + + Assert.Equal(1, allocator.PendingReleaseCount); + Assert.Equal(1, retirement.Count); + retirement.RunAll(); + Assert.Equal(0, allocator.PendingReleaseCount); + int reused = allocator.Allocate(out _); + Assert.Equal(slot, reused); + } + + [Fact] + public void DuplicateFreeBeforeRetirement_IsIdempotent() + { + var retirement = new DeferredRetirementQueue(); + var allocator = new GpuRetiredTerrainSlotAllocator(1, retirement); + int slot = allocator.Allocate(out _); + + allocator.FreeAfterGpuUse(slot); + allocator.FreeAfterGpuUse(slot); + + Assert.Equal(1, allocator.PendingReleaseCount); + Assert.Equal(1, retirement.Count); + retirement.RunAll(); + Assert.Equal(0, allocator.PendingReleaseCount); + Assert.Equal(slot, allocator.Allocate(out _)); + } + + [Fact] + public void RetryPendingPublications_AdvancesLogicallyRemovedSlot() + { + var retirement = new DeferredRetirementQueue { FailNextPublication = true }; + var allocator = new GpuRetiredTerrainSlotAllocator(1, retirement); + int slot = allocator.Allocate(out _); + Assert.Throws(() => allocator.FreeAfterGpuUse(slot)); + + allocator.RetryPendingPublications(); + + Assert.Equal(1, allocator.PendingReleaseCount); + Assert.Equal(1, retirement.Count); + retirement.RunAll(); + Assert.Equal(0, allocator.PendingReleaseCount); + Assert.Equal(slot, allocator.Allocate(out _)); + } + + [Fact] + public void ReplacementPublicationFailure_RetiresOnlyReplacedSlotAfterRetry() + { + var retirement = new DeferredRetirementQueue { FailNextPublication = true }; + var allocator = new GpuRetiredTerrainSlotAllocator(2, retirement); + int replaced = allocator.Allocate(out _); + int replacement = allocator.Allocate(out _); + + Assert.Throws(() => allocator.FreeAfterGpuUse(replaced)); + Assert.Equal(2, allocator.LoadedCount); + Assert.Equal(1, allocator.PendingReleaseCount); + + allocator.RetryPendingPublications(); + retirement.RunAll(); + + Assert.Equal(1, allocator.LoadedCount); + Assert.Equal(0, allocator.PendingReleaseCount); + int reused = allocator.Allocate(out _); + Assert.Equal(replaced, reused); + Assert.NotEqual(replacement, reused); + } + + [Fact] + public void PendingSubmittedSlot_CannotBeReleasedAsUnsubmitted() + { + var retirement = new DeferredRetirementQueue(); + var allocator = new GpuRetiredTerrainSlotAllocator(1, retirement); + int slot = allocator.Allocate(out _); + allocator.FreeAfterGpuUse(slot); + + Assert.Throws(() => allocator.ReleaseUnsubmitted(slot)); + Assert.Equal(1, allocator.PendingReleaseCount); + } + + private sealed class DeferredRetirementQueue : IGpuResourceRetirementQueue + { + private readonly List _releases = []; + + public int Count => _releases.Count; + public bool FailNextPublication { get; set; } + + public void Retire(Action release) + { + if (FailNextPublication) + { + FailNextPublication = false; + throw new InvalidOperationException("Injected publication failure."); + } + + _releases.Add(release); + } + + public void RunAll() + { + foreach (Action release in _releases) + release(); + _releases.Clear(); + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/OrderedResourceTeardownTests.cs b/tests/AcDream.App.Tests/Rendering/OrderedResourceTeardownTests.cs new file mode 100644 index 00000000..e3266ae9 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/OrderedResourceTeardownTests.cs @@ -0,0 +1,52 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class OrderedResourceTeardownTests +{ + [Fact] + public void FailureStopsDependentsAndRetryResumesExactStage() + { + var calls = new List(); + int attempts = 0; + var teardown = new OrderedResourceTeardown( + () => calls.Add(1), + () => + { + calls.Add(2); + if (attempts++ == 0) + throw new InvalidOperationException("synthetic stage failure"); + }, + () => calls.Add(3)); + + Assert.Throws(teardown.Advance); + Assert.Equal([1, 2], calls); + Assert.Equal(1, teardown.NextStage); + + teardown.Advance(); + + Assert.Equal([1, 2, 2, 3], calls); + Assert.True(teardown.IsComplete); + } + + [Fact] + public void ReentrantAdvanceDoesNotReplayActiveStage() + { + OrderedResourceTeardown? teardown = null; + int first = 0; + int second = 0; + teardown = new OrderedResourceTeardown( + () => + { + first++; + teardown!.Advance(); + }, + () => second++); + + teardown.Advance(); + + Assert.Equal(1, first); + Assert.Equal(1, second); + Assert.True(teardown.IsComplete); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/OwnerScopedResourceRegistryTests.cs b/tests/AcDream.App.Tests/Rendering/OwnerScopedResourceRegistryTests.cs new file mode 100644 index 00000000..2089603f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/OwnerScopedResourceRegistryTests.cs @@ -0,0 +1,44 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class OwnerScopedResourceRegistryTests +{ + [Fact] + public void RepeatedAcquireBySameOwnerIsIdempotent() + { + var registry = new OwnerScopedResourceRegistry(); + + Assert.True(registry.Acquire(1, "shared")); + Assert.False(registry.Acquire(1, "shared")); + Assert.Equal(1, registry.OwnerCount); + Assert.Equal(1, registry.ResourceCount); + Assert.Equal(["shared"], registry.ReleaseOwner(1)); + } + + [Fact] + public void SharedResourceRetiresOnlyAfterFinalOwner() + { + var registry = new OwnerScopedResourceRegistry(); + registry.Acquire(1, "shared"); + registry.Acquire(2, "shared"); + + Assert.Empty(registry.ReleaseOwner(1)); + Assert.Equal(1, registry.ResourceCount); + Assert.Equal(["shared"], registry.ReleaseOwner(2)); + Assert.Equal(0, registry.ResourceCount); + } + + [Fact] + public void ReleaseOwnerReturnsAllNewlyUnownedResourcesOnce() + { + var registry = new OwnerScopedResourceRegistry(); + registry.Acquire(7, 10); + registry.Acquire(7, 20); + + Assert.Equal([10, 20], registry.ReleaseOwner(7).Order()); + Assert.Empty(registry.ReleaseOwner(7)); + Assert.Equal(0, registry.OwnerCount); + Assert.Equal(0, registry.ResourceCount); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ParticleEmitterRetirementTrackerTests.cs b/tests/AcDream.App.Tests/Rendering/ParticleEmitterRetirementTrackerTests.cs new file mode 100644 index 00000000..a392dfcd --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ParticleEmitterRetirementTrackerTests.cs @@ -0,0 +1,115 @@ +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Tests.Rendering; + +public sealed class ParticleEmitterRetirementTrackerTests +{ + [Fact] + public void MeshFailureDoesNotBlockGfxOrTextureCleanupAndIsRetried() + { + int meshAttempts = 0; + var removedGfx = new List(); + var releasedTextures = new List(); + var failures = new List(); + var tracker = new ParticleEmitterRetirementTracker( + _ => + { + meshAttempts++; + if (meshAttempts == 1) + throw new InvalidOperationException("mesh release failed"); + }, + removedGfx.Add, + releasedTextures.Add, + failures.Add); + + tracker.BeginRetirement(42); + + Assert.Equal([42], removedGfx); + Assert.Equal([42], releasedTextures); + Assert.Single(failures); + Assert.Equal(1, tracker.PendingCount); + + tracker.RetryPending(); + + Assert.Equal(2, meshAttempts); + Assert.Equal([42], removedGfx); + Assert.Equal([42], releasedTextures); + Assert.Equal(0, tracker.PendingCount); + } + + [Fact] + public void CommittedMeshFailureIsNotReplayed() + { + int meshAttempts = 0; + var tracker = new ParticleEmitterRetirementTracker( + _ => + { + meshAttempts++; + throw new MeshReferenceMutationException( + "committed", + mutationCommitted: true, + new InvalidOperationException()); + }, + _ => { }, + _ => { }); + + tracker.BeginRetirement(42); + tracker.RetryPending(); + + Assert.Equal(1, meshAttempts); + Assert.Equal(0, tracker.PendingCount); + } + + [Fact] + public void IndependentFailuresRetainOnlyTheirOwnObligations() + { + int gfxAttempts = 0; + int textureAttempts = 0; + var tracker = new ParticleEmitterRetirementTracker( + _ => { }, + _ => + { + gfxAttempts++; + if (gfxAttempts == 1) + throw new InvalidOperationException("gfx"); + }, + _ => + { + textureAttempts++; + if (textureAttempts == 1) + throw new InvalidOperationException("texture"); + }); + + tracker.BeginRetirement(7); + tracker.RetryPending(); + + Assert.Equal(2, gfxAttempts); + Assert.Equal(2, textureAttempts); + Assert.Equal(0, tracker.PendingCount); + } + + [Fact] + public void SameHandleReentryDoesNotRepeatActiveRetirementStage() + { + ParticleEmitterRetirementTracker? tracker = null; + int meshReleases = 0; + int gfxRemovals = 0; + int textureReleases = 0; + tracker = new ParticleEmitterRetirementTracker( + handle => + { + meshReleases++; + tracker!.BeginRetirement(handle); + }, + _ => gfxRemovals++, + _ => textureReleases++); + + tracker.BeginRetirement(42); + + Assert.Equal(1, meshReleases); + Assert.Equal(1, gfxRemovals); + Assert.Equal(1, textureReleases); + Assert.Equal(0, tracker.PendingCount); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/PortalMeshReferenceOwnerTests.cs b/tests/AcDream.App.Tests/Rendering/PortalMeshReferenceOwnerTests.cs new file mode 100644 index 00000000..03db4f37 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/PortalMeshReferenceOwnerTests.cs @@ -0,0 +1,200 @@ +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Tests.Rendering; + +public sealed class PortalMeshReferenceOwnerTests +{ + [Fact] + public void PartialAcquireRollsBackEveryCommittedSibling() + { + var adapter = new RecordingMeshAdapter(); + adapter.FailIncrementBeforeCommit(0x01000001u, attempts: 1); + var owner = new PortalMeshReferenceOwner( + adapter, + [0x01000001u, 0x01000002u]); + + Assert.Throws(owner.Acquire); + Assert.Equal(0, adapter.TotalReferences); + + owner.Acquire(); + + Assert.Equal(2, adapter.IncrementAttempts(0x01000001u)); + Assert.Equal(2, adapter.IncrementAttempts(0x01000002u)); + owner.Dispose(); + Assert.Equal(0, adapter.TotalReferences); + } + + [Fact] + public void CommittedAcquireFailureIsRolledBackBeforeRetry() + { + var adapter = new RecordingMeshAdapter(); + adapter.FailIncrementAfterCommit(0x01000001u, attempts: 1); + var owner = new PortalMeshReferenceOwner(adapter, [0x01000001u]); + + Assert.Throws(owner.Acquire); + Assert.Equal(0, adapter.TotalReferences); + owner.Acquire(); + + Assert.Equal(2, adapter.IncrementAttempts(0x01000001u)); + Assert.Equal(1, adapter.ReferenceCount(0x01000001u)); + owner.Dispose(); + Assert.Equal(0, adapter.TotalReferences); + } + + [Fact] + public void FailedAcquireRollbackRetainsExactReferenceForDisposeRetry() + { + var adapter = new RecordingMeshAdapter(); + adapter.FailIncrementBeforeCommit(0x01000001u, attempts: 1); + adapter.FailDecrementBeforeCommit(0x01000002u, attempts: 1); + var owner = new PortalMeshReferenceOwner( + adapter, + [0x01000001u, 0x01000002u]); + + AggregateException failure = Assert.Throws(owner.Acquire); + + Assert.Contains("rollback", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, adapter.ReferenceCount(0x01000001u)); + Assert.Equal(1, adapter.ReferenceCount(0x01000002u)); + + owner.Dispose(); + + Assert.True(owner.IsDisposed); + Assert.Equal(2, adapter.DecrementAttempts(0x01000002u)); + Assert.Equal(0, adapter.TotalReferences); + } + + [Fact] + public void DisposeAttemptsEveryReferenceAndRetriesOnlyPendingRelease() + { + var adapter = new RecordingMeshAdapter(); + var owner = new PortalMeshReferenceOwner( + adapter, + [0x01000001u, 0x01000002u]); + owner.Acquire(); + adapter.FailDecrementBeforeCommit(0x01000001u, attempts: 1); + + Assert.Throws(owner.Dispose); + + Assert.False(owner.IsDisposed); + Assert.Equal(0, adapter.ReferenceCount(0x01000002u)); + owner.Dispose(); + owner.Dispose(); + + Assert.True(owner.IsDisposed); + Assert.Equal(2, adapter.DecrementAttempts(0x01000001u)); + Assert.Equal(1, adapter.DecrementAttempts(0x01000002u)); + Assert.Equal(0, adapter.TotalReferences); + } + + [Fact] + public void CommittedReleaseFailureCompletesOwnershipWithoutDoubleDecrement() + { + var adapter = new RecordingMeshAdapter(); + var owner = new PortalMeshReferenceOwner(adapter, [0x01000001u]); + owner.Acquire(); + adapter.FailDecrementAfterCommit(0x01000001u, attempts: 1); + + Assert.Throws(owner.Dispose); + + Assert.True(owner.IsDisposed); + owner.Dispose(); + Assert.Equal(1, adapter.DecrementAttempts(0x01000001u)); + Assert.Equal(0, adapter.TotalReferences); + } + + [Fact] + public void DisposeReentryDuringAcquireReconcilesBeforeReturning() + { + PortalMeshReferenceOwner? owner = null; + var adapter = new RecordingMeshAdapter + { + AfterIncrement = _ => owner!.Dispose(), + }; + owner = new PortalMeshReferenceOwner(adapter, [0x01000001u]); + + Assert.Throws(owner.Acquire); + owner.Dispose(); + + Assert.True(owner.IsDisposed); + Assert.Equal(1, adapter.IncrementAttempts(0x01000001u)); + Assert.Equal(1, adapter.DecrementAttempts(0x01000001u)); + Assert.Equal(0, adapter.TotalReferences); + } + + private sealed class RecordingMeshAdapter : IWbMeshAdapter + { + private readonly Dictionary _references = []; + private readonly Dictionary _incrementAttempts = []; + private readonly Dictionary _decrementAttempts = []; + private readonly Dictionary _incrementFailuresBeforeCommit = []; + private readonly Dictionary _incrementFailuresAfterCommit = []; + private readonly Dictionary _decrementFailuresBeforeCommit = []; + private readonly Dictionary _decrementFailuresAfterCommit = []; + + public Action? AfterIncrement { get; init; } + public int TotalReferences => _references.Values.Sum(); + + public int ReferenceCount(ulong id) => _references.GetValueOrDefault(id); + public int IncrementAttempts(ulong id) => _incrementAttempts.GetValueOrDefault(id); + public int DecrementAttempts(ulong id) => _decrementAttempts.GetValueOrDefault(id); + + public void FailIncrementBeforeCommit(ulong id, int attempts) => + _incrementFailuresBeforeCommit[id] = attempts; + + public void FailIncrementAfterCommit(ulong id, int attempts) => + _incrementFailuresAfterCommit[id] = attempts; + + public void FailDecrementBeforeCommit(ulong id, int attempts) => + _decrementFailuresBeforeCommit[id] = attempts; + + public void FailDecrementAfterCommit(ulong id, int attempts) => + _decrementFailuresAfterCommit[id] = attempts; + + public void IncrementRefCount(ulong id) + { + _incrementAttempts[id] = IncrementAttempts(id) + 1; + if (Consume(_incrementFailuresBeforeCommit, id)) + throw new InvalidOperationException("synthetic pre-commit acquire failure"); + + _references[id] = ReferenceCount(id) + 1; + AfterIncrement?.Invoke(id); + if (Consume(_incrementFailuresAfterCommit, id)) + { + throw new MeshReferenceMutationException( + "synthetic committed acquire failure", + mutationCommitted: true, + new InvalidOperationException()); + } + } + + public void DecrementRefCount(ulong id) + { + _decrementAttempts[id] = DecrementAttempts(id) + 1; + if (Consume(_decrementFailuresBeforeCommit, id)) + throw new InvalidOperationException("synthetic pre-commit release failure"); + + int references = ReferenceCount(id); + if (references <= 0) + throw new InvalidOperationException("reference underflow"); + _references[id] = references - 1; + if (Consume(_decrementFailuresAfterCommit, id)) + { + throw new MeshReferenceMutationException( + "synthetic committed release failure", + mutationCommitted: true, + new InvalidOperationException()); + } + } + + private static bool Consume(Dictionary remaining, ulong id) + { + int count = remaining.GetValueOrDefault(id); + if (count <= 0) + return false; + remaining[id] = count - 1; + return true; + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs b/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs index 693bdc1e..b6557ecb 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Numerics; using AcDream.App.Rendering; using Xunit; @@ -455,4 +456,237 @@ public class PortalProjectionTests $"non-finite NDC vert leaked from the divide: ({v.X},{v.Y})"); } } + + [Fact] + public void ProjectToClipLease_ReusesPooledWorkWithoutResultArrays() + { + var opening = new[] + { + new Vector3(-1f, -1f, -3f), + new Vector3(1f, -1f, -3f), + new Vector3(1f, 1f, -3f), + new Vector3(-1f, 1f, -3f), + }; + Matrix4x4 viewProjection = ViewProj(); + + using (PortalProjection.ClipPolygonLease warm = + PortalProjection.ProjectToClipLease( + opening, + Matrix4x4.Identity, + viewProjection)) + { + Assert.Equal(4, warm.Count); + } + + long before = GC.GetAllocatedBytesForCurrentThread(); + int totalVertices = 0; + for (int i = 0; i < 1_000; i++) + { + using PortalProjection.ClipPolygonLease lease = + PortalProjection.ProjectToClipLease( + opening, + Matrix4x4.Identity, + viewProjection); + totalVertices += lease.Count; + } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(4_000, totalVertices); + // A tiered-JIT/ArrayPool bookkeeping transition can contribute a few hundred fixed bytes + // to the first measured batch on some runtimes. Keep the ceiling far below the former + // per-call result-array regression (~448 KB / 1,000 calls), while accepting that fixed + // process noise so this test measures linear hot-path allocation rather than JIT timing. + Assert.True(allocated <= 1_024, $"pooled projections allocated {allocated:N0} bytes"); + } + + [Fact] + public void ClipToRegion_FrameOwnedStore_ReusesExactResultArray() + { + var opening = new[] + { + new Vector3(-1f, -1f, -5f), + new Vector3(1f, -1f, -5f), + new Vector3(1f, 1f, -5f), + new Vector3(-1f, 1f, -5f), + }; + Vector4[] subject = PortalProjection.ProjectToClip( + opening, Matrix4x4.Identity, ViewProj()); + Vector2[] region = FullScreenCcw(); + Vector2[] expected = PortalProjection.ClipToRegion(subject, region); + var store = new PortalPolygonVertexStore(); + + Vector2[] first = PortalProjection.ClipToRegion( + subject.AsSpan(), region, store); + Vector2[] firstSnapshot = (Vector2[])first.Clone(); + int allocationHighWater = store.AllocationCount; + + store.ResetUsage(); + Vector2[] second = PortalProjection.ClipToRegion( + subject.AsSpan(), region, store); + + Assert.Same(first, second); + Assert.Equal(allocationHighWater, store.AllocationCount); + Assert.Equal(expected, firstSnapshot); + Assert.Equal(expected, second); + + long before = GC.GetAllocatedBytesForCurrentThread(); + float checksum = 0f; + for (int i = 0; i < 1_000; i++) + { + store.ResetUsage(); + Vector2[] reused = PortalProjection.ClipToRegion( + subject.AsSpan(), region, store); + checksum += reused[0].X; + } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(float.IsFinite(checksum)); + Assert.True(allocated <= 4_096, + $"frame-owned portal clipping allocated {allocated:N0} bytes"); + } + + [Fact] + public void ProjectToNdc_SecondRentFailure_ReturnsFirstBuffer() + { + var pool = new ThrowOnSecondRentPool(); + Vector3[] opening = + [ + new(-1f, -1f, -5f), + new(1f, -1f, -5f), + new(1f, 1f, -5f), + new(-1f, 1f, -5f), + ]; + + Assert.Throws(() => + PortalProjection.ProjectToNdc( + opening, + Matrix4x4.Identity, + ViewProj(), + pool)); + + Assert.Equal(0, pool.OutstandingCount); + } + + [Fact] + public void ProjectToClipLease_SecondRentFailure_ReturnsFirstBuffer() + { + var pool = new ThrowOnSecondRentPool(); + Vector3[] opening = + [ + new(-1f, -1f, -5f), + new(1f, -1f, -5f), + new(1f, 1f, -5f), + new(-1f, 1f, -5f), + ]; + + try + { + using PortalProjection.ClipPolygonLease _ = + PortalProjection.ProjectToClipLease( + opening, + Matrix4x4.Identity, + ViewProj(), + pool); + Assert.Fail("The injected second-rent failure was not observed."); + } + catch (InjectedRentException) + { + // Expected: ownership of the first rent must already be reconciled. + } + + Assert.Equal(0, pool.OutstandingCount); + } + + [Fact] + public void ClipToRegion_SecondRentFailure_ReturnsFirstBuffer() + { + var pool = new ThrowOnSecondRentPool(); + Vector4[] subject = + [ + new(-1f, -1f, 0f, 1f), + new(1f, -1f, 0f, 1f), + new(1f, 1f, 0f, 1f), + new(-1f, 1f, 0f, 1f), + ]; + + Assert.Throws(() => + PortalProjection.ClipToRegion( + subject.AsSpan(), + FullScreenCcw(), + new PortalPolygonVertexStore(), + pool)); + + Assert.Equal(0, pool.OutstandingCount); + } + + [Fact] + public void ClipPolygonLease_AccessAfterDispose_FailsFast() + { + var pool = new ThrowOnSecondRentPool(throwOnRent: int.MaxValue); + Vector3[] opening = + [ + new(-1f, -1f, -5f), + new(1f, -1f, -5f), + new(1f, 1f, -5f), + new(-1f, 1f, -5f), + ]; + PortalProjection.ClipPolygonLease lease = + PortalProjection.ProjectToClipLease( + opening, + Matrix4x4.Identity, + ViewProj(), + pool); + + lease.Dispose(); + Assert.Equal(0, pool.OutstandingCount); + + try + { + _ = lease.Count; + Assert.Fail("A disposed pooled lease exposed its returned buffer."); + } + catch (ObjectDisposedException) + { + // Expected. + } + + try + { + _ = lease.Span.Length; + Assert.Fail("A disposed pooled lease exposed its returned span."); + } + catch (ObjectDisposedException) + { + // Expected. + } + + // Same-instance disposal is idempotent and must not return buffers twice. + lease.Dispose(); + Assert.Equal(0, pool.OutstandingCount); + } + + private sealed class InjectedRentException : Exception { } + + private sealed class ThrowOnSecondRentPool(int throwOnRent = 2) : ArrayPool + { + private int _rentCount; + + internal int OutstandingCount { get; private set; } + + public override T[] Rent(int minimumLength) + { + _rentCount++; + if (_rentCount == throwOnRent) + throw new InjectedRentException(); + OutstandingCount++; + return new T[Math.Max(1, minimumLength)]; + } + + public override void Return(T[] array, bool clearArray = false) + { + Assert.NotNull(array); + Assert.True(OutstandingCount > 0, "A pooled array was returned twice."); + OutstandingCount--; + } + } } diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs index d8c1358e..238c261e 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs @@ -39,6 +39,198 @@ public class PortalVisibilityBuilderTests private static PortalVisibilityFrame Build(LoadedCell cam, Dictionary all) => PortalVisibilityBuilder.Build(cam, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, ViewProj()); + [Fact] + public void Build_ReusedFrame_NestedGraph_MatchesFreshAndStabilizesScratch() + { + var root = Cell(0x0001, + new CellPortalInfo(0x0002, 0, 0, 0), + new CellPortalInfo(0x0003, 1, 0, 0)); + root.PortalPolygons.Add(QuadX(-0.8f, -0.05f, -2f)); + root.PortalPolygons.Add(QuadX(0.05f, 0.8f, -3f)); + var left = Cell(0x0002, new CellPortalInfo(0x0004, 0, 0, 0)); + left.PortalPolygons.Add(QuadX(-0.8f, -0.05f, -5f)); + var right = Cell(0x0003, new CellPortalInfo(0x0004, 0, 0, 0)); + right.PortalPolygons.Add(QuadX(0.05f, 0.8f, -5f)); + var exit = Cell(0x0004, new CellPortalInfo(0xFFFF, 0, 0, 0)); + exit.PortalPolygons.Add(QuadX(-0.8f, 0.8f, -8f)); + var cells = new Dictionary + { + [root.CellId] = root, + [left.CellId] = left, + [right.CellId] = right, + [exit.CellId] = exit, + }; + LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out LoadedCell? cell) ? cell : null; + + PortalVisibilityFrame fresh = PortalVisibilityBuilder.Build( + root, Vector3.Zero, Lookup, ViewProj()); + uint[] expectedOrder = fresh.OrderedVisibleCells.ToArray(); + uint[] expectedCells = fresh.CellViews.Keys.OrderBy(id => id).ToArray(); + int expectedOutsidePolygons = fresh.OutsideView.Polygons.Count; + + var reuse = new PortalVisibilityFrame(); + PortalVisibilityBuilder.Build(root, Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse); + int scratchHighWater = reuse.ClipRegionScratchCount; + int vertexArrayHighWater = reuse.PolygonVertexAllocationCount; + Assert.True(scratchHighWater > 0); + Assert.True(vertexArrayHighWater > 0); + + for (int frame = 0; frame < 16; frame++) + { + PortalVisibilityFrame actual = PortalVisibilityBuilder.Build( + root, Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse); + Assert.Same(reuse, actual); + Assert.Equal(expectedOrder, actual.OrderedVisibleCells); + Assert.Equal(expectedCells, actual.CellViews.Keys.OrderBy(id => id).ToArray()); + Assert.Equal(expectedOutsidePolygons, actual.OutsideView.Polygons.Count); + Assert.Equal(scratchHighWater, reuse.ClipRegionScratchCount); + Assert.Equal(vertexArrayHighWater, reuse.PolygonVertexAllocationCount); + Assert.Empty(reuse.TodoScratch); + } + } + + [Fact] + public void Build_ReusedFrame_AfterLookupException_HasNoStaleWorkOrViews() + { + var root = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0)); + root.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -2f)); + var reuse = new PortalVisibilityFrame(); + + Assert.Throws(() => PortalVisibilityBuilder.Build( + root, + Vector3.Zero, + _ => throw new InvalidOperationException("fixture lookup failure"), + ViewProj(), + reuseFrame: reuse)); + + var isolatedRoot = Cell(0x0010); + PortalVisibilityFrame recovered = PortalVisibilityBuilder.Build( + isolatedRoot, Vector3.Zero, _ => null, ViewProj(), reuseFrame: reuse); + + Assert.Same(reuse, recovered); + Assert.Equal(new[] { isolatedRoot.CellId }, recovered.OrderedVisibleCells); + Assert.Equal(new[] { isolatedRoot.CellId }, recovered.CellViews.Keys); + Assert.Empty(recovered.CrossBuildingViews); + Assert.True(recovered.OutsideView.IsEmpty); + Assert.Empty(reuse.TodoScratch); + } + + [Fact] + public void ResetForBuild_DropsPathologicalContainerHighWater_AfterIdleHysteresis() + { + const int pathologicalCount = + PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity * 4; + var frame = new PortalVisibilityFrame(); + var placeholder = new LoadedCell(); + + for (int i = 0; i < pathologicalCount; i++) + { + uint id = (uint)i + 1u; + frame.CellViews.Add(id, new CellView()); + frame.CrossBuildingViews.Add(id, new CellView()); + frame.OrderedVisibleCells.Add(id); + frame.QueuedScratch.Add(id); + frame.DrawListedScratch.Add(id); + frame.ProcessedViewCountsScratch.Add(id, i); + frame.TodoScratch.Add((placeholder, i)); + } + + Assert.True( + frame.CellViewMapCapacity + > PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity); + Assert.True( + frame.QueuedCapacity + > PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity); + + frame.ResetForBuild(); + + Assert.Empty(frame.CellViews); + Assert.Empty(frame.CrossBuildingViews); + Assert.Empty(frame.OrderedVisibleCells); + Assert.Empty(frame.QueuedScratch); + Assert.Empty(frame.DrawListedScratch); + Assert.Empty(frame.ProcessedViewCountsScratch); + Assert.Empty(frame.TodoScratch); + Assert.InRange( + frame.RetainedCellViewCount, + 0, + PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity); + Assert.True( + frame.CellViewMapCapacity + > PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity); + + for (int i = 0; i < PortalVisibilityFrame.CapacityTrimIdleFrames; i++) + frame.ResetForBuild(); + + Assert.InRange( + frame.CellViewMapCapacity, + 0, + PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity); + Assert.InRange( + frame.CrossBuildingViewMapCapacity, + 0, + PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity); + Assert.InRange( + frame.QueuedCapacity, + 0, + PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity); + Assert.InRange( + frame.DrawListedCapacity, + 0, + PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity); + Assert.InRange( + frame.ProcessedViewCountCapacity, + 0, + PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity); + Assert.InRange( + frame.OrderedVisibleCells.Capacity, + 0, + PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity); + Assert.InRange( + frame.TodoScratch.Capacity, + 0, + PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity); + + LoadedCell root = Cell(0x1000); + PortalVisibilityFrame rebuilt = PortalVisibilityBuilder.Build( + root, + Vector3.Zero, + _ => null, + ViewProj(), + reuseFrame: frame); + + Assert.Same(frame, rebuilt); + Assert.Equal(new[] { root.CellId }, rebuilt.OrderedVisibleCells); + Assert.True(rebuilt.CellViews.ContainsKey(root.CellId)); + } + + [Fact] + public void ResetForBuild_RecurringLargeFlood_PreservesWarmCapacity() + { + const int recurringCount = + PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity + 163; + var frame = new PortalVisibilityFrame(); + + for (int iteration = 0; + iteration < PortalVisibilityFrame.CapacityTrimIdleFrames + 5; + iteration++) + { + for (int i = 0; i < recurringCount; i++) + { + uint id = (uint)i + 1u; + frame.QueuedScratch.Add(id); + frame.OrderedVisibleCells.Add(id); + } + + int queuedCapacity = frame.QueuedCapacity; + int orderedCapacity = frame.OrderedVisibleCells.Capacity; + frame.ResetForBuild(); + + Assert.Equal(queuedCapacity, frame.QueuedCapacity); + Assert.Equal(orderedCapacity, frame.OrderedVisibleCells.Capacity); + } + } + [Fact] public void Builder_Cellar_WindowClippedToStairwell_NotFullWindow() { @@ -627,6 +819,40 @@ public class PortalVisibilityBuilderTests "exterior seed should be clipped to the door opening, not full-screen"); } + [Fact] + public void BuildFromExterior_ReusedFrame_ClearsPriorOutputAndProducesSameResult() + { + var room = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0)); + room.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f)); + room.ClipPlanes.Add(new PortalClipPlane + { + Normal = new Vector3(0f, 0f, 1f), + D = 3f, + InsideSide = 1, + }); + var reuse = new PortalVisibilityFrame(); + LoadedCell? Lookup(uint id) => id == room.CellId ? room : null; + + var first = PortalVisibilityBuilder.BuildFromExterior( + new[] { room }, Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse); + Assert.Same(reuse, first); + Assert.Equal(new[] { room.CellId }, first.OrderedVisibleCells); + + var empty = PortalVisibilityBuilder.BuildFromExterior( + Array.Empty(), Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse); + Assert.Same(reuse, empty); + Assert.Empty(empty.OrderedVisibleCells); + Assert.Empty(empty.CellViews); + Assert.True(empty.OutsideView.IsEmpty); + + var second = PortalVisibilityBuilder.BuildFromExterior( + new[] { room }, Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse); + Assert.Same(reuse, second); + Assert.Equal(new[] { room.CellId }, second.OrderedVisibleCells); + Assert.True(second.CellViews.TryGetValue(room.CellId, out CellView? view)); + Assert.False(view!.IsEmpty); + } + [Fact] public void BuildFromExterior_DoesNotSeedWhenCameraIsOnInteriorSide() { diff --git a/tests/AcDream.App.Tests/Rendering/RendererResourceDisposalLedgerTests.cs b/tests/AcDream.App.Tests/Rendering/RendererResourceDisposalLedgerTests.cs new file mode 100644 index 00000000..3e53ed18 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RendererResourceDisposalLedgerTests.cs @@ -0,0 +1,64 @@ +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RendererResourceDisposalLedgerTests +{ + [Fact] + public void FailedGpuResourceDoesNotStrandSiblingAndFinalizesOnlyAfterRetry() + { + int firstDeleteCalls = 0; + int firstAccountingCalls = 0; + int firstMetadataCalls = 0; + int siblingDeleteCalls = 0; + bool failAccounting = true; + bool disposed = false; + + var first = new RetryableGpuResourceRelease( + () => firstDeleteCalls++, + () => + { + firstAccountingCalls++; + if (failAccounting) + { + failAccounting = false; + throw new InvalidOperationException("injected accounting failure"); + } + }, + () => firstMetadataCalls++); + var sibling = new RetryableGpuResourceRelease( + () => siblingDeleteCalls++); + var ledger = new RetryableResourceReleaseLedger( + [ + ("first", first.Run), + ("sibling", sibling.Run), + ]); + + void AdvanceOwnerDispose() + { + ResourceReleaseAttempt attempt = ledger.Advance(); + if (ledger.IsComplete) + disposed = true; + if (attempt.HasFailures) + throw attempt.ToException("Synthetic renderer teardown failed."); + } + + Assert.Throws(AdvanceOwnerDispose); + + Assert.False(disposed); + Assert.Equal(1, firstDeleteCalls); + Assert.Equal(1, firstAccountingCalls); + Assert.Equal(0, firstMetadataCalls); + Assert.Equal(1, siblingDeleteCalls); + + AdvanceOwnerDispose(); + AdvanceOwnerDispose(); + + Assert.True(disposed); + Assert.Equal(1, firstDeleteCalls); + Assert.Equal(2, firstAccountingCalls); + Assert.Equal(1, firstMetadataCalls); + Assert.Equal(1, siblingDeleteCalls); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ResourceShutdownTransactionTests.cs b/tests/AcDream.App.Tests/Rendering/ResourceShutdownTransactionTests.cs new file mode 100644 index 00000000..3bdfcfc9 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ResourceShutdownTransactionTests.cs @@ -0,0 +1,81 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class ResourceShutdownTransactionTests +{ + [Fact] + public void AttemptsIndependentOperationsAndRetriesOnlyPendingOnes() + { + var calls = new List(); + int retryAttempts = 0; + var transaction = new ResourceShutdownTransaction( + new ResourceShutdownStage("owners", + [ + new("first", () => calls.Add("first")), + new("retry", () => + { + calls.Add("retry"); + if (retryAttempts++ == 0) + throw new InvalidOperationException("synthetic transient failure"); + }), + new("last", () => calls.Add("last")), + ]), + new ResourceShutdownStage("dependent", + [ + new("dependent", () => calls.Add("dependent")), + ])); + + transaction.CompleteOrThrow(); + + Assert.Equal(["first", "retry", "last", "retry", "dependent"], calls); + Assert.True(transaction.IsComplete); + } + + [Fact] + public void PersistentFailureNeverRunsDependentStage() + { + int failures = 0; + int dependentCalls = 0; + var transaction = new ResourceShutdownTransaction( + new ResourceShutdownStage("owner", + [ + new("persistent", () => + { + failures++; + throw new InvalidOperationException("persistent"); + }), + ]), + new ResourceShutdownStage("dependent", + [ + new("must-not-run", () => dependentCalls++), + ])); + + Assert.Throws(transaction.CompleteOrThrow); + + Assert.Equal(2, failures); + Assert.Equal(0, dependentCalls); + Assert.False(transaction.IsComplete); + } + + [Fact] + public void ReentrantCompletionDoesNotReplayActiveOperation() + { + ResourceShutdownTransaction? transaction = null; + int calls = 0; + transaction = new ResourceShutdownTransaction( + new ResourceShutdownStage("stage", + [ + new("reentrant", () => + { + calls++; + transaction!.CompleteOrThrow(); + }), + ])); + + transaction.CompleteOrThrow(); + + Assert.Equal(1, calls); + Assert.True(transaction.IsComplete); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs index 9811be6e..de25a7ba 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs @@ -33,6 +33,8 @@ public sealed class RetailAlphaQueueTests log); Assert.Equal(1, objects.ResetCount); Assert.Equal(1, particles.ResetCount); + Assert.Equal(1, objects.PrepareCount); + Assert.Equal(1, particles.PrepareCount); } [Fact] @@ -115,12 +117,20 @@ public sealed class RetailAlphaQueueTests { public List BatchSizes { get; } = new(); public int ResetCount { get; private set; } + public int PrepareCount { get; private set; } + private int[] _prepared = []; - public void DrawAlphaBatch(ReadOnlySpan tokens) + public void PrepareAlphaDraws(ReadOnlySpan tokens) { - BatchSizes.Add(tokens.Length); - foreach (int token in tokens) - log.Add($"{name}:{token}"); + PrepareCount++; + _prepared = tokens.ToArray(); + } + + public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount) + { + BatchSizes.Add(drawCount); + for (int i = 0; i < drawCount; i++) + log.Add($"{name}:{_prepared[firstPreparedDraw + i]}"); } public void ResetAlphaSubmissions() => ResetCount++; diff --git a/tests/AcDream.App.Tests/Rendering/RetailParticleGeometryClassifierTests.cs b/tests/AcDream.App.Tests/Rendering/RetailParticleGeometryClassifierTests.cs index 3a21d469..f1a2f6e9 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailParticleGeometryClassifierTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailParticleGeometryClassifierTests.cs @@ -1,4 +1,5 @@ using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; using AcDream.Content.Vfx; using AcDream.Core.Meshing; using AcDream.Core.Vfx; @@ -78,6 +79,115 @@ public sealed class RetailParticleGeometryClassifierTests Assert.Equal(new[] { 0x01001666u, 0x01001667u }, decrements); } + [Fact] + public void MeshReferenceTracker_retriesAcquireThatFailedBeforeCommit() + { + int attempts = 0; + var increments = new List(); + using var tracker = new ParticleMeshReferenceTracker( + id => + { + attempts++; + if (attempts == 1) + throw new InvalidOperationException("before commit"); + increments.Add(id); + }, + _ => { }); + + Assert.Throws(() => tracker.Register(7, 0x01001666u)); + tracker.Register(7, 0x01001666u); + + Assert.Equal([0x01001666u], increments); + } + + [Fact] + public void MeshReferenceTracker_doesNotRepeatAcquireThatCommittedBeforeThrowing() + { + int attempts = 0; + using var tracker = new ParticleMeshReferenceTracker( + _ => + { + attempts++; + throw new MeshReferenceMutationException( + "after commit", + mutationCommitted: true, + new InvalidOperationException()); + }, + _ => { }); + + Assert.Throws(() => tracker.Register(7, 0x01001666u)); + tracker.Register(7, 0x01001666u); + + Assert.Equal(1, attempts); + } + + [Fact] + public void MeshReferenceTracker_retriesReleaseWithoutRepeatingCommittedMutation() + { + int attempts = 0; + var tracker = new ParticleMeshReferenceTracker(_ => { }, _ => + { + attempts++; + if (attempts == 1) + throw new InvalidOperationException("before commit"); + }); + tracker.Register(7, 0x01001666u); + + Assert.Throws(() => tracker.Release(7)); + tracker.Release(7); + tracker.Release(7); + + Assert.Equal(2, attempts); + tracker.Dispose(); + } + + [Fact] + public void MeshReferenceTracker_disposeAttemptsEveryOwnerAndCanResume() + { + bool failFirst = true; + var releases = new List(); + var tracker = new ParticleMeshReferenceTracker(_ => { }, id => + { + if (id == 0x01001666u && failFirst) + { + failFirst = false; + throw new InvalidOperationException("before commit"); + } + releases.Add(id); + }); + tracker.Register(7, 0x01001666u); + tracker.Register(8, 0x01001667u); + + Assert.Throws(() => tracker.Dispose()); + Assert.Contains(0x01001667u, releases); + tracker.Dispose(); + + Assert.Equal(1, releases.Count(id => id == 0x01001666u)); + Assert.Equal(1, releases.Count(id => id == 0x01001667u)); + } + + [Fact] + public void MeshReferenceTracker_releaseReentryDuringAcquireReconcilesWithoutLeak() + { + ParticleMeshReferenceTracker? tracker = null; + int increments = 0; + int decrements = 0; + tracker = new ParticleMeshReferenceTracker( + _ => + { + increments++; + tracker!.Release(7); + }, + _ => decrements++); + + tracker.Register(7, 0x01001666u); + tracker.Release(7); + tracker.Dispose(); + + Assert.Equal(1, increments); + Assert.Equal(1, decrements); + } + [Fact] public void BlendResolver_corruptSurfaceFallsBackAndReportsItsDid() { diff --git a/tests/AcDream.App.Tests/Rendering/StandaloneBindlessTextureCacheTests.cs b/tests/AcDream.App.Tests/Rendering/StandaloneBindlessTextureCacheTests.cs new file mode 100644 index 00000000..11a217c3 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/StandaloneBindlessTextureCacheTests.cs @@ -0,0 +1,338 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class StandaloneBindlessTextureCacheTests +{ + [Fact] + public void SharedSurfaceRemainsLiveUntilFinalEmitterOwnerLeaves() + { + var backend = new FakeBackend(); + var retirements = new DeferredRetirementQueue(); + using var cache = CreateCache(backend, retirements); + StandaloneBindlessTextureResource resource = Resource(1, bytes: 4); + + cache.AddAndAcquire(10, resource); + Assert.True(cache.TryAcquire(20, 1, out StandaloneBindlessTextureResource? shared)); + Assert.Same(resource, shared); + + cache.ReleaseOwner(10); + Assert.Equal(1, cache.ActiveResourceCount); + Assert.Equal(0, cache.UnownedEntryCount); + Assert.Empty(retirements.Actions); + + cache.ReleaseOwner(20); + Assert.Equal(0, cache.ActiveResourceCount); + Assert.Equal(1, cache.UnownedEntryCount); + Assert.Empty(retirements.Actions); + + Assert.True(cache.TryAcquire(30, 1, out StandaloneBindlessTextureResource? reused)); + Assert.Same(resource, reused); + Assert.Equal(0, cache.UnownedEntryCount); + Assert.Empty(retirements.Actions); + } + + [Fact] + public void UnownedCountBoundEvictsOldestAndDefersPhysicalRetirement() + { + var backend = new FakeBackend(); + var retirements = new DeferredRetirementQueue(); + using var cache = CreateCache( + backend, + retirements, + unownedBudgetBytes: 1_000, + maximumUnownedCount: 2); + + AddAndRelease(cache, ownerId: 10, Resource(1, bytes: 4)); + AddAndRelease(cache, ownerId: 20, Resource(2, bytes: 4)); + AddAndRelease(cache, ownerId: 30, Resource(3, bytes: 4)); + + Assert.Equal(3, cache.EntryCount); + Assert.Empty(retirements.Actions); + cache.Tick(); + + Assert.Equal(2, cache.EntryCount); + Assert.Equal(2, cache.UnownedEntryCount); + Assert.Single(retirements.Actions); + Assert.Empty(backend.Events); + Assert.False(cache.TryAcquire(40, 1, out _)); + Assert.True(cache.TryAcquire(40, 2, out _)); + + retirements.Drain(); + Assert.Equal(["nonresident:1", "delete:1"], backend.Events); + } + + [Fact] + public void UnownedByteBoundNeverEvictsALiveEmitterTexture() + { + var backend = new FakeBackend(); + var retirements = new DeferredRetirementQueue(); + using var cache = CreateCache( + backend, + retirements, + unownedBudgetBytes: 8, + maximumUnownedCount: 10); + + cache.AddAndAcquire(10, Resource(1, bytes: 8)); + cache.AddAndAcquire(20, Resource(2, bytes: 8)); + cache.ReleaseOwner(10); + + Assert.Equal(2, cache.EntryCount); + Assert.Equal(8, cache.UnownedBytes); + Assert.Empty(retirements.Actions); + + cache.AddAndAcquire(30, Resource(3, bytes: 8)); + cache.ReleaseOwner(30); + + Assert.Equal(3, cache.EntryCount); + Assert.Empty(retirements.Actions); + cache.Tick(); + + Assert.Equal(2, cache.EntryCount); + Assert.Equal(8, cache.UnownedBytes); + Assert.Single(retirements.Actions); + Assert.True(cache.TryAcquire(40, 2, out _)); + Assert.False(cache.TryAcquire(40, 1, out _)); + } + + [Fact] + public void RepeatedAcquireByOneEmitterIsIdempotent() + { + var backend = new FakeBackend(); + using var cache = CreateCache(backend, new DeferredRetirementQueue()); + StandaloneBindlessTextureResource resource = Resource(1, bytes: 4); + + cache.AddAndAcquire(10, resource); + Assert.True(cache.TryAcquire(10, 1, out _)); + Assert.True(cache.TryAcquire(10, 1, out _)); + Assert.Equal(1, cache.OwnerCount); + Assert.Equal(1, cache.ActiveResourceCount); + + cache.ReleaseOwner(10); + Assert.Equal(0, cache.OwnerCount); + Assert.Equal(0, cache.ActiveResourceCount); + Assert.Equal(1, cache.UnownedEntryCount); + } + + [Fact] + public void PortalScaleOwnerReleaseQueuesOnlyThePerFrameEvictionBudget() + { + var backend = new FakeBackend(); + var retirements = new DeferredRetirementQueue(); + using var cache = CreateCache( + backend, + retirements, + unownedBudgetBytes: 1_000, + maximumUnownedCount: 2); + + for (uint id = 1; id <= 100; id++) + AddAndRelease(cache, ownerId: id, Resource(id, bytes: 4)); + + Assert.Empty(retirements.Actions); + cache.Tick(); + Assert.Single(retirements.Actions); + cache.Tick(maximumEvictions: 3); + Assert.Equal(4, retirements.Actions.Count); + Assert.Equal(96, cache.EntryCount); + } + + [Fact] + public void FailedRetirementQueueAdmissionRetainsReleaseForPublicationRetry() + { + var backend = new FakeBackend(); + var retirements = new DeferredRetirementQueue { FailNextAdmission = true }; + using var cache = CreateCache( + backend, + retirements, + unownedBudgetBytes: 1_000, + maximumUnownedCount: 1); + AddAndRelease(cache, ownerId: 10, Resource(1, bytes: 4)); + AddAndRelease(cache, ownerId: 20, Resource(2, bytes: 4)); + + Assert.Throws(() => cache.Tick()); + Assert.Equal(1, cache.EntryCount); + Assert.Equal(1, cache.UnownedEntryCount); + Assert.Equal(1, cache.AwaitingRetirementPublicationCount); + + cache.Tick(); + Assert.Equal(1, cache.EntryCount); + Assert.Single(retirements.Actions); + Assert.Equal(0, cache.AwaitingRetirementPublicationCount); + } + + [Fact] + public void DeleteFailureDoesNotRepeatCommittedResidencyRelease() + { + var backend = new FakeBackend { FailDeleteSurfaceIdOnce = 1 }; + var retirements = new DeferredRetirementQueue(); + using var cache = CreateCache( + backend, + retirements, + unownedBudgetBytes: 1_000, + maximumUnownedCount: 1); + AddAndRelease(cache, ownerId: 10, Resource(1, bytes: 4)); + AddAndRelease(cache, ownerId: 20, Resource(2, bytes: 4)); + cache.Tick(); + + Assert.Throws(() => retirements.DrainOne()); + retirements.Drain(); + + Assert.Equal(1, backend.Events.Count(static e => e == "nonresident:1")); + Assert.Equal(2, backend.Events.Count(static e => e == "delete:1")); + } + + [Fact] + public void DisposeMakesEveryHandleNonResidentBeforeDeletingAnyTexture() + { + var backend = new FakeBackend(); + var cache = CreateCache(backend, new DeferredRetirementQueue()); + cache.AddAndAcquire(10, Resource(1, bytes: 4)); + cache.AddAndAcquire(20, Resource(2, bytes: 4)); + + cache.Dispose(); + + Assert.Equal( + ["nonresident:1", "nonresident:2", "delete:1", "delete:2"], + backend.Events); + } + + [Fact] + public void FailedResidencyReleaseNeverDeletesThatBackingTexture() + { + var backend = new FakeBackend { FailNonResidentSurfaceId = 1 }; + var cache = CreateCache(backend, new DeferredRetirementQueue()); + cache.AddAndAcquire(10, Resource(1, bytes: 4)); + cache.AddAndAcquire(20, Resource(2, bytes: 4)); + + AggregateException failure = Assert.Throws(() => cache.Dispose()); + + Assert.Contains("surface 1", failure.ToString()); + Assert.DoesNotContain("delete:1", backend.Events); + Assert.Contains("delete:2", backend.Events); + } + + [Fact] + public void DisposeRetriesOnlyTheFailedDeleteStage() + { + var backend = new FakeBackend { FailDeleteSurfaceIdOnce = 1 }; + var cache = CreateCache(backend, new DeferredRetirementQueue()); + cache.AddAndAcquire(10, Resource(1, bytes: 4)); + cache.AddAndAcquire(20, Resource(2, bytes: 4)); + + Assert.Throws(() => cache.Dispose()); + cache.Dispose(); + + Assert.Equal(1, backend.Events.Count(static e => e == "nonresident:1")); + Assert.Equal(1, backend.Events.Count(static e => e == "nonresident:2")); + Assert.Equal(2, backend.Events.Count(static e => e == "delete:1")); + Assert.Equal(1, backend.Events.Count(static e => e == "delete:2")); + } + + [Fact] + public void BackendDisposeReentryObservesActiveTransactionWithoutRepeatingStages() + { + var backend = new FakeBackend(); + StandaloneBindlessTextureCache? cache = null; + backend.OnMutation = _ => cache!.Dispose(); + cache = CreateCache(backend, new DeferredRetirementQueue()); + cache.AddAndAcquire(10, Resource(1, bytes: 4)); + + cache.Dispose(); + + Assert.Equal(["nonresident:1", "delete:1"], backend.Events); + cache.Dispose(); + Assert.Equal(["nonresident:1", "delete:1"], backend.Events); + } + + private static StandaloneBindlessTextureCache CreateCache( + FakeBackend backend, + DeferredRetirementQueue retirements, + long unownedBudgetBytes = 64, + int maximumUnownedCount = 8) => + new( + backend, + retirements, + unownedBudgetBytes, + maximumUnownedCount); + + private static void AddAndRelease( + StandaloneBindlessTextureCache cache, + uint ownerId, + StandaloneBindlessTextureResource resource) + { + cache.AddAndAcquire(ownerId, resource); + cache.ReleaseOwner(ownerId); + } + + private static StandaloneBindlessTextureResource Resource(uint surfaceId, long bytes) => + new() + { + SurfaceId = surfaceId, + Name = surfaceId, + Handle = surfaceId + 1_000UL, + Bytes = bytes, + }; + + private sealed class DeferredRetirementQueue : IGpuResourceRetirementQueue + { + public bool FailNextAdmission { get; set; } + public Queue Actions { get; } = new(); + + public void Retire(Action release) + { + if (FailNextAdmission) + { + FailNextAdmission = false; + throw new InvalidOperationException("retirement queue full"); + } + Actions.Enqueue(release); + } + + public void Drain() + { + while (Actions.TryDequeue(out Action? release)) + release(); + } + + public void DrainOne() + { + Assert.True(Actions.TryDequeue(out Action? release)); + try + { + release(); + } + catch + { + Actions.Enqueue(release); + throw; + } + } + } + + private sealed class FakeBackend : IStandaloneBindlessTextureBackend + { + public uint FailNonResidentSurfaceId { get; init; } + public uint FailDeleteSurfaceIdOnce { get; set; } + public List Events { get; } = []; + public Action? OnMutation { get; set; } + + public void MakeNonResident(StandaloneBindlessTextureResource resource) + { + Events.Add($"nonresident:{resource.SurfaceId}"); + OnMutation?.Invoke("nonresident"); + if (resource.SurfaceId == FailNonResidentSurfaceId) + throw new InvalidOperationException($"Could not release surface {resource.SurfaceId}."); + } + + public void Delete(StandaloneBindlessTextureResource resource) + { + Events.Add($"delete:{resource.SurfaceId}"); + OnMutation?.Invoke("delete"); + if (resource.SurfaceId == FailDeleteSurfaceIdOnce) + { + FailDeleteSurfaceIdOnce = 0; + throw new InvalidOperationException($"Could not delete surface {resource.SurfaceId}."); + } + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs index c9328029..c206db91 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs @@ -727,6 +727,7 @@ public sealed class EntityEffectControllerTests WorldEntity entity = fixture.ReadyLive(); fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid)); entity.SetPosition(new Vector3(10, 20, 30)); + fixture.Controller.MarkLiveOwnerPoseDirty(Guid); fixture.Controller.RefreshLiveOwnerPoses(); fixture.Runner.Tick(1.0); @@ -734,6 +735,84 @@ public sealed class EntityEffectControllerTests Assert.Equal(new Vector3(10, 20, 30), Assert.Single(fixture.Sink.Calls).Position); } + [Fact] + public void PoseRefreshWorkset_VisitsOnlyDirtyOwnerAcrossDenseReadySet() + { + const int ownerCount = 2_000; + var fixture = new Fixture(); + WorldEntity? changedEntity = null; + uint changedGuid = 0; + for (int i = 0; i < ownerCount; i++) + { + uint guid = 0x71000000u + (uint)i; + WorldEntity entity = fixture.ReadyLive(guid); + if (i == 777) + { + changedGuid = guid; + changedEntity = entity; + } + } + + fixture.Controller.RefreshLiveOwnerPoses(); + Assert.Equal(0, fixture.Controller.LastPoseRefreshOwnerVisitCount); + + changedEntity!.SetPosition(new Vector3(7, 8, 9)); + fixture.Controller.MarkLiveOwnerPoseDirty(changedGuid); + fixture.Controller.RefreshLiveOwnerPoses(); + Assert.Equal(1, fixture.Controller.LastPoseRefreshOwnerVisitCount); + + fixture.Controller.RefreshLiveOwnerPoses(); + Assert.Equal(0, fixture.Controller.LastPoseRefreshOwnerVisitCount); + } + + [Fact] + public void PoseRefreshWorkset_RetainsCallbackMutationForNextPass() + { + const uint firstGuid = 0x72000001u; + const uint secondGuid = 0x72000002u; + var fixture = new Fixture(); + WorldEntity first = fixture.ReadyLive(firstGuid); + WorldEntity second = fixture.ReadyLive(secondGuid); + fixture.Poses.Publish(first, Array.Empty()); + fixture.Poses.Publish(second, Array.Empty()); + fixture.Controller.RefreshLiveOwnerPoses(); + first.SetPosition(new Vector3(10, 0, 0)); + + bool callbackRan = false; + fixture.Poses.EffectPoseChanged += localId => + { + if (localId != first.Id || callbackRan) + return; + callbackRan = true; + fixture.Controller.MarkLiveOwnerPoseDirty(secondGuid); + }; + fixture.Controller.MarkLiveOwnerPoseDirty(firstGuid); + + fixture.Controller.RefreshLiveOwnerPoses(); + Assert.Equal(1, fixture.Controller.LastPoseRefreshOwnerVisitCount); + fixture.Controller.RefreshLiveOwnerPoses(); + Assert.Equal(1, fixture.Controller.LastPoseRefreshOwnerVisitCount); + Assert.True(callbackRan); + } + + [Fact] + public void PoseRefreshWorkset_RejectsDeletedIncarnationAfterGuidReuse() + { + var fixture = new Fixture(); + fixture.ReadyLive(Guid, generation: 1); + fixture.Controller.MarkLiveOwnerPoseDirty(Guid); + Assert.True(fixture.Runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(Guid, 1), + isLocalPlayer: false)); + + WorldEntity replacement = fixture.ReadyLive(Guid, generation: 2); + replacement.SetPosition(new Vector3(20, 30, 40)); + fixture.Controller.MarkLiveOwnerPoseDirty(Guid); + fixture.Controller.RefreshLiveOwnerPoses(); + + Assert.Equal(1, fixture.Controller.LastPoseRefreshOwnerVisitCount); + } + [Fact] public void ClearNetworkStatePreservesDatStaticProfiles() { diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs index 11d4e164..8fd5ca62 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs @@ -154,6 +154,31 @@ public sealed class EntityEffectPoseRegistryTests Assert.Equal(0xA9B4000Bu, cellId); } + [Fact] + public void ChangeNotifications_SuppressEquivalentStaticPublishes() + { + var poses = new EntityEffectPoseRegistry(); + WorldEntity entity = Entity(13u, new Vector3(1, 2, 3)); + var changed = new List(); + poses.EffectPoseChanged += changed.Add; + Matrix4x4 part = Matrix4x4.CreateTranslation(0, 0, 1); + + poses.Publish(entity, new[] { part }); + poses.Publish(entity, new[] { part }); + Assert.True(poses.UpdateRoot(entity)); + Assert.Equal(new[] { 13u }, changed); + + entity.SetPosition(new Vector3(4, 5, 6)); + Assert.True(poses.UpdateRoot(entity)); + Assert.Equal(new[] { 13u, 13u }, changed); + + poses.Publish(entity, new[] { Matrix4x4.CreateTranslation(0, 0, 2) }); + Assert.Equal(new[] { 13u, 13u, 13u }, changed); + + Assert.True(poses.Remove(entity.Id)); + Assert.Equal(new[] { 13u, 13u, 13u, 13u }, changed); + } + private static WorldEntity Entity(uint id, Vector3 position) => new() { Id = id, diff --git a/tests/AcDream.App.Tests/Rendering/ViewconeCullerReuseTests.cs b/tests/AcDream.App.Tests/Rendering/ViewconeCullerReuseTests.cs new file mode 100644 index 00000000..38c6d201 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ViewconeCullerReuseTests.cs @@ -0,0 +1,60 @@ +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public sealed class ViewconeCullerReuseTests +{ + [Fact] + public void ReusedCuller_ReplacesPriorCellAndOutsideState() + { + const uint firstCell = 0xA9B40100; + const uint secondCell = 0xA9B40101; + var clipFrame = ClipFrame.NoClip(); + var assemblyScratch = new ClipFrameAssembly(); + var culler = new ViewconeCuller(); + + var first = Frame(firstCell, includeOutside: true); + ClipFrameAssembly firstAssembly = ClipFrameAssembler.Assemble( + clipFrame, + first, + assemblyScratch); + Assert.Same(culler, ViewconeCuller.Build(firstAssembly, Matrix4x4.Identity, culler)); + Assert.True(culler.SphereVisibleInCell(firstCell, Vector3.Zero, 0f)); + Assert.True(culler.SphereVisibleOutside(Vector3.Zero, 0f)); + + var second = Frame(secondCell, includeOutside: false); + ClipFrameAssembly secondAssembly = ClipFrameAssembler.Assemble( + clipFrame, + second, + assemblyScratch); + Assert.Same(culler, ViewconeCuller.Build(secondAssembly, Matrix4x4.Identity, culler)); + + Assert.False(culler.SphereVisibleInCell(firstCell, Vector3.Zero, 0f)); + Assert.True(culler.SphereVisibleInCell(secondCell, Vector3.Zero, 0f)); + Assert.False(culler.SphereVisibleInCell(secondCell, new Vector3(2f, 0f, 0f), 0f)); + Assert.False(culler.SphereVisibleOutside(Vector3.Zero, 0f)); + } + + private static PortalVisibilityFrame Frame(uint cellId, bool includeOutside) + { + var result = new PortalVisibilityFrame(); + var cellView = new CellView(); + cellView.Add(Square(0.5f)); + result.CellViews.Add(cellId, cellView); + result.OrderedVisibleCells.Add(cellId); + if (includeOutside) + result.OutsideView.Add(Square(0.5f)); + return result; + } + + private static ViewPolygon Square(float half) + => new(new[] + { + new Vector2(-half, -half), + new Vector2(half, -half), + new Vector2(half, half), + new Vector2(-half, half), + }); +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/ContiguousRangeAllocatorTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/ContiguousRangeAllocatorTests.cs index 385b7f6c..67fbbb2a 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/ContiguousRangeAllocatorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/ContiguousRangeAllocatorTests.cs @@ -81,4 +81,35 @@ public sealed class ContiguousRangeAllocatorTests Assert.Equal(small.Offset, selected.Offset); Assert.Equal(40, allocator.LargestFreeRange); } + + [Fact] + public void ReleasingTailLowersLiveHighWaterAndAllowsTrim() + { + var allocator = new ContiguousRangeAllocator(100); + Assert.True(allocator.TryAllocate(20, out _)); + Assert.True(allocator.TryAllocate(30, out MeshBufferRange tail)); + Assert.Equal(50, allocator.HighWaterMark); + + allocator.Release(tail); + + Assert.Equal(20, allocator.HighWaterMark); + Assert.Equal(80, allocator.TrailingFreeLength); + allocator.Shrink(40); + Assert.Equal(40, allocator.Capacity); + Assert.Equal(20, allocator.HighWaterMark); + Assert.Equal(20, allocator.TrailingFreeLength); + } + + [Fact] + public void InteriorReleaseDoesNotHideAHighLiveAllocation() + { + var allocator = new ContiguousRangeAllocator(100); + Assert.True(allocator.TryAllocate(20, out MeshBufferRange first)); + Assert.True(allocator.TryAllocate(30, out _)); + + allocator.Release(first); + + Assert.Equal(50, allocator.HighWaterMark); + Assert.Throws(() => allocator.Shrink(40)); + } } diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs index c27a632e..4b36fee5 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs @@ -15,8 +15,9 @@ public sealed class EntitySpawnAdapterLifetimeTests const int reuseCount = 32; const uint guidBase = 0x74000000u; var meshes = new RefCountingMeshAdapter(); + var textures = new RecordingTextureLifetime(); var adapter = new EntitySpawnAdapter( - new NullTextureCache(), + textures, _ => MakeSequencer(), meshes); @@ -34,6 +35,7 @@ public sealed class EntitySpawnAdapterLifetimeTests entity.PaletteOverride, [new PartOverride(0, 0x01001000u + (uint)i)]); Assert.NotNull(adapter.OnCreate(entity)); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); } Assert.Equal(ownerCount * 2, meshes.TotalReferenceCount); @@ -49,6 +51,7 @@ public sealed class EntitySpawnAdapterLifetimeTests replacement.PaletteOverride, [new PartOverride(0, 0x01002000u + (uint)i)]); Assert.NotNull(adapter.OnCreate(replacement)); + Assert.True(adapter.SetPresentationResident(replacement, resident: true)); } for (int i = 0; i < ownerCount; i++) @@ -61,6 +64,570 @@ public sealed class EntitySpawnAdapterLifetimeTests Assert.Equal(0, meshes.TotalReferenceCount); Assert.Empty(meshes.ReferenceCounts); Assert.Equal(meshes.IncrementCount, meshes.DecrementCount); + Assert.Equal(ownerCount + reuseCount, textures.ReleasedOwnerIds.Count); + Assert.Equal(ownerCount + reuseCount, textures.ReleasedOwnerIds.Distinct().Count()); + } + + [Fact] + public void ProjectionSuspendResume_IsIdempotentAndPreservesAnimatedState() + { + const uint guid = 0x74001000u; + var meshes = new RefCountingMeshAdapter(); + var textures = new RecordingTextureLifetime(); + int sequencerCreates = 0; + var adapter = new EntitySpawnAdapter( + textures, + _ => + { + sequencerCreates++; + return MakeSequencer(); + }, + meshes); + WorldEntity entity = MakeEntity(41u, guid); + entity.ApplyAppearance( + [ + new MeshRef(0x01000001u, Matrix4x4.Identity), + new MeshRef(0x01000001u, Matrix4x4.Identity), + ], + entity.PaletteOverride, + [new PartOverride(0, 0x01000002u)]); + + AnimatedEntityState state = Assert.IsType(adapter.OnCreate(entity)); + Assert.Equal(0, meshes.TotalReferenceCount); + Assert.False(adapter.SetPresentationResident(entity, resident: false)); + Assert.Empty(textures.ReleasedOwnerIds); + + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + Assert.False(adapter.SetPresentationResident(entity, resident: true)); + Assert.Equal(2, meshes.TotalReferenceCount); + + Assert.True(adapter.SetPresentationResident(entity, resident: false)); + Assert.False(adapter.SetPresentationResident(entity, resident: false)); + Assert.Equal(0, meshes.TotalReferenceCount); + Assert.Equal([entity.Id], textures.ReleasedOwnerIds); + Assert.Same(state, adapter.GetState(guid)); + + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + Assert.Equal(2, meshes.TotalReferenceCount); + Assert.Same(state, adapter.GetState(guid)); + Assert.Equal(1, sequencerCreates); + + adapter.OnRemove(guid); + Assert.Equal(0, meshes.TotalReferenceCount); + Assert.Equal([entity.Id, entity.Id], textures.ReleasedOwnerIds); + Assert.Equal(meshes.IncrementCount, meshes.DecrementCount); + } + + [Fact] + public void RemoveWhileSuspended_DoesNotReleasePresentationResourcesTwice() + { + const uint guid = 0x74002000u; + var meshes = new RefCountingMeshAdapter(); + var textures = new RecordingTextureLifetime(); + var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes); + WorldEntity entity = MakeEntity(51u, guid); + + adapter.OnCreate(entity); + adapter.SetPresentationResident(entity, resident: true); + adapter.SetPresentationResident(entity, resident: false); + int decrementsAfterSuspend = meshes.DecrementCount; + + adapter.OnRemove(guid); + adapter.OnRemove(guid); + + Assert.Null(adapter.GetState(guid)); + Assert.Equal(0, meshes.TotalReferenceCount); + Assert.Equal(decrementsAfterSuspend, meshes.DecrementCount); + Assert.Equal([entity.Id], textures.ReleasedOwnerIds); + } + + [Fact] + public void GuidReplacement_IgnoresDelayedEdgesFromDisplacedOwner() + { + const uint guid = 0x74003000u; + var meshes = new RefCountingMeshAdapter(); + var textures = new RecordingTextureLifetime(); + var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes); + WorldEntity oldEntity = MakeEntity(61u, guid); + oldEntity.MeshRefs = [new MeshRef(0x01000100u, Matrix4x4.Identity)]; + WorldEntity replacement = MakeEntity(62u, guid); + replacement.MeshRefs = [new MeshRef(0x01000200u, Matrix4x4.Identity)]; + + adapter.OnCreate(oldEntity); + adapter.SetPresentationResident(oldEntity, resident: true); + AnimatedEntityState replacementState = Assert.IsType( + adapter.OnCreate(replacement)); + + Assert.Equal(0, meshes.TotalReferenceCount); + Assert.Equal([oldEntity.Id], textures.ReleasedOwnerIds); + Assert.False(adapter.OnRemove(oldEntity)); + Assert.False(adapter.SetPresentationResident(oldEntity, resident: false)); + Assert.False(adapter.SetPresentationResident(oldEntity, resident: true)); + Assert.Equal(0, meshes.TotalReferenceCount); + + Assert.True(adapter.SetPresentationResident(replacement, resident: true)); + Assert.Equal(1, meshes.TotalReferenceCount); + Assert.Same(replacementState, adapter.GetState(guid)); + + adapter.OnRemove(guid); + Assert.Equal(0, meshes.TotalReferenceCount); + Assert.Equal([oldEntity.Id, replacement.Id], textures.ReleasedOwnerIds); + Assert.Equal(meshes.IncrementCount, meshes.DecrementCount); + } + + [Fact] + public void GuidReplacement_FactoryFailure_PreservesResidentPriorOwner() + { + const uint guid = 0x74004000u; + var meshes = new RefCountingMeshAdapter(); + var textures = new RecordingTextureLifetime(); + WorldEntity oldEntity = MakeEntity(71u, guid); + oldEntity.MeshRefs = [new MeshRef(0x01000300u, Matrix4x4.Identity)]; + WorldEntity replacement = MakeEntity(72u, guid); + var adapter = new EntitySpawnAdapter( + textures, + entity => ReferenceEquals(entity, replacement) + ? throw new InvalidOperationException("replacement factory failure") + : MakeSequencer(), + meshes); + + AnimatedEntityState oldState = Assert.IsType(adapter.OnCreate(oldEntity)); + Assert.True(adapter.SetPresentationResident(oldEntity, resident: true)); + + Assert.Throws(() => adapter.OnCreate(replacement)); + + Assert.Same(oldState, adapter.GetState(guid)); + Assert.Equal(1, meshes.TotalReferenceCount); + Assert.Empty(textures.ReleasedOwnerIds); + Assert.False(adapter.SetPresentationResident(replacement, resident: true)); + Assert.True(adapter.SetPresentationResident(oldEntity, resident: false)); + Assert.Equal(0, meshes.TotalReferenceCount); + Assert.Equal([oldEntity.Id], textures.ReleasedOwnerIds); + } + + [Fact] + public void ProjectionSuspend_PartialReleaseFailure_RemainsResidentAndRetriesOnlyUnfinishedResources() + { + const uint guid = 0x74005000u; + const ulong firstMesh = 0x01000400u; + const ulong secondMesh = 0x01000401u; + var meshes = new FaultInjectingMeshAdapter(); + var textures = new FaultInjectingTextureLifetime(); + var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes); + WorldEntity entity = MakeEntity(81u, guid); + entity.MeshRefs = + [ + new MeshRef((uint)firstMesh, Matrix4x4.Identity), + new MeshRef((uint)secondMesh, Matrix4x4.Identity), + ]; + + adapter.OnCreate(entity); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + meshes.FailNextDecrement(secondMesh); + + Assert.Throws( + () => adapter.SetPresentationResident(entity, resident: false)); + + Assert.NotNull(adapter.GetState(guid)); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + Assert.Equal(2, meshes.TotalReferenceCount); + Assert.Equal(1, textures.AttemptCount); + Assert.Equal(1, meshes.DecrementAttempts[firstMesh]); + Assert.Equal(1, meshes.DecrementAttempts[secondMesh]); + + Assert.True(adapter.SetPresentationResident(entity, resident: false)); + Assert.Equal(0, meshes.TotalReferenceCount); + Assert.Equal(2, textures.AttemptCount); + Assert.Equal(2, meshes.DecrementAttempts[firstMesh]); + Assert.Equal(2, meshes.DecrementAttempts[secondMesh]); + Assert.False(adapter.SetPresentationResident(entity, resident: false)); + } + + [Fact] + public void LogicalRemove_ReleaseFailure_KeepsOwnerReachableForRetry() + { + const uint guid = 0x74006000u; + const ulong meshId = 0x01000500u; + var meshes = new FaultInjectingMeshAdapter(); + var textures = new FaultInjectingTextureLifetime(failuresBeforeSuccess: 1); + var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes); + WorldEntity entity = MakeEntity(91u, guid); + entity.MeshRefs = [new MeshRef((uint)meshId, Matrix4x4.Identity)]; + AnimatedEntityState state = Assert.IsType(adapter.OnCreate(entity)); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + + Assert.Throws(() => adapter.OnRemove(entity)); + + Assert.Same(state, adapter.GetState(guid)); + Assert.Equal(0, meshes.TotalReferenceCount); + Assert.Equal(1, meshes.DecrementAttempts[meshId]); + Assert.Equal(1, textures.AttemptCount); + + Assert.True(adapter.OnRemove(entity)); + Assert.Null(adapter.GetState(guid)); + Assert.Equal(1, meshes.DecrementAttempts[meshId]); + Assert.Equal(2, textures.AttemptCount); + Assert.Equal(1, textures.SuccessCount); + } + + [Fact] + public void GuidReplacement_ReleaseFailure_PublishesReplacementOnlyAfterRetrySucceeds() + { + const uint guid = 0x74007000u; + const ulong oldMeshId = 0x01000600u; + var meshes = new FaultInjectingMeshAdapter(); + var textures = new FaultInjectingTextureLifetime(failuresBeforeSuccess: 1); + var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes); + WorldEntity oldEntity = MakeEntity(101u, guid); + oldEntity.MeshRefs = [new MeshRef((uint)oldMeshId, Matrix4x4.Identity)]; + WorldEntity replacement = MakeEntity(102u, guid); + AnimatedEntityState oldState = Assert.IsType(adapter.OnCreate(oldEntity)); + Assert.True(adapter.SetPresentationResident(oldEntity, resident: true)); + + Assert.Throws(() => adapter.OnCreate(replacement)); + + Assert.Same(oldState, adapter.GetState(guid)); + Assert.False(adapter.SetPresentationResident(replacement, resident: true)); + Assert.Equal(0, meshes.TotalReferenceCount); + Assert.Equal(1, meshes.DecrementAttempts[oldMeshId]); + + AnimatedEntityState replacementState = Assert.IsType( + adapter.OnCreate(replacement)); + Assert.Same(replacementState, adapter.GetState(guid)); + Assert.False(adapter.OnRemove(oldEntity)); + Assert.Equal(1, meshes.DecrementAttempts[oldMeshId]); + Assert.Equal(2, textures.AttemptCount); + } + + [Fact] + public void LogicalRemove_ReentrantDuplicate_DoesNotPublishRemovalBeforeCleanupCompletes() + { + const uint guid = 0x74008000u; + var meshes = new FaultInjectingMeshAdapter(); + EntitySpawnAdapter? adapter = null; + WorldEntity entity = MakeEntity(111u, guid); + bool invokeNestedRemove = true; + var textures = new FaultInjectingTextureLifetime( + onRelease: () => + { + if (!invokeNestedRemove) + return; + invokeNestedRemove = false; + Assert.NotNull(adapter!.GetState(guid)); + adapter.OnRemove(entity); + }); + adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes); + adapter.OnCreate(entity); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + + AggregateException error = Assert.Throws(() => adapter.OnRemove(entity)); + Assert.Contains( + error.Flatten().InnerExceptions, + exception => exception is EntityPresentationRemovalDeferredException); + Assert.NotNull(adapter.GetState(guid)); + Assert.True(adapter.OnRemove(entity)); + + Assert.Null(adapter.GetState(guid)); + Assert.Equal(0, meshes.TotalReferenceCount); + Assert.Equal(2, textures.AttemptCount); + } + + [Fact] + public void LogicalRemove_AfterResumeRollbackFailure_ReleasesResidualMeshReference() + { + const uint guid = 0x74009000u; + const ulong firstMesh = 0x01000700u; + const ulong secondMesh = 0x01000701u; + var meshes = new FaultInjectingMeshAdapter(); + var textures = new FaultInjectingTextureLifetime(); + var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes); + WorldEntity entity = MakeEntity(121u, guid); + entity.MeshRefs = + [ + new MeshRef((uint)firstMesh, Matrix4x4.Identity), + new MeshRef((uint)secondMesh, Matrix4x4.Identity), + ]; + AnimatedEntityState state = Assert.IsType(adapter.OnCreate(entity)); + meshes.FailNextIncrement(secondMesh); + meshes.FailNextDecrement(firstMesh); + + Assert.Throws( + () => adapter.SetPresentationResident(entity, resident: true)); + Assert.Equal(1, meshes.TotalReferenceCount); + Assert.Same(state, adapter.GetState(guid)); + + Assert.True(adapter.OnRemove(entity)); + + Assert.Null(adapter.GetState(guid)); + Assert.Equal(0, meshes.TotalReferenceCount); + Assert.Equal(2, meshes.DecrementAttempts[firstMesh]); + Assert.Equal(0, textures.AttemptCount); + } + + [Fact] + public void Resume_IncrementThrowsAfterCommit_RollsBackCommittedReference() + { + const uint guid = 0x7400A000u; + const ulong meshId = 0x01000800u; + var meshes = new FaultInjectingMeshAdapter(); + var adapter = new EntitySpawnAdapter( + new FaultInjectingTextureLifetime(), _ => MakeSequencer(), meshes); + WorldEntity entity = MakeEntity(131u, guid); + entity.MeshRefs = [new MeshRef((uint)meshId, Matrix4x4.Identity)]; + adapter.OnCreate(entity); + meshes.ThrowAfterNextIncrement(meshId); + + Assert.Throws( + () => adapter.SetPresentationResident(entity, resident: true)); + + Assert.Equal(0, meshes.TotalReferenceCount); + Assert.Equal(1, meshes.DecrementAttempts[meshId]); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + Assert.Equal(1, meshes.TotalReferenceCount); + } + + [Fact] + public void Suspend_DecrementThrowsAfterCommit_RetryDoesNotDoubleDecrement() + { + const uint guid = 0x7400B000u; + const ulong meshId = 0x01000900u; + var meshes = new FaultInjectingMeshAdapter(); + var adapter = new EntitySpawnAdapter( + new FaultInjectingTextureLifetime(), _ => MakeSequencer(), meshes); + WorldEntity entity = MakeEntity(141u, guid); + entity.MeshRefs = [new MeshRef((uint)meshId, Matrix4x4.Identity)]; + adapter.OnCreate(entity); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + meshes.ThrowAfterNextDecrement(meshId); + + Assert.Throws( + () => adapter.SetPresentationResident(entity, resident: false)); + Assert.Equal(0, meshes.TotalReferenceCount); + Assert.Equal(1, meshes.DecrementAttempts[meshId]); + + Assert.True(adapter.SetPresentationResident(entity, resident: false)); + Assert.Equal(0, meshes.TotalReferenceCount); + Assert.Equal(1, meshes.DecrementAttempts[meshId]); + } + + [Fact] + public void AppearanceChange_Resident_AcquiresReplacementBeforePublicationAndRetiresOldAfter() + { + const uint guid = 0x7400C000u; + const ulong oldMesh = 0x01000A00u; + const ulong newMesh = 0x01000A01u; + const ulong overrideMesh = 0x01000A02u; + var meshes = new CallbackMeshAdapter(); + var adapter = new EntitySpawnAdapter( + new RecordingTextureLifetime(), _ => MakeSequencer(), meshes); + WorldEntity entity = MakeEntity(151u, guid); + entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)]; + adapter.OnCreate(entity); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + meshes.Operations.Clear(); + + MeshRef[] replacement = [new MeshRef((uint)newMesh, Matrix4x4.Identity)]; + PartOverride[] overrides = [new PartOverride(0, (uint)overrideMesh)]; + Assert.True(adapter.OnAppearanceChanged( + entity, + replacement, + overrides, + () => + { + Assert.Equal(1, meshes.ReferenceCounts[oldMesh]); + Assert.Equal(1, meshes.ReferenceCounts[newMesh]); + Assert.Equal(1, meshes.ReferenceCounts[overrideMesh]); + meshes.Operations.Add("publish"); + entity.ApplyAppearance(replacement, paletteOverride: null, overrides); + })); + + int publicationIndex = meshes.Operations.IndexOf("publish"); + Assert.True(meshes.Operations.IndexOf($"increment:{newMesh:X8}") < publicationIndex); + Assert.True(meshes.Operations.IndexOf($"increment:{overrideMesh:X8}") < publicationIndex); + Assert.True(meshes.Operations.IndexOf($"decrement:{oldMesh:X8}") > publicationIndex); + Assert.False(meshes.ReferenceCounts.ContainsKey(oldMesh)); + Assert.Equal(1, meshes.ReferenceCounts[newMesh]); + Assert.Equal(1, meshes.ReferenceCounts[overrideMesh]); + + Assert.True(adapter.OnRemove(entity)); + Assert.Equal(0, meshes.TotalReferenceCount); + } + + [Fact] + public void AppearanceChange_WhileSuspended_OnlyReplacementSetIsAcquiredOnResume() + { + const uint guid = 0x7400D000u; + const ulong oldMesh = 0x01000B00u; + const ulong newMesh = 0x01000B01u; + var meshes = new RefCountingMeshAdapter(); + var adapter = new EntitySpawnAdapter( + new RecordingTextureLifetime(), _ => MakeSequencer(), meshes); + WorldEntity entity = MakeEntity(161u, guid); + entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)]; + adapter.OnCreate(entity); + + MeshRef[] replacement = [new MeshRef((uint)newMesh, Matrix4x4.Identity)]; + Assert.True(adapter.OnAppearanceChanged( + entity, + replacement, + [], + () => entity.ApplyAppearance(replacement, paletteOverride: null, []))); + + Assert.Equal(0, meshes.TotalReferenceCount); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + Assert.False(meshes.ReferenceCounts.ContainsKey(oldMesh)); + Assert.Equal(1, meshes.ReferenceCounts[newMesh]); + Assert.True(adapter.OnRemove(entity)); + Assert.Equal(0, meshes.TotalReferenceCount); + } + + [Fact] + public void AppearanceChange_CommittedAcquireFailure_RollsBackAndDoesNotPublish() + { + const uint guid = 0x7400E000u; + const ulong oldMesh = 0x01000C00u; + const ulong newMesh = 0x01000C01u; + var meshes = new FaultInjectingMeshAdapter(); + var adapter = new EntitySpawnAdapter( + new RecordingTextureLifetime(), _ => MakeSequencer(), meshes); + WorldEntity entity = MakeEntity(171u, guid); + entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)]; + adapter.OnCreate(entity); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + meshes.ThrowAfterNextIncrement(newMesh); + bool published = false; + + Assert.Throws(() => + adapter.OnAppearanceChanged( + entity, + [new MeshRef((uint)newMesh, Matrix4x4.Identity)], + [], + () => published = true)); + + Assert.False(published); + Assert.Equal(1, meshes.ReferenceCounts[oldMesh]); + Assert.False(meshes.ReferenceCounts.ContainsKey(newMesh)); + Assert.Equal(1, meshes.DecrementAttempts[newMesh]); + Assert.True(adapter.OnRemove(entity)); + Assert.Equal(0, meshes.TotalReferenceCount); + } + + [Fact] + public void AppearanceChange_ReleaseFailure_RetainsMarkerForResidencyRetry() + { + const uint guid = 0x7400F000u; + const ulong oldMesh = 0x01000D00u; + const ulong newMesh = 0x01000D01u; + var meshes = new FaultInjectingMeshAdapter(); + var adapter = new EntitySpawnAdapter( + new RecordingTextureLifetime(), _ => MakeSequencer(), meshes); + WorldEntity entity = MakeEntity(181u, guid); + entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)]; + adapter.OnCreate(entity); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + meshes.FailNextDecrement(oldMesh); + int publications = 0; + MeshRef[] replacement = [new MeshRef((uint)newMesh, Matrix4x4.Identity)]; + + Assert.Throws(() => + adapter.OnAppearanceChanged( + entity, + replacement, + [], + () => + { + publications++; + entity.ApplyAppearance(replacement, paletteOverride: null, []); + })); + + Assert.Equal(1, publications); + Assert.Equal(2, meshes.TotalReferenceCount); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + Assert.Equal(1, publications); + Assert.False(meshes.ReferenceCounts.ContainsKey(oldMesh)); + Assert.Equal(1, meshes.ReferenceCounts[newMesh]); + Assert.Equal(2, meshes.DecrementAttempts[oldMesh]); + Assert.True(adapter.OnRemove(entity)); + Assert.Equal(0, meshes.TotalReferenceCount); + } + + [Fact] + public void AppearanceChange_ReentrantRequestCannotPublishInsideOuterTransition() + { + const uint guid = 0x74010000u; + const ulong oldMesh = 0x01000E00u; + const ulong outerMesh = 0x01000E01u; + const ulong nestedMesh = 0x01000E02u; + var meshes = new CallbackMeshAdapter(); + var adapter = new EntitySpawnAdapter( + new RecordingTextureLifetime(), _ => MakeSequencer(), meshes); + WorldEntity entity = MakeEntity(191u, guid); + entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)]; + adapter.OnCreate(entity); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + bool nestedPublished = false; + bool nestedResult = true; + meshes.AfterIncrement = meshId => + { + if (meshId != outerMesh) + return; + + nestedResult = adapter.OnAppearanceChanged( + entity, + [new MeshRef((uint)nestedMesh, Matrix4x4.Identity)], + [], + () => nestedPublished = true); + }; + + MeshRef[] replacement = [new MeshRef((uint)outerMesh, Matrix4x4.Identity)]; + Assert.True(adapter.OnAppearanceChanged( + entity, + replacement, + [], + () => entity.ApplyAppearance(replacement, paletteOverride: null, []))); + + Assert.False(nestedResult); + Assert.False(nestedPublished); + Assert.Equal(1, meshes.ReferenceCounts[outerMesh]); + Assert.False(meshes.ReferenceCounts.ContainsKey(oldMesh)); + Assert.False(meshes.ReferenceCounts.ContainsKey(nestedMesh)); + Assert.True(adapter.OnRemove(entity)); + Assert.Equal(0, meshes.TotalReferenceCount); + } + + [Fact] + public void AppearanceChange_DelayedOldIncarnationCannotAffectGuidReplacement() + { + const uint guid = 0x74011000u; + const ulong oldMesh = 0x01000F00u; + const ulong replacementMesh = 0x01000F01u; + const ulong staleMesh = 0x01000F02u; + var meshes = new RefCountingMeshAdapter(); + var adapter = new EntitySpawnAdapter( + new RecordingTextureLifetime(), _ => MakeSequencer(), meshes); + WorldEntity oldEntity = MakeEntity(201u, guid); + oldEntity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)]; + adapter.OnCreate(oldEntity); + Assert.True(adapter.SetPresentationResident(oldEntity, resident: true)); + + WorldEntity replacement = MakeEntity(202u, guid); + replacement.MeshRefs = [new MeshRef((uint)replacementMesh, Matrix4x4.Identity)]; + adapter.OnCreate(replacement); + Assert.True(adapter.SetPresentationResident(replacement, resident: true)); + bool stalePublished = false; + + Assert.False(adapter.OnAppearanceChanged( + oldEntity, + [new MeshRef((uint)staleMesh, Matrix4x4.Identity)], + [], + () => stalePublished = true)); + + Assert.False(stalePublished); + Assert.False(meshes.ReferenceCounts.ContainsKey(oldMesh)); + Assert.False(meshes.ReferenceCounts.ContainsKey(staleMesh)); + Assert.Equal(1, meshes.ReferenceCounts[replacementMesh]); + Assert.True(adapter.OnRemove(replacement)); + Assert.Equal(0, meshes.TotalReferenceCount); } private static WorldEntity MakeEntity(uint id, uint serverGuid) => new() @@ -81,12 +648,11 @@ public sealed class EntitySpawnAdapterLifetimeTests public Animation? LoadAnimation(uint id) => null; } - private sealed class NullTextureCache : ITextureCachePerInstance + private sealed class RecordingTextureLifetime : IEntityTextureLifetime { - public uint GetOrUploadWithPaletteOverride( - uint surfaceId, - uint? overrideOrigTextureId, - PaletteOverride paletteOverride) => 1u; + public List ReleasedOwnerIds { get; } = new(); + + public void ReleaseOwner(uint localEntityId) => ReleasedOwnerIds.Add(localEntityId); } private sealed class RefCountingMeshAdapter : IWbMeshAdapter @@ -113,4 +679,118 @@ public sealed class EntitySpawnAdapterLifetimeTests DecrementCount++; } } + + private sealed class CallbackMeshAdapter : IWbMeshAdapter + { + public Dictionary ReferenceCounts { get; } = new(); + public List Operations { get; } = new(); + public Action? AfterIncrement { get; set; } + public int TotalReferenceCount => ReferenceCounts.Values.Sum(); + + public void IncrementRefCount(ulong id) + { + ReferenceCounts[id] = ReferenceCounts.GetValueOrDefault(id) + 1; + Operations.Add($"increment:{id:X8}"); + AfterIncrement?.Invoke(id); + } + + public void DecrementRefCount(ulong id) + { + Assert.True(ReferenceCounts.TryGetValue(id, out int count)); + Assert.True(count > 0); + if (count == 1) + ReferenceCounts.Remove(id); + else + ReferenceCounts[id] = count - 1; + Operations.Add($"decrement:{id:X8}"); + } + } + + private sealed class FaultInjectingTextureLifetime( + int failuresBeforeSuccess = 0, + Action? onRelease = null) : IEntityTextureLifetime + { + private int _failuresRemaining = failuresBeforeSuccess; + + public int AttemptCount { get; private set; } + public int SuccessCount { get; private set; } + + public void ReleaseOwner(uint localEntityId) + { + AttemptCount++; + onRelease?.Invoke(); + if (_failuresRemaining > 0) + { + _failuresRemaining--; + throw new InvalidOperationException("injected texture release failure"); + } + + SuccessCount++; + } + } + + private sealed class FaultInjectingMeshAdapter : IWbMeshAdapter + { + private readonly Dictionary _remainingIncrementFailures = new(); + private readonly Dictionary _remainingDecrementFailures = new(); + private readonly HashSet _throwAfterIncrement = new(); + private readonly HashSet _throwAfterDecrement = new(); + + public Dictionary ReferenceCounts { get; } = new(); + public Dictionary DecrementAttempts { get; } = new(); + public int TotalReferenceCount => ReferenceCounts.Values.Sum(); + + public void FailNextIncrement(ulong id) => + _remainingIncrementFailures[id] = + _remainingIncrementFailures.GetValueOrDefault(id) + 1; + + public void FailNextDecrement(ulong id) => + _remainingDecrementFailures[id] = + _remainingDecrementFailures.GetValueOrDefault(id) + 1; + + public void ThrowAfterNextIncrement(ulong id) => _throwAfterIncrement.Add(id); + public void ThrowAfterNextDecrement(ulong id) => _throwAfterDecrement.Add(id); + + public void IncrementRefCount(ulong id) + { + if (_remainingIncrementFailures.GetValueOrDefault(id) > 0) + { + _remainingIncrementFailures[id]--; + throw new InvalidOperationException("injected mesh acquire failure"); + } + + ReferenceCounts[id] = ReferenceCounts.GetValueOrDefault(id) + 1; + if (_throwAfterIncrement.Remove(id)) + { + throw new MeshReferenceMutationException( + "injected post-commit mesh acquire failure", + mutationCommitted: true, + new InvalidOperationException("injected metadata failure")); + } + } + + public void DecrementRefCount(ulong id) + { + DecrementAttempts[id] = DecrementAttempts.GetValueOrDefault(id) + 1; + if (_remainingDecrementFailures.GetValueOrDefault(id) > 0) + { + _remainingDecrementFailures[id]--; + throw new InvalidOperationException("injected mesh release failure"); + } + + Assert.True(ReferenceCounts.TryGetValue(id, out int count)); + Assert.True(count > 0); + if (count == 1) + ReferenceCounts.Remove(id); + else + ReferenceCounts[id] = count - 1; + if (_throwAfterDecrement.Remove(id)) + { + throw new MeshReferenceMutationException( + "injected post-commit mesh release failure", + mutationCommitted: true, + new InvalidOperationException("injected cancellation failure")); + } + } + } } diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs index 217d1892..b46ad87d 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs @@ -13,6 +13,49 @@ namespace AcDream.App.Tests.Rendering.Wb; public class EnvCellRendererTests { + [Fact] + public void OrderedMdiRanges_CoalesceAdjacentCellsWithIdenticalState() + { + var ranges = new List(); + + EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 3); + EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 3, commandCount: 4); + + Assert.Equal( + [new EnvCellRenderer.MdiDrawRange(GroupIndex: 2, FirstCommand: 0, CommandCount: 7)], + ranges); + } + + [Fact] + public void OrderedMdiRanges_PreserveStateAndCommandGapsAsBoundaries() + { + var ranges = new List(); + + EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 3); + EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 6, firstCommand: 3, commandCount: 2); + EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 5, commandCount: 1); + EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 9, commandCount: 2); + + Assert.Equal( + [ + new EnvCellRenderer.MdiDrawRange(2, 0, 3), + new EnvCellRenderer.MdiDrawRange(6, 3, 2), + new EnvCellRenderer.MdiDrawRange(2, 5, 1), + new EnvCellRenderer.MdiDrawRange(2, 9, 2), + ], + ranges); + } + + [Fact] + public void OrderedMdiRanges_IgnoreEmptyCellRanges() + { + var ranges = new List(); + + EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 0); + + Assert.Empty(ranges); + } + // ----------------------------------------------------------------------- // GetEnvCellGeomId — verbatim port of WB EnvCellRenderManager.cs:94-103 // ----------------------------------------------------------------------- diff --git a/tests/AcDream.App.Tests/Rendering/Wb/GpuRetiredRangeAllocatorTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/GpuRetiredRangeAllocatorTests.cs new file mode 100644 index 00000000..e3e11f1b --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/GpuRetiredRangeAllocatorTests.cs @@ -0,0 +1,137 @@ +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Tests.Rendering.Wb; + +public sealed class GpuRetiredRangeAllocatorTests +{ + [Fact] + public void ReleasedRangeCannotBeOverwrittenBeforeGpuRetirement() + { + var retirement = new DeferredRetirementQueue(); + var allocator = new GpuRetiredRangeAllocator(capacity: 8, retirement); + Assert.True(allocator.TryAllocate(8, out MeshBufferRange first)); + + allocator.ReleaseAfterGpuUse(first); + + Assert.Equal(1, allocator.PendingReleaseCount); + Assert.Equal(8, allocator.PendingReleaseLength); + Assert.False(allocator.TryAllocate(8, out _)); + retirement.RunAll(); + Assert.Equal(0, allocator.PendingReleaseCount); + Assert.Equal(0, allocator.PendingReleaseLength); + Assert.True(allocator.TryAllocate(8, out MeshBufferRange reused)); + Assert.Equal(first, reused); + } + + [Fact] + public void FailedUploadCanReleaseUnsubmittedRangeImmediately() + { + var retirement = new DeferredRetirementQueue(); + var allocator = new GpuRetiredRangeAllocator(capacity: 8, retirement); + Assert.True(allocator.TryAllocate(8, out MeshBufferRange first)); + + allocator.ReleaseUnsubmitted(first); + + Assert.True(allocator.TryAllocate(8, out MeshBufferRange reused)); + Assert.Equal(first, reused); + Assert.Equal(0, retirement.Count); + } + + [Fact] + public void FailedRetirement_KeepsPendingOwnershipAccounting() + { + var retirement = new DeferredRetirementQueue(); + var allocator = new GpuRetiredRangeAllocator(capacity: 8, retirement); + Assert.True(allocator.TryAllocate(8, out MeshBufferRange range)); + + allocator.ReleaseAfterGpuUse(range); + retirement.RunAll(); + allocator.ReleaseAfterGpuUse(range); // injected duplicate makes Release fail before commit + + Assert.Throws(retirement.RunAll); + Assert.Equal(1, allocator.PendingReleaseCount); + Assert.Equal(8, allocator.PendingReleaseLength); + } + + [Fact] + public void PublicationFailure_RetryDoesNotDuplicatePendingAccounting() + { + var retirement = new DeferredRetirementQueue { FailNextPublication = true }; + var allocator = new GpuRetiredRangeAllocator(capacity: 8, retirement); + Assert.True(allocator.TryAllocate(8, out MeshBufferRange range)); + + Assert.Throws(() => allocator.ReleaseAfterGpuUse(range)); + Assert.Equal(1, allocator.PendingReleaseCount); + Assert.Equal(8, allocator.PendingReleaseLength); + Assert.Equal(0, retirement.Count); + + allocator.ReleaseAfterGpuUse(range); + + Assert.Equal(1, allocator.PendingReleaseCount); + Assert.Equal(8, allocator.PendingReleaseLength); + Assert.Equal(1, retirement.Count); + retirement.RunAll(); + Assert.Equal(0, allocator.PendingReleaseCount); + Assert.Equal(0, allocator.PendingReleaseLength); + Assert.True(allocator.TryAllocate(8, out MeshBufferRange reused)); + Assert.Equal(range, reused); + } + + [Fact] + public void DuplicateReleaseBeforeRetirement_DoesNotPublishTwice() + { + var retirement = new DeferredRetirementQueue(); + var allocator = new GpuRetiredRangeAllocator(capacity: 8, retirement); + Assert.True(allocator.TryAllocate(8, out MeshBufferRange range)); + + allocator.ReleaseAfterGpuUse(range); + allocator.ReleaseAfterGpuUse(range); + + Assert.Equal(1, allocator.PendingReleaseCount); + Assert.Equal(8, allocator.PendingReleaseLength); + Assert.Equal(1, retirement.Count); + retirement.RunAll(); + Assert.Equal(0, allocator.PendingReleaseCount); + Assert.Equal(0, allocator.PendingReleaseLength); + } + + [Fact] + public void PendingSubmittedRange_CannotBeReleasedAsUnsubmitted() + { + var retirement = new DeferredRetirementQueue(); + var allocator = new GpuRetiredRangeAllocator(capacity: 8, retirement); + Assert.True(allocator.TryAllocate(8, out MeshBufferRange range)); + allocator.ReleaseAfterGpuUse(range); + + Assert.Throws(() => allocator.ReleaseUnsubmitted(range)); + Assert.Equal(1, allocator.PendingReleaseCount); + Assert.Equal(8, allocator.PendingReleaseLength); + } + + private sealed class DeferredRetirementQueue : IGpuResourceRetirementQueue + { + private readonly List _releases = []; + + public int Count => _releases.Count; + public bool FailNextPublication { get; set; } + + public void Retire(Action release) + { + if (FailNextPublication) + { + FailNextPublication = false; + throw new InvalidOperationException("Injected publication failure."); + } + + _releases.Add(release); + } + + public void RunAll() + { + foreach (Action release in _releases) + release(); + _releases.Clear(); + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/LandblockSpawnAdapterReadinessTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/LandblockSpawnAdapterReadinessTests.cs index 9a9717c1..c9ddbfd2 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/LandblockSpawnAdapterReadinessTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/LandblockSpawnAdapterReadinessTests.cs @@ -81,6 +81,313 @@ public sealed class LandblockSpawnAdapterReadinessTests Assert.DoesNotContain(sharedGeometryId, meshes.ReferenceCounts); } + [Fact] + public void Load_BeforeCommitFailure_RetryAcquiresOnlyUnfinishedReferences() + { + const ulong first = 0x01000010ul; + const ulong failed = 0x01000020ul; + const ulong last = 0x01000030ul; + var meshes = new FaultInjectingMeshAdapter(); + meshes.FailNext(ReferenceOperation.Increment, failed, committed: false); + var adapter = new LandblockSpawnAdapter(meshes); + LoadedLandblock landblock = MakeLandblock(0x1234FFFFu, (uint)first, (uint)failed, (uint)last); + + Assert.Throws(() => adapter.OnLandblockLoaded(landblock)); + + Assert.Equal(1, meshes.ReferenceCounts[first]); + Assert.DoesNotContain(failed, meshes.ReferenceCounts); + Assert.Equal(1, meshes.ReferenceCounts[last]); + Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId)); + + adapter.OnLandblockLoaded(landblock); + + Assert.Equal(1, meshes.ReferenceCounts[first]); + Assert.Equal(1, meshes.ReferenceCounts[failed]); + Assert.Equal(1, meshes.ReferenceCounts[last]); + Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, first)); + Assert.Equal(2, meshes.CallCount(ReferenceOperation.Increment, failed)); + Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, last)); + Assert.True(adapter.IsLandblockRenderReady(landblock.LandblockId)); + } + + [Fact] + public void Load_CommittedPreparedPinFailure_RetryDoesNotDoubleAcquire() + { + const ulong prepared = 0x2_0000_1234ul; + var meshes = new FaultInjectingMeshAdapter(); + meshes.FailNext(ReferenceOperation.PinPrepared, prepared, committed: true); + var adapter = new LandblockSpawnAdapter(meshes); + LoadedLandblock landblock = MakeLandblock(0x1234FFFFu); + + MeshReferenceMutationException error = Assert.Throws( + () => adapter.OnLandblockLoaded(landblock, new[] { prepared })); + + Assert.True(error.MutationCommitted); + Assert.Equal(1, meshes.ReferenceCounts[prepared]); + + adapter.OnLandblockLoaded(landblock, new[] { prepared }); + + Assert.Equal(1, meshes.ReferenceCounts[prepared]); + Assert.Equal(1, meshes.CallCount(ReferenceOperation.PinPrepared, prepared)); + Assert.True(adapter.IsLandblockRenderReady(landblock.LandblockId)); + + adapter.OnLandblockUnloaded(landblock.LandblockId); + Assert.Empty(meshes.ReferenceCounts); + } + + [Fact] + public void Load_CommittedIncrementFailure_RetryDoesNotDoubleAcquire() + { + const ulong id = 0x01000010ul; + var meshes = new FaultInjectingMeshAdapter(); + meshes.FailNext(ReferenceOperation.Increment, id, committed: true); + var adapter = new LandblockSpawnAdapter(meshes); + LoadedLandblock landblock = MakeLandblock(0x1234FFFFu, (uint)id); + + MeshReferenceMutationException error = Assert.Throws( + () => adapter.OnLandblockLoaded(landblock)); + + Assert.True(error.MutationCommitted); + Assert.Equal(1, meshes.ReferenceCounts[id]); + + adapter.OnLandblockLoaded(landblock); + + Assert.Equal(1, meshes.ReferenceCounts[id]); + Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, id)); + Assert.True(adapter.IsLandblockRenderReady(landblock.LandblockId)); + } + + [Fact] + public void Unload_BeforeCommitFailure_RetryReleasesOnlyStillHeldReferences() + { + const ulong first = 0x01000010ul; + const ulong failed = 0x01000020ul; + const ulong last = 0x01000030ul; + var meshes = new FaultInjectingMeshAdapter(); + var adapter = new LandblockSpawnAdapter(meshes); + LoadedLandblock landblock = MakeLandblock(0x1234FFFFu, (uint)first, (uint)failed, (uint)last); + adapter.OnLandblockLoaded(landblock); + meshes.FailNext(ReferenceOperation.Decrement, failed, committed: false); + + Assert.Throws( + () => adapter.OnLandblockUnloaded(landblock.LandblockId)); + + Assert.Single(meshes.ReferenceCounts); + Assert.Equal(1, meshes.ReferenceCounts[failed]); + Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId)); + + adapter.OnLandblockUnloaded(landblock.LandblockId); + + Assert.Empty(meshes.ReferenceCounts); + Assert.Equal(1, meshes.CallCount(ReferenceOperation.Decrement, first)); + Assert.Equal(2, meshes.CallCount(ReferenceOperation.Decrement, failed)); + Assert.Equal(1, meshes.CallCount(ReferenceOperation.Decrement, last)); + + adapter.OnLandblockUnloaded(landblock.LandblockId); + Assert.Equal(2, meshes.CallCount(ReferenceOperation.Decrement, failed)); + } + + [Fact] + public void Unload_CommittedFailure_DropsCompletedRegistrationBeforePropagating() + { + const ulong id = 0x01000010ul; + var meshes = new FaultInjectingMeshAdapter(); + var adapter = new LandblockSpawnAdapter(meshes); + LoadedLandblock landblock = MakeLandblock(0x1234FFFFu, (uint)id); + adapter.OnLandblockLoaded(landblock); + meshes.FailNext(ReferenceOperation.Decrement, id, committed: true); + + MeshReferenceMutationException error = Assert.Throws( + () => adapter.OnLandblockUnloaded(landblock.LandblockId)); + + Assert.True(error.MutationCommitted); + Assert.Empty(meshes.ReferenceCounts); + Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId)); + + adapter.OnLandblockUnloaded(landblock.LandblockId); + Assert.Equal(1, meshes.CallCount(ReferenceOperation.Decrement, id)); + } + + [Fact] + public void OppositeUnloadAfterPartialLoad_ReleasesOnlyReferencesActuallyAcquired() + { + const ulong held = 0x01000010ul; + const ulong neverAcquired = 0x01000020ul; + var meshes = new FaultInjectingMeshAdapter(); + meshes.FailNext(ReferenceOperation.Increment, neverAcquired, committed: false); + var adapter = new LandblockSpawnAdapter(meshes); + LoadedLandblock landblock = MakeLandblock( + 0x1234FFFFu, + (uint)held, + (uint)neverAcquired); + Assert.Throws(() => adapter.OnLandblockLoaded(landblock)); + + adapter.OnLandblockUnloaded(landblock.LandblockId); + + Assert.Empty(meshes.ReferenceCounts); + Assert.Equal(1, meshes.CallCount(ReferenceOperation.Decrement, held)); + Assert.Equal(0, meshes.CallCount(ReferenceOperation.Decrement, neverAcquired)); + Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId)); + } + + [Fact] + public void OppositeLoadAfterPartialUnload_ReusesOverlapAndReconcilesObsoleteResidual() + { + const ulong released = 0x01000010ul; + const ulong residual = 0x01000020ul; + const ulong newlyDesired = 0x01000030ul; + var meshes = new FaultInjectingMeshAdapter(); + var adapter = new LandblockSpawnAdapter(meshes); + LoadedLandblock original = MakeLandblock( + 0x1234FFFFu, + (uint)released, + (uint)residual); + adapter.OnLandblockLoaded(original); + meshes.FailNext(ReferenceOperation.Decrement, residual, committed: false); + Assert.Throws( + () => adapter.OnLandblockUnloaded(original.LandblockId)); + + // The residual id is intentionally absent from the replacement. The + // reverse edge must finish its old release while acquiring the new + // snapshot, without reacquiring the already-released first id. + LoadedLandblock replacement = MakeLandblock( + original.LandblockId, + (uint)newlyDesired); + adapter.OnLandblockLoaded(replacement); + + Assert.Single(meshes.ReferenceCounts); + Assert.Equal(1, meshes.ReferenceCounts[newlyDesired]); + Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, released)); + Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, residual)); + Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, newlyDesired)); + Assert.Equal(2, meshes.CallCount(ReferenceOperation.Decrement, residual)); + Assert.True(adapter.IsLandblockRenderReady(replacement.LandblockId)); + } + + [Fact] + public void OppositeLoadAfterPartialUnload_RetainsStillHeldOverlapWithoutMutation() + { + const ulong released = 0x01000010ul; + const ulong residualOverlap = 0x01000020ul; + var meshes = new FaultInjectingMeshAdapter(); + var adapter = new LandblockSpawnAdapter(meshes); + LoadedLandblock landblock = MakeLandblock( + 0x1234FFFFu, + (uint)released, + (uint)residualOverlap); + adapter.OnLandblockLoaded(landblock); + meshes.FailNext(ReferenceOperation.Decrement, residualOverlap, committed: false); + Assert.Throws( + () => adapter.OnLandblockUnloaded(landblock.LandblockId)); + + LoadedLandblock replacement = MakeLandblock( + landblock.LandblockId, + (uint)residualOverlap); + adapter.OnLandblockLoaded(replacement); + + Assert.Single(meshes.ReferenceCounts); + Assert.Equal(1, meshes.ReferenceCounts[residualOverlap]); + Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, residualOverlap)); + Assert.Equal(1, meshes.CallCount(ReferenceOperation.Decrement, residualOverlap)); + Assert.True(adapter.IsLandblockRenderReady(replacement.LandblockId)); + } + + private static LoadedLandblock MakeLandblock(uint landblockId, params uint[] gfxObjIds) + { + WorldEntity[] entities = gfxObjIds.Length == 0 + ? Array.Empty() + : + [ + new WorldEntity + { + Id = 1, + ServerGuid = 0, + SourceGfxObjOrSetupId = gfxObjIds[0], + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = gfxObjIds + .Select(id => new MeshRef(id, Matrix4x4.Identity)) + .ToArray(), + }, + ]; + return new LoadedLandblock(landblockId, new LandBlock(), entities); + } + + private enum ReferenceOperation + { + Increment, + PinPrepared, + Decrement, + } + + private sealed class FaultInjectingMeshAdapter : IWbMeshAdapter + { + private readonly Dictionary<(ReferenceOperation Operation, ulong Id), Queue> _failures = new(); + private readonly List<(ReferenceOperation Operation, ulong Id)> _calls = new(); + + public Dictionary ReferenceCounts { get; } = new(); + + public void FailNext(ReferenceOperation operation, ulong id, bool committed) + { + if (!_failures.TryGetValue((operation, id), out Queue? failures)) + { + failures = new Queue(); + _failures.Add((operation, id), failures); + } + failures.Enqueue(committed); + } + + public int CallCount(ReferenceOperation operation, ulong id) + => _calls.Count(call => call == (operation, id)); + + public void IncrementRefCount(ulong id) + => Mutate(ReferenceOperation.Increment, id, delta: 1); + + public void PinPreparedRenderData(ulong id) + => Mutate(ReferenceOperation.PinPrepared, id, delta: 1); + + public void DecrementRefCount(ulong id) + => Mutate(ReferenceOperation.Decrement, id, delta: -1); + + public bool IsRenderDataReady(ulong id) => ReferenceCounts.ContainsKey(id); + + private void Mutate(ReferenceOperation operation, ulong id, int delta) + { + _calls.Add((operation, id)); + if (_failures.TryGetValue((operation, id), out Queue? failures) + && failures.TryDequeue(out bool committed)) + { + if (committed) + ApplyDelta(id, delta); + if (failures.Count == 0) + _failures.Remove((operation, id)); + + var inner = new InvalidOperationException("Injected mesh-reference failure."); + if (committed) + { + throw new MeshReferenceMutationException( + "Injected committed mesh-reference failure.", + mutationCommitted: true, + inner); + } + throw inner; + } + + ApplyDelta(id, delta); + } + + private void ApplyDelta(ulong id, int delta) + { + int next = ReferenceCounts.GetValueOrDefault(id) + delta; + if (next < 0) + throw new InvalidOperationException($"Reference underflow for 0x{id:X}."); + if (next == 0) + ReferenceCounts.Remove(id); + else + ReferenceCounts[id] = next; + } + } + private sealed class ReadinessMeshAdapter : IWbMeshAdapter { public HashSet ReadyIds { get; } = new(); diff --git a/tests/AcDream.App.Tests/Rendering/Wb/MeshUploadCachesTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/MeshUploadCachesTests.cs index b64d60d5..2d0bb7a0 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/MeshUploadCachesTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/MeshUploadCachesTests.cs @@ -21,17 +21,17 @@ public sealed class MeshUploadCachesTests Assert.True(staging.Stage(data)); Assert.True(staging.TryDequeue(out var initial)); - Assert.Same(data, initial); - staging.Complete(data.ObjectId); // GPU upload completed, then was later evicted. + Assert.Same(data, initial.Data); + staging.Complete(initial); // GPU upload completed, then was later evicted. data.UploadAttempts = 3; - Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out var cached)); + Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out var cached, out _)); Assert.Same(data, cached); Assert.Equal(0, data.UploadAttempts); Assert.Equal(textureBytes, cached!.TextureBatches.Values.Single().Single().TextureData); - Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out _)); + Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out _, out _)); Assert.True(staging.TryDequeue(out var restaged)); - Assert.Same(data, restaged); + Assert.Same(data, restaged.Data); Assert.False(staging.TryDequeue(out _)); } @@ -43,12 +43,12 @@ public sealed class MeshUploadCachesTests Assert.True(staging.Stage(data)); Assert.True(staging.TryDequeue(out var attempt)); - staging.Requeue(attempt!); + staging.Requeue(attempt); Assert.False(staging.Stage(data)); Assert.True(staging.TryDequeue(out var retry)); - Assert.Same(data, retry); + Assert.Same(data, retry.Data); - staging.Complete(data.ObjectId); + staging.Complete(retry); Assert.True(staging.Stage(data)); } @@ -66,4 +66,244 @@ public sealed class MeshUploadCachesTests Assert.False(ownership.MarkUploadComplete(id)); // late upload after unload Assert.Equal(0, ownership.Count(id)); } + + [Fact] + public void StagingQueueRejectsProducerWorkAboveByteHighWater() + { + var staging = new MeshUploadStagingQueue(maximumCount: 8, maximumBytes: 64); + ObjectMeshData first = DataWithTexture(1, 40); + ObjectMeshData second = DataWithTexture(2, 40); + + Assert.True(staging.Stage(first)); + Assert.False(staging.Stage(second)); + Assert.Equal(1, staging.Count); + Assert.Equal(40, staging.QueuedBytes); + + Assert.True(staging.TryDequeue(out MeshUploadQueueItem firstItem)); + staging.Complete(firstItem); + Assert.True(staging.Stage(second)); + } + + [Fact] + public void CpuCacheHitReportsHighWaterWithoutPretendingItWasStaged() + { + var staging = new MeshUploadStagingQueue(maximumCount: 8, maximumBytes: 64); + var cache = new CpuMeshUploadCache(capacity: 2); + ObjectMeshData queued = DataWithTexture(1, 40); + ObjectMeshData cached = DataWithTexture(2, 40); + cache.Store(cached); + Assert.True(staging.Stage(queued)); + + Assert.True(cache.TryGetAndStage(2, staging, out ObjectMeshData? found, out MeshStageResult result)); + Assert.Same(cached, found); + Assert.Equal(MeshStageResult.HighWater, result); + Assert.Equal(1, staging.Count); + + Assert.True(staging.TryDequeue(out MeshUploadQueueItem queuedItem)); + staging.Complete(queuedItem); + Assert.True(cache.TryGetAndStage(2, staging, out _, out result)); + Assert.Equal(MeshStageResult.Staged, result); + Assert.True(staging.TryDequeue(out MeshUploadQueueItem staged)); + Assert.Same(cached, staged.Data); + } + + [Fact] + public void StalePrefixDrainIsBoundedAndDoesNotConsumeOwnedHead() + { + const int total = 1000; + var staging = new MeshUploadStagingQueue( + maximumCount: total + 1, + maximumBytes: long.MaxValue); + var ownership = new MeshOwnershipCounter(); + for (ulong id = 1; id <= total; id++) + Assert.True(staging.Stage(new ObjectMeshData { ObjectId = id })); + ownership.Acquire(total); + + Assert.Equal(64, staging.DiscardUnownedPrefix(ownership, maximum: 64)); + Assert.Equal(total - 64, staging.Count); + + Assert.Equal(total - 65, staging.DiscardUnownedPrefix(ownership, maximum: total)); + Assert.True(staging.TryPeek(out MeshUploadQueueItem owned)); + Assert.Equal((ulong)total, owned.Data.ObjectId); + } + + [Fact] + public void CpuCacheRearmsMeshAfterStaleStageWasDiscarded() + { + const ulong id = 0x01000010; + var staging = new MeshUploadStagingQueue(); + var cache = new CpuMeshUploadCache(capacity: 2); + var ownership = new MeshOwnershipCounter(); + var data = DataWithTexture(id, 16); + cache.Store(data); + Assert.True(staging.Stage(data)); + + Assert.Equal(1, staging.DiscardUnownedPrefix(ownership, maximum: 1)); + Assert.Equal(0, staging.Count); + + ownership.Acquire(id); + Assert.True(cache.TryGetAndStage(id, staging, out ObjectMeshData? rearmed, out _)); + Assert.Same(data, rearmed); + Assert.Equal(0, staging.DiscardUnownedPrefix(ownership, maximum: 1)); + Assert.True(staging.TryPeek(out MeshUploadQueueItem queued)); + Assert.Same(data, queued.Data); + } + + [Fact] + public void CpuCacheUsesByteBudgetInAdditionToEntryCount() + { + var staging = new MeshUploadStagingQueue(); + var cache = new CpuMeshUploadCache(capacity: 10, byteCapacity: 64); + cache.Store(DataWithTexture(1, 40)); + cache.Store(DataWithTexture(2, 40)); + + Assert.False(cache.TryGetAndStage(1, staging, out _, out _)); + Assert.True(cache.TryGetAndStage(2, staging, out _, out _)); + Assert.Equal(1, cache.Count); + Assert.Equal(40, cache.ResidentBytes); + } + + [Fact] + public void CpuCacheRejectsOversizedEntryBeforeEvictingOrPublishingIt() + { + var staging = new MeshUploadStagingQueue(maximumBytes: 256); + var cache = new CpuMeshUploadCache(capacity: 2, byteCapacity: 64); + ObjectMeshData retained = DataWithTexture(1, 40); + ObjectMeshData oversized = DataWithTexture(2, 80); + + Assert.True(cache.Store(retained)); + Assert.False(cache.Store(oversized)); + + Assert.Equal(1, cache.Count); + Assert.Equal(40, cache.ResidentBytes); + Assert.False(cache.TryGetAndStage(2, staging, out _, out _)); + Assert.True(cache.TryGetAndStage(1, staging, out ObjectMeshData? found, out _)); + Assert.Same(retained, found); + } + + [Fact] + public void OversizedReplacementCannotDisplaceBoundedCachedGeneration() + { + var staging = new MeshUploadStagingQueue(maximumBytes: 256); + var cache = new CpuMeshUploadCache(capacity: 2, byteCapacity: 64); + ObjectMeshData retained = DataWithTexture(1, 40); + + Assert.True(cache.Store(retained)); + Assert.False(cache.Store(DataWithTexture(1, 80))); + Assert.True(cache.TryGetAndStage(1, staging, out ObjectMeshData? found, out _)); + Assert.Same(retained, found); + Assert.Equal(40, cache.ResidentBytes); + } + + [Fact] + public void DequeuedUnownedGenerationHandsOffToRacingCacheHit() + { + const ulong id = 0x01000010; + var staging = new MeshUploadStagingQueue(); + var cache = new CpuMeshUploadCache(capacity: 2); + var ownership = new MeshOwnershipCounter(); + ObjectMeshData data = DataWithTexture(id, 16); + cache.Store(data); + + Assert.True(staging.Stage(data)); + Assert.True(staging.TryDequeue(out MeshUploadQueueItem dequeued)); + + // The new owner cache-hits while the dequeued generation still owns + // staging's id claim, so its direct Stage attempt cannot enqueue yet. + ownership.Acquire(id); + Assert.True(cache.TryGetAndStage(id, staging, out ObjectMeshData? cached, out _)); + Assert.Same(data, cached); + Assert.Equal(0, staging.Count); + + Assert.True(staging.CompleteOrRestageIfOwned(dequeued, ownership)); + Assert.True(staging.TryDequeue(out MeshUploadQueueItem handedOff)); + Assert.Same(data, handedOff.Data); + Assert.False(staging.TryDequeue(out _)); + } + + [Fact] + public void OwnerArrivingAfterUnownedCompletionStagesCachedPayloadExactlyOnce() + { + const ulong id = 0x01000010; + var staging = new MeshUploadStagingQueue(); + var cache = new CpuMeshUploadCache(capacity: 2); + var ownership = new MeshOwnershipCounter(); + ObjectMeshData data = DataWithTexture(id, 16); + cache.Store(data); + + Assert.True(staging.Stage(data)); + Assert.True(staging.TryDequeue(out MeshUploadQueueItem dequeued)); + Assert.False(staging.CompleteOrRestageIfOwned(dequeued, ownership)); + Assert.Equal(0, staging.Count); + + ownership.Acquire(id); + Assert.True(cache.TryGetAndStage(id, staging, out ObjectMeshData? cached, out _)); + Assert.Same(data, cached); + Assert.True(staging.TryDequeue(out MeshUploadQueueItem handedOff)); + Assert.Same(data, handedOff.Data); + Assert.False(staging.TryDequeue(out _)); + } + + [Fact] + public void ReacquiredObjectGetsNewImmutableQueueGeneration() + { + var staging = new MeshUploadStagingQueue(); + var data = new ObjectMeshData { ObjectId = 0x01000010 }; + + Assert.True(staging.Stage(data)); + Assert.True(staging.TryDequeue(out MeshUploadQueueItem first)); + staging.Complete(first); + Assert.True(staging.Stage(data)); + Assert.True(staging.TryPeek(out MeshUploadQueueItem replacement)); + + Assert.NotEqual(first.Generation, replacement.Generation); + Assert.Throws(() => staging.Complete(first)); + Assert.True(staging.TryPeek(out MeshUploadQueueItem stillQueued)); + Assert.Equal(replacement.Generation, stillQueued.Generation); + } + + [Fact] + public void OversizedHeadIsExplicitlyBoundedAndFollowingProducerBackpressures() + { + var staging = new MeshUploadStagingQueue( + maximumCount: 8, + maximumBytes: 64, + maximumSingleEntryBytes: 96); + ObjectMeshData oversized = DataWithTexture(1, 80); + + Assert.True(staging.Stage(oversized)); + Assert.False(staging.Stage(DataWithTexture(2, 1))); + Assert.Throws(() => staging.Stage(DataWithTexture(3, 97))); + Assert.Equal(1, staging.Count); + Assert.Equal(80, staging.QueuedBytes); + } + + [Fact] + public void DequeuedGenerationStillCountsAgainstProducerByteAndCountBounds() + { + var staging = new MeshUploadStagingQueue(maximumCount: 1, maximumBytes: 64); + ObjectMeshData first = DataWithTexture(1, 40); + + Assert.True(staging.Stage(first)); + Assert.True(staging.TryDequeue(out MeshUploadQueueItem inFlight)); + Assert.Equal(0, staging.Count); + Assert.Equal(1, staging.ClaimCount); + Assert.Equal(0, staging.QueuedBytes); + Assert.Equal(40, staging.ClaimedBytes); + Assert.True(staging.IsAtHighWater); + Assert.False(staging.Stage(DataWithTexture(2, 1))); + + staging.Complete(inFlight); + Assert.True(staging.Stage(DataWithTexture(2, 1))); + } + + private static ObjectMeshData DataWithTexture(ulong id, int bytes) + { + var data = new ObjectMeshData { ObjectId = id }; + data.TextureBatches[(1, 1, TextureFormat.RGBA8)] = + [ + new TextureBatchData { TextureData = new byte[bytes] }, + ]; + return data; + } } diff --git a/tests/AcDream.App.Tests/Rendering/Wb/ObjectMeshUploadBudgetTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/ObjectMeshUploadBudgetTests.cs new file mode 100644 index 00000000..46af6ddb --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/ObjectMeshUploadBudgetTests.cs @@ -0,0 +1,292 @@ +using AcDream.App.Rendering.Wb; +using AcDream.Content; +using Chorizite.Core.Render.Enums; + +namespace AcDream.App.Tests.Rendering.Wb; + +public sealed class ObjectMeshUploadBudgetTests +{ + [Fact] + public void EstimateIncludesVerticesIndicesTexturesAndNestedGeometry() + { + var nested = new ObjectMeshData + { + Vertices = new VertexPositionNormalTexture[2], + TextureBatches = + { + [(8, 8, TextureFormat.RGBA8)] = + [ + new TextureBatchData + { + TextureData = new byte[256], + Indices = [0, 1, 2], + }, + ], + }, + }; + var setup = new ObjectMeshData + { + IsSetup = true, + EnvCellGeometry = nested, + }; + + long expected = 1024 + + 2L * VertexPositionNormalTexture.Size + + 256 + + 3L * sizeof(ushort); + Assert.Equal(expected, ObjectMeshManager.EstimateUploadBytes(setup)); + } + + [Fact] + public void First512RgbaLayerIncludesArrayAndWholeMipChain() + { + MeshUploadCost cost = ObjectMeshManager.CalculateNewAtlasFirstUploadCost( + 512, + 512, + TextureFormat.RGBA8, + uploadBytes: 512 * 512 * 4, + sourceBytes: 512 * 512 * 4); + + long expectedArray = TextureAtlasManager.CalculateArrayBytes(512, 512, TextureFormat.RGBA8); + Assert.Equal(expectedArray, cost.ArrayAllocationBytes); + Assert.Equal(expectedArray, cost.MipmapBytes); + Assert.Equal(1, cost.NewArrayCount); + } + + [Fact] + public void First1024RgbaLayerUsesOneLayerAtlasCapacity() + { + const int layerBytes = 1024 * 1024 * 4; + + MeshUploadCost cost = ObjectMeshManager.CalculateNewAtlasFirstUploadCost( + 1024, + 1024, + TextureFormat.RGBA8, + uploadBytes: layerBytes, + sourceBytes: layerBytes); + + Assert.Equal(1, TextureAtlasManager.CalculateInitialCapacity(1024, 1024, TextureFormat.RGBA8)); + Assert.Equal(5_592_404, cost.ArrayAllocationBytes); + } + + [Fact] + public void First512RgbaLayerHasFixedGoldenPhysicalCosts() + { + const int layerBytes = 512 * 512 * 4; + MeshUploadCost cost = ObjectMeshManager.CalculateNewAtlasFirstUploadCost( + 512, + 512, + TextureFormat.RGBA8, + uploadBytes: layerBytes); + + Assert.Equal(6, TextureAtlasManager.CalculateInitialCapacity(512, 512, TextureFormat.RGBA8)); + Assert.Equal(8_388_600, cost.ArrayAllocationBytes); + Assert.Equal(8_388_600, cost.MipmapBytes); + } + + [Fact] + public void Five512RgbaLayersChargeOneArrayAndOneMipGeneration() + { + const int layerBytes = 512 * 512 * 4; + + MeshUploadCost cost = ObjectMeshManager.CalculateNewAtlasUploadCost( + 512, + 512, + TextureFormat.RGBA8, + [layerBytes, layerBytes, layerBytes, layerBytes, layerBytes]); + + Assert.Equal(8_388_600, cost.ArrayAllocationBytes); + Assert.Equal(8_388_600, cost.MipmapBytes); + Assert.Equal(1, cost.NewArrayCount); + } + + [Fact] + public void BoundedIndivisibleHeadRunsImmediatelyWithoutVirtualReservation() + { + var limits = new MeshUploadBudgetLimits( + MaximumObjects: 4, + MaximumSourceBytes: 100, + MaximumArrayAllocationBytes: 100, + MaximumMipmapBytes: 100, + MaximumNewArrays: 2, + MaximumSingleSourceBytes: 200, + MaximumSingleArrayAllocationBytes: 200, + MaximumSingleMipmapBytes: 200, + MaximumSingleNewArrays: 4); + var budget = new MeshUploadFrameBudget(limits); + + var oversized = new MeshUploadCost(150, 150, 150, 3); + Assert.True(budget.TryAdmit(oversized)); + Assert.False(budget.TryAdmit(new MeshUploadCost(1, 0, 0, 0))); + Assert.Equal(1, budget.ObjectCount); + } + + [Fact] + public void HeadAboveExplicitSingleOperationCeilingFailsLoudly() + { + var budget = new MeshUploadFrameBudget(new MeshUploadBudgetLimits( + MaximumObjects: 4, + MaximumSourceBytes: 100, + MaximumArrayAllocationBytes: 100, + MaximumMipmapBytes: 100, + MaximumNewArrays: 1, + MaximumSingleSourceBytes: 200)); + + Assert.Throws( + () => budget.TryAdmit(new MeshUploadCost(250, 0, 0, 0, AdmissionKey: 17))); + } + + [Fact] + public void BufferMigrationCostCannotBePretendedAdmitted() + { + var budget = new MeshUploadFrameBudget(new MeshUploadBudgetLimits( + MaximumObjects: 4, + MaximumSourceBytes: 100, + MaximumArrayAllocationBytes: 100, + MaximumMipmapBytes: 100, + MaximumNewArrays: 1)); + + Assert.Throws( + () => budget.TryAdmit(new MeshUploadCost( + 1, 0, 0, 0, + BufferAllocationBytes: 1, + BufferCopyBytes: 1, + NewBufferCount: 1))); + } + + [Fact] + public void GlobalArenaGrowthIsGeometricAndAvoidsRepeatedPrefixCopies() + { + Assert.Equal( + 1_500, + GlobalMeshBuffer.CalculateGrowthCapacity( + capacity: 1_000, + trailingFreeLength: 100, + requiredContiguousLength: 300, + growthQuantum: 50)); + + Assert.Equal( + 1_000, + GlobalMeshBuffer.CalculateGrowthCapacity( + capacity: 1_000, + trailingFreeLength: 300, + requiredContiguousLength: 300, + growthQuantum: 50)); + } + + [Fact] + public void GlobalArenaTrimUsesThreeToOneHysteresisAndKeepsDoubleHeadroom() + { + Assert.True(GlobalMeshBuffer.TryCalculateTrimCapacity( + capacity: 8_192, + highWaterMark: 1_000, + initialCapacity: 512, + growthQuantum: 256, + out int trimmed)); + Assert.Equal(2_048, trimmed); + + Assert.False(GlobalMeshBuffer.TryCalculateTrimCapacity( + capacity: 2_048, + highWaterMark: 1_200, + initialCapacity: 512, + growthQuantum: 256, + out _)); + } + + [Fact] + public void FrameBudgetRejectsPrefixItemWhenAnySingleDimensionWouldOverflow() + { + var budget = new MeshUploadFrameBudget(new MeshUploadBudgetLimits( + MaximumObjects: 4, + MaximumSourceBytes: 100, + MaximumArrayAllocationBytes: 100, + MaximumMipmapBytes: 100, + MaximumNewArrays: 2, + MaximumBufferUploadBytes: 100, + MaximumBufferAllocationBytes: 100, + MaximumBufferCopyBytes: 100, + MaximumNewBuffers: 2)); + + Assert.True(budget.TryAdmit(new MeshUploadCost(40, 40, 40, 1, BufferUploadBytes: 40))); + Assert.True(budget.TryAdmit(new MeshUploadCost(40, 40, 40, 1, BufferUploadBytes: 40))); + Assert.False(budget.TryAdmit(new MeshUploadCost(1, 1, 1, 0, BufferUploadBytes: 21))); + Assert.Equal(2, budget.ObjectCount); + Assert.Equal(80, budget.BufferUploadBytes); + } + + [Fact] + public void MigrationCopyChunksNeverExceedRealFrameBudget() + { + const long budget = 32L * 1024 * 1024; + const long total = 100L * 1024 * 1024; + + Assert.Equal(budget, GlobalMeshBuffer.CalculateCopyChunk(total, 0, budget)); + Assert.Equal(budget, GlobalMeshBuffer.CalculateCopyChunk(total, budget, budget)); + Assert.Equal(4L * 1024 * 1024, + GlobalMeshBuffer.CalculateCopyChunk(total, 96L * 1024 * 1024, budget)); + Assert.Equal(0, GlobalMeshBuffer.CalculateCopyChunk(total, total, budget)); + } + + [Fact] + public void ModernArenaGeometryIsCountedOnlyByPhysicalBackingCapacity() + { + const long arenaCapacity = 512; + const long geometryRanges = 100; + + long modernNonArena = ObjectMeshManager.CalculateNonArenaGeometryBytes( + usesGlobalArena: true, + geometryRanges); + long legacyNonArena = ObjectMeshManager.CalculateNonArenaGeometryBytes( + usesGlobalArena: false, + geometryRanges); + + Assert.Equal(0, modernNonArena); + Assert.Equal(arenaCapacity, + ObjectMeshManager.CalculateTrackedGpuBytes(modernNonArena, arenaCapacity)); + Assert.Equal(arenaCapacity + geometryRanges, + ObjectMeshManager.CalculateTrackedGpuBytes(legacyNonArena, arenaCapacity)); + Assert.True(ObjectMeshManager.IsWithinGpuCacheBudget(0, arenaCapacity, 600)); + Assert.False(ObjectMeshManager.IsWithinGpuCacheBudget(100, arenaCapacity, 600)); + } + + [Fact] + public void SetupDependencyRollbackAttemptsEveryPartAndRetriesOnlyFailure() + { + var calls = new Dictionary(); + RetryableResourceReleaseLedger rollback = + ObjectMeshManager.CreateSetupPartRollback( + [1, 2, 3], + id => + { + calls[id] = calls.GetValueOrDefault(id) + 1; + if (id == 2 && calls[id] == 1) + throw new InvalidOperationException("part release"); + }); + + ResourceReleaseAttempt first = rollback.Advance(); + Assert.Single(first.Failures); + Assert.Equal(1, calls[1]); + Assert.Equal(1, calls[2]); + Assert.Equal(1, calls[3]); + + ResourceReleaseAttempt retry = rollback.Advance(); + Assert.Empty(retry.Failures); + Assert.True(rollback.IsComplete); + Assert.Equal(1, calls[1]); + Assert.Equal(2, calls[2]); + Assert.Equal(1, calls[3]); + } + + [Fact] + public void ReclamationBudgetCanSkipOversizedHeadAndAdmitSmallerCandidate() + { + Assert.False(ObjectMeshManager.FitsReclamationBudget( + candidateBytes: 80, + alreadyReclaimedBytes: 0, + maximumBytes: 64)); + Assert.True(ObjectMeshManager.FitsReclamationBudget( + candidateBytes: 16, + alreadyReclaimedBytes: 0, + maximumBytes: 64)); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/RetryableResourceReleaseLedgerTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/RetryableResourceReleaseLedgerTests.cs new file mode 100644 index 00000000..d9d3010d --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/RetryableResourceReleaseLedgerTests.cs @@ -0,0 +1,177 @@ +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Tests.Rendering.Wb; + +public sealed class RetryableResourceReleaseLedgerTests +{ + [Fact] + public void Advance_AttemptsEveryStage_AndRetriesOnlyUnfinishedStage() + { + int firstCalls = 0; + int flakyCalls = 0; + int lastCalls = 0; + var ledger = new RetryableResourceReleaseLedger( + [ + ("first", () => firstCalls++), + ("flaky", () => + { + flakyCalls++; + if (flakyCalls == 1) + throw new InvalidOperationException("injected pre-commit failure"); + }), + ("last", () => lastCalls++), + ]); + + ResourceReleaseAttempt first = ledger.Advance(); + + Assert.False(ledger.IsComplete); + Assert.Equal(1, ledger.RemainingCount); + Assert.Equal(3, first.AttemptedCount); + Assert.Equal(2, first.CompletedCount); + Assert.Single(first.Failures); + Assert.False(first.Failures[0].MutationCommitted); + Assert.Equal(1, firstCalls); + Assert.Equal(1, flakyCalls); + Assert.Equal(1, lastCalls); + + ResourceReleaseAttempt retry = ledger.Advance(); + + Assert.True(ledger.IsComplete); + Assert.Equal(0, ledger.RemainingCount); + Assert.Equal(1, retry.AttemptedCount); + Assert.Equal(1, retry.CompletedCount); + Assert.Empty(retry.Failures); + Assert.Equal(1, firstCalls); + Assert.Equal(2, flakyCalls); + Assert.Equal(1, lastCalls); + } + + [Fact] + public void Advance_CommittedExceptionalMutation_IsRecordedButNeverReplayed() + { + int committedCalls = 0; + int laterCalls = 0; + var ledger = new RetryableResourceReleaseLedger( + [ + ("committed", () => + { + committedCalls++; + throw new MeshReferenceMutationException( + "injected post-commit failure", + mutationCommitted: true, + new InvalidOperationException("observer failed")); + }), + ("later", () => laterCalls++), + ]); + + ResourceReleaseAttempt attempt = ledger.Advance(); + + Assert.True(ledger.IsComplete); + Assert.Equal(2, attempt.AttemptedCount); + Assert.Equal(2, attempt.CompletedCount); + ResourceReleaseFailure failure = Assert.Single(attempt.Failures); + Assert.Equal("committed", failure.Stage); + Assert.True(failure.MutationCommitted); + Assert.Equal(1, committedCalls); + Assert.Equal(1, laterCalls); + + ResourceReleaseAttempt duplicate = ledger.Advance(); + + Assert.Equal(0, duplicate.AttemptedCount); + Assert.Empty(duplicate.Failures); + Assert.Equal(1, committedCalls); + Assert.Equal(1, laterCalls); + } + + [Fact] + public void Advance_MultipleIndependentFailures_AllRunAndConvergeIndependently() + { + int firstFailures = 0; + int secondFailures = 0; + int successfulTail = 0; + var ledger = new RetryableResourceReleaseLedger( + [ + ("first", () => + { + if (firstFailures++ == 0) + throw new InvalidOperationException("first"); + }), + ("second", () => + { + if (secondFailures++ < 2) + throw new InvalidOperationException("second"); + }), + ("tail", () => successfulTail++), + ]); + + ResourceReleaseAttempt first = ledger.Advance(); + ResourceReleaseAttempt second = ledger.Advance(); + ResourceReleaseAttempt third = ledger.Advance(); + + Assert.Equal(2, first.Failures.Count); + Assert.Single(second.Failures); + Assert.Empty(third.Failures); + Assert.True(ledger.IsComplete); + Assert.Equal(1, successfulTail); + Assert.Equal(2, firstFailures); + Assert.Equal(3, secondFailures); + } + + [Fact] + public void Advance_ReentrantAttempt_DoesNotExecuteActiveStageTwice() + { + RetryableResourceReleaseLedger? ledger = null; + int activeCalls = 0; + int tailCalls = 0; + ledger = new RetryableResourceReleaseLedger( + [ + ("active", () => + { + activeCalls++; + ledger!.Advance(); + }), + ("tail", () => tailCalls++), + ]); + + ResourceReleaseAttempt attempt = ledger.Advance(); + + Assert.True(ledger.IsComplete); + Assert.Empty(attempt.Failures); + Assert.Equal(1, activeCalls); + Assert.Equal(1, tailCalls); + } + + [Fact] + public void Advance_ReentrantAttemptDoesNotGiveFailedTailTwoAttemptsInOnePass() + { + RetryableResourceReleaseLedger? ledger = null; + int activeCalls = 0; + int flakyTailCalls = 0; + ledger = new RetryableResourceReleaseLedger( + [ + ("active", () => + { + activeCalls++; + ledger!.Advance(); + }), + ("flaky-tail", () => + { + flakyTailCalls++; + if (flakyTailCalls == 1) + throw new InvalidOperationException("first pass"); + }), + ]); + + ResourceReleaseAttempt first = ledger.Advance(); + + Assert.Single(first.Failures); + Assert.Equal(1, activeCalls); + Assert.Equal(1, flakyTailCalls); + Assert.False(ledger.IsComplete); + + ResourceReleaseAttempt second = ledger.Advance(); + Assert.Empty(second.Failures); + Assert.True(ledger.IsComplete); + Assert.Equal(2, flakyTailCalls); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/TextureAtlasCapacityTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/TextureAtlasCapacityTests.cs new file mode 100644 index 00000000..44294ea2 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/TextureAtlasCapacityTests.cs @@ -0,0 +1,76 @@ +using AcDream.App.Rendering.Wb; +using Chorizite.Core.Render.Enums; +using Silk.NET.OpenGL; + +namespace AcDream.App.Tests.Rendering.Wb; + +public sealed class TextureAtlasCapacityTests +{ + [Theory] + [InlineData(128, 128, 32)] + [InlineData(256, 256, 24)] + [InlineData(512, 512, 6)] + [InlineData(1024, 1024, 1)] + public void RgbaCapacityTargetsEightMiBIncludingMipChain( + int width, + int height, + int expected) + { + Assert.Equal( + expected, + TextureAtlasManager.CalculateInitialCapacity(width, height, TextureFormat.RGBA8)); + } + + [Fact] + public void SmallCompressedLayersRemainCountCapped() + { + Assert.Equal( + TextureAtlasManager.MaximumArrayLayers, + TextureAtlasManager.CalculateInitialCapacity(32, 32, TextureFormat.DXT1)); + } + + [Fact] + public void DirectUploadAcceptsExactRgbaPayload() + { + ManagedGLTextureArray.ValidateUploadPayload( + TextureFormat.RGBA8, 2, 2, 16, PixelFormat.Rgba, PixelType.UnsignedByte); + } + + [Theory] + [InlineData(15)] + [InlineData(17)] + public void DirectUploadRejectsNonExactPayloadLength(int bytes) + { + Assert.Throws(() => + ManagedGLTextureArray.ValidateUploadPayload( + TextureFormat.RGBA8, 2, 2, bytes, PixelFormat.Rgba, PixelType.UnsignedByte)); + } + + [Fact] + public void DirectUploadRejectsMismatchedTransferTuple() + { + Assert.Throws(() => + ManagedGLTextureArray.ValidateUploadPayload( + TextureFormat.RGBA8, 2, 2, 16, PixelFormat.Rgb, PixelType.UnsignedByte)); + Assert.Throws(() => + ManagedGLTextureArray.ValidateUploadPayload( + TextureFormat.RGBA8, 2, 2, 16, PixelFormat.Rgba, PixelType.Float)); + } + + [Fact] + public void CompressedUploadRejectsUncompressedDescriptorOverride() + { + int bytes = ManagedGLTextureArray.CalculateExpectedDataSize(TextureFormat.DXT1, 4, 4); + Assert.Throws(() => + ManagedGLTextureArray.ValidateUploadPayload( + TextureFormat.DXT1, 4, 4, bytes, PixelFormat.Rgba, PixelType.UnsignedByte)); + } + + [Fact] + public void RgbPayloadUsesTightlyPackedRows() + { + Assert.Equal(18, ManagedGLTextureArray.CalculateExpectedDataSize(TextureFormat.RGB8, 3, 2)); + ManagedGLTextureArray.ValidateUploadPayload( + TextureFormat.RGB8, 3, 2, 18, PixelFormat.Rgb, PixelType.UnsignedByte); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/TextureAtlasSlotAllocatorTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/TextureAtlasSlotAllocatorTests.cs new file mode 100644 index 00000000..e1443710 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/TextureAtlasSlotAllocatorTests.cs @@ -0,0 +1,162 @@ +using AcDream.App.Rendering.Wb; +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering.Wb; + +public sealed class TextureAtlasSlotAllocatorTests +{ + [Fact] + public void SlotIsNotAvailableUntilGpuRetirementReturnsIt() + { + var slots = new TextureAtlasSlotAllocator(capacity: 2); + int first = slots.Rent(); + int second = slots.Rent(); + + Assert.Equal(0, slots.AvailableCount); + Assert.Throws(() => slots.Rent()); + + // Dropping the texture's CPU key does not call Return. The layer is + // still unavailable while an older draw may be sampling it. + Assert.Equal(0, slots.AvailableCount); + + slots.Return(first); + + Assert.Equal(1, slots.AvailableCount); + Assert.Equal(first, slots.Rent()); + Assert.Equal(0, slots.AvailableCount); + Assert.NotEqual(first, second); + } + + [Fact] + public void DoubleReturnIsRejected() + { + var slots = new TextureAtlasSlotAllocator(capacity: 1); + int slot = slots.Rent(); + slots.Return(slot); + + Assert.Throws(() => slots.Return(slot)); + } + + [Fact] + public void LayerRetirement_PublicationFailureRetainsPhysicalReturn() + { + var queue = new DeferredQueue { FailNextPublication = true }; + var retirement = new TextureAtlasLayerRetirement(queue); + int returns = 0; + int notifications = 0; + + Assert.Throws(() => + retirement.Retire(() => returns++, () => notifications++)); + Assert.Equal(1, retirement.AwaitingPublicationCount); + Assert.Equal(0, returns); + + retirement.RetryPendingPublications(); + Assert.Equal(0, retirement.AwaitingPublicationCount); + queue.Actions.Single()(); + Assert.Equal(1, returns); + Assert.Equal(1, notifications); + } + + [Fact] + public void LayerRetirement_ObserverFailureNeverReturnsSlotTwice() + { + var queue = new DeferredQueue(); + var retirement = new TextureAtlasLayerRetirement(queue); + int returns = 0; + int notifications = 0; + retirement.Retire( + () => returns++, + () => + { + notifications++; + if (notifications == 1) + throw new InvalidOperationException("observer"); + }); + Action callback = queue.Actions.Single(); + + Assert.Throws(callback); + callback(); + + Assert.Equal(1, returns); + Assert.Equal(2, notifications); + } + + [Fact] + public void DisposeTransaction_ArrayPublicationFailureRetainsRetryableOwnership() + { + var transaction = new TextureAtlasDisposeTransaction(); + int layerRetries = 0; + int arrayDisposals = 0; + int logicalCommits = 0; + bool failFirstArrayDisposal = true; + + Assert.Throws(() => transaction.Advance( + () => layerRetries++, + () => { + arrayDisposals++; + if (failFirstArrayDisposal) { + failFirstArrayDisposal = false; + throw new InvalidOperationException("injected array publication failure"); + } + }, + () => logicalCommits++)); + + Assert.False(transaction.IsComplete); + Assert.Equal(1, layerRetries); + Assert.Equal(1, arrayDisposals); + Assert.Equal(0, logicalCommits); + + transaction.Advance( + () => layerRetries++, + () => arrayDisposals++, + () => logicalCommits++); + transaction.Advance( + () => layerRetries++, + () => arrayDisposals++, + () => logicalCommits++); + + Assert.True(transaction.IsComplete); + Assert.Equal(2, layerRetries); + Assert.Equal(2, arrayDisposals); + Assert.Equal(1, logicalCommits); + } + + [Fact] + public void DisposeTransaction_ReentrantAdvanceDoesNotReplayActiveStage() + { + var transaction = new TextureAtlasDisposeTransaction(); + int layerRetries = 0; + int arrayDisposals = 0; + int logicalCommits = 0; + Action retryLayers = () => layerRetries++; + Action commit = () => logicalCommits++; + Action? disposeArray = null; + disposeArray = () => { + arrayDisposals++; + transaction.Advance(retryLayers, disposeArray!, commit); + }; + + transaction.Advance(retryLayers, disposeArray, commit); + + Assert.True(transaction.IsComplete); + Assert.Equal(1, layerRetries); + Assert.Equal(1, arrayDisposals); + Assert.Equal(1, logicalCommits); + } + + private sealed class DeferredQueue : IGpuResourceRetirementQueue + { + public bool FailNextPublication { get; set; } + public List Actions { get; } = []; + + public void Retire(Action release) + { + if (FailNextPublication) + { + FailNextPublication = false; + throw new InvalidOperationException("publication"); + } + Actions.Add(release); + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherCompositeWarmupTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherCompositeWarmupTests.cs new file mode 100644 index 00000000..09bbd20a --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherCompositeWarmupTests.cs @@ -0,0 +1,112 @@ +using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Rendering.Wb; + +public sealed class WbDrawDispatcherCompositeWarmupTests +{ + private const uint Destination = 0x1234FFFFu; + + [Fact] + public void NewlyPublishedEntitySnapshotRequiresRebuildAfterPriorSnapshotWasReady() + { + IReadOnlyList emptyReadySnapshot = Array.Empty(); + IReadOnlyList publishedDestinationSnapshot = + [CreateEntity(parentCell: Destination, withPalette: true)]; + + Assert.True(WbDrawDispatcher.RequiresCompositeWarmupRebuild( + emptyReadySnapshot, + Destination, + currentRadius: 0, + currentGeneration: 0, + publishedDestinationSnapshot, + nextGeneration: 1, + Destination, + nextRadius: 0)); + } + + [Fact] + public void MutatedStableEntityViewRequiresRebuildWhenGenerationChanges() + { + IReadOnlyList stableView = new List(); + + Assert.True(WbDrawDispatcher.RequiresCompositeWarmupRebuild( + stableView, + Destination, + currentRadius: 0, + currentGeneration: 41, + stableView, + nextGeneration: 42, + Destination, + nextRadius: 0)); + } + + [Fact] + public void PaletteEntityInsideDestinationRadiusIsCandidate() + { + WorldEntity entity = CreateEntity(parentCell: 0x1235FFFEu, withPalette: true); + + Assert.True(WbDrawDispatcher.IsCompositeWarmupCandidate(entity, Destination, radius: 1)); + } + + [Fact] + public void EntityOutsideDestinationRadiusIsExcluded() + { + WorldEntity entity = CreateEntity(parentCell: 0x1236FFFEu, withPalette: true); + + Assert.False(WbDrawDispatcher.IsCompositeWarmupCandidate(entity, Destination, radius: 1)); + } + + [Fact] + public void CellLessEntityIsExcludedFromKnownDestination() + { + WorldEntity entity = CreateEntity(parentCell: null, withPalette: true); + + Assert.False(WbDrawDispatcher.IsCompositeWarmupCandidate(entity, Destination, radius: 1)); + } + + [Fact] + public void SameLandblockIndoorCellIsCandidateAtZeroRadius() + { + WorldEntity entity = CreateEntity(parentCell: 0x1234012Au, withPalette: true); + + Assert.True(WbDrawDispatcher.IsCompositeWarmupCandidate(entity, Destination, radius: 0)); + } + + [Fact] + public void EntityWithoutPaletteOrSurfaceOverridesIsExcluded() + { + WorldEntity entity = CreateEntity(parentCell: Destination, withPalette: false); + + Assert.False(WbDrawDispatcher.IsCompositeWarmupCandidate(entity, Destination, radius: 0)); + } + + [Fact] + public void SurfaceOverrideEntityIsCandidateWithoutPalette() + { + WorldEntity entity = CreateEntity(parentCell: Destination, withPalette: false); + entity.MeshRefs = + [ + new MeshRef(0x01000001u, Matrix4x4.Identity) + { + SurfaceOverrides = new Dictionary { [0x08000001u] = 0x05000001u }, + }, + ]; + + Assert.True(WbDrawDispatcher.IsCompositeWarmupCandidate(entity, Destination, radius: 0)); + } + + private static WorldEntity CreateEntity(uint? parentCell, bool withPalette) => new() + { + Id = 1, + SourceGfxObjOrSetupId = 0x01000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = [new MeshRef(0x01000001u, Matrix4x4.Identity)], + ParentCellId = parentCell, + PaletteOverride = withPalette + ? new PaletteOverride(0x04000001u, Array.Empty()) + : null, + }; +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/WbTextureResolutionPolicyTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/WbTextureResolutionPolicyTests.cs new file mode 100644 index 00000000..7bb6261d --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/WbTextureResolutionPolicyTests.cs @@ -0,0 +1,25 @@ +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Tests.Rendering.Wb; + +public sealed class WbTextureResolutionPolicyTests +{ + [Theory] + [InlineData(false, false, false, (int)WbTextureResolutionKind.SharedAtlas)] + [InlineData(false, true, false, (int)WbTextureResolutionKind.SharedAtlas)] + [InlineData(true, false, false, (int)WbTextureResolutionKind.OriginalTextureOverride)] + [InlineData(true, true, false, (int)WbTextureResolutionKind.OriginalTextureOverride)] + [InlineData(false, true, true, (int)WbTextureResolutionKind.PaletteComposite)] + [InlineData(true, true, true, (int)WbTextureResolutionKind.PaletteComposite)] + public void Select_MatchesRetailImageOwnership( + bool hasOriginalTextureOverride, + bool hasPaletteOverride, + bool sourceIsPaletteIndexed, + int expected) + { + Assert.Equal((WbTextureResolutionKind)expected, WbTextureResolutionPolicy.Select( + hasOriginalTextureOverride, + hasPaletteOverride, + sourceIsPaletteIndexed)); + } +} diff --git a/tests/AcDream.App.Tests/RuntimeDatAccessArchitectureTests.cs b/tests/AcDream.App.Tests/RuntimeDatAccessArchitectureTests.cs new file mode 100644 index 00000000..4b9b2d42 --- /dev/null +++ b/tests/AcDream.App.Tests/RuntimeDatAccessArchitectureTests.cs @@ -0,0 +1,91 @@ +using DatReaderWriter; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace AcDream.App.Tests; + +public sealed class RuntimeDatAccessArchitectureTests +{ + [Fact] + public void ProductionAppTypes_DoNotStoreOrAcceptRawDatCollection() + { + Type rawType = typeof(DatReaderWriter.DatCollection); + Type ownerType = typeof(RuntimeDatCollection); + Type[] appTypes = typeof(RuntimeDatCollectionFactory).Assembly.GetTypes(); + var violations = new List(); + + foreach (Type type in appTypes.Where(type => type != ownerType)) + { + foreach (FieldInfo field in type.GetFields( + BindingFlags.Instance | BindingFlags.Static | + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) + { + if (field.FieldType == rawType) + violations.Add($"{type.FullName} field {field.Name}"); + } + + foreach (PropertyInfo property in type.GetProperties( + BindingFlags.Instance | BindingFlags.Static | + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) + { + if (property.PropertyType == rawType) + violations.Add($"{type.FullName} property {property.Name}"); + } + + foreach (MethodBase method in type.GetMethods( + BindingFlags.Instance | BindingFlags.Static | + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly) + .Cast() + .Concat(type.GetConstructors( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))) + { + if (method.GetParameters().Any(parameter => parameter.ParameterType == rawType)) + violations.Add($"{type.FullName} method {method.Name}"); + if (method is MethodInfo info && info.ReturnType == rawType) + violations.Add($"{type.FullName} return {method.Name}"); + } + } + + Assert.Empty(violations); + } + + [Fact] + public void ProductionSource_CreatesRawHandlesAndBoundedAdapterOnlyInRuntimeOwner() + { + string root = FindRepoRoot(); + string appRoot = Path.Combine(root, "src", "AcDream.App"); + string ownerPath = Path.GetFullPath( + Path.Combine(appRoot, "RuntimeDatCollectionFactory.cs")); + string[] sources = Directory.GetFiles(appRoot, "*.cs", SearchOption.AllDirectories); + + string[] rawCreates = FindMatchingFiles(sources, @"\bnew\s+DatCollection\s*\("); + string[] adapterCreates = FindMatchingFiles(sources, @"\bnew\s+DatCollectionAdapter\s*\("); + string[] rawTypedReads = FindMatchingFiles( + sources, + @"\.Db\s*\.\s*(?:Get|TryGet)\s*<"); + + Assert.Equal([ownerPath], rawCreates); + Assert.Equal([ownerPath], adapterCreates); + Assert.Empty(rawTypedReads); + } + + private static string[] FindMatchingFiles(IEnumerable sources, string pattern) => + sources + .Where(path => Regex.IsMatch(File.ReadAllText(path), pattern)) + .Select(Path.GetFullPath) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static string FindRepoRoot() + { + string? dir = AppContext.BaseDirectory; + while (dir is not null) + { + if (File.Exists(Path.Combine(dir, "AcDream.slnx"))) + return dir; + dir = Directory.GetParent(dir)?.FullName; + } + + throw new DirectoryNotFoundException("Could not locate AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/RuntimeDatCollectionFactoryTests.cs b/tests/AcDream.App.Tests/RuntimeDatCollectionFactoryTests.cs new file mode 100644 index 00000000..9b75b529 --- /dev/null +++ b/tests/AcDream.App.Tests/RuntimeDatCollectionFactoryTests.cs @@ -0,0 +1,40 @@ +using DatReaderWriter.Options; + +namespace AcDream.App.Tests; + +public sealed class RuntimeDatCollectionFactoryTests +{ + [Fact] + public void CreateReadOnlyOptions_BoundsFilePayloadResidencyAndRetainsIndexLookupCache() + { + const string datDirectory = @"C:\RetailDats"; + + DatCollectionOptions options = + RuntimeDatCollectionFactory.CreateReadOnlyOptions(datDirectory); + + Assert.Equal(datDirectory, options.DatDirectory); + Assert.Equal(DatAccessType.Read, options.AccessType); + Assert.Equal(IndexCachingStrategy.OnDemand, options.IndexCachingStrategy); + Assert.Equal(FileCachingStrategy.Never, options.FileCachingStrategy); + + Assert.Equal(IndexCachingStrategy.OnDemand, options.PortalIndexCachingStrategy); + Assert.Equal(IndexCachingStrategy.OnDemand, options.CellIndexCachingStrategy); + Assert.Equal(IndexCachingStrategy.OnDemand, options.LocalIndexCachingStrategy); + Assert.Equal(IndexCachingStrategy.OnDemand, options.HighResIndexCachingStrategy); + + Assert.Equal(FileCachingStrategy.Never, options.PortalFileCachingStrategy); + Assert.Equal(FileCachingStrategy.Never, options.CellFileCachingStrategy); + Assert.Equal(FileCachingStrategy.Never, options.LocalFileCachingStrategy); + Assert.Equal(FileCachingStrategy.Never, options.HighResFileCachingStrategy); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void CreateReadOnlyOptions_RejectsMissingDatDirectory(string? datDirectory) + { + Assert.ThrowsAny(() => + RuntimeDatCollectionFactory.CreateReadOnlyOptions(datDirectory!)); + } +} diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index 02fc8aca..edc446aa 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -30,6 +30,7 @@ public sealed class RuntimeOptionsTests Assert.Null(opts.LiveUser); Assert.Null(opts.LivePass); Assert.False(opts.DevTools); + Assert.False(opts.UncappedRendering); Assert.False(opts.DumpMoveTruth); Assert.False(opts.NoAudio); Assert.False(opts.EnableSkyPesDebug); @@ -183,12 +184,14 @@ public sealed class RuntimeOptionsTests var allOn = RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_DEVTOOLS"] = "1", + ["ACDREAM_UNCAPPED_RENDER"] = "1", ["ACDREAM_DUMP_MOVE_TRUTH"] = "1", ["ACDREAM_NO_AUDIO"] = "1", ["ACDREAM_ENABLE_SKY_PES"] = "1", ["ACDREAM_DUMP_SCENERY_Z"] = "1", })); Assert.True(allOn.DevTools); + Assert.True(allOn.UncappedRendering); Assert.True(allOn.DumpMoveTruth); Assert.True(allOn.NoAudio); Assert.True(allOn.EnableSkyPesDebug); @@ -199,12 +202,14 @@ public sealed class RuntimeOptionsTests var anyOther = RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_DEVTOOLS"] = "true", + ["ACDREAM_UNCAPPED_RENDER"] = "true", ["ACDREAM_DUMP_MOVE_TRUTH"] = "yes", ["ACDREAM_NO_AUDIO"] = "2", ["ACDREAM_ENABLE_SKY_PES"] = "on", ["ACDREAM_DUMP_SCENERY_Z"] = " 1", })); Assert.False(anyOther.DevTools); + Assert.False(anyOther.UncappedRendering); Assert.False(anyOther.DumpMoveTruth); Assert.False(anyOther.NoAudio); Assert.False(anyOther.EnableSkyPesDebug); diff --git a/tests/AcDream.App.Tests/Streaming/GpuWorldStateAnimatedIndexTests.cs b/tests/AcDream.App.Tests/Streaming/GpuWorldStateAnimatedIndexTests.cs new file mode 100644 index 00000000..78959927 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/GpuWorldStateAnimatedIndexTests.cs @@ -0,0 +1,77 @@ +using System.Numerics; +using AcDream.App.Streaming; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Streaming; + +public sealed class GpuWorldStateAnimatedIndexTests +{ + private const uint Landblock = 0x0101FFFFu; + + [Fact] + public void StableLandblock_ReusesAnimatedIndexAcrossFrames() + { + var state = new GpuWorldState(); + WorldEntity entity = Entity(1u, 0u); + state.AddLandblock(Loaded(entity)); + + IReadOnlyDictionary? first = SingleIndex(state); + IReadOnlyDictionary? second = SingleIndex(state); + + Assert.Same(first, second); + Assert.Same(entity, first![entity.Id]); + } + + [Fact] + public void ReplacedLandblockRecord_RebuildsOnlyThatGeneration() + { + var state = new GpuWorldState(); + WorldEntity original = Entity(1u, 0u); + WorldEntity live = Entity(2u, 0x70000002u); + state.AddLandblock(Loaded(original)); + IReadOnlyDictionary? first = SingleIndex(state); + + state.PlaceLiveEntityProjection(Landblock, live); + IReadOnlyDictionary? second = SingleIndex(state); + + Assert.NotSame(first, second); + Assert.Same(original, second![original.Id]); + Assert.Same(live, second[live.Id]); + Assert.Same(second, SingleIndex(state)); + } + + [Fact] + public void RemoveThenReload_DoesNotRetainRemovedGeneration() + { + var state = new GpuWorldState(); + WorldEntity removed = Entity(1u, 0u); + state.AddLandblock(Loaded(removed)); + IReadOnlyDictionary? first = SingleIndex(state); + + state.RemoveLandblock(Landblock); + WorldEntity replacement = Entity(2u, 0u); + state.AddLandblock(Loaded(replacement)); + IReadOnlyDictionary? second = SingleIndex(state); + + Assert.NotSame(first, second); + Assert.False(second!.ContainsKey(removed.Id)); + Assert.Same(replacement, second[replacement.Id]); + } + + private static IReadOnlyDictionary? SingleIndex(GpuWorldState state) => + Assert.Single(state.LandblockEntries).AnimatedById; + + private static LoadedLandblock Loaded(params WorldEntity[] entities) => + new(Landblock, new LandBlock(), entities); + + private static WorldEntity Entity(uint id, uint serverGuid) => new() + { + Id = id, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; +} diff --git a/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs b/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs index 2ff08029..a2a07967 100644 --- a/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs +++ b/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs @@ -11,6 +11,361 @@ namespace AcDream.App.Tests.Streaming; public sealed class GpuWorldStateVisibilityTests { + [Fact] + public void PendingSpawnFlood_DoesNotPublishOrRebuildLoadedView() + { + const uint landblock = 0x0101FFFFu; + const int count = 10_000; + var state = new GpuWorldState(); + int edges = 0; + state.LiveProjectionVisibilityChanged += (_, _) => edges++; + long commitsBefore = state.VisibilityCommitCount; + + using (state.BeginMutationBatch()) + { + for (int i = 0; i < count; i++) + { + state.PlaceLiveEntityProjection( + landblock, + Entity((uint)(i + 1), 0x70000000u + (uint)i)); + } + + Assert.Empty(state.Entities); + Assert.Equal(count, state.PendingLiveEntityCount); + Assert.Equal(commitsBefore, state.VisibilityCommitCount); + Assert.Equal(0, edges); + } + + Assert.Empty(state.Entities); + Assert.Equal(count, state.PendingLiveEntityCount); + Assert.Equal(commitsBefore + 1, state.VisibilityCommitCount); + Assert.Equal(0, edges); + } + + [Fact] + public void LoadedSpawnFlood_AppendsInPlaceAndPublishesOneBatch() + { + const uint landblock = 0x0101FFFFu; + const int count = 10_000; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + landblock, + new LandBlock(), + Array.Empty())); + Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? loaded)); + IReadOnlyList residentBucket = loaded!.Entities; + int edges = 0; + state.LiveProjectionVisibilityChanged += (_, visible) => + { + Assert.True(visible); + edges++; + }; + long commitsBefore = state.VisibilityCommitCount; + + using (state.BeginMutationBatch()) + { + for (int i = 0; i < count; i++) + { + state.PlaceLiveEntityProjection( + landblock, + Entity((uint)(i + 1), 0x71000000u + (uint)i)); + } + + Assert.Same(residentBucket, loaded.Entities); + Assert.Equal(count, residentBucket.Count); + Assert.Equal(count, state.Entities.Count); + Assert.Equal(0, edges); + Assert.Equal(commitsBefore, state.VisibilityCommitCount); + } + + Assert.Equal(count, edges); + Assert.Equal(commitsBefore + 1, state.VisibilityCommitCount); + Assert.Equal(count, state.Entities.Count); + } + + [Fact] + public void DenseSameBucketRebucketAndDelete_PreserveEveryProjectionIndex() + { + const uint sourceLandblock = 0x0101FFFFu; + const uint targetLandblock = 0x0202FFFFu; + const int count = 10_000; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + sourceLandblock, + new LandBlock(), + Array.Empty())); + state.AddLandblock(new LoadedLandblock( + targetLandblock, + new LandBlock(), + Array.Empty())); + var entities = new WorldEntity[count]; + using (state.BeginMutationBatch()) + { + for (int i = 0; i < count; i++) + { + entities[i] = Entity((uint)(i + 1), 0x72000000u + (uint)i); + state.PlaceLiveEntityProjection(sourceLandblock, entities[i]); + } + } + + using (state.BeginMutationBatch()) + { + for (int i = 0; i < count; i++) + state.RebucketLiveEntity(entities[i], targetLandblock); + } + + Assert.True(state.TryGetLandblock(sourceLandblock, out LoadedLandblock? source)); + Assert.True(state.TryGetLandblock(targetLandblock, out LoadedLandblock? target)); + Assert.Empty(source!.Entities); + Assert.Equal(count, target!.Entities.Count); + Assert.Equal(count, state.Entities.Count); + + using (state.BeginMutationBatch()) + { + for (int i = 0; i < count; i++) + state.RemoveLiveEntityProjection(entities[i]); + } + + Assert.Empty(target.Entities); + Assert.Empty(state.Entities); + } + + [Fact] + public void DenseDemotion_CompactsLiveEntriesAndKeepsTheirIndexesValid() + { + const uint sourceLandblock = 0x0101FFFFu; + const uint targetLandblock = 0x0202FFFFu; + const int liveCount = 2_000; + var state = new GpuWorldState(); + var mixed = new List(liveCount * 2); + var live = new List(liveCount); + for (int i = 0; i < liveCount; i++) + { + mixed.Add(Entity(0xC1000000u + (uint)i, 0u)); + WorldEntity projection = Entity((uint)(i + 1), 0x73000000u + (uint)i); + mixed.Add(projection); + live.Add(projection); + } + state.AddLandblock(new LoadedLandblock( + sourceLandblock, + new LandBlock(), + mixed)); + state.AddLandblock(new LoadedLandblock( + targetLandblock, + new LandBlock(), + Array.Empty())); + + state.RemoveEntitiesFromLandblock(sourceLandblock); + + Assert.True(state.TryGetLandblock(sourceLandblock, out LoadedLandblock? source)); + Assert.Equal(liveCount, source!.Entities.Count); + Assert.All(source.Entities, entity => Assert.NotEqual(0u, entity.ServerGuid)); + + using (state.BeginMutationBatch()) + { + foreach (WorldEntity entity in live) + state.RebucketLiveEntity(entity, targetLandblock); + } + + Assert.Empty(source.Entities); + Assert.True(state.TryGetLandblock(targetLandblock, out LoadedLandblock? target)); + Assert.Equal(liveCount, target!.Entities.Count); + } + + [Fact] + public void PendingDemotion_CompactsMixedStaticAndLiveEntriesAndKeepsLiveIndexesValid() + { + const uint pendingLandblock = 0x0101FFFFu; + const uint targetLandblock = 0x0202FFFFu; + var state = new GpuWorldState(); + WorldEntity first = Entity(1u, 0x73500001u); + WorldEntity second = Entity(2u, 0x73500002u); + WorldEntity[] mixed = + [ + Entity(0xC1000001u, 0u), + first, + Entity(0xC1000002u, 0u), + second, + ]; + + Assert.False(state.AddEntitiesToExistingLandblock(pendingLandblock, mixed)); + Assert.Equal(4, state.PendingLiveEntityCount); + + state.RemoveEntitiesFromLandblock(pendingLandblock); + + Assert.Equal(2, state.PendingLiveEntityCount); + state.AddLandblock(new LoadedLandblock( + pendingLandblock, + new LandBlock(), + Array.Empty())); + state.AddLandblock(new LoadedLandblock( + targetLandblock, + new LandBlock(), + Array.Empty())); + + Assert.True(state.TryGetLandblock(pendingLandblock, out LoadedLandblock? pending)); + Assert.Equal([first, second], pending!.Entities); + + state.RebucketLiveEntity(first, targetLandblock); + state.RebucketLiveEntity(second, targetLandblock); + + Assert.Empty(pending.Entities); + Assert.True(state.TryGetLandblock(targetLandblock, out LoadedLandblock? target)); + Assert.Equal(2, target!.Entities.Count); + Assert.Contains(first, target.Entities); + Assert.Contains(second, target.Entities); + } + + [Fact] + public void UnloadToPendingThenReload_PreservesProjectionIdentityAndVisibilityEdges() + { + const uint landblock = 0x0101FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + landblock, + new LandBlock(), + Array.Empty())); + WorldEntity entity = Entity(1u, 0x73600001u); + state.PlaceLiveEntityProjection(landblock, entity); + var edges = new List<(uint Guid, bool Visible)>(); + state.LiveProjectionVisibilityChanged += (guid, visible) => edges.Add((guid, visible)); + + state.RemoveLandblock(landblock); + + Assert.Empty(state.Entities); + Assert.Equal(1, state.PendingLiveEntityCount); + Assert.False(state.IsLiveEntityVisible(entity.ServerGuid)); + + state.AddLandblock(new LoadedLandblock( + landblock, + new LandBlock(), + Array.Empty())); + + Assert.Same(entity, Assert.Single(state.Entities)); + Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? reloaded)); + Assert.Same(entity, Assert.Single(reloaded!.Entities)); + Assert.True(state.IsLiveEntityVisible(entity.ServerGuid)); + Assert.Equal( + [(entity.ServerGuid, false), (entity.ServerGuid, true)], + edges); + + state.RemoveLiveEntityProjection(entity); + Assert.Empty(state.Entities); + Assert.Empty(reloaded.Entities); + } + + [Fact] + public void BatchedVisibilityObserver_SeesCommittedFlatAndResidentViews() + { + const uint landblock = 0x0101FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + landblock, + new LandBlock(), + Array.Empty())); + WorldEntity entity = Entity(1u, 0x73700001u); + int callbacks = 0; + state.LiveProjectionVisibilityChanged += (guid, visible) => + { + callbacks++; + Assert.Equal(entity.ServerGuid, guid); + Assert.True(visible); + Assert.True(state.IsLiveEntityVisible(guid)); + Assert.Same(entity, Assert.Single(state.Entities)); + Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? loaded)); + Assert.Same(entity, Assert.Single(loaded!.Entities)); + }; + + using (state.BeginMutationBatch()) + { + state.PlaceLiveEntityProjection(landblock, entity); + Assert.Equal(0, callbacks); + } + + Assert.Equal(1, callbacks); + } + + [Fact] + public void LoadedToLoadedRebucket_EmitsNoVisibilityPulse() + { + const uint sourceLandblock = 0x0101FFFFu; + const uint targetLandblock = 0x0202FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + sourceLandblock, + new LandBlock(), + Array.Empty())); + state.AddLandblock(new LoadedLandblock( + targetLandblock, + new LandBlock(), + Array.Empty())); + WorldEntity entity = Entity(1u, 0x73800001u); + state.PlaceLiveEntityProjection(sourceLandblock, entity); + var edges = new List<(uint Guid, bool Visible)>(); + state.LiveProjectionVisibilityChanged += (guid, visible) => edges.Add((guid, visible)); + + state.RebucketLiveEntity(entity, targetLandblock); + + Assert.Empty(edges); + Assert.True(state.IsLiveEntityVisible(entity.ServerGuid)); + Assert.True(state.TryGetLandblock(sourceLandblock, out LoadedLandblock? source)); + Assert.Empty(source!.Entities); + Assert.True(state.TryGetLandblock(targetLandblock, out LoadedLandblock? target)); + Assert.Same(entity, Assert.Single(target!.Entities)); + Assert.Same(entity, Assert.Single(state.Entities)); + } + + [Fact] + public void SameGuidOverlap_ExactRemovalPromotesSecondaryWithoutVisibilityPulse() + { + const uint landblock = 0x0101FFFFu; + const uint guid = 0x73900001u; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + landblock, + new LandBlock(), + Array.Empty())); + WorldEntity first = Entity(1u, guid); + WorldEntity second = Entity(2u, guid); + state.PlaceLiveEntityProjection(landblock, first); + state.PlaceLiveEntityProjection(landblock, second); + var edges = new List<(uint Guid, bool Visible)>(); + state.LiveProjectionVisibilityChanged += (edgeGuid, visible) => + edges.Add((edgeGuid, visible)); + + state.RemoveLiveEntityProjection(first); + + Assert.Empty(edges); + Assert.True(state.IsLiveEntityVisible(guid)); + Assert.Same(second, Assert.Single(state.Entities)); + + state.RemoveLiveEntityProjection(guid); + + Assert.Equal([(guid, false)], edges); + Assert.False(state.IsLiveEntityVisible(guid)); + Assert.Empty(state.Entities); + } + + [Fact] + public void DuplicateDirectPlacement_IsRejectedBeforeAnyBucketMutation() + { + const uint landblock = 0x0101FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + landblock, + new LandBlock(), + Array.Empty())); + WorldEntity entity = Entity(1u, 0x74000001u); + state.PlaceLiveEntityProjection(landblock, entity); + + Assert.Throws(() => + state.PlaceLiveEntityProjection(landblock, entity)); + + Assert.Same(entity, Assert.Single(state.Entities)); + Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? loaded)); + Assert.Same(entity, Assert.Single(loaded!.Entities)); + } + [Fact] public void ThrowingObserver_DoesNotStrandLaterSubscribersOrQueuedOwnerEdges() { @@ -80,6 +435,42 @@ public sealed class GpuWorldStateVisibilityTests Assert.Equal(0, state.PendingVisibilityTransitionCount); } + [Fact] + public void CopyLiveEntitiesNearLandblock_UsesBoundedNeighborhoodAndSkipsStatics() + { + var centerLive = Entity(1, 0x70000001u); + var neighborLive = Entity(2, 0x70000002u); + var farLive = Entity(3, 0x70000003u); + WorldEntity[] staticEntities = Enumerable.Range(0, 20_000) + .Select(index => Entity((uint)(index + 10), 0u)) + .ToArray(); + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + 0x1010FFFFu, + new LandBlock(), + staticEntities.Append(centerLive).ToArray())); + state.AddLandblock(new LoadedLandblock( + 0x1110FFFFu, + new LandBlock(), + new[] { neighborLive })); + state.AddLandblock(new LoadedLandblock( + 0x1310FFFFu, + new LandBlock(), + new[] { farLive })); + var destination = new List> + { + new(0xDEADBEEFu, farLive), + }; + + state.CopyLiveEntitiesNearLandblock(0x10100001u, 1, destination); + + Assert.Equal(2, destination.Count); + Assert.Contains(destination, pair => pair.Key == centerLive.ServerGuid); + Assert.Contains(destination, pair => pair.Key == neighborLive.ServerGuid); + Assert.DoesNotContain(destination, pair => pair.Key == farLive.ServerGuid); + Assert.DoesNotContain(destination, pair => pair.Key == 0u); + } + private static WorldEntity Entity(uint id, uint guid) => new() { Id = id, diff --git a/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs new file mode 100644 index 00000000..a1325e27 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs @@ -0,0 +1,202 @@ +using System.Numerics; +using AcDream.App.Streaming; +using AcDream.Core.Terrain; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using Xunit; + +namespace AcDream.App.Tests.Streaming; + +public sealed class LandblockRetirementCoordinatorTests +{ + [Fact] + public void EntityStageFailure_DetachesImmediately_AndResumesAtFailedEntity() + { + const uint landblockId = 0x2020FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + landblockId, + new LandBlock(), + new[] { Entity(1), Entity(2), Entity(3) })); + + var calls = new Dictionary(); + bool failEntityTwo = true; + var coordinator = new LandblockRetirementCoordinator( + state, + ticket => CompletePresentation( + ticket, + lighting: entity => + { + calls[entity.Id] = calls.GetValueOrDefault(entity.Id) + 1; + if (entity.Id == 2 && failEntityTwo) + { + failEntityTwo = false; + throw new InvalidOperationException("injected owner failure"); + } + })); + + coordinator.BeginFull(landblockId); + + Assert.False(state.IsLoaded(landblockId)); + Assert.Equal(1, coordinator.PendingCount); + Assert.Equal(1, calls[1]); + Assert.Equal(1, calls[2]); + Assert.False(calls.ContainsKey(3)); + + coordinator.Advance(); + + Assert.Equal(0, coordinator.PendingCount); + Assert.Equal(1, calls[1]); + Assert.Equal(2, calls[2]); + Assert.Equal(1, calls[3]); + } + + [Fact] + public void ForceReload_RetiresEveryLoadedId_WhenOneOwnerRemainsPending() + { + uint[] ids = [0x2020FFFFu, 0x2121FFFFu, 0x2222FFFFu]; + var state = new GpuWorldState(); + foreach (uint id in ids) + state.AddLandblock(new LoadedLandblock(id, new LandBlock(), Array.Empty())); + + bool failOnce = true; + var coordinator = new LandblockRetirementCoordinator( + state, + ticket => CompletePresentation( + ticket, + terrain: () => + { + if (ticket.LandblockId == ids[1] && failOnce) + { + failOnce = false; + throw new InvalidOperationException("injected terrain failure"); + } + })); + var controller = Controller(state, coordinator); + + controller.ForceReloadWindow(); + + foreach (uint id in ids) + Assert.False(state.IsLoaded(id)); + Assert.Equal(1, coordinator.PendingCount); + + coordinator.Advance(); + Assert.Equal(0, coordinator.PendingCount); + } + + [Fact] + public void ReplacementPublication_WaitsForSameIdRetirementFence() + { + const uint landblockId = 0x3232FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + landblockId, + new LandBlock(), + Array.Empty())); + + int terrainAttempts = 0; + var coordinator = new LandblockRetirementCoordinator( + state, + ticket => CompletePresentation( + ticket, + terrain: () => + { + terrainAttempts++; + if (terrainAttempts <= 2) + throw new InvalidOperationException("injected terrain failure"); + })); + + var pending = new Queue(); + int applyCount = 0; + var controller = Controller( + state, + coordinator, + drain: max => Drain(pending, max), + apply: (_, _) => applyCount++); + controller.ForceReloadWindow(); // generation 1, first retirement attempt + + var replacement = new LoadedLandblock( + landblockId, + new LandBlock(), + Array.Empty()); + pending.Enqueue(new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + replacement, + EmptyMesh(), + generation: 1)); + + controller.Tick(0x32, 0x32); // second failure; publication stays deferred + Assert.Equal(0, applyCount); + Assert.False(state.IsLoaded(landblockId)); + Assert.True(coordinator.IsPending(landblockId)); + + controller.Tick(0x32, 0x32); // third attempt completes, then publishes once + Assert.Equal(1, applyCount); + Assert.True(state.IsLoaded(landblockId)); + Assert.False(coordinator.IsPending(landblockId)); + } + + private static StreamingController Controller( + GpuWorldState state, + LandblockRetirementCoordinator coordinator, + Func>? drain = null, + Action? apply = null) => + new( + enqueueLoad: (_, _, _) => { }, + enqueueUnload: (_, _) => { }, + drainCompletions: drain ?? (_ => Array.Empty()), + applyTerrain: apply ?? ((_, _) => { }), + state: state, + nearRadius: 0, + farRadius: 0, + retirementCoordinator: coordinator); + + private static IReadOnlyList Drain( + Queue pending, + int max) + { + var result = new List(max); + while (result.Count < max && pending.TryDequeue(out LandblockStreamResult? item)) + result.Add(item); + return result; + } + + private static void CompletePresentation( + LandblockRetirementTicket ticket, + Action? lighting = null, + Action? terrain = null) + { + ticket.RunForEachEntity( + LandblockRetirementStage.EntityLighting, + static _ => true, + lighting ?? (_ => { })); + ticket.RunForEachEntity( + LandblockRetirementStage.EntityTranslucency, + static _ => true, + static _ => { }); + ticket.RunForEachEntity( + LandblockRetirementStage.PluginProjection, + static entity => entity.ServerGuid == 0, + static _ => { }); + if (ticket.Kind == LandblockRetirementKind.Full) + ticket.RunOnce(LandblockRetirementStage.Terrain, terrain ?? (() => { })); + ticket.RunOnce(LandblockRetirementStage.Physics, static () => { }); + ticket.RunOnce(LandblockRetirementStage.CellVisibility, static () => { }); + ticket.RunOnce(LandblockRetirementStage.BuildingRegistry, static () => { }); + ticket.RunOnce(LandblockRetirementStage.EnvironmentCells, static () => { }); + } + + private static WorldEntity Entity(uint id) => new() + { + Id = id, + SourceGfxObjOrSetupId = 0x01000000u + id, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + private static LandblockMeshData EmptyMesh() => new( + Array.Empty(), + Array.Empty()); +} diff --git a/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs index 302d2291..5a1bf804 100644 --- a/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs +++ b/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs @@ -327,7 +327,7 @@ public sealed class StreamingControllerReadinessTests var outbox = new Queue(); int ensureCalls = 0; var appliedBuilds = new List(); - var demotedWhileStaticPresent = new List(); + var demotedAfterStaticDetach = new List(); var shell = new EnvCellShellPlacement( 0x12360001u, geometryId, @@ -374,8 +374,8 @@ public sealed class StreamingControllerReadinessTests farRadius: 3, demoteNearLayer: demotedId => { - Assert.Contains(staticEntity, state.Entities); - demotedWhileStaticPresent.Add(demotedId); + Assert.DoesNotContain(staticEntity, state.Entities); + demotedAfterStaticDetach.Add(demotedId); }, ensureEnvCellMeshes: _ => ensureCalls++); @@ -396,7 +396,7 @@ public sealed class StreamingControllerReadinessTests Assert.Equal(1, ensureCalls); Assert.DoesNotContain(geometryId, meshes.ReferenceCounts); Assert.DoesNotContain(staticEntity, state.Entities); - Assert.Equal(new[] { id }, demotedWhileStaticPresent); + Assert.Equal(new[] { id }, demotedAfterStaticDetach); Assert.True(state.IsLoaded(id)); Assert.False(state.IsNearTier(id)); Assert.Equal(2, appliedBuilds.Count); diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index bd6c1dfe..31030d0d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -179,6 +179,36 @@ public class ChatWindowControllerTests Assert.Contains(ctrl!.Input, bar!.Children); } + [Fact] + public void TranscriptLayout_IsReusedUntilContentOrWidthChanges() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + var ctrl = ChatWindowController.Bind( + rootInfo, layout, vm, () => bus, null, null, NoTex)!; + + vm.ShowSystemMessage("one wrapped transcript line"); + IReadOnlyList first = ctrl.Transcript.LinesProvider(); + IReadOnlyList unchanged = ctrl.Transcript.LinesProvider(); + + Assert.Same(first, unchanged); + Assert.Equal(1, ctrl.TranscriptLayoutBuildCount); + + vm.ShowSystemMessage("second line"); + IReadOnlyList appended = ctrl.Transcript.LinesProvider(); + Assert.NotSame(first, appended); + Assert.Equal(2, ctrl.TranscriptLayoutBuildCount); + + ctrl.Transcript.Width -= 40f; + IReadOnlyList resized = ctrl.Transcript.LinesProvider(); + Assert.NotSame(appended, resized); + Assert.Equal(3, ctrl.TranscriptLayoutBuildCount); + + vm.Clear(); + Assert.Empty(ctrl.Transcript.LinesProvider()); + Assert.Equal(4, ctrl.TranscriptLayoutBuildCount); + } + // ── Test 4: Input.OnSubmit publishes SendChatCmd via the capture bus ───── [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs b/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs index 343705f9..16f5957c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs @@ -187,6 +187,55 @@ public sealed class RadarSnapshotProviderTests Assert.Equal(monster, Assert.Single(snapshot.Blips).ObjectId); } + [Fact] + public void BuildSnapshot_UsesBoundedSpatialCandidatesBeforeRetailProjection() + { + const uint player = 40u; + const uint nearby = 41u; + const uint unrelated = 42u; + var objects = new ClientObjectTable(); + objects.Ingest(Weenie(player, "Player", ItemType.Creature)); + objects.Ingest(Weenie(nearby, "Nearby", ItemType.Creature) with + { + RadarBehavior = (byte)RadarBehavior.ShowAlways, + }); + objects.Ingest(Weenie(unrelated, "Unrelated", ItemType.Creature) with + { + RadarBehavior = (byte)RadarBehavior.ShowAlways, + }); + var entities = new Dictionary + { + [player] = Entity(player, Vector3.Zero, Quaternion.Identity), + [nearby] = Entity(nearby, new Vector3(5f, 0f, 0f), Quaternion.Identity), + [unrelated] = Entity(unrelated, new Vector3(6f, 0f, 0f), Quaternion.Identity), + }; + var spawns = entities.Keys.ToDictionary(guid => guid, Spawn); + uint copiedCell = 0; + int copiedRadius = -1; + var provider = new RadarSnapshotProvider( + objects, + () => entities, + () => spawns, + playerGuid: () => player, + playerYawRadians: () => 0f, + playerCellId: () => 0xA9B40001u, + selectedGuid: () => null, + coordinatesOnRadar: () => true, + uiLocked: () => false, + copySpatialCandidates: (cell, radius, destination) => + { + copiedCell = cell; + copiedRadius = radius; + destination.Add(new KeyValuePair(nearby, entities[nearby])); + }); + + UiRadarSnapshot snapshot = provider.BuildSnapshot(); + + Assert.Equal(0xA9B40001u, copiedCell); + Assert.Equal(1, copiedRadius); + Assert.Equal(nearby, Assert.Single(snapshot.Blips).ObjectId); + } + private static WorldEntity Entity(uint guid, Vector3 position, Quaternion rotation) => new() { Id = guid, diff --git a/tests/AcDream.App.Tests/UI/UiCompositeShutdownTests.cs b/tests/AcDream.App.Tests/UI/UiCompositeShutdownTests.cs new file mode 100644 index 00000000..cfc96f01 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiCompositeShutdownTests.cs @@ -0,0 +1,118 @@ +using AcDream.App.Rendering; +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public sealed class UiCompositeShutdownTests +{ + [Fact] + public void UiHostShutdownRetainsFailedInputAndProtectsDependentOwners() + { + var calls = new List(); + bool failInput = true; + ResourceShutdownTransaction transaction = UiHost.CreateShutdownTransaction( + [ + () => calls.Add("last-input"), + () => + { + calls.Add("failed-input"); + if (failInput) + throw new InvalidOperationException("synthetic unsubscribe failure"); + }, + ], + () => calls.Add("input-state"), + () => calls.Add("windows"), + () => calls.Add("text")); + + Assert.Throws(transaction.CompleteOrThrow); + + Assert.Equal(1, calls.Count(call => call == "last-input")); + Assert.Equal(3, calls.Count(call => call == "failed-input")); + Assert.DoesNotContain("input-state", calls); + Assert.DoesNotContain("windows", calls); + Assert.DoesNotContain("text", calls); + + failInput = false; + transaction.CompleteOrThrow(); + + Assert.True(transaction.IsComplete); + Assert.Equal(1, calls.Count(call => call == "last-input")); + Assert.Equal(4, calls.Count(call => call == "failed-input")); + Assert.Equal(1, calls.Count(call => call == "input-state")); + Assert.Equal(1, calls.Count(call => call == "windows")); + Assert.Equal(1, calls.Count(call => call == "text")); + } + + [Fact] + public void UiHostShutdownRetriesRendererWithoutReplayingWindowManager() + { + int windowCalls = 0; + int rendererCalls = 0; + bool failRenderer = true; + ResourceShutdownTransaction transaction = UiHost.CreateShutdownTransaction( + [], + () => { }, + () => windowCalls++, + () => + { + rendererCalls++; + if (failRenderer) + throw new InvalidOperationException("synthetic renderer failure"); + }); + + Assert.Throws(transaction.CompleteOrThrow); + Assert.Equal(1, windowCalls); + Assert.Equal(2, rendererCalls); + + failRenderer = false; + transaction.CompleteOrThrow(); + + Assert.True(transaction.IsComplete); + Assert.Equal(1, windowCalls); + Assert.Equal(3, rendererCalls); + } + + [Fact] + public void RetailRuntimeShutdownReentersSafelyAndGatesHostBehindControllers() + { + var calls = new List(); + bool failGameplay = true; + ResourceShutdownTransaction? transaction = null; + transaction = RetailUiRuntime.CreateShutdownTransaction( + () => calls.Add("persistence"), + () => calls.Add("visibility"), + () => + { + calls.Add("item"); + transaction!.CompleteOrThrow(); + }, + () => + { + calls.Add("gameplay"); + if (failGameplay) + throw new InvalidOperationException("synthetic controller failure"); + }, + () => calls.Add("dialogs"), + () => calls.Add("panels"), + () => calls.Add("host")); + + Assert.Throws(transaction.CompleteOrThrow); + + Assert.Equal(1, calls.Count(call => call == "persistence")); + Assert.Equal(1, calls.Count(call => call == "visibility")); + Assert.Equal(1, calls.Count(call => call == "item")); + Assert.Equal(3, calls.Count(call => call == "gameplay")); + Assert.DoesNotContain("dialogs", calls); + Assert.DoesNotContain("panels", calls); + Assert.DoesNotContain("host", calls); + + failGameplay = false; + transaction.CompleteOrThrow(); + + Assert.True(transaction.IsComplete); + Assert.Equal(1, calls.Count(call => call == "item")); + Assert.Equal(1, calls.Count(call => call == "dialogs")); + Assert.Equal(1, calls.Count(call => call == "panels")); + Assert.Equal(1, calls.Count(call => call == "host")); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiElementChildOrderTests.cs b/tests/AcDream.App.Tests/UI/UiElementChildOrderTests.cs new file mode 100644 index 00000000..7af5005b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiElementChildOrderTests.cs @@ -0,0 +1,67 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public sealed class UiElementChildOrderTests +{ + [Fact] + public void StableTree_ReusesBothTraversalSnapshots() + { + var parent = new TestElement(); + parent.AddChild(new TestElement { ZOrder = 2 }); + parent.AddChild(new TestElement { ZOrder = 1 }); + + UiElement[] drawFirst = parent.ChildrenBackToFrontSnapshot(); + UiElement[] drawSecond = parent.ChildrenBackToFrontSnapshot(); + UiElement[] hitFirst = parent.ChildrenFrontToBackSnapshot(); + UiElement[] hitSecond = parent.ChildrenFrontToBackSnapshot(); + + Assert.Same(drawFirst, drawSecond); + Assert.Same(hitFirst, hitSecond); + Assert.Equal([1, 2], drawFirst.Select(child => child.ZOrder).ToArray()); + Assert.Equal([2, 1], hitFirst.Select(child => child.ZOrder).ToArray()); + } + + [Fact] + public void ChildZOrderChange_InvalidatesBothTraversalSnapshots() + { + var parent = new TestElement(); + var first = new TestElement { ZOrder = 1 }; + var second = new TestElement { ZOrder = 2 }; + parent.AddChild(first); + parent.AddChild(second); + UiElement[] oldDraw = parent.ChildrenBackToFrontSnapshot(); + UiElement[] oldHit = parent.ChildrenFrontToBackSnapshot(); + + first.ZOrder = 3; + UiElement[] newDraw = parent.ChildrenBackToFrontSnapshot(); + UiElement[] newHit = parent.ChildrenFrontToBackSnapshot(); + + Assert.NotSame(oldDraw, newDraw); + Assert.NotSame(oldHit, newHit); + Assert.Same(second, newDraw[0]); + Assert.Same(first, newDraw[1]); + Assert.Same(first, newHit[0]); + Assert.Same(second, newHit[1]); + } + + [Fact] + public void MembershipChange_InvalidatesSnapshotsWithoutMutatingPriorWalk() + { + var parent = new TestElement(); + var first = new TestElement { ZOrder = 1 }; + var second = new TestElement { ZOrder = 2 }; + parent.AddChild(first); + UiElement[] prior = parent.ChildrenBackToFrontSnapshot(); + + parent.AddChild(second); + UiElement[] current = parent.ChildrenBackToFrontSnapshot(); + + Assert.Single(prior); + Assert.Same(first, prior[0]); + Assert.Equal(2, current.Length); + Assert.NotSame(prior, current); + } + + private sealed class TestElement : UiElement; +} diff --git a/tests/AcDream.App.Tests/World/CompositeLiveEntityResourceLifecycleTests.cs b/tests/AcDream.App.Tests/World/CompositeLiveEntityResourceLifecycleTests.cs new file mode 100644 index 00000000..fe69424e --- /dev/null +++ b/tests/AcDream.App.Tests/World/CompositeLiveEntityResourceLifecycleTests.cs @@ -0,0 +1,109 @@ +using AcDream.App.World; +using AcDream.Core.World; + +namespace AcDream.App.Tests.World; + +public sealed class CompositeLiveEntityResourceLifecycleTests +{ + [Fact] + public void LateReleaseFailureDoesNotReplayEarlierCommittedOwner() + { + var entity = Entity(); + int firstReleases = 0; + int secondReleases = 0; + var lifecycle = new CompositeLiveEntityResourceLifecycle( + new(_ => { }, _ => + { + firstReleases++; + if (firstReleases == 1) + throw new InvalidOperationException("first failed"); + }), + new(_ => { }, _ => secondReleases++)); + lifecycle.Register(entity); + + Assert.Throws(() => lifecycle.Unregister(entity)); + Assert.Equal(1, secondReleases); + + lifecycle.Unregister(entity); + + Assert.Equal(2, firstReleases); + Assert.Equal(1, secondReleases); + } + + [Fact] + public void RegistrationFailureAttemptsEveryEnteredOwnerRollback() + { + var entity = Entity(); + var releases = new List(); + var lifecycle = new CompositeLiveEntityResourceLifecycle( + new(_ => { }, _ => releases.Add(1)), + new( + _ => throw new InvalidOperationException("second registration failed"), + _ => releases.Add(2)), + new(_ => throw new InvalidOperationException("must not enter"), _ => releases.Add(3))); + + Assert.Throws(() => lifecycle.Register(entity)); + + Assert.Equal([2, 1], releases); + lifecycle.Unregister(entity); + Assert.Equal([2, 1], releases); + } + + [Fact] + public void RegisterCallbackUnregistersSameEntityWithoutOrphaningLaterOwners() + { + var entity = Entity(); + CompositeLiveEntityResourceLifecycle? lifecycle = null; + int firstRegisters = 0; + int firstReleases = 0; + int laterRegisters = 0; + lifecycle = new CompositeLiveEntityResourceLifecycle( + new( + current => + { + firstRegisters++; + lifecycle!.Unregister(current); + }, + _ => firstReleases++), + new(_ => laterRegisters++, _ => { })); + + lifecycle.Register(entity); + lifecycle.Unregister(entity); + + Assert.Equal(1, firstRegisters); + Assert.Equal(1, firstReleases); + Assert.Equal(0, laterRegisters); + } + + [Fact] + public void UnregisterCallbackReentryDoesNotRepeatActiveOwnerRelease() + { + var entity = Entity(); + CompositeLiveEntityResourceLifecycle? lifecycle = null; + int releases = 0; + lifecycle = new CompositeLiveEntityResourceLifecycle( + new CompositeLiveEntityResourceLifecycle.Owner( + _ => { }, + current => + { + releases++; + lifecycle!.Unregister(current); + })); + lifecycle.Register(entity); + + lifecycle.Unregister(entity); + lifecycle.Unregister(entity); + + Assert.Equal(1, releases); + } + + private static WorldEntity Entity() => new() + { + Id = 1, + ServerGuid = 0x70000001u, + SourceGfxObjOrSetupId = 0x01000001u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = Array.Empty(), + }; +} diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index 341c6115..03345ed6 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -1,5 +1,6 @@ using AcDream.App.Streaming; using AcDream.App.World; +using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Vfx; using AcDream.Core.Net; using AcDream.Core.Net.Messages; @@ -34,6 +35,11 @@ public sealed class LiveEntityRuntimeTests public PhysicsBody Body { get; } = new(); } + private sealed class ProjectileRuntime(PhysicsBody body) : ILiveEntityProjectileRuntime + { + public PhysicsBody Body { get; } = body; + } + private sealed class FailingRegisterResources : ILiveEntityResourceLifecycle { public int RegisterCount { get; private set; } @@ -48,6 +54,34 @@ public sealed class LiveEntityRuntimeTests public void Unregister(WorldEntity entity) => UnregisterCount++; } + private sealed class FailingRegisterAndRollbackOnceResources : ILiveEntityResourceLifecycle + { + private bool _registerFailurePending = true; + private bool _unregisterFailurePending = true; + public int RegisterCount { get; private set; } + public int UnregisterCount { get; private set; } + + public void Register(WorldEntity entity) + { + RegisterCount++; + if (_registerFailurePending) + { + _registerFailurePending = false; + throw new InvalidOperationException("partial registration failure"); + } + } + + public void Unregister(WorldEntity entity) + { + UnregisterCount++; + if (_unregisterFailurePending) + { + _unregisterFailurePending = false; + throw new InvalidOperationException("rollback failure"); + } + } + } + private sealed class FailingUnregisterResources(uint failingGuid) : ILiveEntityResourceLifecycle { public int UnregisterCount { get; private set; } @@ -62,6 +96,38 @@ public sealed class LiveEntityRuntimeTests } } + private sealed class FailingOnceUnregisterResources(uint failingGuid) : ILiveEntityResourceLifecycle + { + private bool _failurePending = true; + public int UnregisterCount { get; private set; } + public void Register(WorldEntity entity) { } + public void Unregister(WorldEntity entity) + { + UnregisterCount++; + if (entity.ServerGuid == failingGuid && _failurePending) + { + _failurePending = false; + throw new InvalidOperationException("fixture one-shot unregister failure"); + } + } + } + + private sealed class CallbackTextureLifetime : IEntityTextureLifetime + { + public Action? OnRelease { get; set; } + public int ReleaseCount { get; private set; } + public void ReleaseOwner(uint localEntityId) + { + ReleaseCount++; + OnRelease?.Invoke(); + } + } + + private sealed class NullAnimationLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } + private sealed class CallbackResources : ILiveEntityResourceLifecycle { public Action? OnRegister { get; set; } @@ -123,6 +189,167 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(new[] { false, true }, visibilityEdges); } + [Fact] + public void SpatialComponentWorksets_FollowLoadedProjectionWhileLogicalOwnersSurvive() + { + const uint guid = 0x70000070u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u); + runtime.RegisterLiveEntity(spawn); + WorldEntity entity = runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid))!; + var animation = new AnimationRuntime(entity); + var remote = new RemoteMotionRuntime(); + var projectile = new ProjectileRuntime(remote.Body); + runtime.SetAnimationRuntime(guid, animation); + runtime.SetRemoteMotionRuntime(guid, remote); + runtime.SetProjectileRuntime(guid, projectile); + + Assert.Same(animation, Assert.Single(runtime.SpatialAnimationRuntimes).Value); + Assert.Same(remote, Assert.Single(runtime.SpatialRemoteMotionRuntimes).Value); + Assert.Same(projectile, Assert.Single(runtime.SpatialProjectileRuntimes).Value); + + // Hidden suppresses presentation, not the live CPhysicsObj workset. + Assert.True(runtime.TryApplyState(new SetState.Parsed( + guid, + (uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.IgnoreCollisions), + 1, + 2), out _)); + Assert.Single(runtime.SpatialAnimationRuntimes); + Assert.Single(runtime.SpatialRemoteMotionRuntimes); + Assert.Single(runtime.SpatialProjectileRuntimes); + + // A pending projection retains every logical component but disappears + // from all per-frame worksets. + Assert.True(runtime.RebucketLiveEntity(guid, 0x02020001u)); + Assert.Empty(runtime.SpatialAnimationRuntimes); + Assert.Empty(runtime.SpatialRemoteMotionRuntimes); + Assert.Empty(runtime.SpatialProjectileRuntimes); + Assert.True(runtime.TryGetAnimationRuntime(entity.Id, out var retainedAnimation)); + Assert.Same(animation, retainedAnimation); + Assert.True(runtime.TryGetRemoteMotionRuntime(guid, out var retainedRemote)); + Assert.Same(remote, retainedRemote); + Assert.True(runtime.TryGetProjectileRuntime(guid, out var retainedProjectile)); + Assert.Same(projectile, retainedProjectile); + + spatial.AddLandblock(EmptyLandblock(0x0202FFFFu)); + Assert.Single(runtime.SpatialAnimationRuntimes); + Assert.Single(runtime.SpatialRemoteMotionRuntimes); + Assert.Single(runtime.SpatialProjectileRuntimes); + + Assert.True(runtime.WithdrawLiveEntityProjection(guid)); + Assert.Empty(runtime.SpatialAnimationRuntimes); + Assert.Empty(runtime.SpatialRemoteMotionRuntimes); + Assert.Empty(runtime.SpatialProjectileRuntimes); + } + + [Fact] + public void AnimationView_EnumeratesSpatialOwnersButRetainsLogicalLookupAndRemoval() + { + const uint guid = 0x70000071u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + WorldEntity entity = runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid))!; + var animation = new AnimationRuntime(entity); + runtime.SetAnimationRuntime(guid, animation); + var view = new LiveEntityAnimationRuntimeView(() => runtime); + + Assert.Equal(1, view.Count); + Assert.Equal(entity.Id, Assert.Single(view.Keys)); + Assert.Same(animation, Assert.Single(view).Value); + + Assert.True(runtime.RebucketLiveEntity(guid, 0x02020001u)); + Assert.Equal(0, view.Count); + Assert.Empty(view.Keys); + Assert.Empty(view); + Assert.True(view.TryGetValue(entity.Id, out AnimationRuntime retained)); + Assert.Same(animation, retained); + + Assert.True(view.Remove(entity.Id)); + Assert.False(view.TryGetValue(entity.Id, out _)); + Assert.Empty(runtime.AnimationRuntimes); + Assert.Empty(runtime.SpatialAnimationRuntimes); + } + + [Fact] + public void AnimationView_RebucketCallbackSkipsDisplacedSnapshotOwner() + { + const uint firstGuid = 0x70000073u; + const uint secondGuid = 0x70000074u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + foreach (uint guid in new[] { firstGuid, secondGuid }) + { + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + WorldEntity entity = runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid))!; + runtime.SetAnimationRuntime(guid, new AnimationRuntime(entity)); + } + var view = new LiveEntityAnimationRuntimeView(() => runtime); + var visited = new List(); + + foreach (KeyValuePair pair in view) + { + uint guid = pair.Value.Entity.ServerGuid; + visited.Add(guid); + uint other = guid == firstGuid ? secondGuid : firstGuid; + Assert.True(runtime.RebucketLiveEntity(other, 0x02020001u)); + } + + Assert.Single(visited); + Assert.Single(runtime.SpatialAnimationRuntimes); + Assert.Equal(2, runtime.AnimationRuntimes.Count); + } + + [Fact] + public void SpatialComponentWorksets_IsolateGuidReuseAcrossVisibilityCallbacks() + { + const uint guid = 0x70000072u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + WorldEntity oldEntity = runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid))!; + var oldAnimation = new AnimationRuntime(oldEntity); + runtime.SetAnimationRuntime(guid, oldAnimation); + + runtime.ProjectionVisibilityChanged += (record, visible) => + { + if (visible || record.Generation != 1) + return; + + runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010002u)); + WorldEntity replacement = runtime.MaterializeLiveEntity( + guid, + 0x01010002u, + id => Entity(id, guid))!; + runtime.SetAnimationRuntime(guid, new AnimationRuntime(replacement)); + }; + + Assert.False(runtime.WithdrawLiveEntityProjection(guid)); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); + Assert.Equal((ushort)2, current.Generation); + KeyValuePair indexed = + Assert.Single(runtime.SpatialAnimationRuntimes); + Assert.Same(current.AnimationRuntime, indexed.Value); + Assert.NotSame(oldAnimation, indexed.Value); + } + [Fact] public void EffectProfile_SurvivesRebucketAndClearsWithLogicalTeardown() { @@ -843,6 +1070,100 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(1, resources.UnregisterCount); } + [Fact] + public void RegistrationRollbackFailureRetainsTombstoneUntilRetryConverges() + { + const uint guid = 0x7000005Au; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new FailingRegisterAndRollbackOnceResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + + Assert.Throws(() => runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid))); + + Assert.Equal(0, runtime.Count); + Assert.Equal(1, runtime.PendingTeardownCount); + Assert.Equal(1, runtime.MaterializedCount); + Assert.False(runtime.TryGetRecord(guid, out _)); + spatial.MarkPersistent(guid); + + Assert.Equal(1, runtime.RetryPendingTeardowns()); + + Assert.Equal(0, runtime.PendingTeardownCount); + Assert.Equal(0, runtime.MaterializedCount); + Assert.Equal(2, resources.UnregisterCount); + Assert.Equal(1, spatial.PersistentGuidCount); + + LiveEntityRegistrationResult recovered = runtime.RegisterLiveEntity( + Spawn(guid, 1, 1, 0x01010001u)); + Assert.True(recovered.LogicalRegistrationCreated); + runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)); + spatial.RemoveLandblock(0x0101FFFFu); + + Assert.Equal(1, spatial.PendingRescueCount); + Assert.Equal(1, spatial.PersistentGuidCount); + } + + [Fact] + public void RegistrationRollbackTombstoneIsIsolatedFromSameGuidReplacement() + { + const uint guid = 0x7000005Bu; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new FailingRegisterAndRollbackOnceResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + Assert.Throws(() => runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid))); + + runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010001u)); + WorldEntity replacement = runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid))!; + + Assert.Equal(1, runtime.RetryPendingTeardowns()); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); + Assert.Same(replacement, current.WorldEntity); + Assert.Same(replacement, Assert.Single(runtime.MaterializedWorldEntities).Value); + Assert.Equal(0, runtime.PendingTeardownCount); + } + + [Fact] + public void SameGenerationCreateCannotRecoverUntilRegistrationTombstoneConverges() + { + const uint guid = 0x7000005Cu; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new FailingRegisterAndRollbackOnceResources(); + var runtime = new LiveEntityRuntime(spatial, resources); + WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u); + runtime.RegisterLiveEntity(spawn); + Assert.Throws(() => runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid))); + + LiveEntityRegistrationResult retransmit = runtime.RegisterLiveEntity(spawn); + + Assert.False(retransmit.LogicalRegistrationCreated); + Assert.Null(retransmit.Record); + Assert.Equal(0, runtime.Count); + Assert.Equal(1, runtime.PendingTeardownCount); + + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, InstanceSequence: 1), + isLocalPlayer: false)); + Assert.Equal(0, runtime.PendingTeardownCount); + Assert.False(runtime.TryGetSnapshot(guid, out _)); + } + [Fact] public void SessionClear_AttemptsEveryTeardownAndClearsIdentityWhenOneResourceFails() { @@ -864,11 +1185,107 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(2, resources.UnregisterCount); Assert.Equal(0, runtime.Count); - Assert.Equal(0, runtime.MaterializedCount); + Assert.Equal(1, runtime.PendingTeardownCount); + Assert.Equal(1, runtime.MaterializedCount); Assert.Empty(runtime.WorldEntities); Assert.Empty(spatial.Entities); } + [Fact] + public void Delete_UnregisterFailureRetainsTombstoneAndSameDeleteRetriesCleanup() + { + const uint guid = 0x7000004Au; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new FailingOnceUnregisterResources(guid); + var runtime = new LiveEntityRuntime(spatial, resources); + WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u); + runtime.RegisterLiveEntity(spawn); + runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)); + var delete = new DeleteObject.Parsed(guid, InstanceSequence: 1); + + Assert.Throws( + () => runtime.UnregisterLiveEntity(delete, isLocalPlayer: false)); + + Assert.Equal(0, runtime.Count); + Assert.Equal(1, runtime.PendingTeardownCount); + Assert.Equal(1, runtime.MaterializedCount); + Assert.Equal(1, resources.UnregisterCount); + + Assert.True(runtime.UnregisterLiveEntity(delete, isLocalPlayer: false)); + Assert.Equal(0, runtime.PendingTeardownCount); + Assert.Equal(0, runtime.MaterializedCount); + Assert.Equal(2, resources.UnregisterCount); + } + + [Fact] + public void SessionClear_OneShotUnregisterFailure_SecondClearConverges() + { + const uint guid = 0x7000004Bu; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new FailingOnceUnregisterResources(guid); + var runtime = new LiveEntityRuntime(spatial, resources); + WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u); + runtime.RegisterLiveEntity(spawn); + runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)); + + Assert.Throws(() => runtime.Clear()); + Assert.Equal(1, runtime.PendingTeardownCount); + Assert.Equal(1, runtime.MaterializedCount); + + runtime.Clear(); + Assert.Equal(0, runtime.PendingTeardownCount); + Assert.Equal(0, runtime.MaterializedCount); + Assert.Empty(spatial.Entities); + Assert.Equal(2, resources.UnregisterCount); + } + + [Fact] + public void ReentrantDeleteDuringPresentationSuspend_IsQueuedAndNextUpdateCompletesIt() + { + const uint guid = 0x7000004Cu; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var textures = new CallbackTextureLifetime(); + var adapter = new EntitySpawnAdapter( + textures, + _ => new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader())); + var resources = new DelegateLiveEntityResourceLifecycle( + entity => adapter.OnCreate(entity), + entity => + { + if (!adapter.OnRemove(entity)) + throw new InvalidOperationException("presentation owner was not registered"); + }); + var runtime = new LiveEntityRuntime(spatial, resources); + runtime.ProjectionVisibilityChanged += (record, visible) => + { + if (record.WorldEntity is { } entity) + adapter.SetPresentationResident(entity, visible); + }; + WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u); + runtime.RegisterLiveEntity(spawn); + WorldEntity entity = runtime.MaterializeLiveEntity( + guid, 0x01010001u, id => Entity(id, guid))!; + var delete = new DeleteObject.Parsed(guid, InstanceSequence: 1); + textures.OnRelease = () => + { + textures.OnRelease = null; + runtime.UnregisterLiveEntity(delete, isLocalPlayer: false); + }; + + Assert.Throws( + () => adapter.SetPresentationResident(entity, resident: false)); + Assert.Equal(1, runtime.PendingTeardownCount); + Assert.NotNull(adapter.GetState(guid)); + + Assert.Equal(1, runtime.RetryPendingTeardowns()); + Assert.Equal(0, runtime.PendingTeardownCount); + Assert.Null(adapter.GetState(guid)); + Assert.Equal(0, runtime.MaterializedCount); + } + [Fact] public void GenerationReplacement_InstallsNewRecordBeforeReportingOldCleanupFailure() { @@ -1646,13 +2063,19 @@ public sealed class LiveEntityRuntimeTests exception => exception is InvalidOperationException && exception.Message.Contains("while the session lifetime is clearing", StringComparison.Ordinal)); Assert.Equal(0, runtime.Count); - Assert.Equal(0, runtime.MaterializedCount); + Assert.Equal(1, runtime.PendingTeardownCount); + Assert.Equal(1, runtime.MaterializedCount); Assert.Empty(runtime.WorldEntities); - Assert.Empty(runtime.MaterializedWorldEntities); Assert.Empty(spatial.Entities); Assert.Equal(0, spatial.PendingLiveEntityCount); Assert.Equal(1, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); + + runtime.Clear(); + Assert.Equal(0, runtime.PendingTeardownCount); + Assert.Equal(0, runtime.MaterializedCount); + Assert.Empty(runtime.MaterializedWorldEntities); + Assert.Equal(2, resources.UnregisterCount); } [Fact] @@ -1680,6 +2103,52 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(1, resources.UnregisterCount); } + [Fact] + public void IncarnationCleanup_OldTombstoneCannotMutateReplacementGuidState() + { + const uint guid = 0x70000035u; + var oldRecord = new LiveEntityRecord(Spawn(guid, 1, 1, 0x01010001u)); + var replacement = new LiveEntityRecord(Spawn(guid, 2, 1, 0x01010001u)); + LiveEntityRecord? current = replacement; + var cleanup = new LiveEntityIncarnationCleanup(oldRecord, _ => current); + object oldHost = new(); + object newHost = new(); + var hosts = new Dictionary { [guid] = newHost }; + bool guidStateCleared = false; + + var plan = new LiveEntityTeardownPlan( + [ + () => cleanup.RunIfNoReplacement(() => guidStateCleared = true), + () => cleanup.RemoveCaptured(hosts, oldHost), + ]); + plan.Advance(); + + Assert.False(guidStateCleared); + Assert.Same(newHost, hosts[guid]); + Assert.True(plan.IsComplete); + } + + [Fact] + public void IncarnationCleanup_RemovesOnlyCapturedOwnerAndResumesAfterReplacementEnds() + { + const uint guid = 0x70000036u; + var oldRecord = new LiveEntityRecord(Spawn(guid, 1, 1, 0x01010001u)); + var replacement = new LiveEntityRecord(Spawn(guid, 2, 1, 0x01010001u)); + LiveEntityRecord? current = replacement; + var cleanup = new LiveEntityIncarnationCleanup(oldRecord, _ => current); + object oldHost = new(); + var hosts = new Dictionary { [guid] = oldHost }; + int guidCleanupCount = 0; + + cleanup.RemoveCaptured(hosts, oldHost); + cleanup.RunIfNoReplacement(() => guidCleanupCount++); + current = null; + cleanup.RunIfNoReplacement(() => guidCleanupCount++); + + Assert.Empty(hosts); + Assert.Equal(1, guidCleanupCount); + } + private static LoadedLandblock EmptyLandblock(uint canonicalId) => new(canonicalId, new LandBlock(), Array.Empty()); diff --git a/tests/AcDream.App.Tests/World/LiveEntityTeardownPlanTests.cs b/tests/AcDream.App.Tests/World/LiveEntityTeardownPlanTests.cs new file mode 100644 index 00000000..89975b41 --- /dev/null +++ b/tests/AcDream.App.Tests/World/LiveEntityTeardownPlanTests.cs @@ -0,0 +1,57 @@ +using AcDream.App.World; + +namespace AcDream.App.Tests.World; + +public sealed class LiveEntityTeardownPlanTests +{ + [Fact] + public void AdvanceRetriesOnlyFailedSteps() + { + int first = 0; + int middle = 0; + int last = 0; + var plan = new LiveEntityTeardownPlan( + [ + () => first++, + () => + { + middle++; + if (middle == 1) + throw new InvalidOperationException("middle failed"); + }, + () => last++, + ]); + + Assert.Throws(() => plan.Advance()); + Assert.Equal(2, plan.CompletedCount); + + plan.Advance(); + + Assert.True(plan.IsComplete); + Assert.Equal(1, first); + Assert.Equal(2, middle); + Assert.Equal(1, last); + } + + [Fact] + public void AdvanceAttemptsEveryIndependentFailureInOnePass() + { + int first = 0; + int last = 0; + var plan = new LiveEntityTeardownPlan( + [ + () => + { + first++; + throw new InvalidOperationException("first"); + }, + () => last++, + ]); + + Assert.Throws(() => plan.Advance()); + + Assert.Equal(1, first); + Assert.Equal(1, last); + Assert.Equal(1, plan.CompletedCount); + } +} diff --git a/tests/AcDream.Content.Tests/BoundedDatObjectCacheTests.cs b/tests/AcDream.Content.Tests/BoundedDatObjectCacheTests.cs new file mode 100644 index 00000000..a9d11adf --- /dev/null +++ b/tests/AcDream.Content.Tests/BoundedDatObjectCacheTests.cs @@ -0,0 +1,125 @@ +using DatReaderWriter.DBObjs; +using DatReaderWriter.Lib.IO; +using System.Collections.Concurrent; + +namespace AcDream.Content.Tests; + +public sealed class BoundedDatObjectCacheTests +{ + [Fact] + public void GetOrAdd_EvictsLeastRecentlyUsedEntryAtEntryLimit() + { + var cache = CreateCache(entryLimit: 2, byteLimit: 100); + var first = Surface(1); + var second = Surface(2); + var third = Surface(3); + + cache.GetOrAdd(1, first); + cache.GetOrAdd(2, second); + Assert.True(cache.TryGet(1, out _)); // first is now hottest + cache.GetOrAdd(3, third); + + Assert.True(cache.TryGet(1, out var retainedFirst)); + Assert.Same(first, retainedFirst); + Assert.False(cache.TryGet(2, out _)); + Assert.True(cache.TryGet(3, out var retainedThird)); + Assert.Same(third, retainedThird); + Assert.Equal(2, cache.Count); + } + + [Fact] + public void GetOrAdd_EnforcesEstimatedByteBudgetAndDoesNotRetainOversizeEntry() + { + var cache = new BoundedDatObjectCache( + entryLimit: 10, + estimatedByteLimit: 10, + estimateRetainedBytes: value => value.Id); + + cache.GetOrAdd(6, Surface(6)); + cache.GetOrAdd(5, Surface(5)); + + Assert.False(cache.TryGet(6, out _)); + Assert.True(cache.TryGet(5, out _)); + Assert.Equal(5, cache.EstimatedBytes); + + var oversize = Surface(11); + Assert.Same(oversize, cache.GetOrAdd(11, oversize)); + Assert.False(cache.TryGet(11, out _)); + Assert.Equal(1, cache.Count); + Assert.Equal(5, cache.EstimatedBytes); + } + + [Fact] + public void ProductionEstimator_ChargesKnownRenderSurfacePayload() + { + const int byteLimit = 512 * 1024; + var cache = new BoundedDatObjectCache( + entryLimit: 10, + estimatedByteLimit: byteLimit); + var oversizeTexture = new RenderSurface + { + Id = 12, + SourceData = new byte[byteLimit + 1], + }; + + Assert.Same(oversizeTexture, cache.GetOrAdd(12, oversizeTexture)); + Assert.False(cache.TryGet(12, out _)); + Assert.Equal(0, cache.Count); + Assert.Equal(0, cache.EstimatedBytes); + } + + [Fact] + public void GetOrAdd_DuplicateKeyKeepsOneCanonicalObject() + { + var cache = CreateCache(entryLimit: 4, byteLimit: 100); + var canonical = Surface(7); + var duplicate = Surface(7); + + Assert.Same(canonical, cache.GetOrAdd(7, canonical)); + Assert.Same(canonical, cache.GetOrAdd(7, duplicate)); + Assert.Equal(1, cache.Count); + Assert.Equal(1, cache.EstimatedBytes); + } + + [Fact] + public void GetOrAdd_ConcurrentDuplicateKeyPublishesOneCanonicalObject() + { + var cache = CreateCache(entryLimit: 4, byteLimit: 100); + var returned = new ConcurrentBag(); + + Parallel.For(0, 1_000, i => + { + returned.Add(cache.GetOrAdd(42, Surface((uint)(1_000 + i)))); + }); + + Surface[] results = returned.ToArray(); + Assert.NotEmpty(results); + Surface canonical = results[0]; + Assert.All(results, value => Assert.Same(canonical, value)); + Assert.True(cache.TryGet(42, out var retained)); + Assert.Same(canonical, retained); + Assert.Equal(1, cache.Count); + } + + [Fact] + public void SameFileIdWithDifferentRequestedTypes_DoesNotAlias() + { + var cache = CreateCache(entryLimit: 4, byteLimit: 100); + var surface = Surface(99); + var palette = new Palette { Id = 99 }; + + cache.GetOrAdd(99, surface); + cache.GetOrAdd(99, palette); + + Assert.True(cache.TryGet(99, out var retainedSurface)); + Assert.True(cache.TryGet(99, out var retainedPalette)); + Assert.Same(surface, retainedSurface); + Assert.Same(palette, retainedPalette); + Assert.Equal(2, cache.Count); + } + + private static BoundedDatObjectCache CreateCache(int entryLimit, long byteLimit) => + new(entryLimit, byteLimit, _ => 1); + + private static Surface Surface(uint id) => new() { Id = id }; +} diff --git a/tests/AcDream.Content.Tests/DecodedTextureCacheTests.cs b/tests/AcDream.Content.Tests/DecodedTextureCacheTests.cs new file mode 100644 index 00000000..f1a4ed3e --- /dev/null +++ b/tests/AcDream.Content.Tests/DecodedTextureCacheTests.cs @@ -0,0 +1,152 @@ +using AcDream.Content; +using Xunit; + +namespace AcDream.Content.Tests; + +public sealed class DecodedTextureCacheTests { + private static DecodedTextureKey Key( + uint id, + bool clip = false, + bool additive = false) => new(id, clip, additive); + + [Fact] + public void RetainOrUse_EvictsLeastRecentlyUsedEntryToMeetByteBudget() { + var cache = new DecodedTextureCache(maximumBytes: 8, maximumEntries: 8); + var first = new byte[4]; + var second = new byte[4]; + var third = new byte[4]; + + Assert.Same(first, cache.RetainOrUse(Key(1), first, out var firstCached)); + Assert.Same(second, cache.RetainOrUse(Key(2), second, out var secondCached)); + Assert.True(cache.TryGet(Key(1), out _)); // ID 2 is now least recently used. + + Assert.Same(third, cache.RetainOrUse(Key(3), third, out var thirdCached)); + + Assert.True(firstCached); + Assert.True(secondCached); + Assert.True(thirdCached); + Assert.True(cache.TryGet(Key(1), out _)); + Assert.False(cache.TryGet(Key(2), out _)); + Assert.True(cache.TryGet(Key(3), out _)); + Assert.Equal(8, cache.ResidentBytes); + } + + [Fact] + public void RetainOrUse_DoesNotAdmitOversizedEntry() { + var cache = new DecodedTextureCache(maximumBytes: 3, maximumEntries: 8); + var pixels = new byte[4]; + + Assert.Same(pixels, cache.RetainOrUse(Key(1), pixels, out var isCached)); + + Assert.False(isCached); + Assert.False(cache.TryGet(Key(1), out _)); + Assert.Equal(0, cache.Count); + Assert.Equal(0, cache.ResidentBytes); + } + + [Fact] + public void RetainOrUse_ExistingCanonicalEntryWinsConcurrentDecodeRace() { + var cache = new DecodedTextureCache(maximumBytes: 16, maximumEntries: 8); + var canonical = new byte[4]; + var duplicate = new byte[4]; + + cache.RetainOrUse(Key(1), canonical, out _); + var result = cache.RetainOrUse(Key(1), duplicate, out var isCached); + + Assert.True(isCached); + Assert.Same(canonical, result); + Assert.Equal(1, cache.Count); + Assert.Equal(4, cache.ResidentBytes); + } + + [Fact] + public void RetainOrUse_EnforcesEntryBudget() { + var cache = new DecodedTextureCache(maximumBytes: 1024, maximumEntries: 2); + + cache.RetainOrUse(Key(1), new byte[1], out _); + cache.RetainOrUse(Key(2), new byte[1], out _); + cache.RetainOrUse(Key(3), new byte[1], out _); + + Assert.False(cache.TryGet(Key(1), out _)); + Assert.True(cache.TryGet(Key(2), out _)); + Assert.True(cache.TryGet(Key(3), out _)); + Assert.Equal(2, cache.Count); + } + + [Fact] + public void DecodeAffectingSurfaceFlags_DoNotAliasSameRenderSurface() + { + var cache = new DecodedTextureCache(maximumBytes: 1024, maximumEntries: 8); + var opaque = new byte[] { 1 }; + var clip = new byte[] { 2 }; + var additive = new byte[] { 3 }; + + cache.RetainOrUse(Key(1), opaque, out _); + cache.RetainOrUse(Key(1, clip: true), clip, out _); + cache.RetainOrUse(Key(1, additive: true), additive, out _); + + Assert.Same(opaque, AssertCacheHit(cache, Key(1))); + Assert.Same(clip, AssertCacheHit(cache, Key(1, clip: true))); + Assert.Same(additive, AssertCacheHit(cache, Key(1, additive: true))); + } + + [Fact] + public async Task GetOrCreate_ConcurrentMissRunsFactoryOnce() + { + var cache = new DecodedTextureCache(maximumBytes: 1024, maximumEntries: 8); + int calls = 0; + using var release = new ManualResetEventSlim(); + Task[] readers = Enumerable.Range(0, 8) + .Select(readerIndex => Task.Run(() => cache.GetOrCreate( + Key(1), + () => + { + Interlocked.Increment(ref calls); + release.Wait(); + return new byte[] { 1, 2, 3, 4 }; + }, + out _))) + .ToArray(); + + Assert.True(SpinWait.SpinUntil(() => Volatile.Read(ref calls) == 1, 5_000)); + release.Set(); + byte[][] results = await Task.WhenAll(readers); + + Assert.Equal(1, calls); + Assert.All(results, result => Assert.Same(results[0], result)); + } + + [Fact] + public void GetOrCreate_FailedFactoryCanRetry() + { + var cache = new DecodedTextureCache(maximumBytes: 1024, maximumEntries: 8); + int calls = 0; + + Assert.Throws(() => cache.GetOrCreate( + Key(1), + () => + { + calls++; + throw new InvalidOperationException("decode failed"); + }, + out _)); + byte[] recovered = cache.GetOrCreate( + Key(1), + () => + { + calls++; + return new byte[] { 1 }; + }, + out var retained); + + Assert.Equal(2, calls); + Assert.True(retained); + Assert.Equal(new byte[] { 1 }, recovered); + } + + private static byte[] AssertCacheHit(DecodedTextureCache cache, DecodedTextureKey key) + { + Assert.True(cache.TryGet(key, out byte[] pixels)); + return pixels; + } +} diff --git a/tests/AcDream.Content.Tests/Vfx/RetailDatLoaderTests.cs b/tests/AcDream.Content.Tests/Vfx/RetailDatLoaderTests.cs index 0a330648..7d8425fa 100644 --- a/tests/AcDream.Content.Tests/Vfx/RetailDatLoaderTests.cs +++ b/tests/AcDream.Content.Tests/Vfx/RetailDatLoaderTests.cs @@ -292,7 +292,8 @@ public sealed class RetailDatLoaderTests return; using var dats = new DatCollection(datDir, DatAccessType.Read); - var loader = new RetailPhysicsScriptLoader(dats); + using var boundedDats = new DatCollectionAdapter(dats); + var loader = new RetailPhysicsScriptLoader(boundedDats); foreach (uint id in new[] { 0x33000AEAu, 0x33000AF8u }) { diff --git a/tests/AcDream.Core.Tests/Physics/HumanoidMotionTableRootMotionTests.cs b/tests/AcDream.Core.Tests/Physics/HumanoidMotionTableRootMotionTests.cs index 48856116..3fd69470 100644 --- a/tests/AcDream.Core.Tests/Physics/HumanoidMotionTableRootMotionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/HumanoidMotionTableRootMotionTests.cs @@ -1,4 +1,5 @@ using AcDream.Content.Vfx; +using AcDream.Content; using AcDream.Core.Physics; using AcDream.Core.Tests.Conformance; using DatReaderWriter; @@ -48,12 +49,13 @@ public sealed class HumanoidMotionTableRootMotionTests return; using var dats = new DatCollection(datDir, DatAccessType.Read); + using var boundedDats = new DatCollectionAdapter(dats); Setup setup = Assert.IsType(dats.Get(HumanoidSetup)); MotionTable table = Assert.IsType(dats.Get(HumanoidMotionTable)); var sequencer = new AnimationSequencer( setup, table, - new RetailAnimationLoader(dats)); + new RetailAnimationLoader(boundedDats)); sequencer.SetCycle(NonCombatStyle, Ready); sequencer.SetCycle(NonCombatStyle, InterpretedWalkForward); diff --git a/tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs b/tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs index 0868b703..0eb55d43 100644 --- a/tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs +++ b/tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs @@ -84,4 +84,121 @@ public class WorldEventsTests Assert.Contains(1u, seen); Assert.Contains(3u, seen); } + + [Fact] + public void RehydratedEntity_ReplacesReplaySnapshotInsteadOfAppendingHistory() + { + var events = new WorldEvents(); + events.FireEntitySpawned(S(1)); + events.FireEntitySpawned(S(1) with { SourceId = 0x01000001u }); + + var seen = new List(); + events.EntitySpawned += seen.Add; + + WorldEntitySnapshot replay = Assert.Single(seen); + Assert.Equal(0x01000001u, replay.SourceId); + } + + [Fact] + public void ForgottenEntity_IsNotReplayedToLateSubscriber() + { + var events = new WorldEvents(); + events.FireEntitySpawned(S(1)); + events.FireEntitySpawned(S(2)); + + Assert.True(events.ForgetEntity(1)); + + var seen = new List(); + events.EntitySpawned += e => seen.Add(e.Id); + Assert.Equal(new uint[] { 2 }, seen); + } + + [Fact] + public void ClearCurrent_LeavesNoReplayHistory() + { + var events = new WorldEvents(); + events.FireEntitySpawned(S(1)); + events.FireEntitySpawned(S(2)); + events.ClearCurrent(); + + var seen = new List(); + events.EntitySpawned += e => seen.Add(e.Id); + Assert.Empty(seen); + } + + [Fact] + public async Task LiveEventDuringReplay_IsDeliveredAfterSnapshotWithoutStaleReordering() + { + var events = new WorldEvents(); + events.FireEntitySpawned(S(1)); + using var replayEntered = new ManualResetEventSlim(); + using var releaseReplay = new ManualResetEventSlim(); + var seen = new List(); + Action handler = snapshot => + { + lock (seen) + seen.Add(snapshot.SourceId); + if (snapshot.SourceId == 0x01000000u) + { + replayEntered.Set(); + releaseReplay.Wait(); + } + }; + + Task subscribe = Task.Run(() => events.EntitySpawned += handler); + Assert.True(replayEntered.Wait(5_000)); + events.FireEntitySpawned(S(1) with { SourceId = 0x01000001u }); + releaseReplay.Set(); + await subscribe; + + lock (seen) + Assert.Equal(new uint[] { 0x01000000u, 0x01000001u }, seen); + } + + [Fact] + public async Task ClearCurrent_DropsLiveEventsQueuedBehindAnActiveReplay() + { + var events = new WorldEvents(); + events.FireEntitySpawned(S(1)); + using var replayEntered = new ManualResetEventSlim(); + using var releaseReplay = new ManualResetEventSlim(); + var seen = new List(); + Action handler = snapshot => + { + lock (seen) + seen.Add(snapshot.Id); + if (snapshot.Id == 1) + { + replayEntered.Set(); + releaseReplay.Wait(); + } + }; + + Task subscribe = Task.Run(() => events.EntitySpawned += handler); + Assert.True(replayEntered.Wait(5_000)); + events.FireEntitySpawned(S(2)); + events.ClearCurrent(); + releaseReplay.Set(); + await subscribe; + + lock (seen) + Assert.Equal(new uint[] { 1 }, seen); + } + + [Fact] + public void UpsertCurrent_RestoresLateReplayWithoutNotifyingExistingSubscriber() + { + var events = new WorldEvents(); + var existing = new List(); + events.EntitySpawned += snapshot => existing.Add(snapshot.Id); + events.FireEntitySpawned(S(1)); + events.ForgetEntity(1); + + events.UpsertCurrent(S(1) with { SourceId = 0x01000001u }); + + Assert.Equal(new uint[] { 1 }, existing); + var late = new List(); + events.EntitySpawned += late.Add; + Assert.Equal(0x01000001u, Assert.Single(late).SourceId); + } } diff --git a/tests/AcDream.Core.Tests/Plugins/WorldGameStateTests.cs b/tests/AcDream.Core.Tests/Plugins/WorldGameStateTests.cs new file mode 100644 index 00000000..40cb7197 --- /dev/null +++ b/tests/AcDream.Core.Tests/Plugins/WorldGameStateTests.cs @@ -0,0 +1,50 @@ +using System.Numerics; +using AcDream.Core.Plugins; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Tests.Plugins; + +public sealed class WorldGameStateTests +{ + private static WorldEntitySnapshot S(uint id, uint sourceId = 0x01000000u) => + new(id, sourceId, Vector3.Zero, Quaternion.Identity); + + [Fact] + public void Add_ReplacesExistingProjectionById() + { + var state = new WorldGameState(); + state.Add(S(1)); + state.Add(S(1, 0x01000001u)); + + WorldEntitySnapshot current = Assert.Single(state.Entities); + Assert.Equal(0x01000001u, current.SourceId); + } + + [Fact] + public void RemoveById_CompactsIndexWithoutLosingOtherEntities() + { + var state = new WorldGameState(); + state.Add(S(1)); + state.Add(S(2)); + state.Add(S(3)); + + Assert.True(state.RemoveById(2)); + state.Add(S(3, 0x01000003u)); + + Assert.Equal(2, state.Entities.Count); + Assert.DoesNotContain(state.Entities, entity => entity.Id == 2); + Assert.Equal(0x01000003u, Assert.Single(state.Entities, entity => entity.Id == 3).SourceId); + } + + [Fact] + public void Clear_RemovesCurrentProjectionAndIndex() + { + var state = new WorldGameState(); + state.Add(S(1)); + state.Clear(); + state.Add(S(1, 0x01000001u)); + + WorldEntitySnapshot current = Assert.Single(state.Entities); + Assert.Equal(0x01000001u, current.SourceId); + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/Issue93TownNetworkFountainRoomLightInspectionTests.cs b/tests/AcDream.Core.Tests/Rendering/Issue93TownNetworkFountainRoomLightInspectionTests.cs index aa7e1159..9b6a228b 100644 --- a/tests/AcDream.Core.Tests/Rendering/Issue93TownNetworkFountainRoomLightInspectionTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Issue93TownNetworkFountainRoomLightInspectionTests.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using AcDream.Content; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Options; @@ -100,6 +101,7 @@ public class Issue93TownNetworkFountainRoomLightInspectionTests var datDir = ResolveDatDir(); if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); + using var boundedDats = new DatCollectionAdapter(dats); const uint setupId = 0x02000365u; var setup = dats.Get(setupId); @@ -115,7 +117,7 @@ public class Issue93TownNetworkFountainRoomLightInspectionTests int survivors = 0, markerSkipped = 0, gfxNull = 0; foreach (var mr in flat) { - if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(dats, mr.GfxObjId)) + if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(boundedDats, mr.GfxObjId)) { markerSkipped++; _out.WriteLine($" part gfx=0x{mr.GfxObjId:X8} -> MARKER (skipped)"); @@ -164,6 +166,7 @@ public class Issue93TownNetworkFountainRoomLightInspectionTests var datDir = ResolveDatDir(); if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); + using var boundedDats = new DatCollectionAdapter(dats); var setup = dats.Get(setupId); Assert.NotNull(setup); @@ -174,7 +177,7 @@ public class Issue93TownNetworkFountainRoomLightInspectionTests int survivors = 0; foreach (var mr in flat) { - if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(dats, mr.GfxObjId)) continue; + if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(boundedDats, mr.GfxObjId)) continue; if (dats.Get(mr.GfxObjId) is null) continue; survivors++; } diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs index 16c1907e..fe57939e 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs @@ -331,7 +331,6 @@ public class EntityClassificationCacheTests uint ibo, uint firstIndex, int indexCount, ulong texHandle) { var key = new GroupKey( - Ibo: ibo, FirstIndex: firstIndex, BaseVertex: 0, IndexCount: indexCount, diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs index 016ce651..d72ca883 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering.Wb; using AcDream.Core.Physics; @@ -16,8 +15,8 @@ public sealed class EntitySpawnAdapterTests [Fact] public void OnCreate_ServerSpawnedEntity_RegistersAnimatedEntityState() { - var cache = new CapturingTextureCache(); - var adapter = MakeAdapter(cache); + var lifetime = new RecordingTextureLifetime(); + var adapter = MakeAdapter(lifetime); var entity = MakeEntity(id: 1, serverGuid: 0xDEAD0001u); var state = adapter.OnCreate(entity); @@ -42,8 +41,8 @@ public sealed class EntitySpawnAdapterTests [Fact] public void OnCreate_ProceduralEntity_ReturnsNullAndRegistersNothing() { - var cache = new CapturingTextureCache(); - var adapter = MakeAdapter(cache); + var lifetime = new RecordingTextureLifetime(); + var adapter = MakeAdapter(lifetime); // ServerGuid == 0 → atlas-tier, must not be processed here. var entity = MakeEntity(id: 2, serverGuid: 0u); @@ -51,101 +50,19 @@ public sealed class EntitySpawnAdapterTests Assert.Null(state); Assert.Null(adapter.GetState(0u)); - // No texture decode should have been triggered. - Assert.Empty(cache.Calls); - } - - // ── Palette-override texture decode ─────────────────────────────────── - - [Fact] - public void OnCreate_WithPaletteOverrideAndSurfaceOverrides_TriggersTextureCacheDecode() - { - var cache = new CapturingTextureCache(); - var adapter = MakeAdapter(cache); - - var palette = new PaletteOverride( - BasePaletteId: 0x04001234u, - SubPalettes: new[] - { - new PaletteOverride.SubPaletteRange(0x04002000u, 0, 2), - }); - - // Entity carries two parts each with one surface override. - var entity = new WorldEntity - { - Id = 10, - ServerGuid = 0xBEEF0001u, - SourceGfxObjOrSetupId = 0x02000001u, - Position = Vector3.Zero, - Rotation = Quaternion.Identity, - PaletteOverride = palette, - MeshRefs = new[] - { - new MeshRef(0x01000010u, Matrix4x4.Identity) - { - SurfaceOverrides = new Dictionary - { - { 0x08000100u, 0u }, // surfaceId → origTex (0 = none) - }, - }, - new MeshRef(0x01000020u, Matrix4x4.Identity) - { - SurfaceOverrides = new Dictionary - { - { 0x08000200u, 0x05000300u }, // with origTex override - }, - }, - }, - }; - - adapter.OnCreate(entity); - - // One call per surface-with-override: (0x08000100, null) and (0x08000200, 0x05000300). - Assert.Equal(2, cache.Calls.Count); - - Assert.Contains(cache.Calls, c => c.SurfaceId == 0x08000100u - && c.OrigTexOverride == null - && c.Palette == palette); - Assert.Contains(cache.Calls, c => c.SurfaceId == 0x08000200u - && c.OrigTexOverride == 0x05000300u - && c.Palette == palette); + Assert.Empty(lifetime.ReleasedOwnerIds); } [Fact] - public void OnCreate_WithPaletteOverrideButNoSurfaceOverrides_DoesNotCallCache() + public void OnCreate_DoesNotAcquireTexturesBeforeTheSurfaceIsDrawn() { - // Surfaces without SurfaceOverrides == null are decoded lazily at draw - // time; the adapter only pre-warms what it knows at spawn time. - var cache = new CapturingTextureCache(); - var adapter = MakeAdapter(cache); - - var entity = new WorldEntity - { - Id = 11, - ServerGuid = 0xBEEF0002u, - SourceGfxObjOrSetupId = 0x02000002u, - Position = Vector3.Zero, - Rotation = Quaternion.Identity, - PaletteOverride = new PaletteOverride(0x04001235u, Array.Empty()), - // MeshRef with NO SurfaceOverrides. - MeshRefs = new[] { new MeshRef(0x01000011u, Matrix4x4.Identity) }, - }; + var lifetime = new RecordingTextureLifetime(); + var adapter = MakeAdapter(lifetime); + var entity = MakeEntity(id: 10, serverGuid: 0xBEEF0001u); adapter.OnCreate(entity); - Assert.Empty(cache.Calls); - } - - [Fact] - public void OnCreate_WithoutPaletteOverride_DoesNotCallCache() - { - var cache = new CapturingTextureCache(); - var adapter = MakeAdapter(cache); - var entity = MakeEntity(id: 12, serverGuid: 0xBEEF0003u); - - adapter.OnCreate(entity); - - Assert.Empty(cache.Calls); + Assert.Empty(lifetime.ReleasedOwnerIds); } // ── OnRemove ───────────────────────────────────────────────────────── @@ -153,14 +70,17 @@ public sealed class EntitySpawnAdapterTests [Fact] public void OnRemove_ReleasesPerEntityState() { - var adapter = MakeAdapter(); + var lifetime = new RecordingTextureLifetime(); + var adapter = MakeAdapter(lifetime); var entity = MakeEntity(id: 20, serverGuid: 0xCAFE0001u); adapter.OnCreate(entity); + adapter.SetPresentationResident(entity, resident: true); Assert.NotNull(adapter.GetState(0xCAFE0001u)); adapter.OnRemove(0xCAFE0001u); Assert.Null(adapter.GetState(0xCAFE0001u)); + Assert.Equal([20u], lifetime.ReleasedOwnerIds); } [Fact] @@ -208,10 +128,10 @@ public sealed class EntitySpawnAdapterTests // ── Helpers ─────────────────────────────────────────────────────────── - private static EntitySpawnAdapter MakeAdapter(ITextureCachePerInstance? cache = null) + private static EntitySpawnAdapter MakeAdapter(IEntityTextureLifetime? lifetime = null) { - cache ??= new CapturingTextureCache(); - return new EntitySpawnAdapter(cache, _ => MakeSequencer()); + lifetime ??= new RecordingTextureLifetime(); + return new EntitySpawnAdapter(lifetime, _ => MakeSequencer()); } private static WorldEntity MakeEntity(uint id, uint serverGuid) @@ -230,23 +150,11 @@ public sealed class EntitySpawnAdapterTests // ── Mocks / stubs ───────────────────────────────────────────────────── - /// - /// Capture every call to GetOrUploadWithPaletteOverride so tests can - /// assert without a live GL context. - /// - private sealed class CapturingTextureCache : ITextureCachePerInstance + private sealed class RecordingTextureLifetime : IEntityTextureLifetime { - public readonly record struct Call(uint SurfaceId, uint? OrigTexOverride, PaletteOverride Palette); - public List Calls { get; } = new(); + public List ReleasedOwnerIds { get; } = new(); - public uint GetOrUploadWithPaletteOverride( - uint surfaceId, - uint? overrideOrigTextureId, - PaletteOverride paletteOverride) - { - Calls.Add(new Call(surfaceId, overrideOrigTextureId, paletteOverride)); - return 1u; // Fake GL handle. - } + public void ReleaseOwner(uint localEntityId) => ReleasedOwnerIds.Add(localEntityId); } private sealed class NullAnimationLoader : IAnimationLoader diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs index 1ad2a125..73707dc7 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs @@ -421,7 +421,6 @@ public sealed class WbDrawDispatcherBucketingTests Vector3? localSortCenter = null) { var key = new GroupKey( - Ibo: ibo, FirstIndex: firstIndex, BaseVertex: 0, IndexCount: indexCount, diff --git a/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs b/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs index 666b7507..454ce86a 100644 --- a/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs @@ -307,6 +307,74 @@ public class LandblockStreamerTests Assert.NotEqual(testThreadId, loaderThreadId.Value); } + [Fact] + public async Task DisposeAndConcurrentDisposeWaitForInFlightLoad() + { + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var streamer = new LandblockStreamer(loadLandblock: _ => + { + entered.Set(); + release.Wait(); + return null; + }); + + try + { + streamer.Start(); + streamer.EnqueueLoad(0x12340000u); + Assert.True(entered.Wait(TimeSpan.FromSeconds(2))); + + Task firstDispose = Task.Run(streamer.Dispose); + Task secondDispose = Task.Run(streamer.Dispose); + await Task.Delay(50); + Assert.False(firstDispose.IsCompleted); + Assert.False(secondDispose.IsCompleted); + + release.Set(); + await Task.WhenAll(firstDispose, secondDispose).WaitAsync(TimeSpan.FromSeconds(2)); + } + finally + { + release.Set(); + streamer.Dispose(); + } + } + + [Fact] + public void DisposeRejectsEveryEnqueueKind() + { + var streamer = new LandblockStreamer(loadLandblock: _ => null); + streamer.Start(); + streamer.Dispose(); + + Assert.Throws(() => streamer.EnqueueLoad(0x12340000u)); + Assert.Throws(() => streamer.EnqueueUnload(0x12340000u)); + Assert.Throws(streamer.ClearPendingLoads); + } + + [Fact] + public async Task ConcurrentStartAndDisposeLeaveAClosedStreamer() + { + for (int iteration = 0; iteration < 20; iteration++) + { + var streamer = new LandblockStreamer(loadLandblock: _ => null); + Exception? startFailure = null; + + Task start = Task.Run(() => + { + try { streamer.Start(); } + catch (ObjectDisposedException ex) { startFailure = ex; } + }); + Task dispose = Task.Run(streamer.Dispose); + await Task.WhenAll(start, dispose).WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.True(startFailure is null or ObjectDisposedException); + Assert.Throws(() => streamer.EnqueueLoad(0x12340000u)); + streamer.Dispose(); + } + } + private static async Task DrainFirstAsync(LandblockStreamer streamer) { for (int i = 0; i < SpinMaxIterations; i++) diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs index da4ed6d3..20ff3a99 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs @@ -149,6 +149,58 @@ public class StreamingControllerPriorityApplyTests Assert.Equal(3, applied.Count); } + [Fact] + public void DeferredCompaction_ApplyFailureRetainsExactResultWithoutReplayingCommittedPrefix() + { + uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180); + uint first = StreamingRegion.EncodeLandblockIdForTest(169, 182); + uint retry = StreamingRegion.EncodeLandblockIdForTest(169, 183); + uint last = StreamingRegion.EncodeLandblockIdForTest(169, 184); + var outbox = new Queue( + [ + LoadedOf(first), + LoadedOf(retry), + LoadedOf(last), + ]); + var applied = new List(); + bool failRetryOnce = true; + var ctrl = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => + { + var batch = new List(); + while (batch.Count < max && outbox.Count > 0) + batch.Add(outbox.Dequeue()); + return batch; + }, + applyTerrain: (build, _) => + { + if (build.LandblockId == retry && failRetryOnce) + { + failRetryOnce = false; + throw new InvalidOperationException("synthetic upload failure"); + } + applied.Add(build.LandblockId); + }, + state: new GpuWorldState(), + nearRadius: 4, + farRadius: 12) + { + MaxCompletionsPerFrame = 3, + PriorityLandblockId = priority, + }; + + Assert.Throws(() => ctrl.Tick(169, 180)); + Assert.Equal([first], applied); + Assert.Equal(2, ctrl.DeferredApplyBacklog); + + ctrl.Tick(169, 180); + + Assert.Equal([first, retry, last], applied); + Assert.Equal(0, ctrl.DeferredApplyBacklog); + } + [Fact] public void ForceReloadWindow_DiscardsBufferedCompletionsFromOldWindow() { diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs index f871ee0e..b3d946b9 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs @@ -11,10 +11,15 @@ public class StreamingControllerTests private sealed class FakeStreamer { public List Loads { get; } = new(); + public List<(uint Id, LandblockStreamJobKind Kind)> LoadJobs { get; } = new(); public List Unloads { get; } = new(); public Queue Pending { get; } = new(); - public void EnqueueLoad(uint id, LandblockStreamJobKind _) => Loads.Add(id); + public void EnqueueLoad(uint id, LandblockStreamJobKind kind) + { + Loads.Add(id); + LoadJobs.Add((id, kind)); + } public void EnqueueUnload(uint id) => Unloads.Add(id); public IReadOnlyList DrainCompletions(int max) { @@ -65,6 +70,285 @@ public class StreamingControllerTests Assert.Empty(fake.Unloads); } + [Fact] + public void ReconfigureRadii_SmallerWindowUnloadsOnlyOutsideResidents() + { + var state = new GpuWorldState(); + var fake = new FakeStreamer(); + int pendingClears = 0; + var controller = new StreamingController( + fake.EnqueueLoad, + fake.EnqueueUnload, + fake.DrainCompletions, + (_, _) => { }, + state, + nearRadius: 2, + farRadius: 2, + clearPendingLoads: () => pendingClears++); + controller.Tick(50, 50); + for (int dx = -2; dx <= 2; dx++) + for (int dy = -2; dy <= 2; dy++) + AddPublished(state, 50 + dx, 50 + dy, LandblockStreamTier.Near); + fake.Loads.Clear(); + fake.LoadJobs.Clear(); + + controller.ReconfigureRadii(0, 0); + + Assert.Equal(1, pendingClears); + Assert.Empty(fake.Loads); + Assert.Equal(24, fake.Unloads.Count); + Assert.DoesNotContain(0x3232FFFFu, fake.Unloads); + } + + [Fact] + public void ReconfigureRadii_LargerWindowLoadsOnlyMissingResidents() + { + var state = new GpuWorldState(); + var fake = new FakeStreamer(); + var controller = new StreamingController( + fake.EnqueueLoad, + fake.EnqueueUnload, + fake.DrainCompletions, + (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0); + controller.Tick(50, 50); + AddPublished(state, 50, 50, LandblockStreamTier.Near); + fake.Loads.Clear(); + fake.LoadJobs.Clear(); + + controller.ReconfigureRadii(1, 1); + + Assert.Equal(8, fake.LoadJobs.Count); + Assert.All( + fake.LoadJobs, + static job => Assert.Equal(LandblockStreamJobKind.LoadNear, job.Kind)); + Assert.DoesNotContain(fake.LoadJobs, static job => job.Id == 0x3232FFFFu); + Assert.Empty(fake.Unloads); + } + + [Fact] + public void ReconfigureRadii_UnchangedWindowDoesNotClearOrRebootstrap() + { + var state = new GpuWorldState(); + var fake = new FakeStreamer(); + int pendingClears = 0; + var controller = new StreamingController( + fake.EnqueueLoad, + fake.EnqueueUnload, + fake.DrainCompletions, + (_, _) => { }, + state, + nearRadius: 1, + farRadius: 2, + clearPendingLoads: () => pendingClears++); + controller.Tick(50, 50); + fake.Loads.Clear(); + fake.LoadJobs.Clear(); + + controller.ReconfigureRadii(1, 2); + + Assert.Equal(0, pendingClears); + Assert.Empty(fake.Loads); + Assert.Empty(fake.Unloads); + } + + [Fact] + public void ReconfigureRadii_ClearFailureRetainsOldWindowAndResumesWithoutDuplicateLoads() + { + var state = new GpuWorldState(); + var fake = new FakeStreamer(); + int clearAttempts = 0; + var controller = new StreamingController( + fake.EnqueueLoad, + fake.EnqueueUnload, + fake.DrainCompletions, + (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0, + clearPendingLoads: () => + { + clearAttempts++; + if (clearAttempts == 1) + throw new InvalidOperationException("clear not admitted"); + }); + controller.Tick(50, 50); + AddPublished(state, 50, 50, LandblockStreamTier.Near); + fake.LoadJobs.Clear(); + + Assert.Throws(() => controller.ReconfigureRadii(1, 1)); + Assert.Equal(0, controller.NearRadius); + Assert.Equal(0, controller.FarRadius); + Assert.Empty(fake.LoadJobs); + + controller.ReconfigureRadii(1, 1); + + Assert.Equal(2, clearAttempts); + Assert.Equal(8, fake.LoadJobs.Count); + Assert.Equal(8, fake.LoadJobs.Select(static job => job.Id).Distinct().Count()); + Assert.Equal(1, controller.NearRadius); + Assert.Equal(1, controller.FarRadius); + } + + [Fact] + public void ReconfigureRadii_QueueFailureRetriesOnlyUncommittedAdmission() + { + var state = new GpuWorldState(); + var fake = new FakeStreamer(); + int loadAttempts = 0; + bool failNext = false; + void EnqueueLoad(uint id, LandblockStreamJobKind kind) + { + loadAttempts++; + if (failNext) + { + failNext = false; + throw new InvalidOperationException("load not admitted"); + } + fake.EnqueueLoad(id, kind); + } + var controller = new StreamingController( + EnqueueLoad, + fake.EnqueueUnload, + fake.DrainCompletions, + (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0); + controller.Tick(50, 50); + AddPublished(state, 50, 50, LandblockStreamTier.Near); + fake.LoadJobs.Clear(); + loadAttempts = 0; + failNext = true; + + Assert.Throws(() => controller.ReconfigureRadii(1, 1)); + Assert.Equal(8, loadAttempts); + Assert.Equal(7, fake.LoadJobs.Count); + Assert.Equal(0, controller.NearRadius); + + controller.ReconfigureRadii(1, 1); + + Assert.Equal(9, loadAttempts); + Assert.Equal(8, fake.LoadJobs.Count); + Assert.Equal(8, fake.LoadJobs.Select(static job => job.Id).Distinct().Count()); + Assert.Equal(1, controller.NearRadius); + } + + [Fact] + public void ReconfigureRadii_CommittedQueueFailureIsReportedButNeverReplayed() + { + var state = new GpuWorldState(); + var fake = new FakeStreamer(); + bool throwCommitted = false; + int loadAttempts = 0; + void EnqueueLoad(uint id, LandblockStreamJobKind kind) + { + loadAttempts++; + fake.EnqueueLoad(id, kind); + if (throwCommitted) + { + throwCommitted = false; + throw new StreamingMutationException( + "post-admission diagnostic failed", + mutationCommitted: true); + } + } + var controller = new StreamingController( + EnqueueLoad, + fake.EnqueueUnload, + fake.DrainCompletions, + (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0); + controller.Tick(50, 50); + AddPublished(state, 50, 50, LandblockStreamTier.Near); + fake.LoadJobs.Clear(); + loadAttempts = 0; + throwCommitted = true; + + Assert.Throws(() => controller.ReconfigureRadii(1, 1)); + + Assert.Equal(8, loadAttempts); + Assert.Equal(8, fake.LoadJobs.Count); + Assert.Equal(1, controller.NearRadius); + controller.ReconfigureRadii(1, 1); + Assert.Equal(8, loadAttempts); + } + + [Fact] + public void ReconfigureRadii_ClearCallbackReentryDefersWithoutRecursiveClear() + { + var state = new GpuWorldState(); + var fake = new FakeStreamer(); + StreamingController? controller = null; + int clearCalls = 0; + controller = new StreamingController( + fake.EnqueueLoad, + fake.EnqueueUnload, + fake.DrainCompletions, + (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0, + clearPendingLoads: () => + { + clearCalls++; + controller!.ReconfigureRadii(1, 1); + }); + controller.Tick(50, 50); + AddPublished(state, 50, 50, LandblockStreamTier.Near); + fake.LoadJobs.Clear(); + + controller.ReconfigureRadii(1, 1); + + Assert.Equal(1, clearCalls); + Assert.Equal(8, fake.LoadJobs.Count); + Assert.Equal(1, controller.NearRadius); + } + + [Fact] + public void ReconfigureRadii_QueueCallbackReentryDefersWithoutDuplicateAdmission() + { + var state = new GpuWorldState(); + var fake = new FakeStreamer(); + StreamingController? controller = null; + bool reenter = false; + int loadCalls = 0; + void EnqueueLoad(uint id, LandblockStreamJobKind kind) + { + loadCalls++; + fake.EnqueueLoad(id, kind); + if (reenter) + { + reenter = false; + controller!.ReconfigureRadii(1, 1); + } + } + controller = new StreamingController( + EnqueueLoad, + fake.EnqueueUnload, + fake.DrainCompletions, + (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0); + controller.Tick(50, 50); + AddPublished(state, 50, 50, LandblockStreamTier.Near); + fake.LoadJobs.Clear(); + loadCalls = 0; + reenter = true; + + controller.ReconfigureRadii(1, 1); + + Assert.Equal(8, loadCalls); + Assert.Equal(8, fake.LoadJobs.Count); + Assert.Equal(8, fake.LoadJobs.Select(static job => job.Id).Distinct().Count()); + Assert.Equal(1, controller.NearRadius); + } + [Fact] public void DrainingLoadedResult_AddsToState() { @@ -115,4 +399,59 @@ public class StreamingControllerTests Assert.False(state.IsLoaded(landblockId)); } + + [Fact] + public void DrainingUnloadedResult_CommitsStateBeforePresentationTeardown() + { + var state = new GpuWorldState(); + var fake = new FakeStreamer(); + const uint landblockId = 0x2020FFFFu; + var lb = new LoadedLandblock( + landblockId, + new LandBlock(), + new[] + { + new WorldEntity + { + Id = 1, + SourceGfxObjOrSetupId = 0x01000000u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = Array.Empty(), + }, + }); + state.AddLandblock(lb); + bool callbackSawDetachedState = false; + var controller = new StreamingController( + fake.EnqueueLoad, + fake.EnqueueUnload, + fake.DrainCompletions, + (_, _) => { }, + state, + nearRadius: 2, + farRadius: 2, + removeTerrain: id => + { + callbackSawDetachedState = id == landblockId + && !state.TryGetLandblock(id, out _); + }); + fake.Pending.Enqueue(new LandblockStreamResult.Unloaded(landblockId)); + + controller.Tick(50, 50); + + Assert.True(callbackSawDetachedState); + Assert.False(state.IsLoaded(landblockId)); + } + + private static void AddPublished( + GpuWorldState state, + int x, + int y, + LandblockStreamTier tier) + { + uint id = ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu; + state.AddLandblock( + new LoadedLandblock(id, new LandBlock(), Array.Empty()), + tier: tier); + } } diff --git a/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs b/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs index bac89585..126d8ef5 100644 --- a/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs +++ b/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs @@ -338,6 +338,58 @@ public sealed class ParticleHookSinkTests Assert.Equal(1, deathNotices); } + [Fact] + public void LogicalTeardown_AttemptsEveryEmitterWhenDeathSubscriberThrows() + { + const uint emitterId = 0x3200005Bu; + var (system, sink, _) = Harness(emitterId); + sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId: 31u)); + sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId: 32u)); + int deathAttempts = 0; + system.EmitterDied += _ => + { + deathAttempts++; + throw new InvalidOperationException("observer failed after emitter removal"); + }; + + AggregateException error = Assert.Throws( + () => sink.StopAllForEntity(Owner, fadeOut: false)); + + Assert.Equal(2, error.InnerExceptions.Count); + Assert.Equal(2, deathAttempts); + Assert.Equal(0, system.ActiveEmitterCount); + Assert.Equal(0, sink.ActiveBindingCount); + Assert.Equal(0, sink.LogicalEmitterCount); + Assert.Equal(0, sink.TrackedOwnerCount); + + // Every physical stop committed despite observer failures, so a retry + // is a clean no-op rather than replaying dead handles. + sink.StopAllForEntity(Owner, fadeOut: false); + Assert.Equal(2, deathAttempts); + } + + [Fact] + public void LogicalTeardown_DeathCallbackCannotResurrectSameOwnerEmitter() + { + const uint emitterId = 0x3200005Cu; + var (system, sink, _) = Harness(emitterId); + sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId: 41u)); + int resurrectionAttempts = 0; + system.EmitterDied += _ => + { + resurrectionAttempts++; + sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId: 42u)); + }; + + sink.StopAllForEntity(Owner, fadeOut: false); + + Assert.Equal(1, resurrectionAttempts); + Assert.Equal(0, system.ActiveEmitterCount); + Assert.Equal(0, sink.ActiveBindingCount); + Assert.Equal(0, sink.LogicalEmitterCount); + Assert.Equal(0, sink.TrackedOwnerCount); + } + [Fact] public void ExaminationObjectPolicyAppliesToExistingAndFutureEmittersAndTearsDown() { @@ -362,17 +414,115 @@ public sealed class ParticleHookSinkTests Assert.Equal(0, sink.ExaminationOwnerCount); } - private sealed class MutablePoseSource : IEntityEffectPoseSource + [Fact] + public void PoseChangeWorkset_RefreshesOnlyChangedOwnersAndVisibilityEdges() + { + const uint emitterId = 0x32000059u; + const int ownerCount = 2_000; + var registry = new EmitterDescRegistry(); + registry.Register(MakeDesc(emitterId, attachLocal: true)); + var system = new ParticleSystem(registry, new Random(42)); + var poses = new MutablePoseSource(); + var sink = new ParticleHookSink(system, poses); + + for (uint owner = 1; owner <= ownerCount; owner++) + { + poses.Publish(owner, Matrix4x4.Identity, Matrix4x4.Identity); + sink.OnHook(owner, Vector3.Zero, Create(emitterId, logicalId: 0)); + } + + // Spawn resolves the current pose directly; unchanged static owners + // create no recurring frame work. + sink.RefreshAttachedEmitters(); + Assert.Equal(0, sink.LastRefreshOwnerVisitCount); + Assert.Equal(0, sink.LastRefreshBindingVisitCount); + + poses.Publish(777u, Matrix4x4.CreateTranslation(1, 2, 3), Matrix4x4.Identity); + poses.Publish(777u, Matrix4x4.CreateTranslation(7, 8, 9), Matrix4x4.Identity); + sink.RefreshAttachedEmitters(); + Assert.Equal(1, sink.LastRefreshOwnerVisitCount); + Assert.Equal(1, sink.LastRefreshBindingVisitCount); + Assert.Equal(new Vector3(7, 8, 9), + system.EnumerateEmitters().Single(emitter => emitter.AttachedObjectId == 777u).AnchorPos); + + sink.RefreshAttachedEmitters(); + Assert.Equal(0, sink.LastRefreshOwnerVisitCount); + + sink.SetEntityPresentationVisible(777u, false); + sink.SetEntityPresentationVisible(777u, true); + sink.RefreshAttachedEmitters(); + Assert.Equal(1, sink.LastRefreshOwnerVisitCount); + Assert.True(system.EnumerateEmitters() + .Single(emitter => emitter.AttachedObjectId == 777u) + .PresentationVisible); + } + + [Fact] + public void PoseChangeRaisedDuringRefresh_IsRetainedForNextRefresh() + { + const uint emitterId = 0x3200005Au; + const uint firstOwner = 1u; + const uint secondOwner = 2u; + var registry = new EmitterDescRegistry(); + registry.Register(MakeDesc(emitterId, attachLocal: true)); + var system = new ParticleSystem(registry, new Random(42)); + var poses = new MutablePoseSource(); + poses.Publish(firstOwner, Matrix4x4.Identity, Matrix4x4.Identity); + poses.Publish(secondOwner, Matrix4x4.Identity, Matrix4x4.Identity); + var sink = new ParticleHookSink(system, poses); + sink.OnHook(firstOwner, Vector3.Zero, Create(emitterId, logicalId: 0)); + sink.OnHook(secondOwner, Vector3.Zero, Create(emitterId, logicalId: 0)); + + bool raised = false; + poses.RootRead = owner => + { + if (owner != firstOwner || raised) + return; + raised = true; + poses.Publish( + secondOwner, + Matrix4x4.CreateTranslation(20, 0, 0), + Matrix4x4.Identity); + }; + poses.Publish( + firstOwner, + Matrix4x4.CreateTranslation(10, 0, 0), + Matrix4x4.Identity); + + sink.RefreshAttachedEmitters(); + Assert.Equal(1, sink.LastRefreshOwnerVisitCount); + sink.RefreshAttachedEmitters(); + Assert.Equal(1, sink.LastRefreshOwnerVisitCount); + Assert.Equal(new Vector3(20, 0, 0), + system.EnumerateEmitters() + .Single(emitter => emitter.AttachedObjectId == secondOwner) + .AnchorPos); + } + + private sealed class MutablePoseSource : + IEntityEffectPoseSource, + IEntityEffectPoseChangeSource { private readonly Dictionary _poses = new(); - public void Publish(uint id, Matrix4x4 root, params Matrix4x4[] parts) => - _poses[id] = (root, parts); + public event Action? EffectPoseChanged; + public Action? RootRead { get; set; } - public void Remove(uint id) => _poses.Remove(id); + public void Publish(uint id, Matrix4x4 root, params Matrix4x4[] parts) + { + _poses[id] = (root, parts); + EffectPoseChanged?.Invoke(id); + } + + public void Remove(uint id) + { + _poses.Remove(id); + EffectPoseChanged?.Invoke(id); + } public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld) { + RootRead?.Invoke(localEntityId); if (_poses.TryGetValue(localEntityId, out var pose)) { rootWorld = pose.Root; diff --git a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs index 58d54251..171b35cb 100644 --- a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs +++ b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs @@ -607,6 +607,196 @@ public sealed class ParticleSystemTests Assert.All(sys.EnumerateEmitters(), emitter => Assert.True(emitter.ViewEligible)); } + [Fact] + public void DenseSuspendedSet_IsExcludedFromSimulationViewAndRenderWorksets() + { + var sys = MakeSystem(); + var desc = new EmitterDesc + { + DatId = 0x32000080u, + Type = ParticleType.Still, + MaxDegradeDistance = 100f, + MaxParticles = 1, + InitialParticles = 1, + LifetimeMin = 100f, + LifetimeMax = 100f, + }; + var visibleCells = new HashSet { 0x01010001u }; + + for (int i = 0; i < 2_000; i++) + { + int handle = sys.SpawnEmitter(desc, Vector3.Zero, attachedObjectId: (uint)(i + 1)); + sys.UpdateEmitterOwnerCell(handle, 0x01010001u); + if (i >= 3) + { + sys.SetEmitterPresentationVisible(handle, false); + sys.SetEmitterSimulationEnabled(handle, false); + } + } + + sys.ApplyRetailView(Vector3.Zero, visibleCells, hasCompletedView: true); + sys.Tick(0.01f); + + Assert.Equal(3, sys.LastRetailViewEmitterVisitCount); + Assert.Equal(3, sys.LastTickEmitterVisitCount); + Assert.Equal(3, sys.EnumerateRenderableEmitters(ParticleRenderPass.Scene).Count()); + Assert.Equal(2_000, sys.ActiveEmitterCount); + } + + [Fact] + public void OwnerRenderScope_VisitsOnlyRequestedOwnersAndUnattachedEmittersInSpawnOrder() + { + var sys = MakeSystem(); + var desc = new EmitterDesc + { + DatId = 0x32000083u, + Type = ParticleType.Still, + MaxParticles = 1, + }; + int unattached = sys.SpawnEmitter(desc, Vector3.Zero); + int ownerSeven = 0; + int ownerNine = 0; + for (uint owner = 1; owner <= 2_000; owner++) + { + int handle = sys.SpawnEmitter(desc, Vector3.Zero, attachedObjectId: owner); + if (owner == 7u) + ownerSeven = handle; + if (owner == 9u) + ownerNine = handle; + } + + var destination = new List(); + sys.CopyRenderableEmittersForOwners( + ParticleRenderPass.Scene, + new HashSet { 9u, 7u, 4_000u }, + includeUnattached: true, + destination); + + Assert.Equal(3, sys.LastRenderScopeEmitterVisitCount); + Assert.Equal(new[] { unattached, ownerSeven, ownerNine }, + destination.Select(emitter => emitter.Handle)); + + sys.CopyRenderableEmittersForOwners( + ParticleRenderPass.Scene, + new HashSet { 7u, 9u }, + includeUnattached: false, + destination, + excludedAttachedOwnerIds: new HashSet { 7u }); + Assert.Equal(1, sys.LastRenderScopeEmitterVisitCount); + Assert.Equal(new[] { ownerNine }, destination.Select(emitter => emitter.Handle)); + } + + [Fact] + public void SpatialReentryWaitsForFreshRetailViewBeforeBecomingRenderable() + { + var sys = MakeSystem(); + int handle = sys.SpawnEmitter( + new EmitterDesc + { + DatId = 0x32000084u, + Type = ParticleType.Still, + MaxDegradeDistance = 100f, + MaxParticles = 1, + }, + Vector3.Zero, + attachedObjectId: 84u); + sys.UpdateEmitterOwnerCell(handle, 0x01010001u); + var visible = new HashSet { 0x01010001u }; + var destination = new List(); + + sys.ApplyRetailView(Vector3.Zero, visible, hasCompletedView: true); + Assert.Single(sys.EnumerateRenderableEmitters(ParticleRenderPass.Scene)); + + sys.SetEmitterPresentationVisible(handle, false); + sys.SetEmitterSimulationEnabled(handle, false); + Assert.False(Assert.Single(sys.EnumerateEmitters()).ViewEligible); + + sys.SetEmitterPresentationVisible(handle, true); + sys.SetEmitterSimulationEnabled(handle, true); + sys.CopyRenderableEmittersForOwners( + ParticleRenderPass.Scene, + new HashSet { 84u }, + includeUnattached: false, + destination); + Assert.Empty(destination); + + sys.ApplyRetailView(Vector3.Zero, visible, hasCompletedView: true); + sys.CopyRenderableEmittersForOwners( + ParticleRenderPass.Scene, + new HashSet { 84u }, + includeUnattached: false, + destination); + Assert.Single(destination); + } + + [Fact] + public void OrderedIndexes_PreserveSpawnOrderAcrossHardRemovalAndPassFiltering() + { + var sys = MakeSystem(); + var desc = new EmitterDesc + { + DatId = 0x32000081u, + Type = ParticleType.Still, + MaxParticles = 1, + }; + int first = sys.SpawnEmitter(desc, Vector3.Zero); + int removedScene = sys.SpawnEmitter(desc, Vector3.Zero); + int sky = sys.SpawnEmitter( + desc, + Vector3.Zero, + renderPass: ParticleRenderPass.SkyPreScene, + visibilityPolicy: ParticleVisibilityPolicy.PassOwned); + int removedSky = sys.SpawnEmitter( + desc, + Vector3.Zero, + renderPass: ParticleRenderPass.SkyPreScene, + visibilityPolicy: ParticleVisibilityPolicy.PassOwned); + int last = sys.SpawnEmitter(desc, Vector3.Zero); + + sys.StopEmitter(removedScene, fadeOut: false); + sys.StopEmitter(removedSky, fadeOut: false); + + Assert.Equal(new[] { first, sky, last }, + sys.EnumerateEmitters().Select(emitter => emitter.Handle)); + Assert.Equal(new[] { first, last }, + sys.EnumerateRenderableEmitters(ParticleRenderPass.Scene) + .Select(emitter => emitter.Handle)); + Assert.Equal(new[] { sky }, + sys.EnumerateRenderableEmitters(ParticleRenderPass.SkyPreScene) + .Select(emitter => emitter.Handle)); + } + + [Fact] + public void TickSnapshot_ToleratesNestedEmitterRemovalFromDeathCallback() + { + var sys = MakeSystem(); + var desc = new EmitterDesc + { + DatId = 0x32000082u, + Type = ParticleType.Still, + MaxParticles = 1, + InitialParticles = 1, + TotalParticles = 1, + LifetimeMin = 0.01f, + LifetimeMax = 0.01f, + Lifespan = 0.01f, + }; + int first = sys.SpawnEmitter(desc, Vector3.Zero); + int second = sys.SpawnEmitter(desc, Vector3.Zero); + sys.EmitterDied += handle => + { + if (handle == first) + sys.StopEmitter(second, fadeOut: false); + }; + + sys.Tick(0.02f); + sys.Tick(0.01f); + + Assert.Equal(1, sys.LastTickEmitterVisitCount); + Assert.Equal(0, sys.ActiveEmitterCount); + Assert.Empty(sys.EnumerateEmitters()); + } + [Fact] public void FiniteEmitter_DegradedBranchExpiresAndRemovesIt() { @@ -849,6 +1039,26 @@ public sealed class ParticleSystemTests Assert.False(sys.IsEmitterAlive(handle)); } + [Fact] + public void EmitterDied_AttemptsEverySubscriberWhenOneThrows() + { + var sys = MakeSystem(); + var delivered = new List(); + sys.EmitterDied += _ => throw new InvalidOperationException("first subscriber failed"); + sys.EmitterDied += delivered.Add; + + int handle = sys.SpawnEmitter( + MakeDesc(emitRate: 5f, lifetime: 0.2f, maxParticles: 4), + Vector3.Zero); + + AggregateException failure = Assert.Throws( + () => sys.StopEmitter(handle, fadeOut: false)); + + Assert.Contains("first subscriber failed", failure.ToString()); + Assert.Equal([handle], delivered); + Assert.False(sys.IsEmitterAlive(handle)); + } + [Fact] public void Birthrate_PerSec_EmitsOnePerTickWhenIntervalElapsed() { diff --git a/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs b/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs index 241d95b3..8e90e108 100644 --- a/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs +++ b/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs @@ -141,10 +141,10 @@ public class LandblockLoaderTests var idsB = entitiesLbB.Select(e => e.Id).ToArray(); Assert.Empty(idsA.Intersect(idsB)); - // The namespace top byte is 0xC0 for stabs (distinct from 0x80 scenery, - // 0x40 interior, low-range live entities). - Assert.All(idsA, id => Assert.Equal(0xC0u, (id >> 24) & 0xFFu)); - Assert.All(idsB, id => Assert.Equal(0xC0u, (id >> 24) & 0xFFu)); + // The namespace top nibble is 0xC for stabs (distinct from 0x8 + // scenery, 0x4 interior, and low-range live entities). + Assert.All(idsA, id => Assert.True(LandblockStaticEntityIdAllocator.IsInNamespace(id))); + Assert.All(idsB, id => Assert.True(LandblockStaticEntityIdAllocator.IsInNamespace(id))); } [Fact] @@ -186,17 +186,34 @@ public class LandblockLoaderTests Assert.Equal(1u, entities[0].Id); } + [Fact] + public void BuildEntitiesFromInfo_MoreThan255EntriesStayInTheOwningLandblockRange() + { + var info = new LandBlockInfo(); + for (uint i = 0; i < 300u; i++) + info.Objects.Add(new Stab { Id = 0x01000001u, Frame = new Frame() }); + + IReadOnlyList entities = + LandblockLoader.BuildEntitiesFromInfo(info, landblockId: 0xA9B40000u); + + Assert.Equal(300, entities.Count); + Assert.Equal(300, entities.Select(entity => entity.Id).Distinct().Count()); + Assert.All(entities, entity => + Assert.True(LandblockStaticEntityIdAllocator.IsInNamespace(entity.Id))); + Assert.True(entities[^1].Id < LandblockStaticEntityIdAllocator.Base(0xA9u, 0xB5u)); + } + [Fact] public void BuildEntitiesFromInfo_NamespacedIdOverflowFailsBeforeCrossLandblockAlias() { var info = new LandBlockInfo(); - for (uint i = 0; i < 256u; i++) + for (uint i = 0; i <= LandblockStaticEntityIdAllocator.MaxCounter + 1u; i++) info.Objects.Add(new Stab { Id = 0x01000001u, Frame = new Frame() }); InvalidDataException error = Assert.Throws( () => LandblockLoader.BuildEntitiesFromInfo(info, landblockId: 0xA9B40000u)); - Assert.Contains("255-entry", error.Message, StringComparison.Ordinal); + Assert.Contains("4096-entry", error.Message, StringComparison.Ordinal); } [Fact] diff --git a/tests/AcDream.Core.Tests/World/LandblockStaticEntityIdAllocatorTests.cs b/tests/AcDream.Core.Tests/World/LandblockStaticEntityIdAllocatorTests.cs new file mode 100644 index 00000000..2c7c53ed --- /dev/null +++ b/tests/AcDream.Core.Tests/World/LandblockStaticEntityIdAllocatorTests.cs @@ -0,0 +1,46 @@ +using AcDream.Core.World; + +namespace AcDream.Core.Tests.World; + +public sealed class LandblockStaticEntityIdAllocatorTests +{ + [Fact] + public void Base_PreservesFullCoordinatesAndStabNamespace() + { + var bases = new HashSet(); + for (uint y = 0; y <= 255; y++) + for (uint x = 0; x <= 255; x++) + { + uint value = LandblockStaticEntityIdAllocator.Base(x, y); + Assert.True(LandblockStaticEntityIdAllocator.IsInNamespace(value)); + Assert.True(bases.Add(value), $"duplicate base for ({x},{y})"); + } + } + + [Fact] + public void MaximumCounterDoesNotAliasAdjacentLandblock() + { + for (uint y = 0; y < 255; y++) + { + uint current = LandblockStaticEntityIdAllocator.Base(0, y); + uint next = LandblockStaticEntityIdAllocator.Base(0, y + 1); + Assert.True(current + LandblockStaticEntityIdAllocator.MaxCounter < next); + } + } + + [Fact] + public void Allocate_AcceptsLastCounterThenFailsBeforeNamespaceWrap() + { + uint counter = LandblockStaticEntityIdAllocator.MaxCounter; + + uint last = LandblockStaticEntityIdAllocator.Allocate(0xA9u, 0xB4u, ref counter); + + Assert.Equal( + LandblockStaticEntityIdAllocator.Base(0xA9u, 0xB4u) + + LandblockStaticEntityIdAllocator.MaxCounter, + last); + InvalidDataException error = Assert.Throws( + () => LandblockStaticEntityIdAllocator.Allocate(0xA9u, 0xB4u, ref counter)); + Assert.Contains("4096-entry", error.Message, StringComparison.Ordinal); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs index d8fe9bca..c49353ca 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs @@ -11,17 +11,17 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Settings; public sealed class DisplaySettingsTests { [Fact] - public void Default_values_match_pre_L0_runtime_state() + public void Default_values_match_normal_client_policy() { - // Defaults pinned to match the actual pre-L.0 startup state: + // Defaults pin the normal-client startup policy: // · Resolution matches WindowOptions (1280×720 in GameWindow.Run) // · FieldOfView matches camera FovY (60° = π/3) - // · VSync matches WindowOptions (false during dev) + // · VSync is on so normal presentation follows monitor refresh // · ShowFps false matches retail's initially-hidden SmartBox meter var d = DisplaySettings.Default; Assert.Equal("1280x720", d.Resolution); Assert.False(d.Fullscreen); - Assert.False(d.VSync); + Assert.True(d.VSync); Assert.Equal(60f, d.FieldOfView); Assert.Equal(1.0f, d.Gamma); Assert.False(d.ShowFps); @@ -61,7 +61,7 @@ public sealed class DisplaySettingsTests Assert.Equal(90f, d.FieldOfView); // Other fields untouched. Assert.Equal("1280x720", d.Resolution); - Assert.False(d.VSync); + Assert.True(d.VSync); Assert.False(d.ShowFps); }